훌륭한 객체지향을 위해서는 클래스가 아닌 객체를 지향해야 한다. 협력 안에서 객체가 수행하는 책임에 추점을 맞춰야 한다.
책임은 객체가 수신할 수 있는 메시지의 기반이 된다.
객체지향 애플리케이션의 가장 중요한 재료는 객체들이 주고받는 메시지다.
매시지들이 객체의 퍼블리 인터페이스를 구성한다.
1. 협력과 메시지
클라이언트-서버 모델
- 클라이언트: 메시지를 전송하는 객체
Screening은 '가격을 계산하라' 메시지를 전송함으로써 도움 요청
Movie는 '할인 요금을 계산하라' 메시지를 전송함으로써 도움 요청 - 서버: 메시질르 수신하는 객체
Movie는 가격을 계산하는 서비스를 제공함으로써 메시지에 응답
DiscountPolicy는 할인 요금을 계산하는 서비스를 제공함으로써 메시지에 응답 - 협력: 클라이언트가 서버의 서비스를 요청하는 단방향 상호작용
객체는 협력에 참여하는 동안 클라이언트와 서버의 역할을 동시에 수행하는 것이 일반적이다.
객체는 수신하는 메시지의 집합과 외부의 객체에게 전송하는 메시지의 집합으로 구성된다.
- 메시지: 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단 (오퍼레이션명 + 인자)
- 메시지 전송/패싱: 한 객체가 다른 객체에게 도움을 요청하는 것 (메시지 + 메시지 수신자)
- 메시지 전송자(클라이언트): 메시지를 전송하는 객체
- 메시지 수신자(서버): 메시지를 수신하는 객체
- 메서드: 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저
객체는 메시지와 메서드라는 두 가지 서로 다른 개념을 실행 시점에 연결해야 하기 때문에,
컴파일 시점과 실행 시점의 의미가 달라질 수 있다.
객체지향은 메시지 전송과 메서드 호출을 명확하게 구분한다.
전송자와 수신자는 서로에 대한 상세한 정보를 알지 못한 채 메시지라는 끈을 통해서만 연결된다.
실생 시점에 메시지와 메서드를 바인딩하는 메커니즘은 결합도를 낮춘다.
- 퍼블릭 인터페이스: 객체가 의사소통을 위해 외부에 공개하는 메시지의 집합
- 오퍼레이션: 퍼블릭 인터페이스에 포함된 메시지. 수행 가능한 어떤 행동에 대한 추상화
DiscountCondition 인터페이스에 정의된 isSatisfiedBy - 메서드: 메시지를 수신했을 때 실제로 실행되는 코드
SequenceCondition과 PeriodCondition의 두 메서드
오퍼레이션이란 실행하기 위해 객체가 호출될 수 있는 변환이나 정의에 관한 명세다.
인터페이스의 각 요소는 오퍼레이션이다. 오퍼레이션은 구현이 아닌 추상화다.
메서드가 오퍼레이션에 대한 구현이다.
객체가 메시지를 전송하면, 런타임 시스템은 메시지 전송을 오퍼레이션 호출로 해석하고,
수신한 객체의 실제 타입을 기반으로 적절한 메서드를 찾아 실행한다.
메서드 호출보다는 오퍼레잇녀 호출이 더 적절한 표현이다.
- 시그니처: 오퍼레이션(메서드)의 이름과 파라미터 목록
- 오퍼레이션: 실행 코드 없이 시그니처만 정의
- 메서드: 시그니처 + 구현
- 다형성: 동일한 오퍼레이션 호출에 대해 서로 다른 메서드들이 실행되는 것
메시지를 수신하면 오퍼레이션의 시그니처와 동일한 메서드가 실행된다.
다형성을 통해 하나의 오퍼레이션에 대해 다양한 메서드를 구현하게 된다.
메시지가 객체의 퍼블릭 인터페이스와 그 안에 포함될 오퍼레이션을 결정한다.
2. 인터페이스와 설계 품질
좋은 인터페이스는 최소한의 인터페이스와 추상적인 인터페이스라는 조건을 만족해야 한다.
책임 주도 설계는 메시지를 먼저 선택함으로써 협력과 무관한 오퍼레이션이 인터페이스에 스며드는 것을 방지한다.
퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법에 관해 살펴보자.
1. 디미터 법칙: 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라
할인 가능 여부를 체크하는 코드를 보자.
class ReservationAgency {
public reserve(
screening: Screening,
customer: Customer,
audienceCount: number
): Reservation {
const movie: Movie = screening.getMovie();
let discountable: boolean = false;
for (const condition of movie.getDiscountConditions()) {
if (condition.getType() === "PERIOD") {
discountable =
condition.getDayOfWeek() === screening.getWhenScreened().dayOfWeek &&
condition.getStartTime() <= screening.getWhenScreened().localTime &&
condition.getEndTime() >= screening.getWhenScreened().localTime;
} else {
discountable = condition.getSequence() === screening.getSequence();
}
if (discountable) {
break;
}
}
}
가장 큰 문제는 ReservationAgency와 Screening 사이의 결합도가 너무 높다는 것이다.
ReservationAgency가 Screening뿐만 아니라 Movie와 DiscountCondition에도 직접 접근하기 때문이다.
디미터 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송하도록 프로그래밍해야 한다.
클래스 C와 C에 구현된 메서드 M에 대해서, M이 메세지를 전송할 수 있는 객체는 다음에 서술된 클래스의 인스턴스여야 한다.
- M의 인자로 전달된 클래스(C 자체를 포함)
- C의 인스턴스 변수의 클래스
클래스 내부의 메서드가 아래 조건을 만족하는 인스턴스에만 메시지를 전송하도록 해야 한다
- this 객체
- 메서드의 매개변수
- this의 속성
- this의 속성인 컬렉션의 요소
- 메서드 내에서 생성된 지역 객체
결합도 문제를 해결한 ReservationAgency의 코드를 보자.
class ReservationAgency {
public reserve(screening: Screening, customer: Customer, audienceCount: number): Reservation {
const fee: Money = screening.calculateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
ReservationAgency는 인자로 전달된 Screening 인스턴스에게만 메시지를 전송한다.
디미터 법칙을 따르면 shy code를 작성할 수 있다.
shy code: 불필요한 어떤 것도 다른 객체에게 보여주지 않으며, 다른 객체의 구현에 의존하지 않는 코드
// 디미터 법칙 위반
screening.getMovie().getDiscountConditions();
// 개선 코드
screening.calculateFee(audienceCount);
클래스의 내부 구현이 외부로 노출됐을 때 나타나는 전형적인 형태로 기차 충돌이 있다.
전송자가 수신자의 내부 구조에 대해 물어보고 받환받은 요소에 대해 연쇄적으로 메시지를 전송한다.
객체의 내부 구조를 묻는 메시지보다 수신자가 무언가를 시키는 메시지가 더 좋다.
2. 묻지 말고 시켜라: 객체의 상태에 관해 묻지말고 언하는 것을 시켜라
전송자가 수신자의 상태를 기반으로 결정을 내린 후 수신자의 상태를 바꿔서는 안 된다.
밀접하게 연관된 정보와 행동을 함께 가지는 객체를 만들어야 한다.
이 원칙에 따르면 자연스럽게 정보 전문가에게 책임을 할당하게 된다.
내부 상태를 묻는 오퍼레이션은 행동을 요청하는 오퍼레이션으로 대체함으로써 인터페이스를 향상시키자.
3. 의도를 드러내는 인터페이스
훌륭한 인터페이스를 위해서는 객체가 어떻게 작업을 수행하는지를 노출해서는 안 된다.
인터페이스는 어떻게가 아니라 무엇을 하는지를 서술해야 한다.
나쁜 스타일: 메서드가 작업을 어떻게 수행하는지를 나타내도록 명명
class PeriodCondition {
public isSatisfiedByPeriod(screening: Screening): boolean {}
}
class SequenceCondition {
public isSatisfiedBySequence(screening: Screening): boolean {}
}
- 메서드에 대해 제대로 커뮤니케이션하지 못한다.
메서드의 이름이 다르기 때문에 내부 구현을 모르면 두 메서드가 동일한 작업을 수행한다는 사실을 알기 어렵다. - 메서드 수준에서 캡슐화를 위반한다. 클라이언트가 협력하는 객체의 종류를 알도록 강요한다.
좋은 스타일: 메서드가 무엇을 수행하는지를 나타내도록 명명
interface DiscountCondition {
isSatisfiedBy(screening: Screening): boolean;
}
class PeriodCondition implements DiscountCondition {
public isSatisfiedBy(screening: Screening): boolean {}
}
class SequenceCondition implements DiscountCondition {
public isSatisfiedBy(screening: Screening): boolean {}
}
메서드가 무엇을 하느냐에 초점을 맞추면 클라이언트의 관점에서 동일한 작업을 수행하는 경우 하나의 타입 계층으로 묶을 수 있게 된다.
- 의도를 드러내는 선택자: 무엇을 하느냐에 따라 메서드의 이름을 짓는 패턴
- 의도를 드러내는 인터페이스: 의도를 드러내는 선택자를 인퍼테이스 레벨로 확장한 패턴
구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현해야 함
객체에게 문지 말고 시키되 구현 방법이 아닌 클라이언트의 의도를 드러내야 한다.
정리하기
디미터 법칙을 위반하는 티켓 판매 도메인
class Theater {
private ticketSeller: TicketSeller;
constructor(ticketSeller: TicketSeller) {
this.ticketSeller = ticketSeller;
}
public enter(audience: Audience): void {
if (audience.getBag().hasInvitation()) {
const ticket: Ticket = this.ticketSeller.getTicketOffice().getTicket();
audience.getBag().setTicket(ticket);
} else {
const ticket: Ticket = this.ticketSeller.getTicketOffice().getTicket();
audience.getBag().minusAmount(ticket.getFee()) // 기차 충돌;
this.ticketSeller.getTicketOffice().plusAmount(ticket.getFee());
audience.getBag().setTicket(ticket);
}
}
}
Theater는 Audience의 퍼블릭 인터페이스뿐만 아니라 내부 구조에 대해서도 결합된다.
디미터 법칙을 위반하는 설계는 인터페이스와 구현의 분리 원칙을 위반한다.
디미터 법칙을 위반한 코드는 결합도가 높고, 요구사항 변경에 취약하고, 사용하기 어렵다.
Audience가 퍼블릭 인터페이스에 getBag을 포함시키는 순간 객체의 구현이 퍼블릭 인터페이스를 통해 외부로 새어나갔다.
Audience와 TicketSeller의 내부 구조를 묻는 대신 Audience와 TicketSeller가 직접 자신의 책임을 수행하도록 시키자.
묻지 말고 시켜라
Audience와 TicketSeller는 묻지 말고 시켜라 스타일을 따르는 퍼블릭 인터페이스를 가져야 한다.
Theater가 TicketSeller에게 자신이 원하는 일(Audience가 Ticket을 가지도록 만드는 것)을 시키도록 수정하자.
TicketSeller에 setTicket을 추가하고 enter 메서드의 로직을 옮겨오자.
class TicketSeller {
private ticketOffice: TicketOffice;
public setTicket(audience: Audience): void {
const ticket: Ticket = this.ticketOffice.getTicket();
if (!audience.getBag().hasInvitation()) {
audience.getBag().minusAmount(ticket.getFee());
this.ticketOffice.plusAmount(ticket.getFee());
}
audience.getBag().setTicket(ticket);
}
}
Theater는 속성으로 포함하고 있는 TicketSeller에게만 메시지를 전송해야 한다.
class Theater {
private ticketSeller: TicketSeller;
public enter(audience: Audience): void {
this.ticketSeller.setTicket(audience);
}
}
Audience에 setTicket을 추가하고 스스로 티켓을 가지도록 하자.
class Audience {
private bag: Bag;
public setTicket(ticket: Ticket): number {
this.bag.setTicket(ticket);
if (this.bag.hasInvitation()) {
return 0;
} else {
this.bag.minusAmount(ticket.getFee());
return ticket.getFee();
}
}
}
TicketSeller는 속성으로 포함하고 있는 TicketOffice와 인자로 전달된 Audience에게만 메시지를 전송해야 한다.
class TicketSeller {
private ticketOffice: TicketOffice;
public setTicket(audience: Audience): void {
this.ticketOffice.plusAmount(
audience.setTicket(this.ticketOffice.getTicket())
);
}
}
Audience가 Bag에게 원하는 일을 시키기 전에 hasInvitation을 사용하고 있다. 디미터 법칙을 위반하고 있다.
Audience의 setTicket을 Bag으로 옮기자.
class Bag {
private amount: number;
private invitation: Invitation | null;
private ticket: Ticket | null;
private hasInvitation(): boolean {
return this.invitation !== null;
}
public setTicket(ticket: Ticket): number {
this.ticket = ticket;
if (this.hasInvitation()) {
return 0;
} else {
this.minusAmount(ticket.getFee());
return ticket.getFee();
}
}
private minusAmount(amount: number): void {
this.amount -= amount;
}
}
class Audience {
private bag: Bag;
public setTicket(ticket: Ticket): number {
return this.bag.setTicket(ticket);
}
}
디미터 법칙과 묻지 말고 시켜라 원칙에 따라 코드를 리팩터링한 후 Audience는 스스로 자신의 상태를 제어하게 됐다.
인터페이스에 의도를 드러내자
디미터 법칙과 묻지 말고 시켜라 원칙을 따르는 인터페이스를 얻었다면,
인터페이스가 클라이언트의 의도를 올바르게 반영했는지 확인해야 한다.
TicketSeller, Audience, Bag 각각의 입장에서 setTicket이라는 이름은 협력하는 클라이언트의 의도를 명확히 드러내지 못한다.
- Theater가 TicketSeller에게 원했던 것은 Audience에게 티켓을 판매하는 것이다. (setTicket -> sellTo)
- TicketSeller가 Audience에게 원했던 것은 Audience가 티켓을 사는 것이다. (setTicket -> buy)
- Audience가 Bag에게 원했던 것은 티켓을 보관하는 것이다. (setTicket -> hold)
오퍼레이션의 이름은 협력이라는 문맥을 반영해야 한다. 객체 자신이 아닌 클라이언트의 의도를 표현해야 한다.
- 디미터 법칙: 캡슐화를 위반하는 메시지가 인터페이스에 포함되지 않도록 제한
- 묻지 말고 시켜라: 디미터 법칙을 준수하는 협력을 만들기 위한 스타일 제시
- 의도를 드러내는 인터페이스: 객체의 퍼블릭 인터페이스에 어떤 이름이 드러나야 하는지에 대한 지침 제공
3. 원칙의 함정
원칙에는 예외가 넘쳐난다. 설계는 트레이드오프의 산물이다.
원칙을 아는 것보다 더 중요한 것은 언제 원칙이 유용하고 유용하지 않은지를 판단하는 것이다.
디미터 법칙은 하나의 도트(.)를 강제하는 규칙이 아니다
디미터 법칙은 "오직 하나의 도트만을 사용하라"라는 말로 요약되기도 한다.
그러나 디미터 법칙은 결합도과 관련된 것이며, 객체의 내부 구조가 외부로 노출되는 경우로 한정된다.
기차 충돌처럼 보이는 코드라도 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 디미터 법칙을 주수한 것이다.
결합도와 응집도의 충돌
우리는 캡슐화를 위반하는 코드를 개선하기 위해 객체가 스스로 행동할 수 있도록 객체에게 위임 메서드를 추가했다.
위임 메서드를 통해 객체의 내부 구조를 감추는 것은 결합도를 낮추고 응집도를 높이는 효과적인 방법이다.
그러나 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다.
결과적으로 상관 없는 책임들을 한꺼번에 떠안게 되기 때문에 응집도가 낮아진다.
클래스는 하나의 변경 원인만을 가져야 한다.
디미터 법칙과 묻지 말고 시켜라 원칙을 무작정 따르면 응집도는 낮아질 것이다.
PeriodCondition은 screening에게 상영 시작 시간을 물어서 할인 여부를 결정한다.
Screening의 내부 상태를 가져와서 사용하기 때문에 캡슐화를 위반한 것으로 보일 수 있다.
할인 여부를 판단하는 로직을 Screening으로 옮기면 묻지 말고 시켜라 스타일을 준수하는 것으로 보일 수 있지만,
이렇게 하면 Screening이 기간에 따른 할인 조건을 판단하는 책임을 떠안게 된다. 이는 본질적인 책임이 아니다.
묻는 것 외에는 다른 방법이 존재하지 않는 경우도 있다. 물으려는 객체가 정말로 데이터인 경우도 있다.
객체는 내부 구조를 숨겨야 하므로 디미터 법칙을 따르는 것이 좋지만,
자료 구조라면 당연히 내부를 노출해야 하므로 디미터 법칙을 적용할 필요가 없다.
4. 명령-쿼리 분리 원칙
명령-쿼리 분리 원칙은 퍼블릭 인터페이스에 오퍼레이션을 정의할 때 참고할 수 있는 지침을 제공한다.
- 루틴: 어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈
- 프로시저: 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류
부수효과를 발생시킬 수 있지만 값을 반환할 수 없음 - 함수: 어떤 절차에 따라 필요한 값을 계산해서 반환하는 루틴의 한 종류
값을 반환할 수 있지만 부수효과를 발생시킬 수 없음
- 프로시저: 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류
어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안 된다. 질문이 답변을 수정해서는 안 된다.
- 명령: 인터페이스 측면에서의 프로시저. 객체의 상태를 수정하는 오퍼레이션
반환값을 가질 수 없다. - 쿼리: 인터페이스 측면에서의 쿼리. 객체와 관련된 정보를 반환하는 오퍼레이션
상태를 변경할 수 없다.
마틴 파울러는 명령-쿼리 분리 원칙에 따라 작성된 객체의 인터페이스를 명령-쿼리 인터페이스라 부른다.
class Schedule {
private month;
private date;
constructor(month: number, date: number) {
(this.month = month), (this.date = date);
}
public isSame(schedule: Schedule): boolean {
if (this.month !== schedule.month || this.date !== schedule.date) {
this.reschedule(schedule);
return false;
}
return true;
}
private reschedule(schedule: Schedule): void {
this.month = schedule.month;
this.date = schedule.date;
}
}
두 날짜가 동일한지 판별하는 isSame에는 버그가 있다. 항상 true를 반환하게 될 것이다.
reschedule이 상태를 변경하여 isSatisfied가 명령과 쿼리의 두 역할을 동시에 수행하기 때문이다.
아마 기능을 추가하는 과정에서 누군가 날짜가 다르면 일정을 동일하게 수정해야 한다는 요구사항을 추가했고,
프로그래머는 별다른 생각 없이 기존의 isSatisfied에 reschedule을 호출하는 코드를 추가해 버린 것이다.
명령과 쿼리를 뒤섞으면 실행 결과를 예측하기 어려워진다.
- 반환 값을 돌려주는 메서드: 쿼리이므로 부수 효과에 대한 부담이 없다
- 반환 값을 가지지 않는 메서드: 명령이므로 호출할 때 부수효과에 주의해야 한다
퍼블릭 인터페이스 설계 시 이 둘을 분리하도록 하자.
명령-쿼리 분리와 참조 투명성
프로그램에서 부수효과를 발생시키는 문법은 대입문과 함수다.
대입문을 이용해 다른 값으로 변경 가능하며,
함수 내부에 부수효과를 포함할 경우 동일한 인자를 전달하더라고 결괏값이 매번 달라질 수 있다.
참조 투명성이란 어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성이다.
수학은 참조 투명성을 엄격하게 준수하는 체계다. 값이 불변성을 지니기 때문이다.
수학에서의 함수는 어떤 값도 변경하지 않는다.
객체지향은 객체의 상태 변경이라는 부수효과를 기반으로 하기 때문에 참조 투명성은 예외에 가깝다.
하지만 명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성의 장점을 제한적이나마 누릴 수 있게 된다.
- 명령형 프로그래밍: 부수효과를 기반으로 하는 프로그래밍
상태를 변경시키는 연산들을 적절한 순서대로 나열함으로써 프로그램을 작성
대부분의 객체지향 언어들은 메시지에 의한 객체의 상태 변경에 집중하기 때문에 명령형 프로그래밍 언어로 분류됨 - 함수형 프로그래밍: 부수효과가 존재하지 않는 수학적인 함수에 기반
책임에 초점을 맞춰라
디미터 법칙을 준수하고 묻지 말고 시켜라 스타일을 따르면서 의도를 드러내는 인터페이스를 설계하는 쉬운 방법은,
메시지를 먼저 선택하고 그 후에 메시지를 처리할 객체를 선택하는 것이다.
명령과 쿼리를 분리하고 계약에 의한 설계 개념을 통해 객체의 협력 방식을 명시적으로 드러낼 수 있는 방법은,
객체의 구현 이전에 객체 사이의 협력에 초점을 맞추고 협력 방식을 단순하고 유연하게 만드는 것이다.
메시지를 먼저 선택하는 방식은 긍정적인 영향을 미친다
- 디미터 법칙: 두 객체 사이의 구조적인 결합도를 낮출 수 있음
- 묻지 말고 시켜라: 클라이언트 관점에서 메시지를 선택하며 협력을 구조화하게 됨
- 의도를 드러내는 인터페이스: 클라이언트 관점에서 메시지의 이름을 정하게 됨
- 명령-쿼리 분리 원칙: 예측 가능한 협력을 만들기 위해 명령과 쿼리를 분리함
중요한 것은 협력에 적합한 객체가 아닌 메시지다.
지금까지의 원칙들은 실제 실행 시점에 필요한 구체적인 제약이나 조건을 명확하게 표현하지는 못한다.
협력을 위해 두 객체가 보장해야 하는 실행 시점의 제약을 인터페이스에 명시할 수 있는 방법은 존재하지 않는다.
이런 문제를 해결하기 위해 버트란드 마이어는 계약에 의한 설계 개념을 제안했다. (부록 A)