변수를 비공개로 두는 이유는 단순히 문법적으로 감추기 위해서가 아니다. 외부 코드가 내부 표현에 의존하지 않게 만들고, 나중에 구현을 바꾸더라도 사용하는 쪽의 영향을 줄이기 위해서다. 그래서 getter와 setter를 붙였다고 자동으로 캡슐화가 되는 것은 아니다. 중요한 것은 객체가 어떤 자료를 갖고 있는지가 아니라, 외부에 어떤 의미 있는 동작을 제공하는지다.
자료 추상화
구현을 감추려면 자료를 그대로 드러내는 대신 추상적인 인터페이스를 제공해야 한다. 예를 들어 좌표값을 그대로 반환하는 클래스는 내부 표현을 노출한다. 반대로 “이 점은 직교 좌표계 기준으로 어디에 있는가”, “극좌표계 기준으로 어디에 있는가”처럼 사용자가 필요한 개념을 제공하면 내부 구현을 바꿀 여지가 생긴다.
객체를 설계할 때는 자료를 어떻게 저장할지보다, 사용자가 어떤 방식으로 다뤄야 자연스러운지를 먼저 생각해야 한다. 내부 필드에 함수를 한 겹 씌우는 것만으로는 추상화가 되지 않는다.
객체와 자료 구조의 차이
객체와 자료 구조는 서로 다른 방향의 유연성을 제공한다. 절차적인 코드는 기존 자료 구조를 그대로 둔 채 새 함수를 추가하기 쉽다. 반대로 새로운 자료 구조를 추가하려면 그 자료를 다루는 모든 함수를 고쳐야 한다. 객체지향 코드는 기존 동작을 유지하면서 새 타입을 추가하기 쉽지만, 모든 객체에 새 동작을 추가하려면 각 클래스를 수정해야 한다.
절차적인 접근은 이런 모습에 가깝다.
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 double area(Object shape) throws NoSuchShapeException {
if (shape instanceof Square) {
Square s = (Square) shape;
return s.side * s.side;
}
if (shape instanceof Rectangle) {
Rectangle r = (Rectangle) shape;
return r.height * r.width;
}
if (shape instanceof Circle) {
Circle c = (Circle) shape;
return Math.PI * c.radius * c.radius;
}
throw new NoSuchShapeException();
}
}새로운 perimeter 함수를 추가하기는 쉽지만, Triangle 같은 새 도형을 추가하면 Geometry의 여러 함수가 함께 바뀐다. 객체지향 방식에서는 계산 책임이 각 도형 안으로 들어간다.
public class Square implements Shape {
private Point topLeft;
private double side;
public double area() {
return side * side;
}
}이 경우 새 도형을 추가하기는 쉽다. 대신 모든 도형에 새로운 연산을 추가해야 한다면 각 클래스를 열어야 한다. 어느 쪽이 더 깨끗한지는 상황에 따라 달라진다. 변화가 주로 타입 추가로 일어나는지, 동작 추가로 일어나는지를 보고 선택해야 한다.
디미터 법칙
디미터 법칙은 모듈이 자신이 조작하는 객체의 내부 사정을 너무 많이 알면 안 된다는 원칙이다. 객체는 내부 구조를 숨기고 동작을 제공해야 하므로, 연쇄 호출로 깊숙한 내부 객체를 꺼내 쓰는 코드는 위험하다.
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();이 코드는 ctxt뿐 아니라 Options, ScratchDir의 구조까지 호출자가 알고 있음을 드러낸다. 단순히 중간 변수를 둔다고 문제가 사라지지는 않는다.
Options opts = ctxt.getOptions();
File dir = opts.getScratchDir();
final String outputDir = dir.getAbsolutePath();핵심은 “경로를 어떻게 얻을 것인가”가 아니라 “이 객체에게 무엇을 시킬 것인가”다. 임시 파일 스트림이 필요하다면, 경로를 꺼내 조립하기보다 ctxt에게 그 일을 맡기는 쪽이 더 객체답다.
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);객체와 자료 구조를 어설프게 섞으면 새 자료 구조도, 새 동작도 추가하기 어려운 잡종 구조가 된다. 내부 자료는 공개하면서 비즈니스 로직도 함께 넣는 식의 설계가 특히 그렇다.
DTO와 활성 레코드
DTO는 공개 변수만 있거나 단순 조회·설정 함수만 있는 자료 전달용 구조다. 이런 구조는 나쁜 것이 아니라 용도가 분명할 뿐이다. 데이터베이스 결과를 옮기거나 계층 사이에서 값을 전달할 때는 DTO가 자연스럽다. 다만 getter와 setter를 붙였다고 객체가 되는 것은 아니다.
활성 레코드는 DTO와 비슷하지만 save, find 같은 영속성 메서드를 함께 제공한다. 이것도 자료 구조로 취급하는 편이 낫다. 여기에 비즈니스 규칙을 계속 넣기 시작하면 자료 구조와 객체의 경계가 흐려지고, 변경하기 어려운 구조가 되기 쉽다.
마무리
객체는 자료를 숨기고 동작을 공개한다. 자료 구조는 동작 없이 자료를 드러낸다. 새 타입을 자주 추가해야 한다면 객체가 유리하고, 새 동작을 자주 추가해야 한다면 절차적인 코드가 더 단순할 수 있다. 중요한 것은 한쪽이 항상 옳다고 믿는 것이 아니라, 변경의 방향에 맞는 구조를 선택하는 일이다.
다음장으로 7장