객체지향의 본질은 클래스가 아니라 메시지다. 클래스는 메시지를 처리하기 위해 나중에 정해지는 구현 수단일 뿐이다. 이 장은 시선을 클래스에서 협력으로 옮긴다 — 객체들이 어떤 메시지를 주고받아야 유연한 협력이 되는가. 5장이 “책임을 누구에게 줄까”였다면 6장은 “그 책임을 어떤 메시지로 요청할까”다.

디미터 법칙 — 기차 충돌을 멈춰라

디미터 법칙은 “낯선 자에게 말하지 말라”로 요약된다. 객체는 자신과 직접 아는 이웃하고만 대화해야 한다. 이웃이 데리고 온 객체의 내부까지 파고들면 a.getB().getC().doSomething() 같은 **기차 충돌(train wreck)**이 생긴다.

// before — Screening의 내부 구조를 밖에서 헤집는다
screening.getMovie().getDiscountConditions().get(0).getStartTime();
 
// after — Screening에게 묻는 대신, 필요한 것을 시킨다
screening.calculateFee(audienceCount);

기차 충돌이 위험한 건 호출하는 쪽이 Screening → Movie → DiscountCondition으로 이어지는 내부 구조 전체에 결합되기 때문이다. 중간의 어느 한 구조만 바뀌어도 이 한 줄이 깨진다. 디미터 법칙을 지키면 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);
}

isSatisfiedByPeriodisSatisfiedBySequence는 같은 의도(할인 조건을 만족하는가)를 서로 다른 이름으로 부른다. 그래서 둘을 하나의 타입으로 묶을 수 없다. 이름에서 구현(Period, Sequence)을 지우고 의도(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()처럼 관례적으로 굳어 둘을 겸하는 메서드도 있다. 원칙은 절대 규칙이 아니라 “예측 가능성을 지불하고 편의를 산다”는 거래임을 알고 어긴다.

정리

  • 객체지향의 중심은 클래스가 아니라 메시지다. 협력을 먼저 설계하고 클래스는 그것을 처리할 구현으로 나중에 정한다.
  • 디미터 법칙: 낯선 객체의 내부를 헤집지 않는다. 기준은 점의 개수가 아니라 “남의 내부 구조에 의존하는가”다.
  • 묻지 말고 시켜라: 상태를 꺼내 밖에서 판단하지 말고, 정보를 가진 객체에게 판단과 처리를 함께 맡긴다.
  • 의도를 드러내는 인터페이스: 이름에서 구현을 지우고 의도만 남기면 다형적 대체가 가능해진다. 이름은 클라이언트 관점에서 짓는다.
  • 명령과 쿼리를 분리하면 쿼리가 참조 투명해져 예측·조합·테스트가 쉬워진다.

다음장으로 7장