자바스크립트를 활성화 해주세요

Clean Code 6장: "객체와 자료 구조" 정리

 ·  ☕ 8 min read

6장 객체와 자료 구조를 읽으면서 내용에 대해 보충하거나, 개인적인 의견으로 반박하거나, 고민해 볼 부분에 대해 적어보려 한다.

자료 추상화

직교 좌표계에서 점을 표현하는 두 예시를 비교해보자.

public class Point {
    public double x;
    public double y;
}
public interface Point {
    double getX();
    double getY();
    void setCartesian(double x, double y);
    double getR();
    double getTheta();
    void setPolar(double r, double theta);
}

추상적인 Point는 해당 점이 내부적으로 직교좌표계를 사용하는지, 극좌표계를 사용하는지 알 수 없다. (심지어 둘다 아닐 수도 있다.) 하지만 메서드를 통해 직교좌표값이나 극좌표값을 알아낼 수 있다.

반면 구체적인 Point는 직교좌표계로 구현되어있고, 이를 직접 사용할 수 있게 한다. 다만 직교좌표값만 알아낼 수 있다.

자료/객체 비대칭

객체와 자료구조의 차이를 아래와 같이 정의하고 있다.

  • 객체는 데이터를 추상화로 숨기고, 데이터를 사용하는 함수를 노출한다.
  • 자료구조는 데이터를 노출하고, 의미있는 함수가 존재하지 않는다.
public class Square {
    public Point topLeft;
    public double side;
}

public class Rectangle {
    public Point topLeft;
    public double height;
    public double width;
}

public class Circle {
    public Point center;
    public double radius;
}

public class Geometry {
    public final double PI = 3.141592653589793;

    public double area(Object shape) throws NoSuchShapeException
    {
        if (shape instanceof Square) {
            Square s = (Square)shape;
            return s.side * s.side;
        }
        else if (shape instanceof Rectangle) {
            Rectangle r = (Rectangle)shape;
            return r.height * r.width;
        }
        else if (shape instanceof Circle) {
            Circle c = (Circle)shape;
            return PI * c.radius * c.radius;
        }
        throw new NoSuchShapeException();
    }
}
public class Square implements Shape {
    private Point topLeft;
    private double side;

    public double area() {
        return side*side;
    }
}

public class Rectangle implements Shape {
    private Point topLeft;
    private double height;
    private double width;
    
    public double area() {
        return height * width;
    }
}

public class Circle implements Shape {
    private Point center;
    private double radius;
    public final double PI = 3.141592653589793;

    public double area() {
        return PI * radius * radius;
    }
}

여기서는 도형을 절차지향적(자료구조 표현)으로 구현한 방식과 객체지향적(상속을 통한 다형성) 구현 방식의 차이를 설명하고 있다.

절차지향적 구현

  • 절차지향의 경우, 새로운 도형의 추가는 다른 도형 class에 영향을 미치지 않음
  • 하지만 Geometry 클래스의 area() 함수는 변경되어야 함
  • 만약 도형에 대해 둘레를 구하는 perimeter() 함수를 추가하려면 Geometry 클래스만 변경하면 됨

객체지향적 구현

  • area() 함수가 다형적으로 구현되어있으므로, Geometry 클래스가 불필요함
  • 새로운 도형 추가로 인해 기존의 area()가 변경될 필요가 없음
  • 도형에 대해 둘레를 구하는 perimeter()를 추가하려면 모든 클래스가 변경되어야 함

상황에 따라 객체지향적인 구현이 유리할 수도 있고, 절차지향적인 구현이 유리할 수도 있다.

디미터 법칙

디미터 법칙, 혹은 최소 지식의 원칙은 소프트웨어 모듈 사이의 결합도를 줄여서 코드의 품질을 높이자는 취지의 가이드라인이다.

책에서 설명하는 디미터의 법칙은 아래와 같다.

  • 클래스 C와, 해당 클래스의 메서드 f
  • 메서드 f가 생성한 객체
  • 메서드 f에 인자로 전달 된 객체
  • 클래스 C의 인스턴스 변수 안에 있는 객체

위의 목록에 해당하는 객체, 객체의 메서드들만 호출할 것을 말하고 있다. 해당 객체에 직접적으로 연관된 객체들만 사용할 것을 말하고 있다.

디미터의 법칙을 어기는 예시로는, 해당 클래스나 메서드에 관련되지 않은 객체를 사용하지 말라는 것이다. 메서드 내부에서만 다른 객체를 사용하면, 각 객체간의 결합도를 알 수 없기 때문인 것으로 보인다.

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

위 코드는 디미터의 법칙을 어기는 코드다.

  • getOptions() 함수가 호출됨 (디미터의 법칙을 지킴)
  • getScratchDir() 함수가 호출됨 (getOptions()의 결과 객체를 사용하므로 디미터의 법칙을 어김)
  • getAbsolutePath() 함수가 호출됨 (getScratchDir()의 결과 객체를 사용하므로 디미터의 법칙을 어김)

