자료 추상화
다음 두 클래스를 보자.
// 1
public class Point {
public double x;
public double y;
}
// 2
public interface Point {
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}
1번은 클래스 구현을 외부로 노출하고, 2번은 구현을 완전히 숨긴다. 둘 중 어떤 클래스가 '나은' 구현일까?
답은 2번이다. 자료를 세세하게 공개하기보다는 추상적인 개념으로 표현하는 게 좋다. 즉, 추상화가 중요하다. 그러나 사실 두 클래스 모두 좋은 구현이 아니다. 2번에서는 조회/설정 함수(getter/setter)가 존재하기 때문이다. 개발자는 객체가 포함하는 자료를 표현할 가장 좋은 방법을 심각하게 고민해야 한다.
자료/객체 비대칭
앞서 제공한 예시 중, 사실 1번은 자료 구조라고 할 수 있고, 2번은 객체라고 할 수 있다.
객체는 추상화 뒤로 자료를 숨긴 채 자료를 다루는 함수만 공개한다.
자료 구조는 자료를 그대로 공개하며 별다른 함수는 제공하지 않는다.
객체와 자료 구조의 정의는 상반된다는 것을 아는 것이 중요하다.
public class Square {
public Point topLeft;
public double side;
}
public class Circle {
public Point center;
public double radius;
}
public class Geometry {
public double area(Object shape) {
if (shape instanceof Square) {
Square s = (Square)shape;
return s.side * s.side;
}
else if (shape instanceof Circle) {
Circle c = (Circle)shape;
return PI * c.radius * c.radius;
}
}
}
위 코드를 보자. Square와 Circle은 외부로 정보를 공개하고 있는 자료 구조이다(객체가 아니다).
사실 위 코드는 절차지향적이라고도 할 수 있다. 여기서 중요한 특성 중 하나는, 새로운 Geometry에 행위(함수)를 추가하기가 쉽다는 것이다. 만약 둘레 길이를 구하는 periemeter() 함수를 추가하고 싶다면, 그저 추가하면 될 일이다. 자료 구조는 영향을 받지 않는다.
이번엔 객체지향적 코드를 보자.
public class Square implements Shape {
private Point topLeft;
private double side;
public double area() {
return side * side;
}
}
public class Circle implements Shape {
private Point center;
private double radius;
public double area() {
return PI * radius * radius;
}
}
Shape 인터페이스를 상속받고 있다. 확실하게 Square와 Circle은 자료 구조가 아닌 객체이다. 이 경우 새로운 perimeter() 라는 새로운 함수가 추가되어야 한다면?
Shape을 상속받는 모든 객체들(Square, Circle)에서 정의해줘야 한다.
이처럼 절차지향적 코드와 객체지향적 코드는 상호 보완적인 특징이 있다.
(자료 구조를 사용하는) 절차적인 코드는 기존 자료 구조를 변경하지 않으면서 새 함수를 추가하기 쉽다. 반면, 객체지향 코드는 기존 함수를 변경하지 않으면서 새 클래스를 추가하기 쉽다.
객체지향은 절차지향의 super set이 아니다. 문제 정의에 따라 객체지향이 필요한 경우, 절차지향이 필요한 경우가 있다.
디미터 법칙
디미터 법칙은 모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다는 법칙이다.
조금 더 정확하게 표현하자면, 디미터 법칙은 "클래스 C의 메서드 f는 다음과 같은 객체의 메서드만 호출해야 한다"고 주장한다.
1. 클래스 C
class C {
public void f() {
C test = new C();
test.doSomething(); // OK
}
}
2. f가 생성한 객체
class C {
public void f() {
B test = new B();
test.doSomething(); // OK
}
}
3. f 인수로 넘어온 객체
class C {
public void f(B test) {
test.doSomething(); // OK
}
}
4. C 인스턴스 변수에 저장된 객체
class C {
private B test = new B();
public void f() {
test.doSomething(); // OK
}
}
즉, 낯선 사람은 경계하고 친구랑만 놀라는 의미이다.
다음 코드는 디미터 법칙을 어기는 대표적인 예시이다.
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
- 기차 충돌
위와 같은 코드를 흔히 기차 충돌이라고 부른다. 여러 객차가 한 줄로 이어진 기차처럼 보이기 때문이다.
그렇다면 디미터 법칙을 위반하지 않도록 하려면 어떻게 할까?
많은 사람들이 다음과 같이 생각하기 쉽다.
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();
디미터 법칙의 정의를 다시 생각해보자. 만약 ctxt, opts, scratchDir이 자료 구조가 아닌 객체라면 여전히 디미터 법칙을 위반한다.
그렇다고 해서 디미터 법칙을 준수하게 위해 이들을 자료 구조로 개방할 수는 없는 노릇이다. 그렇게 되면 객체와 자료 구조가 뒤섞인 잡종 구조가 된다.
그럼 해결 방법은 무엇일까? 객체에게 무언가를 하라고 말해야지, 속을 드러내라고 말하면 안된다.
ctxt.getAbsolutePathOfScratchDirectoryOption(); // 1번 방법
1번 방법은 어떤가? 디미터의 법칙을 위반하지는 않지만, ctxt 객체에 공개해야 하는 메서드가 너무 많아진다.
이때는 코드의 목적을 살펴봐야 한다. 위 예제는 임시 파일을 생성하기 위한 절차이다. 따라서 ctxt에게 다음과 같은 행동을 시키면 된다.
ctxt.createScratchFileStream(classFileName);
이를 통해 ctxt는 내부 구조를 드러내지 않으며, 모듈에서 해당 함수는 자신이 몰라야 하는 여러 객체를 탐색할 필요가 없다.
자료 전달 객체
흔히 DTO라고 말하기도 한다. 일반적인 형태는 빈(bean) 구조다. 빈은 비공개 변수를 조회/설정 함수로 조작한다. 일종의 사이비 캡슐화로, 캡슐화의 이점은 없다.
- 활성 레코드
활성 레코드는 DTO의 특수한 형태다. save나 find같은 탐색 함수도 제공한다. 이 역시 자료 구조이다. 객체로 생각하면 안된다.
'클린 코드' 카테고리의 다른 글
[클린 코드] 8장: 경계 (0) | 2022.12.03 |
---|---|
[클린 코드] 7장: 오류 처리 (0) | 2022.12.02 |
[클린 코드] 5장: 형식 맞추기 (0) | 2022.11.29 |
[클린 코드] 4장: 주석 (0) | 2022.11.28 |
[클린 코드] 3장: 함수 (0) | 2022.11.27 |