애플리케이션은 클래스로 구성되지만 메시지를 통해 정의된다는 사실을 기억하라.
📖 6.1 협력과 메시지
🔖 6.1.1 클라이언트-서버 모델
메시지는 객체 사이의 협력을 가능하게 하는 매개체다. 두 객체 사이의 협력 관계를 설명하기 위해 사용하는 전통적인 메타포는 클라이언트-서버 모델 이다.
Movie와 같이 객체는 협력에 참여하는 동안 클라이언트와 서버의 역할을 동시에 수행하는 것이 일반적- 협력의 관점에서 객체는 두 가지 종류의 메시지 집합으로 구성
- 객체가 수신하는 메시지의 집합
- 외부의 객체에게 전송하는 메시지의 집합
- 두 객체 사이의 협력을 가능하게 해주는 매개체가 메시지
🔖 6.1.2 메시지와 메시지 전송
- 메시지는 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단
- 메시지 전송(메시지 패싱): 한 객체가 다른 객체에게 도움을 요청하는 것
- 메시지 전송자(클라이언트): 메시지를 전송하는 객체
- 메시지 수신자(서버): 메시지를 수신하는 객체
- 메시지는 오퍼레이션명과 인자로 구성
- 메시지 전송은 여기에 메시지 수신자를 추가한 것
🔖 6.1.3 메시지와 메서드
- 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라고 부른다.
- 동일한 메시지를 전송하더라도 객체의 타입에 따라 실행되는 메서드가 달라질 수 있다.
- 객체는 메시지와 메서드라는 두 가지 서로 다른 개념을 실행 시점에 연결해야 하기 때문에 컴파일 시점과 실행 시점의 의미가 달라질 수 있다.
- 메시지와 메서드의 구분은 메시지 전송자와 메시지 수신자가 느슨하게 결합될 수 있게 한다.
- 실행 시점에 메시지와 메서드를 바인딩하는 메커니즘은 두 객체 사이의 결합도를 낮춤으로써 유연하고 확장 가능한 코드를 작성할 수 있게 만든다.
🔖 6.1.4 퍼블릭 인터페이스와 오퍼레이션
- 퍼블릭 인터페이스: 객체가 의사소통을 위해 외부에 공개하는 메시지의 집합
- 오퍼레이션: 프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지
- 수행 가능한 어떤 행동에 대한 추상화
- UML의 관점에서 오퍼레이션이란 실행하기 위해 객체가 호출될 수 있는 변환이나 정의에 관한 명세
🔖 6.1.5 시그니처
- 시그니처: 오퍼레이션(또는 메서드)의 이름과 파라미터 목록을 합친 것
- 오퍼레이션은 실행 코드 없이 시그니처만을 정의한 것
- 메서드는 이 시그니처에 구현을 더한 것
📖 6.2 인터페이스와 설계 품질
좋은 인터페이스는 최소한의 인터페이스와 추상적인 인터페이스라는 조건을 만족해야 한다.
퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법
- 디미터 법칙
- 묻지 말고 시켜라
- 의도를 드러내는 인터페이스
- 명령-쿼리 분리
🔖 6.2.1 디미터 법칙
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Movie movie = screening.getMovie();
boolean discountable = false;
for (DiscountCondition condition : movie.getDiscountConditions()) {
if (condition.getType() == DiscountConditionType.PERIOD) {
discountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
!condition.getStartTime().isAfter(screening.getWhenScreened().toLocalTime()) &&
!condition.getEndTime().isBefore(screening.getWhenScreened().toLocalTime());
} else {
discountable = condition.getSequence() == screening.getSequence();
}
if (discountable) {
break;
}
}
Money fee;
if (discountable) {
Money discountAmount = Money.ZERO;
switch (movie.getMovieType()) {
case AMOUNT_DISCOUNT -> discountAmount = movie.getDiscountAmount();
case PERCENT_DISCOUNT -> discountAmount = movie.getFee().times(movie.getDiscountPercent());
case NONE_DISCOUNT -> discountAmount = Money.ZERO;
}
fee = movie.getFee().minus(discountAmount);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
- 위 코드의 가장 큰 단점은
ReservationAgency와 인자로 전달된Screening사이의 결합도가 너무 높은 것이다. - 이처럼 협력하는 객체의 내부 구조에 대한 결합으로 인해 발생하는 설계 문제를 해결하기 위해 제안된 원칙이 디미터 법칙
낯선 자에게 말하지 말라
오직 인접한 이웃하고만 말하라
디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송하도록 프로그래밍
- 모든 클래스 C와 C에 구현된 모든 메서드 M에 대해서, M이 메시지를 전송할 수 있는 모든 객체는 아래 클래스의 인스턴스여야 한다.
- M의 인자로 전달된 클래스(C 자체를 포함)
- C의 인스턴스 변수의 클래스
- M에 의해 생성된 객체나 M이 호출하는 메서드에 의해 생성된 객체, 전역 변수로 선언된 객체는 모두 M의 인자로 간주
즉, 클래스 내부의 메서드가 아래 조건을 만족하는 인스턴스에만 메시지를 전송하도록 해야 한다.
- this 객체
- 메서드의 매개변수
- this의 속성
- this의 속성인 컬렉션의 요소
- 메서드 내에서 생성된 지역 객체
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Money fee = screening.calculateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
- 디미터 법칙을 따른 결과 결합도가 개선되었다.
- 디미터 법칙을 따르면 부끄럼타는 코드(shy code) 를 작성할 수 있다.
- 불필요한 어떤 것도 다른 객체에게 보여주지 않음
- 다른 객체의 구현에 의존하지 않는 코드
screening.getMovie().getDiscountConditions();
- 위 코드는 디미터 법칙을 위반한 코드다.
- 메시지 전송자가 수신자의 내부 구조에 대해 물어보고 반환받은 요소에 대해 연쇄적으로 메시지를 전송
- 기차 충돌(train wreck)
- 여러 대의 기차가 한 줄로 늘어서 충돌한 것처럼 보이기 때문
- 클래스의 내부 구현이 외부로 노출됐을 때 나타나는 전형적인 형태
- 디미터 법칙을 적용한 코드는
screening.calculateFee(audienceCount);
무비판적으로 디미터 법칙을 수용하면 퍼블릭 인터페이스 관점에서 객체의 응집도가 낮아질 수도 있다.
🔖 6.2.2 묻지 말고 시켜라
메시지 전송자는 메시지 수신자의 상태를 기반으로 결정을 내린 후 메시지 수신자의 상태를 바꿔서는 안된다.
- 묻지 말고 시켜라 원칙을 따르면 밀접하게 연관된 정보와 행동을 함께 가지는 객체를 만들 수 있다.
- 상태를 묻는 오퍼레이션을 행동을 요청하는 오퍼레이션으로 대체함으로써 인터페이스를 향상시켜라
- 하지만 단순하게 객체에게 묻지 않고 시킨다고 해서 모든 문제가 해결되는 것은 아니다.
- 훌륭한 인터페이스를 수확하기 위해서는 객체가 어떻게 작업을 수행하는지를 노출해서는 안 된다.
🔖 6.2.3 의도를 드러내는 인터페이스
메서드를 명명하는 방법
public class PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening) {
...
}
}
public class SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening) {
...
}
}
-
메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓는 것
- 메서드의 이름은 내부의 구현 방법을 드러낸다.
- 메서드에 대해 제대로 커뮤니케이션하지 못한다.
- 메서드 수준에서 캡슐화를 위반한다.
- 클라이언트로 하여금 협력하는 객체의 종류를 알도록 강요
-
'어떻게'가 아니라 '무엇'을 하는지를 드러내는 것
- 어떻게 수행하는지를 드러내는 이름이란 메서드의 내부 구현을 설명하는 이름
- 무엇을 하는지를 드러내도록 메서드의 이름을 짓기 위해서는 객체가 협력 안에서 수행해야 하는 책임에 관해 고민
public class PeriodCondition {
public boolean isSatisfiedBy(Screening screening) {
...
}
}
public class SequenceCondition {
public boolean isSatisfiedBy(Screening screening) {
...
}
}
- 위 코드는 자연스레 아래와 같이 바뀐다.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
이와 같은 메서드 명명 방법을 의도를 드러내는 선택자(Intention Revealing Selector) 라고 부른다.
- 에릭 에반스는 의도를 드러내는 선택자를 인터페이스 레벨로 확장한 **의도를 드러내는 인터페이스(Intention Revealing Interface)**를 제시
- 구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현
🔖 6.2.4 함께 모으기
🛠️ 6.2.4.1 디미터 법칙을 위반하는 티켓 판매 도메인
@AllArgsConstructor
public class Theater {
private TicketSeller ticketSeller;
// 관람객 입장
public void enter(Audience audience) {
if (audience.getBag().hasInvitation()) {
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().setTicket(ticket);
return;
}
Ticket ticket = ticketSeller.getTicketOffice().getTicket();
audience.getBag().minusAmount(ticket.getFee());
ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
audience.getBag().setTicket(ticket);
}
}
- 디미터 법칙을 위반한 전형적인 코드
- 인터페이스와 구현의 분리 원칙 위반
- 불안정하고, 사용하기 힘들고, 클라이언트에게 구현을 노출
🛠️ 6.2.4.2 묻지 말고 시켜라
@AllArgsConstructor
public class TicketSeller {
private TicketOffice ticketOffice;
public void setTicket(Audience audience) {
if (audience.getBag().hasInvitation()) {
Ticket ticket = ticketOffice.getTicket();
audience.getBag().setTicket(ticket);
return;
}
Ticket ticket = ticketOffice.getTicket();
audience.getBag().minusAmount(ticket.getFee());
ticketOffice.plusAmount(ticket.getFee());
audience.getBag().setTicket(ticket);
}
}
@AllArgsConstructor
public class Theater {
private TicketSeller ticketSeller;
public void enter(Audience audience) {
ticketSeller.setTicket(audience);
}
}
Theater는TicketSeller와Audience의 내부 구조에 관해 묻지 말고 원하는 작업을 시켜야 한다.
@AllArgsConstructor
public class Audience {
private Bag bag;
public Long setTicket(Ticket ticket) {
if (bag.hasInvitation()) {
bag.setTicket(ticket);
return 0L;
}
bag.setTicket(ticket);
bag.minusAmount(ticket.getFee());
return ticket.getFee();
}
}
@AllArgsConstructor
public class TicketSeller {
private TicketOffice ticketOffice;
public void setTicket(Audience audience) {
ticketOffice.plusAmount(audience.setTicket(ticketOffice.getTicket()));
}
}
TicketSeller는 속성으로 포함되고 있는TicketOffice의 인스턴스와 인자로 전달된Audience에게만 메시지를 전송한다.Audience는hasInvitation메서드를 이용해 초대권을 가지고 있는지를 묻는다. 즉, 디미터 법칙을 위반한다.
public class Bag {
private Long amount;
private Invitation invitation;
private Ticket ticket;
public Long setTicket(Ticket ticket) {
if (hasInvitation()) {
setTicket(ticket);
return 0L;
}
setTicket(ticket);
minusAmount(ticket.getFee());
return ticket.getFee();
}
public boolean hasInvitation() {
return invitation != null;
}
public void minusAmount(Long amount) {
this.amount -= amount;
}
}
@AllArgsConstructor
public class Audience {
private Bag bag;
public Long setTicket(Ticket ticket) {
return bag.setTicket(ticket);
}
}
Audience는 자율적인 존재가 되었다.
디미터 법칙과 묻지 말고 시켜라 스타일을 따르면 자연스럽게 자율적인 객체로 구성된 유연한 협력을 얻게 된다.
🛠️ 6.2.4.3 인터페이스에 의도를 드러내자
위 코드들의 setTicket 메서드는 미묘하게 다른 의미를 가지고 있다❗️
public class TicketSeller {
public void sellTo(Audience audience) {
...
}
}
public class Audience {
public Long buy(Ticket ticket) {
...
}
}
public class Bag {
public Long hold(Ticket ticket) {
...
}
}
- 위 메서드 명칭은 클라이언트가 객체에게 무엇을 원하는지를 명확하게 표현
📖 6.3 원칙의 함정
원칙이 현재 상황에 부적합하다고 판단된다면 과감하게 원칙을 무시하라.
🔖 6.3.1 디미터 법칙은 하나의 도트(.)를 강제하는 규칙이 아니다
IntStream.of(1, 15, 20, 3, 9).filter(x -> x > 10).distinct().count();
- 위 코드는 동일한 클래스의 인스턴스를 반환하므로 디미터 법칙을 위반하지 않는다.
- 기차 충돌처럼 보이는 코드라도 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수한 것이다.
🔖 6.3.2 결합도와 응집도의 충돌
- 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 응집도 ⬇️
- 가끔씩은 묻는 것 외에는 다른 방법이 존재하지 않는 경우도 있다.
- 컬렉션에 포함된 객체들을 처리하는 유일한 방법은 객체에게 물어보는 것
- 묻는 대상이 자료 구조라면 당연히 내부를 노출해야 하므로 디미터 법칙을 적용할 필요가 없다.
설계는 trade-off의 산물이다. 즉, 경우에 따라 다르다
원칙이 적절한 상황과 부적절한 상황을 판단할 수 있는 안목을 길러라❗️
📖 6.4 명령-쿼리 분리 원칙
- 루틴(routine): 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈
- **프로시저(procedure)**와 **함수(function)**로 구분
- 프로시저(procedure): 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류
- 부수효과를 발생시킬 수 있지만 값을 반환할 수 없다.
- 객체의 인터페이스 측면에서 **명령(Command)**와 동일
- 함수(function): 어떤 절차에 따라 필요한 값을 계산해서 반환하는 루틴의 한 종류
- 값을 반환할 수 있지만 부수효과를 발생시킬 수 없다.
- 객체의 인터페이스 측면에서 **쿼리(Query)**와 동일
명령-쿼리 분리 원칙의 요지는 오퍼레이션은 부수효과를 발생시키는 명령이거나 부수효과를 발생시키지 않는 쿼리 중 하나여야 한다는 것
- 객체의 상태를 변경하는 명령은 반환값을 가질 수 없다.
- 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없다.
- "질문이 답변을 수정해서는 안 된다"
🔖 6.4.1 반복 일정의 명령과 쿼리 분리하기
예시 도메인의 용어 정리
- 이벤트: 특정 일자에 실제로 발생하는 사건
- 반복 일정: 일주일 단위로 돌아오는 특정 시간 간격에 발생하는 사건 전체를 포괄적으로 지칭
@AllArgsConstructor
public class Event {
private String subject;
private LocalDateTime from;
private Duration duration;
}
@AllArgsConstructor
public class RecurringSchedule {
private String subject;
@Getter
private DayOfWeek dayOfWeek;
@Getter
private LocalTime from;
@Getter
private Duration duration;
}
RecurringSchedule schedule = new RecurringSchedule("회의", DayOfWeek.WEDNESDAY, LocalTime.of(10, 30), Duration.ofMinutes(30));
Event meeting = new Event("회의", LocalDateTime.of(2019, 5, 8, 10, 30), Duration.ofMinutes(30));
assert meeting.isSatisfied(schedule);
- 위 코드는 당연히 true를 반환한다.
RecurringSchedule schedule = 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);
assert meeting.isSatisfied(schedule);
- 5/9는 목요일이므로
false를 반환한다. 하지만 다시 한번 더 실행시키면true를 반환한다.
@AllArgsConstructor
public class Event {
private String subject;
private LocalDateTime from;
private Duration duration;
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;
}
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();
}
}
isSatisfied가 명령과 쿼리의 두 가지 역할을 동시에 수행하고 있었기 때문에 발생한 문제- 명령과 쿼리를 뒤섞으면 실행 결과를 예측하기가 어려워질 수 있다.
@AllArgsConstructor
public class Event {
private String subject;
private LocalDateTime from;
private Duration duration;
public boolean isSatisfied(RecurringSchedule schedule) {
return from.getDayOfWeek() == schedule.getDayOfWeek() &&
from.toLocalTime().equals(schedule.getFrom()) &&
duration.equals(schedule.getDuration());
}
public 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를 사용하는 쪽에서 결정할 수 있다. - 예측 가능하고 이해하기 쉬우며 디버깅이 용이한 동시에 유지보수가 수월해질 것
🔖 6.4.2 명령-쿼리 분리와 참조 투명성
명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 **참조 투명성(referential transparency)**의 장점을 제한적이나마 누릴 수 있게 된다.
- 참조 투명성이란 어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성
- 참조 투명성을 잘 활용하면 버그가 적고, 디버깅이 용이하며, 쿼리의 순서에 따라 실행 결과가 변하지 않는 코드를 작성할 수 있다.
- 컴퓨터의 세계와 수학의 세계를 나누는 가장 큰 특징은 부수효과(side effect) 의 존재 유무다.
- 어떤 값이 변하지 않는 성질을 불변성(immutability) 이라고 부른다.
참조 투명성을 만족하는 식은 우리에게 두 가지 장점을 제공
- 모든 함수를 이미 알고 있는 하나의 결괏값으로 대체할 수 있기 때문에 식을 쉽게 계산할 수 있다.
- 모든 곳에서 함수의 결괏값이 동일하기 때문에 식의 순서를 변경하더라도 각 식의 결과는 달라지지 않는다.
🔖 6.4.3 책임에 초점을 맞춰라
메시지를 먼저 선택하고 그 후에 메시지를 처리할 객체를 선택하라.
- 디미터 법칙
- 협력이라는 컨텍스트 안에서 객체보다 메시지를 먼저 결정하면 두 객체 사이의 구조적인 결합도를 낮출 수 있다.
- 묻지 말고 시켜라
- 메시지를 먼저 선택하면 협력을 구조화하게 된다.
- 의도를 드러내는 인터페이스
- 메시지를 먼저 선택한다는 것은 메시지를 전송하는 클라이언트의 관점에서 메시지의 이름을 정한다는 것
- 명령-쿼리 분리 원칙
- 메시지를 먼저 선택한다는 것은 협력이라는 문맥 안에서 객체의 인터페이스에 관해 고민한다는 것을 의미
협력을 위해 두 객체가 보장해야 하는 실행 시점의 제약을 인터페이스에 명시할 수 있는 방법이 존재하지 않음.
- 시그니처에는 어떤 조건이 만족돼야만 오퍼레이션을 호출할 수 있고 어떤 경우에 결과를 반환받을 수 없는지를 표현할 수 없다.
- 이 문제를 해결하기 위해 계약에 의한 설계(Design By Contract) 개념이 나옴.