기차 충돌

아까 위에서 본 코드를 기차 충돌이라고 한다. 함수 호출이 연속되며, 조잡한 방식이기 때문에 이런 방식의 코드를 사용하지 말라고 한다.

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();

여기에서 디미터의 법칙 위반 여부는 각 함수 호출의 결과 객체인 File, Options가 객체냐 자료구조에 따라 다르다. (자료구조라면 당연히 데이터를 노출하고, 의미있는 함수가 존재하지 않으므로 디미터의 법칙을 위반하지 않는다고 한다.)

그런데 자료구조라면 굳이 메서드로 각 객체를 가져올 필요가 없다. 아래와 같이 멤버 변수를 바로 접근하면 된다.

final String outputDir = ctxt.options.scratchDir.absolutePath;

위와 같이 클래스 안에 멤버 변수로 사용된 클래스, 이런 상황이 중첩된 상황을 예전에 겪어본 적이 있었다.

임베디드 개발에서 C언어로 객체지향적인 설계를 하려면 구조체별로 묶어서 모듈로 관리하는데, 각 변수를 의미 기준으로 묶다보면 구조체 속 구조체와 같이 중첩된 정의를 하게 된다.

문제는 중첩된 구조체의 변수에 접근하게 될 때 지금의 기차 충돌처럼 호출이 너무 복잡해진다.

int Driver_AddCtrlPkt(Driver *pDriverCb, CtrlPktHdr *pCtrlHdr, char *pPayload)
{
    int tRetCode = RET_FAIL;

    if (pDriverCb->tMsgMgr.tCtrlBuf.tHead == pDriverCb->tMsgMgr.tCtrlBuf.tTail)
    {
        // Implementation...
    }

    return tRetCode;
}
int Driver_AddCtrlPkt(Driver *pDriverCb, CtrlPktHdr *pCtrlHdr, char *pPayload)
{
    int tRetCode = RET_FAIL;
    DriverMsgBuf *pCtrlBuf = &(pDriverCb->tMsgMgr.tCtrlBuf);
    
    if (pCtrlBuf->tHead == pCtrlBuf->tTail)
    {
        // Implementation...
    }

    return tRetCode;
}

책에서 기차 충돌을 피하라는 것 처럼, 구조체 속 구조체를 매번 접근하면 변수 길이가 길어진다. 만약 유사한 변수라도 있다면 작성하다가 잘못 호출할 수도 있다. 코드 호출을 단순화 하고 오류를 줄이기 위해 필요한 구조체의 포인터 타입으로 연결하여 접근하도록 개선해봤다.

그리고 위와 같은 기차 충돌 식 코드는 함수형 프로그래밍 패러다임에서 자주 봤던 것으로 기억한다.

public String cleanNames(List<String> names) {
    return names
            .stream()
            .filter(name -> name != null)
            .filter(name -> name.length() > 1)
            .map(name -> capitalize(name))
            .collect(Collectors.joining(","))
}
public String cleanNames(listOfNames) {
    listOfNames
        .findAll { it.length() > 1 }
        .collect { it.capitalize() }
        .join ','
}

절차적인 프로그래밍 패러다임에서는 지역변수에 상태를 저장하면서 각 연산 단계를 나누는 것이 일반적이기 때문에 함수 호출을 분리하는 것이 일반적이지만, 함수형 프로그래밍 패러다임은 수학 공식을 모델링하는 표현과 변형으로 기술하기 때문에 가변 상태를 지양하려 하는 것으로 알고있다.

잡종 구조

아까 기차 충돌의 코드에서 함수의 결과 값을 자료구조라고 생각하면 디미터의 법칙을 지킬수 있다고 했지만, 기존의 코드를 보면 getter 형태로 메서드가 호출된 것을 알 수 있다.

즉, 이 중간 객체들은 함수를 호출하는 형태로 봤을 때는 객체임에도, 디미터의 법칙을 지키기 위해 내부 구조를 개방하면 객체이면서 자료구조가 되게 된다. 이런 상황은 객체와 자료구조의 단점을 모두 갖게 되므로 지양해야 한다.

구조체 감추기

만약 중간 결과 값이 실제 행위를 수행할 수 있는 객체라면 어떨까? 이전의 기차 충돌 식 코드를 예시로 마저 설명해보자.

디미터의 법칙을 지키기 위해, 중간 변수들은 드러나선 안된다. 하지만 이렇게 되면 처음 호출한 객체가 하위 객체의 정보를 모두 알아야 한다. 즉, 객체 설계에서 결합도가 높아지는 문제가 생긴다.

