혼자 정리

6. 객체와 자료구조 본문

클린코드

6. 객체와 자료구조

tbonelee 2021. 10. 10. 19:28

이번 단원에서는 지속적으로 객체(object)자료구조(data structure)를 언급한다.

이는 단순히 클래스와 구조체를 구분하는 문제가 아니다(둘 다 클래스를 사용해서 표현할 수 있다)

객체는 추상화 뒤로 자료를 숨기고 자료를 다루는 함수만 제공한다.

반면, 자료구조는 자료를 그대로 오픈하여 별다른 함수를 제공하지 않는다.

자료 추상화

2차원 점을 표현하는 예시 두 가지를 통해 자료 구조와 객체를 구분해보자.

public class Point {
    public double x;
    public double y;
}
  • 첫번째 예시는 자료 구조를 그대로 드러낸다.
  • 따라서 사용자가 내부가 어떻게 생겼는지 알고 있어야 제대로 사용할 수 있다.
    • 위 코드에서 public접근자를 private으로 바꾸고 getter, setter 함수를 추가한다고 해서 이 사실이 변하지는 않는다.
public interface Point {
    double getX();
    double getY();
    void setCartesian(double x, double y);
    double getR();
    double getTheta();
    void setPolar(double r, double theta);
}
  • 두번째 예시는 실제 내부 구조가 어떻게 되어 있는지 몰라도 사용할 수 있다.
  • 사용자가 접하는 것은 추상화된 2차원 점에 대한 함수다.

즉, 자료구조가 아닌 객체를 만들고자 한다면 '추상화'를 통해 내부 구현을 감춰야 한다.

자료/객체 비대칭

여기까지 보면 객체가 자료구조의 업그레이드 버전인 것처럼 느껴질 수 있지만 각각 상대적으로 우월한 부분이 존재한다.

결론부터 말하자면 새로운 자료 타입을 추가할 일이 많으면 클래스와 객체 지향 기법이 적합하고, 새로운 함수를 추가할 일이 많으면 자료 구조와 절차적인 코드가 적합하다.

아래 코드를 통해 절차지향적 코드의 강점과 약점을 살펴보자.

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();
    }
}
  • 위의 절차지향적 코드에서 둘레 길이를 구하는 새 함수 perimeter()를 추가하고 싶다면 Geometry에 있는 다른 코드는 건드리지 않고 추가하면 된다.
  • 반면 새 도형을 추가하고 싶다면 Geometry클래스에 있는 모든 메소드를 수정해야 한다(물론 예시에서는 하나 밖에 없지만 여러 개가 있다면)

이번에는 객체 지향적 코드의 예시를 보자

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;
    }
}
  • area()는 polymorphic한 메서드이다.
  • Geometry클래스가 따로 필요하지 않으므로 새 도형을 추가하더라도 기존 함수를 수정해야할 이유가 없다.
  • 하지만 도형에 대한 새 함수를 추가하고 싶다면 관련된 모든 도형 클래스에 새 메서드를 추가해줘야 한다.

따라서 다음과 같이 볼 수 있다.

  • (자료 구조를 사용하는) 절차적인 코드는 기존 자료 구조를 변경하지 않으면서 새 함수를 추가하기 쉽다. 반면, 객체 지향 코드는 기존 함수를 변경하지 않으면서 새 클래스를 추가하기 쉽다.
  • 절차적인 코드는 새로운 자료 구조를 추가하기 어렵다. 그러려면 모든 함수를 고쳐야 한다. 객체 지향 코드는 새로운 함수를 추가하기 어렵다. 그러려면 모든 클래스를 고쳐야 한다.

때로는 단순한 자료 구조와 절차적 코드가 적합한 상황도 있다는 것을 명심하자.

디미터 법칙

디미터 법칙을 간단히 말하면 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 법칙이다.

그렇게 해야 조작하는 객체의 클래스 내부 구조에 수정이 일어나도 모듈을 수정할 필요가 생기지 않을 것이므로 유지 보수에 용이하다.

디미터 법칙에 대한 논문을 보면 다음과 같이 정의하고 있다.

  • 모든 클래스 C와 C의 메소드 M에 대해, M이 메시지를 보내는 모든 객체는 다음의 인스턴스로 제한되어야 한다.
    • C를 포함한 M의 인자
    • C의 인스턴스 변수
    • (M이나 M이 호출하는 함수로 '생성된' 객체, 전역변수는 M의 인자로 취급한다)

cf) M이 호출한 메서드로 생성된 객체와 메서드가 접근하게 해주는 객체를 구분해야 한다.

  • 생성된 객체는 메서드를 호출하면서 새롭게 생성된 것으로 어떠한 객체 안에 있던 것이 아니므로 객체의 내부 구조를 노출하는 것이 아니다.
  • 하지만 객체에 접근하게 해주는 것은 기존에 있던 객체를 반환하는 것으로 어떠한 객체의 내부 구조를 바깥으로 노출하는 것이 되고 이를 이용하는 것은 객체의 내부에 있는 객체에 직접 접근하는 것이 되므로 바람직하지 않다.

