객체지향의 본질은 클래스가 아니라 메시지다. 클래스는 메시지를 처리하기 위해 나중에 정해지는 구현 수단일 뿐이다. 이 장은 시선을 클래스에서 협력으로 옮긴다 — 객체들이 어떤 메시지를 주고받아야 유연한 협력이 되는가. 5장이 “책임을 누구에게 줄까”였다면 6장은 “그 책임을 어떤 메시지로 요청할까”다.
용어를 먼저 정렬한다
협력을 이야기하려면 몇 가지 용어를 정확히 구분해야 한다. 이들은 자주 뭉뚱그려지지만, 이 장의 논의는 그 차이 위에 서 있다.
- 메시지(message): 객체가 다른 객체에게 협력을 요청하는 유일한 수단.
오퍼레이션 이름 + 인자로 이뤄진다. - 오퍼레이션(operation): 객체가 외부에 제공하는, 추상적인 행동의 명세. “무엇을 하는가”만 있고 “어떻게”는 없다.
- 메서드(method): 그 오퍼레이션을 실제로 구현한 코드. 하나의 오퍼레이션이 다형적으로 여러 메서드로 구현될 수 있다.
- 퍼블릭 인터페이스(public interface): 객체가 외부에 노출하는 오퍼레이션들의 집합.
- 시그니처(signature): 오퍼레이션의 이름과 매개변수 목록. 메시지를 보내려면 시그니처를 알아야 한다.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening); // 오퍼레이션(명세) + 시그니처
}
// 아래 두 클래스는 같은 오퍼레이션의 서로 다른 '메서드'다
class PeriodCondition implements DiscountCondition { public boolean isSatisfiedBy(Screening s) { ... } }
class SequenceCondition implements DiscountCondition { public boolean isSatisfiedBy(Screening s) { ... } }
// 실제로 오가는 것은 '메시지': condition.isSatisfiedBy(screening)이 구분이 중요한 이유는, 이 장의 원칙들이 퍼블릭 인터페이스의 품질을 다루기 때문이다. 좋은 인터페이스는 최소한의 오퍼레이션만 노출하고(최소 인터페이스), 구현이 아니라 의도를 드러내며(추상적 인터페이스), 사용법을 예측 가능하게 만든다. 아래 네 원칙 — 디미터 법칙, 묻지 말고 시켜라, 의도를 드러내는 인터페이스, 명령-쿼리 분리 — 은 모두 이 품질을 높이는 서로 다른 각도다.
판단 기준: “이 객체가 밖에 무엇을 노출하는가”를 오퍼레이션의 집합으로 그려 본다. 게터가 대부분이면 데이터를 노출하는 것이고, 의도가 담긴 명령이 대부분이면 행동을 노출하는 것이다. 함정: 메서드(구현)를 먼저 떠올리면 인터페이스가 구현에 오염된다. 오퍼레이션(명세)을 먼저 정하고 메서드는 나중에 채운다.
디미터 법칙 — 기차 충돌을 멈춰라
디미터 법칙은 “낯선 자에게 말하지 말라”로 요약된다. 객체는 자신과 직접 아는 이웃하고만 대화해야 한다. 이웃이 데리고 온 객체의 내부까지 파고들면 a.getB().getC().doSomething() 같은 **기차 충돌(train wreck)**이 생긴다.
// before — Screening의 내부 구조를 밖에서 헤집는다
screening.getMovie().getDiscountConditions().get(0).getStartTime();
// after — Screening에게 묻는 대신, 필요한 것을 시킨다
screening.calculateFee(audienceCount);기차 충돌이 위험한 건 호출하는 쪽이 Screening → Movie → DiscountCondition으로 이어지는 내부 구조 전체에 결합되기 때문이다. 중간의 어느 한 구조만 바뀌어도 이 한 줄이 깨진다. Screening이 Movie를 필드가 아니라 다른 방식으로 참조하도록 바뀌거나, Movie가 discountConditions를 List가 아닌 Map으로 바꾸거나, DiscountCondition이 startTime을 다른 이름으로 바꾸면 — 이 세 가지 서로 다른 변경 중 어느 것이든 저 한 줄을 무너뜨린다. 즉 이 한 줄은 세 클래스의 내부 결정에 동시에 볼모로 잡혀 있다.
// 기차 충돌의 확장 — 조회 결과에 계속 메시지를 이어 보내며 남의 내부로 파고든다
LocalTime start = screening.getMovie() // Screening의 내부(Movie)를 꺼내고
.getDiscountConditions() // Movie의 내부(List)를 꺼내고
.get(0) // List의 내부(첫 원소)를 꺼내고
.getStartTime(); // DiscountCondition의 내부까지 꺼낸다디미터 법칙을 지키면 Screening의 내부가 어떻게 생겼든 호출하는 쪽은 영향받지 않는다.
디미터 법칙을 조금 더 정밀하게 말하면, 어떤 객체의 메서드 안에서 메시지를 보내도 되는 대상은 다음 네 부류로 한정된다 — (1) this 객체, (2) 메서드의 매개변수, (3) this의 인스턴스 변수(속성), (4) 메서드 안에서 직접 생성한 지역 객체. 이 네 부류를 벗어난 객체, 특히 다른 메시지의 반환값으로 얻은 객체에게 다시 메시지를 보내는 순간 법칙을 어긴다. 반환값에 또 메시지를 보낸다는 것은, 그 반환 객체의 타입과 구조를 이쪽이 안다는 뜻이기 때문이다.
디미터 법칙을 지키는 코드는 결과적으로 “부드러운 표현(skinny expression)” 을 가진다. 클래스 밖으로 나가는 표현식에 오직 하나의 도트(.)만 있는 상태다. screening.calculateFee(count)처럼 하나의 메시지로 끝나면, 그 뒤에 이어지는 내부 구조를 밖에서 알 필요가 없다. 물론 도트의 개수 자체가 판정 기준은 아니지만, 부드러운 표현은 디미터 법칙을 지켰다는 좋은 징후다.
법칙을 지키는 흔한 방법은 중간 객체를 대신 물어봐 주는 위임 메서드(wrapper method) 를 두는 것이다. Screening이 getMovie().getFee()를 밖에서 하게 두는 대신, Screening에 getMovieFee()를 두어 안에서 movie.getFee()를 호출한다. 밖에서는 Screening에게만 말하고, Movie를 향한 도트는 Screening 내부로 숨는다.
판단 기준: 메서드 안에서 접근해도 되는 대상은 (1) this 자신, (2) 메서드의 인자, (3) this의 필드, (4) 메서드 안에서 생성한 객체뿐이다. 이 범위 밖의 객체가 리턴한 객체에 다시 메시지를 보내면 위반이다. 함정: .의 개수만 세면 안 된다. IntStream.range().mapToObj().collect() 같은 흐름은 점이 많아도 하나의 표현을 이어가는 것이지 남의 내부를 뒤지는 게 아니다. 기준은 “낯선 객체의 내부에 의존하는가”이지 “점이 몇 개인가”가 아니다.
묻지 말고 시켜라
기차 충돌의 뿌리는 대개 “물어보는” 스타일이다. 객체의 상태를 꺼내 와서(get) 호출하는 쪽이 판단하고 결정한다. 대신 그 판단을 정보를 가진 객체에게 시켜야(Tell, Don’t Ask) 한다.
// before — 상태를 물어보고 밖에서 판단한다
if (account.getBalance() >= amount) {
account.setBalance(account.getBalance() - amount);
}
// after — 판단과 처리를 계좌에게 시킨다
account.withdraw(amount);withdraw는 잔액 확인과 차감을 자기 안에 캡슐화한다. 밖에서는 “출금해”라고 의도만 전한다. 이렇게 하면 잔액 규칙(마이너스 통장 허용 여부 등)이 바뀌어도 호출하는 쪽은 그대로다. 상태를 묻는 게터가 줄어드는 만큼 캡슐화가 단단해진다.
“묻지 말고 시켜라”와 디미터 법칙은 같은 병의 다른 증상이다. 상태를 물어보는 스타일이 굳어지면, 물어본 값을 또 다른 객체에게 넘겨 또 물어보게 되고, 그것이 기차 충돌로 자란다. 반대로 판단을 정보 가진 객체에게 시키기 시작하면, 물어볼 일 자체가 사라져 도트가 짧아진다. 두 원칙은 함께 캡슐화를 지킨다.
이 원칙을 따르다 보면 자연스럽게 객체의 인터페이스가 “무엇을 해달라”는 요청의 집합이 된다. 상태를 노출하는 게터가 줄고, 그 자리를 의도를 담은 명령이 채운다. 인터페이스가 절차가 아니라 의도로 채워지는 것 — 이것이 다음 원칙으로 이어진다.
판단 기준: 게터로 꺼낸 값으로 곧바로 그 객체에 무언가를 다시 시킨다면, 그 둘을 합쳐 하나의 메시지로 만들 수 있다. 함정: 화면 표시나 직렬화처럼 상태 자체가 진짜 목적인 경우는 게터가 정당하다. “묻지 말고 시켜라”는 판단·행동을 밖으로 빼돌리지 말라는 것이지 게터 전면 금지가 아니다.
의도를 드러내는 인터페이스
메서드 이름은 두 가지 방식으로 지을 수 있다. 어떻게 하는지를 드러내거나, 무엇을 원하는지를 드러내거나. 구현을 드러내는 이름은 구현이 바뀌면 이름도 거짓이 된다.
// before — 구현(어떻게)을 이름에 박았다
public class PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening) { ... }
}
public class SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening) { ... }
}
// after — 의도(무엇을)만 남긴다 → 하나의 인터페이스로 대체 가능
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}isSatisfiedByPeriod와 isSatisfiedBySequence는 같은 의도(할인 조건을 만족하는가)를 서로 다른 이름으로 부른다. 그래서 둘을 하나의 타입으로 묶을 수 없다. 이름에서 구현(Period, Sequence)을 지우고 의도(isSatisfiedBy)만 남기자 다형적으로 대체 가능한 인터페이스가 생긴다. 5장에서 조건 타입을 다형성으로 흩을 수 있었던 것도 이 이름 덕분이다.
이 원칙에는 이름이 있다. 켄트 벡(Kent Beck)의 의도를 드러내는 선택자(intention revealing selector) 다 — 메서드 이름을 그 메서드가 “어떻게” 동작하는지가 아니라 “무엇을 하려는지”로 짓는다. isSatisfiedByPeriod는 “기간으로 검사한다”는 구현을 드러내고, isSatisfiedBy는 “만족하는가”라는 의도를 드러낸다. 구현을 지운 이름만이 서로 다른 구현을 하나의 인터페이스로 묶을 수 있다.
의도를 드러내는 인터페이스는 클라이언트의 관점에서 출발한다. 이것은 5장의 “메시지가 객체를 결정한다”와 정확히 맞닿는다. 클라이언트가 “이 협력에서 나는 무엇을 원하는가”를 기준으로 메시지 이름을 정하면, 그 이름은 자연히 구현에서 자유로운 의도가 된다. 서버가 자기 구현을 기준으로 이름 지으면 그 반대가 된다.
판단 기준: 메서드 이름에 자료구조·알고리즘·타입(ArrayList, Period, ByIndex)이 들어가면 구현을 드러낸 것이다. “이 메시지를 받는 객체가 무엇을 해주길 바라는가”를 클라이언트 관점에서 이름 붙인다. 함정: 의도를 드러내는 이름은 클라이언트가 정한다. 서버 객체를 만드는 사람이 자기 구현을 기준으로 이름 지으면 다시 구현이 샌다.
명령과 쿼리를 분리하라
메시지에는 두 종류가 있다. 상태를 바꾸는 **명령(command)**과 값을 돌려주는 쿼리(query). 하나의 메서드가 둘을 동시에 하면, 값을 얻으려는 호출이 몰래 상태를 바꿔 예측이 무너진다.
// before — getTotal()이 값을 돌려주면서 캐시를 갱신한다(부수효과)
public Money getTotal() {
this.total = items.stream().map(Item::getPrice).reduce(Money.ZERO, Money::plus);
return this.total; // 호출할 때마다 상태가 바뀐다
}
// after — 쿼리는 계산만, 명령은 상태 변경만
public Money total() { // 쿼리: 몇 번 불러도 같은 값, 부수효과 없음
return items.stream().map(Item::getPrice).reduce(Money.ZERO, Money::plus);
}
public void addItem(Item item) { // 명령: 상태만 바꾸고 값은 안 돌려줌
items.add(item);
}명령과 쿼리를 분리하면 쿼리는 참조 투명해진다 — 같은 입력에 늘 같은 값을 주고, 아무 때나 몇 번을 호출해도 부작용이 없다. 그래서 자유롭게 조합하고, 순서를 바꾸고, 캐시하고, 테스트할 수 있다. 부수효과는 명령에만 격리된다.
판단 기준: 값을 돌려주는 메서드가 필드를 대입하거나 컬렉션을 수정한다면 명령과 쿼리가 섞인 것이다. 쿼리는 void가 아니고 부수효과가 없어야, 명령은 void(또는 상태 변경이 본분)여야 한다. 함정: stack.pop()처럼 관례적으로 굳어 둘을 겸하는 메서드도 있다. 원칙은 절대 규칙이 아니라 “예측 가능성을 지불하고 편의를 산다”는 거래임을 알고 어긴다.
쿼리가 부수효과를 가질 때의 디버깅 지옥
마틴 파울러(Martin Fowler)는 명령-쿼리 분리를 설명하며 회의실 예약(Reservation) 예제를 든다. 예약 여부를 확인하는 것처럼 보이는 메서드가, 확인하는 김에 예약까지 해버리면 어떤 일이 벌어지는지 보자.
public class RecurringSchedule {
// 나쁜 설계 — 이름은 '이벤트를 반환'인데 몰래 예약 상태를 바꾼다
public List<Event> events(LocalDate from, LocalDate to) {
List<Event> events = expand(from, to);
reserveAll(events); // 숨은 부수효과! 조회했을 뿐인데 예약이 잡힌다
return events;
}
}이 설계의 무서운 점은, 버그가 그 메서드 안에 없다는 것이다. events()를 호출한 어떤 코드는 그저 “이번 달 일정을 화면에 보여주려” 조회했을 뿐이다. 그런데 조회하는 순간 모든 슬롯이 예약된다. 나중에 진짜 예약을 시도한 다른 사용자는 “이미 예약됨” 오류를 만난다. 오류가 난 곳은 예약 코드지만, 원인은 전혀 무관해 보이는 조회 코드다. 로그를 아무리 봐도 예약을 명령한 코드가 없다 — 조회가 예약을 했기 때문이다.
// 좋은 설계 — 쿼리와 명령을 갈라 놓으면 부수효과의 출처가 이름에 드러난다
public List<Event> events(LocalDate from, LocalDate to) { // 쿼리: 조회만
return expand(from, to);
}
public void reserve(List<Event> events) { // 명령: 예약만
reserveAll(events);
}쿼리에서 부수효과를 걷어내면, “상태를 바꾼 게 누구인가”라는 질문의 답이 항상 명령 메서드의 이름 안에 있다. 참조 투명한 쿼리는 몇 번을 호출하든, 어떤 순서로 호출하든 시스템 상태를 흔들지 않으므로 안심하고 조합할 수 있다. 디버깅이 지옥이 되는 것은 대개 “값을 얻으려는 호출이 몰래 상태를 바꿨을 때”다.
판단 기준: 어떤 메서드를 여러 번 불러도 안전하다고 자신 있게 말할 수 없다면, 그 메서드는 쿼리인 척하는 명령이다. 함정: 성능을 위한 캐시 갱신 같은 “선의의 부수효과”도 마찬가지로 위험하다. 캐시는 호출자에게 보이지 않아야 하며, 보인다면 그건 이미 명령이다.
원칙의 함정 — 무조건 금지가 아니다
지금까지의 원칙들은 강력하지만, 규칙으로 굳혀 기계적으로 적용하면 오히려 설계를 망친다. 특히 디미터 법칙과 “묻지 말고 시켜라”는 흔히 “게터를 절대 쓰지 마라” 로 오해된다. 그렇지 않다.
핵심은 4장에서 구분한 자료구조와 객체의 차이로 돌아간다. 디미터 법칙과 캡슐화 원칙은 객체, 즉 데이터를 숨기고 행동으로 협력하는 대상에 적용된다. 반면 순수하게 데이터를 나르는 것이 목적인 자료구조(DTO, 값 객체 등) 라면 내부 데이터를 드러내는 것이 오히려 옳다.
// 자료구조 — 데이터 노출이 곧 본분이다. 여기에 디미터 법칙을 들이대는 건 과잉이다
public record Point(int x, int y) {} // x, y를 꺼내 쓰라고 만든 것
double distance = Math.hypot(a.x() - b.x(), a.y() - b.y()); // 이건 위반이 아니다
// 객체 — 행동으로 협력하는 대상. 여기서 내부를 헤집으면 캡슐화가 무너진다
public class Account {
private Money balance;
public void withdraw(Money amount) { ... } // 상태는 숨기고 행동만 노출
}Point의 x(), y()를 꺼내 거리 계산에 쓰는 것은 디미터 법칙 위반이 아니다. Point는 좌표라는 데이터를 담아 나르는 자료구조이고, 그 데이터를 노출하는 것이 존재 이유다. 반대로 Account의 잔액을 꺼내 밖에서 차감 판단을 하는 것은 위반이다. Account는 잔액 규칙이라는 행동을 책임지는 객체이기 때문이다.
판단 기준: 그 타입이 “데이터를 나르려고” 있는가, “행동으로 협력하려고” 있는가를 먼저 정한다. 자료구조라면 노출이 맞고, 객체라면 은닉이 맞다. 함정: 모든 클래스에 원칙을 획일적으로 적용하면, DTO에 억지로 행동을 붙이거나 도메인 객체의 데이터를 부주의하게 흘리는 두 가지 실수를 동시에 저지른다. 원칙은 대상의 성격에 따라 달리 적용한다.
정리
- 객체지향의 중심은 클래스가 아니라 메시지다. 협력을 먼저 설계하고 클래스는 그것을 처리할 구현으로 나중에 정한다.
- 메시지·오퍼레이션·메서드·퍼블릭 인터페이스·시그니처를 구분한다. 이 장의 원칙은 모두 퍼블릭 인터페이스의 품질을 높이는 서로 다른 각도다.
- 디미터 법칙: 낯선 객체의 내부를 헤집지 않는다. 기준은 점의 개수가 아니라 “남의 내부 구조에 의존하는가”다. 부드러운 표현과 위임 메서드가 그 실천 도구다.
- 묻지 말고 시켜라: 상태를 꺼내 밖에서 판단하지 말고, 정보를 가진 객체에게 판단과 처리를 함께 맡긴다. 디미터 법칙과 같은 병의 다른 증상이다.
- 의도를 드러내는 인터페이스: 이름에서 구현을 지우고 의도만 남기면 다형적 대체가 가능해진다. 켄트 벡의 의도를 드러내는 선택자다. 이름은 클라이언트 관점에서 짓는다.
- 명령과 쿼리를 분리하면 쿼리가 참조 투명해져 예측·조합·테스트가 쉬워진다. 쿼리가 몰래 상태를 바꾸면 디버깅이 지옥이 된다.
- 이 원칙들은 규칙이 아니다. 자료구조라면 데이터 노출이 옳고, 객체라면 은닉이 옳다. 대상의 성격에 따라 달리 적용한다.
다음장으로 7장