변수를 비공개로 두는 이유는 문법적으로 감추기 위해서가 아니다. 외부 코드가 내부 표현에 의존하지 않게 만들어, 나중에 구현을 바꿔도 사용하는 쪽이 영향을 덜 받게 하기 위해서다. 그래서 모든 필드에 getter와 setter를 붙였다고 캡슐화가 되는 것은 아니다. 오히려 그런 관행은 비공개 변수를 공개 변수와 다를 바 없게 만들어 캡슐화를 무너뜨린다. 중요한 것은 객체가 어떤 자료를 갖는가가 아니라 외부에 어떤 의미 있는 동작을 제공하는가다.
자료 추상화
구현을 감추려면 자료를 그대로 드러내는 대신 추상적인 인터페이스를 제공해야 한다.
// before — 내부 표현(직교 좌표)을 그대로 노출한다
public class Point {
public double x;
public double y;
}// after — "무엇을 알 수 있는가"만 드러내고 표현은 감춘다
public interface Point {
double getX();
double getY();
void setCartesian(double x, double y);
double getR();
double getTheta();
void setPolar(double r, double theta);
}두 번째 설계는 자료가 직교 좌표로 저장되는지 극좌표로 저장되는지 드러내지 않는다. 사용자는 좌표계를 골라 접근할 뿐이고, 내부 표현은 언제든 바꿀 수 있다. 판단 기준: 내부 필드에 함수를 한 겹 씌우는 것으로는 추상화가 되지 않는다. “저장 방식”이 아니라 “사용자가 다뤄야 자연스러운 개념”을 인터페이스로 삼아야 진짜 추상화다.
자료/객체 비대칭성
객체와 자료 구조는 정반대 방향의 유연성을 준다. 이 비대칭이 이 장의 핵심이다.
- 자료 구조 + 절차적 코드: 기존 자료 구조를 그대로 둔 채 새 함수를 추가하기 쉽다. 대신 새 자료 구조(타입)를 추가하면 그 자료를 다루는 모든 함수를 고쳐야 한다.
- 객체 + 다형성: 기존 동작을 건드리지 않고 새 타입을 추가하기 쉽다. 대신 모든 타입에 새 동작을 추가하려면 모든 클래스를 열어야 한다.
절차적 도형 코드는 이런 모습이다.
public class Square { public Point topLeft; public double side; }
public class Rectangle { public Point topLeft; public double height, width; }
public class Circle { public Point center; public double radius; }
public class Geometry {
public double area(Object shape) throws NoSuchShapeException {
if (shape instanceof Square s) return s.side * s.side;
if (shape instanceof Rectangle r) return r.height * r.width;
if (shape instanceof Circle c) return Math.PI * c.radius * c.radius;
throw new NoSuchShapeException();
}
}여기에 perimeter()를 추가하기는 쉽다 — Geometry에 함수 하나만 더하면 되고 도형 클래스는 그대로다. 하지만 Triangle을 추가하면 area, perimeter 등 도형을 훑는 모든 함수를 고쳐야 한다. 다형 방식에서는 계산 책임이 각 도형 안으로 들어간다.
public class Square implements Shape {
private Point topLeft;
private double side;
public double area() { return side * side; }
}이제 Triangle을 추가하는 것은 새 클래스 하나로 끝난다 — 기존 도형은 건드리지 않는다. 대신 모든 도형에 perimeter()를 추가하려면 모든 클래스를 열어야 한다. 어느 쪽이 옳은지는 변경이 어느 방향으로 자주 일어나는가로 정한다.
flowchart TD Q{"변경이 주로 어느 방향으로 일어나는가?"} Q -->|"새 타입 추가가 잦다"| OBJ["객체 + 다형성<br/>타입 추가에 강함<br/>동작 추가에 약함"] Q -->|"새 동작(함수) 추가가 잦다"| DS["자료 구조 + 절차적 코드<br/>함수 추가에 강함<br/>타입 추가에 약함"] OBJ --> ON["함정: 모든 타입에<br/>새 동작을 넣으려면<br/>모든 클래스를 열어야 함"] DS --> DN["함정: 새 타입을 넣으면<br/>훑는 함수를 전부 고쳐야 함"]
함정: “객체지향이 항상 옳다”는 믿음으로 자료 구조에까지 다형성을 억지로 씌우면, 함수 하나 추가할 때마다 인터페이스와 모든 구현체를 함께 수정하는 역효과가 난다. 절차적 코드가 더 단순한 상황이 분명히 있다.
디미터 법칙과 기차 충돌
디미터 법칙은 모듈이 자신이 조작하는 객체의 내부 사정을 너무 많이 알면 안 된다는 원칙이다. 메서드 f는 자기 클래스, 자기가 만든 객체, 인수로 받은 객체, 자기 필드 객체의 메서드까지만 호출해야 한다. 그 이상으로 반환된 객체를 타고 들어가면 호출자가 남의 내부 구조까지 아는 셈이 된다.
// 기차 충돌 — ctxt 는 Options, ScratchDir 의 구조까지 호출자에게 노출한다
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();중간 변수로 풀어 쓴다고 문제가 사라지지는 않는다. 여전히 호출자는 세 단계의 내부 구조를 안다.
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
final String outputDir = scratchDir.getAbsolutePath();핵심 질문은 “경로를 어떻게 얻을 것인가”가 아니라 “이 객체에게 무엇을 시킬 것인가”다. 경로를 꺼내는 이유가 결국 임시 파일 스트림을 만들기 위해서라면, 그 일을 ctxt에게 통째로 맡긴다.
// ctxt 에게 "무엇을 할지"를 시킨다 — 내부 구조는 ctxt 안에 숨는다
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);이제 Options나 ScratchDir의 구조가 바뀌어도 호출자는 영향받지 않는다. 판단 기준: 반환값에서 다시 메서드를 부르는 사슬(a.getB().getC().doX())이 보이면 디미터 위반 신호다. 함정: 다만 대상이 순수 자료 구조라면 이야기가 다르다. 자료 구조는 애초에 내부를 드러내는 것이 목적이므로 record.field.subField처럼 값을 파고드는 것은 위반이 아니다. 디미터 법칙은 동작을 숨겨야 할 객체에 적용되는 규칙이다.
잡종 구조와 DTO
객체와 자료 구조를 어설프게 섞으면 잡종(hybrid)이 된다. 공개 변수(또는 무분별한 getter/setter)로 내부 자료를 드러내면서 동시에 비즈니스 로직까지 담은 구조가 그렇다. 이런 잡종은 새 자료 구조를 추가하기도, 새 동작을 추가하기도 어렵다 — 두 방식의 단점만 합친 꼴이다.
DTO는 공개 변수만 있거나 단순 조회/설정 함수만 있는 자료 전달용 구조다. 이것은 나쁜 것이 아니라 용도가 분명한 것이다. DB 결과를 옮기거나 계층 사이에서 값을 나를 때는 DTO가 자연스럽다. 활성 레코드는 DTO에 save, find 같은 영속성 메서드를 더한 형태로, 역시 자료 구조로 취급하는 편이 낫다. 함정: 활성 레코드에 비즈니스 규칙을 계속 얹기 시작하면 자료 구조와 객체의 경계가 흐려져 잡종이 된다. 규칙은 별도의 객체로 빼고, 활성 레코드는 자료로 남긴다.
마무리
- 객체는 자료를 숨기고 동작을 공개한다. 자료 구조는 동작 없이 자료를 드러낸다. 둘은 정반대다.
- 자료/객체 비대칭성: 타입 추가가 잦으면 객체, 동작 추가가 잦으면 자료 구조가 유리하다. 한쪽이 항상 옳지 않다.
- 디미터 법칙은 객체(동작을 숨기는 것)에 적용된다. 자료 구조를 파고드는 것은 위반이 아니다.
- 기차 충돌은 “무엇을 얻을까” 대신 “무엇을 시킬까”로 바꾸면 풀린다.
- 두 방식을 섞은 잡종 구조는 두 방식의 단점만 합친다. 변경의 방향에 맞는 한쪽을 고른다.
다음장으로 7장