기차 충돌

아래와 같은 코드를 train wreck이라고 부른다.

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

일반적으로 이런 코드는 조잡해 보이기 쉬우므로 피하는 것이 좋다(메서드 체이닝?)

그러면 아래처럼 분리하면 문제가 해결되는가 했을 때 마냥 그렇지도 않다.

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

ctxt, Options, ScratchDir 가 자료 구조라면 디미터 법칙이 적용되지 않을 것이므로 위와 같은 코드를 써도 디미터 법칙을 위배하는 것은 아닐 것이다. (물론 그렇다고 해서 코드의 유지 보수 측면에서 바람직하다고 볼 수 있을지는 조금 고민이 필요하지 않을까 생각..)

하지만 자료 구조가 아니고 객체라면 메서드가 반환하는 객체가 내부 구조를 노출하는 것이 아닌지 생각해봐야 한다.

반환하는 객체가 메서드에 의해 생성된 객체라면 그렇지 않으므로 디미터 법칙을 위배하지 않겠지만 기존에 있던 객체라면 법칙을 위배하는 것이 된다.

애초에 자료 구조는 공개된 멤버 변수로만 접근하고 객체는 비공개 변수와 이를 사용하는 공개 함수로 이용한다면 구분이 쉬울 것이다. 하지만 단순한 자료 구조에도 getter와 setter를 요구하는 프레임워크와 표준 등이 있으므로 단순하게 구분하기 쉽지 않다.

잡종 구조

바로 위에서 말했듯이 절반은 객체, 절반은 자료 구조인 잡종 구조가 존재한다.

잡종 구조는 중요한 기능을 수행하는 함수도 있고, 공개 변수나 공개 getter, setter도 있다.

공개 getter, setter는 비공개 변수를 그대로 사용할 수 있게 해주므로 다른 메서드가 절차적 프로그래밍의 자료 구조 접근 방식처럼 비공개 변수를 사용할 유혹에 쉽게 빠지게 한다.

이러한 잡종 구조는 새 함수 추가도 어렵고 새 자료 구조 추가도 어려우므로 피하자.

이는 단순히 함수나 타입을 보호할지 공개할지 결정 못해서 어중간하게 짠 설계일 뿐이다.

구조체 감추기

만약 ctxt, opts, scratchDir이 객체라면 코드를 고치긴 고쳐야 한다.

임시디렉토리의 절대 경로를 얻기 위해 다음처럼 고칠 수도 있다.

ctxt.getAbsolutePathOfScratchDirectoryOption();
ctxt.getScratchDirectoryOption()getAbsolutePath();

첫번째 방법 같은 것을 쓰면 메서드가 너무 많아질 경향이 있다. 두번째 방법도 바람직해보이지는 않는다.

생각해 볼 점은 ctxt가 객체라면 뭔가를 하라고 말해야지 속을 드러내라고 하면 안 된다.

절대 경로를 얻기 위한 이유를 살펴 보면 ctxt에 무엇을 시킬 수 있을지 알 수 있다.

위 코드를 가져온 모듈을 살펴 보면 임시 디렉토리의 절대 경로를 얻어서 임시 파일을 생성하려 한다는 것을 알 수 있다.

그러면 ctxt객체에 직접 임시 파일을 생성하라고 시키는 솔루션도 생각해 볼 수 있다.

BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);

자료 전달 객체

자료 전달 객체(Data Transfer Object; DTO)는 공개 변수만 있고 함수가 없는 클래스다.

DTO는 데이터베이스와 통신하거나 소켓에서 받은 메시지의 구문을 분석할 때 유용하다.

또한 데이터베이스에 저장된 가공되지 않은 정볼르 애플리케이션 코드에서 사용할 객체로 변환하는 일련의 단계에서 가장 처음으로 사용하는 구조체다.

보다 일반적으로는 비공개 변수를 getter, setter로 조작하는 bean 구조지만 이는 껍데기만 존재하는 캡슐화로 별다른 이익을 제공하지 않는다.

활성 레코드

활성 레코드는 DTO의 특수한 형태로 공개 변수가 있거나 비공개 변수에 getter/setter가 있는 형태이고, 이에 더해 savefind같은 탐색 함수도 제공한다.

활성 레코드는 데이터베이스 테이블이나 다른 소스에서 자료를 직접 반환한 결과다.

활성 레코드에 비즈니스 규칙 메서드를 추가해서 객체처럼 취급하는 경우도 많다. 하지만 그러면 자료 구조도 아니고 객체도 아닌 잡종 구조가 나오므로 바람직하지 않다.

대신 활성 레코드는 자료 구조로 취급하고, 비즈니스 규칙을 담고 내부 자료를 숨기는 객체는 따로 생성하자. 이때 내부 자료는 활성 레코드의 인스턴스일 가능성이 크다.

'클린코드' 카테고리의 다른 글

9장. Unit Test  (0) 2021.10.28
7. 오류 처리  (0) 2021.10.12
5. 형식 맞추기  (0) 2021.10.06
4. 주석  (0) 2021.09.27
3. 함수  (0) 2021.09.23