객체지향 프로그래밍에 대한 가장 흔한 오해는 애플리케이션이 클래스의 집합으로 구성된다는 것이다.
훌륭한 객체지향 코드를 얻기 위해서는 클래스가 아니라 객체를 지향해야 한다.
두 객체 사이의 협력 관계를 설명하기 위해 사용하는 전통적인 메타포는 클라이언트-서버 모델이다
메시지(message)는 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단이다.
메시지 전송 또는 메시지 패싱: 한 객체가 다른 객체에게 도움을 요청하는 것
메시지를 전송하는 객체: 메시지 전송자
메시지 수신자: 메시지를 수신하는 객체
메시지는 오퍼레이션명 + 인자 구성
메시지 전송은 메시지에 메시지 수신자를 추가한 것
메시지를 수신했을 때 실제 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입이 무엇인가에 달려 있다.
메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라고 부른다. 중요한 것은 코드 상에서 동일한 이름의 변수(condition)에게 동일한 메시지를 전송하더라도 객체의 타입에 따라 실행되는 메서드가 달라질 수 있다는 것이다.(ex: PeriodCondition, SequenceCondition)
객체가 의사소통을 위해 외부에 공개하는 메시지의 집합을 퍼블릭 인터페이스라고 부른다.
프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션(opertaion)이라고 부른다. 오퍼레이션은 어떤 행동에 대한 추상화다.
오퍼레이션(또는 메서드)의 이름과 파라미터 목록을 합쳐 시그니처(signature)라고 부른다. 오퍼레이션은 실행 코드 없이 시그니처만을 정의한 것이다. 메서드는 이 시그니처에 구현을 더한 것이다.
좋은 인터페이스는 최소한의 인터페이스와 추상적인 인터페이스라는 조건을 만족해야 한다.
ReservationAgency
는 사소한 변경에도 흔들리는 의존성의 집결지다.
이처럼 협력하는 객체의 내부 구조에 대한 결합으로 인해 발생하는 설계 문제를 해결하기 위해 제안된 원칙이 바로 디미터 법칙(Loaw of Demeter)이다. 디미터 법칙을 간단하게 요약하면 객체 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 것이다.
다음은 디미터 법칙을 위반하는 코드의 전형적인 모습을 표현한 것이다.
screnning.getMoive().getDiscountConditions();
메시지 전송자가 수신자의 내부 구조에 대해 물어보고 반환 받은 요소에 대해 연쇄적으로 메시지를 전송한다.
디미터 법칙을 따르도록 코드를 개선하면 메시지 전송자는 더 이상 메시지 수신자의 내부 구조에 관해 묻지 않게 된다. 단지 자신이 원하는 것이 무엇인지를 명시하고 단순히 수행하도록 요청한다.
screening.calculdateFee(audienceCount)
묻지 말고 시켜라 원칙을 따르면 밀접하게 연관된 정보와 행동을 함께 가지는 객체를 만들 수 있다.
켄트 백(Kent Beck)은 메서드를 명명하는 두 가지 방법을 설명했다. 첫 번째 방법은 메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓는 것이다. 이 경우 메서드의 이름은 내부의 구현 방법을 드라낸다. 다음은 ㅓㅅ 번째 방법에 따라 메서드를 명명한 것이다.
public class PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening) { ... }
}
public class SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening) { ... }
}
이런 스타일은 좋지 않은데 그 이유를 두 가지로 요악할 수 있다.
isSatisfiedByPeriod, isSatisfiedBySequence
모두 할인 조건을 판단하는 동일한 작업을 수행한다.두 번째 방법은 '어떻게'가 아니라 '무엇'을 하는지 드러내는 것이다.
두 메서드 모두 클라이언트의 의도를 담을 수 있도록 변경하자.
public class PeriodCondition {
public boolean isSatisfiedBy(Screening screening) { ... }
}
public class SequenceCondition {
public boolean isSatisfiedBy(Screening screening) { ... }
}
클라이어느가 두 메서드를 가진 객체를 동일한 타입으로 간주할 수 있도록 동일한 타입 계층으로 묶어야 한다. 가장 간단한 방법은 인터페이스를 정의하고 오퍼레이션을 정의하는 것이다.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
디미터 법칙, 묻지 말고 시켜라 스타일은 인터페이스를 깔끔하고 유연하게 만들 수 있는 훌륭한 설계 원칙이다. 하지만 절대적인 법칙은 아니다.
사실 설계가 트레이드오프의 산물이라는 것이다.
IntStream.of(1, 15, 20, 3, 9).filter(x -> x > 10).distinct().count();
하지만 이것은 디미터 법칙을 제대로 이해하지 못한 것이다. 위 코드에서 of, filter, distinct
메서드는 모두 IntStream
이라는 동일한 클래스의 인스턴스를 반환한다.
일반적으로 어떤 객체의 상태를 물어본 후 반환된 상태를 기반으로 결정을 내리고 그 결정에 따라 객체의 상태를 변경하는 코드를 묻지 말고 시켜라 스타일로 변경해야 한다.
public class Theater {
public void legacyEnter(Audience audience) {
if (audience.getBag().hasInvitation()) {
ticketSeller.getTicketOffice().getTicket();
audience.getBag().setTicket(ticket);
} else {
ticketSeller.getTicketOffice().getTicket();
audience.getBag().minusAmount(ticket.getFee());
ticketSeller.getTicketOffice().plusAmount(ticketSeller.getFee());
audience.getBag().setTicket(ticket);
}
}
}
Theater
는 Audience
내부에 포함된 Bag
에 대해 질문한 후 반환된 결과를 이용해 Bag
의 상태를 변경한다. 이 코드는 분명히 Audience
의 캡슐화를 위반하기 때문에 Theater
는 Audience
의 내부 구조에 강하게 결합된다. 이 문제를 해결 하기 위해 상태 변경 코드를 Audience
로 옮겨야 한다.
public class Audience {
public Long legacyBuy(Ticket ticket) {
if (bag.hasInvitation()) {
bag.setTicket(ticket);
return 0L;
} else {
bag.setTicket(ticket);
bag.minusAmount(ticket.getFee());
return ticket.getFee();
}
}
}
이제 Audience
는 상태와 함께 상태를 조작하는 행동도 포함하기 때문에 응집도가 높아졌다.
isSatisfiedBy
메서드는 screening에게 질의한 후 할인 여부를 결정한다. 이 코드는 Screening
의 내부 상태를 가져와 사용하기 때문에 캡슐화를 위반한 것으로 보일 수 있다.
public class PeriodCondition implements DiscountCondition {
@Override
public boolean isSatisfiedBy(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
}
따라서 할인 여부를 판단하는 로직을 Screening
의 isDiscountable
메서드로 옮기고 PeriodCondition
이 이 메서드를 호출하도록 변경한다면 묻지 말고 시켜라 스타일을 준수하는 퍼블릭 인터페이스를 얻을 수 있다고 생각할 것이다.
public class Screening {
public boolean isSatisfiedByBad(Screening screening) {
return screening.isDiscountableBad(dayOfWeek, startTime, endTime);
}
}
public class PeriodCondition implements DiscountCondition {
public boolean isSatisfiedByBad(Screening screening) {
return screening.isDiscountableBad(dayOfWeek, startTime, endTime);
}
}
하지만 이렇게 하면 Screening
이 기간에 따른 할인 조건을 판단하는 책임을 떠안게 된다. 이것은 Screening
이 담당해야 하는 본질적인 책임이 아니다. Screening
의 책임은 영화를 예매하는 것이다.
가끔씩은 필요에 따라 물어야 한다는 사실에 납득했다면 명령-쿼리 분리(Command-Query Separation) 원칙을 알아두면 도움이 될 것이다. 명령-쿼리 분리 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있는 지침을 제공한다.
명령(Command)과 쿼리(Query)는 객체의 인터페이스 측면에서 프로시저와 함수를 부르는 또 다른 이름이다.
명령과 쿼리를 분리하기 위해서는 다음의 두 가지 규칙을 준수해야 한다.
도메인의 중요한 두 가지 용어인 "이벤트(event)"와 "반복 일정(recurring schedule)"에 관해 살펴보자. "이벤트"는 특정 일자에 실제로 발생하는 사건을 의미한다.
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;
}
}
"반복 일정"은 RecurringSchedule 클래스로 구현한다. 이 클래스는 주 단위로 반복되는 일정을 정의하기 위한 클래스다.
@Getter
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;
}
}
isSatisfied
함수를 통해 검증을 하는데 버그가 발견된다.
RecurringSchedule schdule = new RecurringSchedule("회의", DayOfWeek.WEDNESDAY, LocalTime.of(10, 30), Duration.ofMinutes(30));
Event meeting = new Event("회의", LocalDateTime.of(2019, 5, 9, 10, 30), Duration.ofMinutes(30));
assert meeting.isSatisfied(schedule) == false;
assert meeting.isSatisfied(schedule) == true;
2019년 5월 9일은 목요일이므로 수요일이라는 반복 일정의 조건을 만족시키지 못한다. 따라서 isSatisfied
메서드는 false를 반환한다.
다시 한 번 isSatisfied
메서드를 호출하면 놀랍게도 true를 반환한다.
public class Event {
public boolean isSatisfied(RecurringSchedule schedule) {
if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
!from.toLocalTime().equals(schedule.getFrom()) ||
!duration.equals(schedule.getDuration())) {
reschedule(schedule);
return false;
}
return true;
}
}
isSatisfied
메서드는 false를 반환하기 전에 reschedule
메서드를 호출하고 있다. 이 메서드는 Event 객체의 상태를 수정한다.
public class Event {
private void reschedule(RecurringSchedule schedule) {
from = LocalDateTime.of(from.toLocalDate().plusDays(daysDistance(schedule)),
schedule.getFrom());
duration = schedule.getDuration();
}
private long daysDistance(RecurringSchedule schedule) {
return schedule.getDayOfWeek().getValue() - from.getDayOfWeek().getValue();
}
}
reschedule
메서드는 Event의 일정을 인자로 전달된 RecurringSchedule
의 조건에 맞게 변경한다. Event가 RecurringSchedule에 설정된 조건을 만족하지 못하는 경우 Event의 상태를 변경한다.
버그를 찾기 어려웠던 이유는 isSatisfied가 명령과 쿼리의 두 가지 역할을 동시에 수행하고 있었기 때문이다.
명령과 쿼리를 뒤섞으면 실행 결과를 예측하기가 어려워질 수 있다. 가장 깔끔한 해결은 명령과 쿼리를 명확하게 분리하는 것이다.
public class Event {
public boolean isSatisfied(RecurringSchedule schedule) {
if (from.getDayOfWeek() != schedule.getDayOfWeek() ||
!from.toLocalTime().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();
}
}
수정 후의 isSatisfied 메서드는 부수효과를 가지지 않기 때문에 순수한 쿼리가 됐다.
reschedule 메서드도 public으로 변경되어 외부에서 접근할 수 있으므로 아래와 같이 사용할 수 있다.
if (!event.isSatisfied(schedule)) {
event.reschedule(scheudle);
}
명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성의 장점을 제한적이나마 누릴 수 있게 된다.
참조 투명성이란 "어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성"을 의미한다.