여태까지 다뤘던 객체 간의 협력을 클라이언트-서버 모델로 표현할 수 있다.
*즉, 메시지는 추상적이지만 메서드는 명확한 실체가 존재한다.
이외에도 몇가지 내용을 지키면 퍼블릭 인터페이스 품질을 올릴 수 있음
이 설명이 이해하기 어려우면 아래의 조건을 만족할 때만 인스턴스에 메시지를 전송하도록 프로그래밍해야 한다.
디미터 법칙을 따르면 부끄럼타는 코드(shy code) 작성 가능.
단, 과하게 지키게 되면 결합도가 낮아지지만 응집도도 낮아질 가능성이 있음
인터페이스는 객체가 어떻게 하는지가 아니라 무엇을 하는지 서술한다.
⇒ 결론은 객체에게 묻지 말고 시키되 구현 방법이 아닌 클라이언트의 의도를 드러내야 한다. 그래야 이해하기 쉽고 유연하며 협력적인 객체를 작성할 수 있다.
앞서 본 세개의 원칙을 이해하기 위해서 셋 다 안 지킨 코드를 확인했다
public void enter(Audience audience) {
if (audience.getBag().hasInvitation()) {
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().setTicket(ticket);
} else {
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().minusAmount(ticket.getFee());
ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
audience.getBag().setTicket(ticket);
}
}
묻지 말고 시켜라
인터페이스에 의도를 드러내자
TicketSeller.setTicket()
sellTo()
Audience.setTicket()
buy()
Bag.setTicket()
hold()
⇒ 오퍼레이션 이름은 협력이라는 문맥을 반영해야 한다.
앞서 소개한 스타일은 절대적이지 않다. 설계는 트레이드오프의 산물이기 때문이다.
몇 가지 이슈를 보며 트레이드 오프에 대해 고민해보자
IntStream.of(1,15,20,3,9).filter(x->x>10).distinct().count();
다음 IntStream 코드의 경우 여러 개의 도트를 찍었기 때문에 얼핏 보면 디미터 법칙을 위반한 것처럼 보일 수 있다.
그러나 디미터 법칙은 결합도에 관한 것이다.
결합도는 내부 구조가 외부로 노출될 때에 한정한다. IntStream은 내부적으로 IntStream을 반환하므로 자기 자신을 사용하고 있을 뿐이다. 따라서 외부에 객체에 대한 정보가 노출되지 않는다.
⇒ 즉, 객체 내부 구현에 대해 무엇도 외부로 노출하지 않으면 디미터 법칙을 준수한 것이다.
일반적으로 어떤 객체 상태를 물어보고 상태 기반으로 결정내리고 결정에 따라 객체 상태를 변경하는 코드는 묻지 말고 시켜라 스타일로 변경해야 한다.
대표적인 예시가 1장의 Theater.enter()
이다.
이 예시는 결합도는 낮추면서 응집도도 높아진다.
그러나 예외 상황도 있다. 모든 상황에 맹목적으로 위임 메서드를 추가하면 퍼블릭 메서드 답지 않은 불필요한 책임을 떠안게 된다.
PeriodCondition.isSatisfiedBy()
를 보면 디미터 법칙을 준수하기 위해서 상영 할인 판단을 Screening으로 위임 시 Screening이 할인 판단에 대한 책임을 떠안게 된다.
Screening은 영화를 예매하기 위한 책임을 갖고 있다.
할인 판단이 Screening의 본질적인 책임이 맞는가?
때에 따라 캡슐화보다 응집도를 선택하는 것이 방법일 수 있다. 판단 기준은 전체를 보았을 때 이득이 되는 것으로 고르면 된다.
일급 컬렉션의 경우도 살펴보자.
for(Movie movie : movies) {
total += movie.getFee();
}
다음과 같이 내부에 속한 객체에 대해 연산할 때 물어보는 방법밖에는 존재하지 않는다.
디미터 법칙의 위반 여부는 묻는 대상이 객체인지, 자료구조인지에 달려있다.
위의 예시와 같이 Movie가 단순 자료구조일 때에는 물어보는 행위가 디미터 법칙을 위반하는 것이 아니다.
가끔씩 필요에 따라 물어야 할 때도 있다는 사실을 납득했으면 명령-쿼리 원칙을 알아보면 좋다.
오퍼레이션은 부수효과를 발생하는 명령 혹은 부수 효과를 발생시키지 않는 쿼리 둘 중 하나로 이루어져 있다. (명령이자 쿼리인 것은 존재하지 않는다)
이벤트와 반복 일정을 코드로 구현하면 다음과 같다.
public class Event {
private String subject;
private LocalDateTime from;
private Duration duration;
public Event(String subject, LocalDateTime from, Duration duration) {
this.subject = subject;
this.from = from;
this.duration = duration;
}
}
public class RecurringSchedule {
private String subject;
private DayOfWeek dayOfWeek;
private LocalTime from;
private Duration duration;
public RecurringSchedule(String subject, DayOfWeek dayOfWeek, LocalTime from, Duration duration) {
this.subject = subject;
this.dayOfWeek = dayOfWeek;
this.from = from;
this.duration = duration;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public LocalTime getFrom() {
return from;
}
public Duration getDuration() {
return duration;
}
}
Event meeting = new Event("회의",
LocalDateTime.of(2025, 4, 24, 19, 0, 0),
Duration.ofMinutes(30));
RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.THURSDAY,
LocalTime.of(10, 30), Duration.ofMinutes(30));
반복 일정에 이벤트를 발생시킬 수 있다.
Event 클래스는 이벤트가 RecurringSchedule 클래스가 정의한 반복 일정 조건을 만족하는지 검사하는 isSatisfied()
메서드를 제공한다.
그런데 다음과 같은 버그가 발생했다.
RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.THURSDAY,
LocalTime.of(10, 30), Duration.ofMinutes(30));
Event meeting2 = new Event("회의",
LocalDateTime.of(2025, 4, 25, 10, 30, 0),
Duration.ofMinutes(30));
assert meeting2.isSatisfied(schedule) == false;
assert meeting2.isSatisfied(schedule) == true;
처음에는 요일 조건을 만족하지 않아서 false를 반환하는데, 이후에 한번 더 같은 코드를 실행했을 때 true를 반환하는 것이다.
isSatisfied()의 내부 구조를 보면 원인을 알 수 있다.
public boolean isSatisfied(RecurringSchedule schedule) {
if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
!from.toLocalDate().equals(schedule.getFrom()) ||
!duration.equals(schedule.getDuration())) {
reschedule(schedule);
return false;
}
return true;
}
reschedule(schedule)
구문을 통해 내부 상태가 수정되고 있었기 때문이다.
버그를 찾기 어려웠던 이유는 isSatisfied()
가 명령과 쿼리 두 가지 역할을 동시에 수행하고 있었기 때문이다.
명령과 쿼리를 뒤섞으면 실행 결과를 예측하기 어려워질 수 있다. isSatisfied()처럼 겉으로 보기에 쿼리처럼 보이지만 내부적으로 부수효과를 가지는 메서드는
가장 깔끔한 해결책은 명령과 쿼리를 명확히 분리하는 것이다.
public boolean isSatisfied(RecurringSchedule schedule) {
if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
!from.toLocalDate().equals(schedule.getFrom()) ||
!duration.equals(schedule.getDuration())) {
return false;
}
return true;
}
public void reschedule(RecurringSchedule schedule) {
from = LocalDateTime.of(from.toLocalDate().plusDays(daysDistance(schedule)), schedule.getFrom());
duration = schedule.getDuration();
}
if (!meeting2.isSatisfied(schedule)) {
meeting2.reschedule(schedule);
}
f(1) + f(1) = 6
f(1) * 2 = 6
f(1) - 1 = 2
3 + 3 = 6
3 * 2 = 6
3 - 1 = 2
실제 객체지향 패러다임은 객체 상태 변경이라는 부수효과를 기반으로 하므로 이러한 참조 투명성은 예외에 가깝다.
하지만 명령-쿼리 분리 원칙을 사용하면 이 균열을 조금이나마 줄일 수 있다. 그래서 제한적으로나마 참조 투명성의 혜택을 누릴 수 있게 된다.
앞선 예제에서 Event 인스턴스의 reschedule()
을 호출하지 않는다면 isSatisfied()
를 어떤 순서로 몇 번이나 호출하던 결과는 항상 동일하다.
앞서 소개한 디미터 법칙, 묻지 말고 시켜라, 인터페이스에 의도를 드러내라 … 를 만족시키면서도 쉬운 방법이 있다.
메시지 먼저 선택하고 그 후에 메시지를 처리할 객체를 선택하는 것이다.
결국 훌륭한 메시지를 얻기 위한 출발은 책임 주도 설계 원칙(RDD)를 따르는 것이다.