해당 코드를 메서드 이름으로 의도를 유추하면, 임시 디렉토리의 절대 경로를 얻는 것이다. 그렇다면 절대 경로를 얻는 것이 목적의 끝일까? 해당 변수가 사용되는 코드를 보니 임시 디렉토리에 임시 파일을 생성하는 것이었다면, 이와 관련된 부분을 더 숨길 수 있다.

final String outputDir = ctxt.getAbsolutePathOfScratchDirectoryOption();
// otherwise,
final String outputDir = ctxt.getScratchDirectoryOption().getAbsolutePath();
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
String outFile = outputDir + "/" + className.replace('.', '/') + ".class";
FileOutputStream fout = new FileOutputStream(outFile);
BufferedOutputStream bos = new BufferedOutputStream(fout);
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

자료 전달 객체

통신이나 데이터베이스의 값을 가져오기 위해 자료구조만 표현하는 형태의 객체가 사용되는데, 이를 DTO(Data Transfer Object)라 한다. ORM(Object-Relational Mapping) 혹은 protobuf로 생성한 클래스, 객체가 그 예시라 생각한다.

아래와 같이 객체의 초기 값만 설정하고, 그 값을 읽어 오는 메서드만 제공하는 형태의 클래스를 bean이라 한다.

public class Address {
    private String street;
    private String streetExtra;
    private String city;
    private String state;
    private String zip;
    
    public Address(String street, String streetExtra,
                    String city, String state, String zip) {
        this.street = street;
        this.streetExtra = streetExtra;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }

    public String getStreet() {
        return street;
    }

    public String getStreetExtra() {
        return streetExtra;
    }

    public String getCity() {
        return city;
    }
    
    public String getState() {
        return state;
    }
    
    public String getZip() {
        return zip;
    }
}

활성 레코드

데이터베이스의 테이블을 직접 변환하여, 기존 DTO와 유사하지면서 find(), save() 등의 레코드 작업을 수행하는 형태를 활성 레코드라 한다.

가끔 개발자들이 이런 자료구조에 비즈니스 로직(업무적인 기능)을 넣는 형태로 개발하기도 하는데, 이는 잡종 구조를 만들 수 있으니 지양하라고 한다. 최대한 객체와 자료구조를 구분하여 다룰 것을 추천하고 있다.

결론

이번 장에서 이야기하는 핵심은 객체(행동 기반)과 자료구조(자료 기반)의 차이를 명확히 하고, 이를 잘 구별할 것을 말하고 있다.

사실 객체지향은 완전히 이해하기 상당히 어려운 주제다. 고작 책의 일부로 다루기엔 많이 복잡한 내용임이 확실하다. 하지만 내가 경험했던 것을 바탕으로 생각해보면 완전 객체지향으로 작성하는 것은 생각만큼 효율적이지 않을 수도 있다.

데이터베이스 이론에서는 각 테이블의 상황에 따른 문제를 해결하기 위한 정규화 개념이 있지만, 모든 단계의 정규화를 적용하면 성능이 떨어지기 때문에 역정규화를 수행하듯, 이론이 현실에 완전 적용되지 않는 것을 볼 수 있다.

객체지향에서 추상화에 집중한다면 라자냐 코드를 생성하게 될 가능성이 높다.

직접 겪어보니 초급 개발자와 중급 이상 개발자의 가장 큰 차이는 적절한 선을 두고 저울질 할 수 있는 능력을 포함하는 것 같다. 디자인 패턴이 코드의 구조를 규격화하는 데 유용한 도구지만, 무분별하게 패턴을 적용할 필요가 없듯, 디미터의 법칙도 적절히 적용하는 것이 맞을 것 같다.

객체지향과 관련된 다른 이야기

최근에는 객체지향 방식 자체에 대한 반발이 있다고 한다. 분명 객체지향은 코드를 추상화하는 좋은 방법이지만, 객체지향적인 코드를 작성하기 위해 작성해야 하는 보일러 플레이트 코드들이 많아진다는 문제가 있다.

아마 함수형 프로그래밍 패러다임의 유행에는 객체 방식의 표현으로 인한 피로를 줄이고, 코드를 명확하게 하기 위한 전략 중 하나일 가능성도 있다고 생각한다.

이 외에 객체지향이란 한국어 표현 자체가 적합하지 않고, SOLID 지향 프로그래밍이라고 표현하는 것이 더 적합하다는 의견도 있다. 이미 지금의 객체지향 방식은 객체를 기본으로 사용하고 있기 때문에 지향이란 표현이 어울리지 않는다는 것이다.

오히려 SOLID 법칙은 객체지향을 효율적으로 작성하기 위한 법칙이지만, 위의 역정규화 예시처럼 현실적으로 모든 법칙을 지키기 힘들다. SOLID라는 이상향을 쫒는 상황이니 SOLID 지향 프로그래밍이라는 표현을 하는 것이다. 이 관점에 대해 더 관심이 있다면 아래 영상을 참고하길 바란다.


JaeSang Yoo
글쓴이
JaeSang Yoo
The Programmer

목차