어떤 객체에게 어떤 책임을 할당할지를 결정하는 것은 쉽지 않다.
책임 할당 과정은 일종의 트레이드오프이며, 올바른 책임을 할당하기 위해서는 다양한 관점에서 설계를 평가할 수 있어야 한다.
이번에 살펴볼 GRASP 패턴은 책임 할당의 어려움을 해결해준다.
1. 책임 주도 설계를 향해
데이터보다 행동을 먼저 결정하라
데이터는 책임을 수행하는 데 필요한 재료를 제공할 뿐이다.
객체의 데이터에서 행동으로 무게 중심을 옮겨야 한다.
- 데이터 중심 설계
이 객체가 포함해야 하는 데이터가 무엇인가 => 데이터를 처리하는 데 필요한 오퍼레이션은 무엇인가 - 책임 중심 설계
이 객체가 수행해야 하는 책임은 무엇인가 => 이 책임을 수행하는 데 필요한 데이터는 무엇인가
협력이라는 문맥 안해서 책임을 결정하라
책임의 품질은 협력에 적합한 정도로 결정된다.
책임이 조금 어색해 보이더라도 협력에 적합하다면 좋은 책임이다.
책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다.
협력을 시작하는 주체는 메시지 전송자이며, 협력에 적합한 책임이란 메시지 전송자에게 적합한 책임이다.
메시지를 전송하는 클라이언트의 의도에 적합한 책임을 할당해야 한다.
메시지를 결정한 후에 객체를 선택해야 한다.
- 클래스 기반 설계
이 클래스가 필요하긴한데 이 클래스는 뭘 해야 하는가 - 메시지 기반 설계
메시지를 전송해야 하는데 누구에게 전송해야 하는가
클라이언트는 어떤 객체가 메시지를 수신할지 알지 못한다.
그리고 수신하기로 결정된 객체는 메시지를 처리할 책임을 할당받게 된다.
메시지를 먼저 결정하기 때문에 송신자는 수신자에 대한 어떠한 가정도 할 수 없다. 수신자는 캡슐화된다.
책임 주도 설계
- 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악
- 시스템 책임을 더 작은 책임으로 분할
- 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임 할당
- 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 도는 역할 탐색
- 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력
2. 책임 할당을 위한 GRASP 패턴
크레이그 라만의 GRASP 패턴
- General Responsibility Assignment Software Pattern (일반적인 책임 할당을 위한 소프트웨어 패턴)
설계 시작 전 도메인에 대한 개략적인 모습을 그려 보는 것이 유용하다.
영화 예매 시스템을 구성하는 도메인 개념과 개념 사이의 관계를 대략적으로 표현해보자.
책임을 할당받을 객체들의 종류와 관계에 대한 유용한 정보를 제공할 수 있다면 충분하다.
중요한 것은 설계를 시작하는 것이다.
위 그림은 2장의 도메인 모델과 약간 다르다. 할인 정책이 영화의 종류로 표현돼 있다.
두 도메인 모델 모두 올바른 구현을 이끌어낼 수만 있다면 둘 다 올바른 도메인 모델이다.
도메인 모델 안에 포함된 개념과 관계는 구현의 기반이 돼야 한다.
도메인 모델의 구조는 코드의 구조에 영향을 미치며, 코드를 구현하면서 얻게 되는 통찰은 역으로 도메인에 대한 개념을 바꾼다.
INFORMATION EXPERT(정보 전문가) 패턴
책임 주도 설계의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다.
애플리케이션은 영화를 에매할 책임이 있다.
- 메시지를 전송할 객체는 무엇을 원하는가?
영화를 예매하는 것이다. 메시지의 이름은 '예매하라'로 하자. - 메시지를 수신할 적합한 객체는 누구인가?
책임을 수행할 정보를 알고 있는 객체, 정보 전문가에게 책임을 할당하자.
정보를 알고 있는 객체만이 책임을 어떻게 수행할지 스스로 결정할 수 있다.
이 패턴을 따르면정보와 행동이 최대한 가까운 곳에 위치되기 때문에 캡슐화를 유지할 수 있다.
필요한 정보를 가진 객체들로 책임이 분산되므로 높은 응집도와 낮은 결합도가 가능하다.
정보와 데이터는 다르다.
정보를 알고 있다고 해서 그 정보를 저장하고 있을 필요는 없다.
정보 전문가가 데이터를 반드시 저장하고 있을 필요는 없다.
그렇다면 '예매하라' 메시지를 처리할 책임을 누구에게 할당해야 할까?
영화에 대한 정보와 상영시간, 상영 순번 등의 정보가 필요하다.
'상영'이라는 도메인 개념이 적합할 것이다.
이제부터는 외부의 인터페이스가 아닌 Screening의 내부로 들어가 메시지를 처리하기 위해 필요한 절차과 구현을 고민해야 한다.
스스로 처리할 수 없는 작업이 있다면 외부에 도움을 요청해야 하며, 이는 새로운 메시지가 되고, 세로운 객체의 책임으로 할당된다.
연쇄적인 메시지 송수신을 통해 협력 공동체가 구성된다.
'예매하라' 메시지를 완료하기 위해서는 예매 가격을 계산하는 작업이 필요하다.
영화 한 편의 가격 정보가 필요하다. Screening은 이 정보를 모른다.
새로운 메시지를 '가격을 계산하라'로 두자.
필요한 정보를 알고 있는 전문가는 영화다. Movie에게 책임을 할당하자.
이제 가격을 계산하기 위해 Movie가 어떤 작업을 해야 하는지 생각해보자.
영화가 할인 가능한지를 판단한 후 할인 정책에 따라 할인 요금을 제외한 금액을 계산해야 한다.
할인 조건에 따라 영화가 할인 가능한지를 판단하는 것은 영화가 스스로 처리할 수 없는 일이다.
새로운 메시지를 '할인 여부를 판단하라'로 두자.
필요한 정보를 알고 있는 전문가는 할인 조건이다. DiscountCondition에게 책임을 할당하자.
정보 전문가 패턴은 객체에게 책임을 할당할 때 가장 기본이 되는 책임 할당 원칙이다.
객체란 상태와 행동을 함께 가지는 단위라는 객체지향의 기본 원리를 책임 할당의 관점에서 표현한다.
LOW COUPLING(낮은 결합도) 패턴, HIGH COHESION(높은 응집도) 패턴
설계는 트레이드오프 활동이다. 동일한 기능을 구현할 수 있는 무수히 많은 설계가 존재한다.
올바른 책임 할당을 위해 정보 전문가 패턴 이외의 다른 책임 할당 패턴들을 함께 고려해야 한다.
이전의 설계에서는 할인 요금 게산을 위해 Movie가 DiscountCoundition에 '할인 여부를 판단하라' 메시지를 전송했다.
Movie 대신 Screening이 직접 DiscountCondition과 협력하게 하는 것은 어떨까?
기능은 동일하다. 차이점은 응집도와 결합도다. 높은 응집도와 낮은 결합도는 기본 원리다.
이를 낮은 결합도 패턴과 높은 응집도 패턴이라고 한다.
- 낮은 결합도 패턴 관점
의존성을 낮추고 변화의 영향을 줄이며 재사용성을 증가시키려면 전체적인 결합도가 낮게 유지되어야 한다.
이전 설계의 경우 도메인 상으로 Movie가 DiscountCondition의 목록을 속성으로 포함하고 있다.
Movie와 DiscountCondition이 이미 결합돼 있기 때문에, 둘을 협력하게 하면 결합도를 추가하지 않고로 협력을 완성할 수 있다.
하지만 새로운 설계의 경우 Screening과 DiscountCondition 사이에 새로운 결합도가 추가된다.
따라서 이전 설계가 더 나은 설계 대안이다.
- 높은 응집도 패턴 관점
복잡성을 관리할 수 있는 수준으로 유지하기 위해서는 응집도가 높게 유지되어야 한다.
Screening의 주된 책임은 예매를 생성하는 것이다.
새로운 설게에서 Screening은 영화 요금 계산과 관련된 책임 일부를 떠안게 된다.
즉, 예매 요금을 계산하는 방식이 변경될 경우 Screening도 함께 변경해야 한다.
Screening은 서로 다른 이유로 변경되는 책임을 짊어지게 되므로 응집도가 낮아진다.
Movie의 주된 책임은 영화 요금을 계산하는 것이다.
영화 요금을 계산하는 데 필요한 할인 조건을 판단하는 것은 응집도에 아무런 해를 끼치지 않는다.
따라서 이전 설계가 더 나은 설계 대안이다.
CREATOR(창조자) 패턴
영화 예매 협력의 최종 결과물은 Reservation 인스턴스다.
협력에 참여하는 어떤 객체에게는 Reservation 인스턴스를 생성할 책임을 할당해야 한다.
창조자 패턴은 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공한다.
객체 A를 생성해야 할 때, 아래 조건을 최대한 많이 만족하는 B에게 객체 생성 책임을 할당하라.
- B가 A를 포함하거나 참조한다.
- B가 A를 기록한다.
- B가 A를 긴밀하게 사용한다.
- B가 A를 초기화하는 데 필요한 데이터를 가지고 있다 (B가 A에 대한 정보 전문가)
이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 결합도에 영향을 미치지 않는다.
Reservation의 Creator로 누구를 선택해야 할까? 바로 Screening이다.
Screening은 예매 정보를 생성하는 데 필요한 정보에 대한 전문가이며, 예매 요금을 계산하는 데 필수적인 Movie를 알고 있다.
여기까지의 책임 분배는 설계를 시작하기 위한 대략적인 스케치에 불과하다.
실제 설계는 코드를 작성하는 동안 이뤄진다.
3. 구현을 통한 검증
Screening부터 구현해보자. Screening은 예매에 대한 정보 전문가이자 Reservation의 창조자다.
1. Screening은 '예매하라' 메시지에 응답할 수 있어야 한다.
class Screening {
public reserve(
customer: CustomElementRegistry,
audienceCount: number
): Reservation {}
}
2. 책임을 수행하는 데 필요한 인스턴스 변수를 결정하자.
type LocalDateTime = {
dayOfWeek: number;
localTime: number;
};
class Screening {
private movie: Movie;
private sequence: number;
private whenScreened: LocalDateTime;
public reserve(customer: CustomElementRegistry, audienceCount: number): Reservation {}
}
3. 영화를 예매하기 위해서는 Movie에게 '가격을 계산하라' 메시지를 전송해서 게산된 영화 요금을 반환받아야 한다.
class Screening {
private movie: Movie;
private sequence: number;
private whenScreened: LocalDateTime;
public reserve(customer: CustomElementRegistry, audienceCount: number): Reservation {
return new Reservation(customer, this, calculate(audienceCount), audienceCount);
}
private calculateFee(audienceCount: number): Money {
return this.movie.calculateMovieFee(this).times(audienceCount);
}
}
Movie에 전송하는 메시지의 시그니처를 calculateMovieFee(screening: Screening)으로 선언했다.
수신자인 Movie가 아니라 송신자인 Screening의 의도를 표현했다.
Screening은 Movie의 내부 구현에 대한 어떤 지식도 없이 전송할 메시지를 결정했다.
내부 구현을 고려하지 않고 메시지를 결정하면 내부 구현을 깔끔하게 캡슐화할 수 있다.
Screening과 Movie를 연결하는 유일한 연결 고리는 메시지뿐이다.
메시지를 기반으로 협력을 구성하면 객체들 사이의 결합도를 느슨하게 유지할 수 있다.
4. Movie는 calculateMovieFee 메시지에 응답할 수 있어야 한다.
class Movie {
public calculateMovieFee(screening: Screening): Money {}
}
5. 요금을 게산하기 위한 정보를 인스턴스 변수로 선언하자.
type MovieType = "AMOUNT_DISCOUNT" | "PERCENT_DISCOUNT" | "NONE_DISCOUNT";
class Movie {
private title: string;
private runningTime: number;
private fee: Money;
private discountConditions: DiscountCondition[];
private movieType: MovieType;
private discountAmount: Money;
private discountPercent: number;
public calculateMovieFee(screening: Screening): Money {}
}
6. 할인 여부를 판단하고 할인 요금을 계산하는 로직을 추가하자.
class Movie {
private title: string;
private runningTime: number;
private fee: Money;
private discountConditions: DiscountCondition[];
private movieType: MovieType;
private discountAmount: Money;
private discountPercent: number;
public calculateMovieFee(screening: Screening): Money {
if (this.isDiscountable(screening)) {
return this.fee.minus(this.calculateDiscountAmount());
}
return this.fee;
}
private isDiscountable(screening: Screening): boolean {
return this.discountConditions.find((condition) => condition.isSatisfiedBy(screening))
? true
: false;
}
private calculateDiscountAmount(): Money {
switch (this.movieType) {
case "AMOUNT_DISCOUNT":
return this.calculateAmountDiscountAmount();
case "PERCENT_DISCOUNT":
return this.calculatePercentDiscountAmount();
case "NONE_DISCOUNT":
return this.calculateNoneDiscountAmount();
}
throw new Error();
}
private calculateAmountDiscountAmount(): Money {
return this.discountAmount;
}
private calculatePercentDiscountAmount(): Money {
return this.fee * (this.discountPercent / 100);
}
private calculateNoneDiscountAmount(): Money {
return new Money(0).ZERO;
}
}
7. DiscountCondition은 '할인 여부를 판단하라' 메시지를 처리해야 한다.
class DiscountCondition {
public isSatisfiedBy(screening: Screening): boolean {}
}
8. 적절한 인스턴스 변수와 메서드를 추가하자.
class DiscountCondition {
private type: DiscountConditionType;
private sequence: number;
private dayOfWeek: number;
private startTime: number;
private endTime: number;
public isSatisfiedBy(screening: Screening): boolean {
if (this.type === "PERIOD") {
return this.isSatisfiedByPeriod(screening);
}
return this.isSatisfiedBySequence(screening);
}
private isSatisfiedByPeriod(screening: Screening): boolean {
return (
this.dayOfWeek === screening.getWhenScreened().dayOfWeek &&
this.startTime <= screening.getWhenScreened().localTime &&
this.endTime >= screening.getWhenScreened().localTime
);
}
private isSatisfiedBySequence(screening: Screening): boolean {
return this.sequence === screening.getSequence();
}
}
9. DiscountCondition이 할인 조건을 판단하기 위해 필요한 정보를 제공하는 메서드를 Screening에 추가하자.
class Screening {
private movie: Movie;
private sequence: number;
private whenScreened: LocalDateTime;
public getWhenScreened(): LocalDateTime {
return this.whenScreened;
}
public getSequence(): number {
return this.sequence;
}
public reserve(customer: CustomElementRegistry, audienceCount: number): Reservation {
return new Reservation(customer, this, calculate(audienceCount), audienceCount);
}
private calculateFee(audienceCount: number): Money {
return this.movie.calculateMovieFee(this).times(audienceCount);
}
}
DiscountCondition 개선하기
변경에 취약한 클래스란 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스다.
DiscountCondition은 세 가지 이유로 변경될 수 있다.
- 새로운 할인 조건 추가
- 순번 조건을 판단하는 로직 변경
- 기간 조건을 판단하는 로직 변경
따라서 응집도가 낮다. 이를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리해야 한다.
isSatisfiedBySequence와 isSatisfiedByPeriod는 서로 다른 이유로 변경된다.
1. 인스턴스 변수가 초기화되는 시점 살펴보기
- 응집도가 높은 클래스: 인스턴스를 생성할 때 모든 속성을 함께 초기화
- 응집도가 낮은 클래스: 객체의 속성 중 일부만 초기화하고 일부는 초기화되지 않은 상태로 남겨짐
함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
- DiscountCondition이 순번 조건을 표현: sequence는 초기화되지만 dayOfWeek, startTime, endTime은 초기화되지 않음
- DiscountCondition이 기간 조건을 표현: dayOfWeek, startTime, endTime은 초기화되지만 sequence는 초기화되지 않음
2. 메서드들이 인스턴스 변수를 사용하는 방식 살펴보기
- 응집도가 높은 클래스: 모든 메서드가 객체의 모든 속성을 사용
- 응집도가 낮은 클래스: 메서드들이 사용하는 속성에 따라 그룹이 나뉨
속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.
- isSatisfiedBySequence는 sequence는 사용하지만 dayOfWeek, startTime, endTime은 사용하지 않음
- isSatisfiedByPeriod는 dayOfWeek, startTime, endTime은 사용하지만 sequence는 사용하지 않음
메서드의 크기가 너무 크다면, 긴 메서드를 응집도 높은 작은 메서드로 잘게 분해해 나가면 숨겨져 있던 문제점이 드러난다.
타입 분리하기
DiscountCondition의 가장 큰 문제는 순번 조건과 기간 조건의 두 독립적인 타입이 하나의 클래스 안에 공존하고 있다는 점이다.
두 타입을 SequenceCondition과 PeriodCondition으로 분리하자.
class PeriodCondition {
private dayOfWeek: number;
private startTime: number;
private endTime: number;
constructor(dayOfWeek: number, startTime: number, endTime: number) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
private isSatisfiedBy(screening: Screening): boolean {
return (
this.dayOfWeek === screening.getWhenScreened().dayOfWeek &&
this.startTime <= screening.getWhenScreened().localTime &&
this.endTime >= screening.getWhenScreened().localTime
);
}
}
class SequenceCondition {
private sequence: number;
constructor(sequence: number) {
this.sequence = sequence;
}
private isSatisfiedBy(screening: Screening): boolean {
return this.sequence === screening.getSequence();
}
}
Movie의 인스턴스는 이제 두 서로 다른 클래스의 인스턴스와 협력할 수 있어야 한다.
Movie 클래스 안에서 SequenceCondition의 목록과 PeriodCondition의 목록을 따로 유지해보자.
class Movie {
private periodConditions: PeriodCondition[];
private sequenceConditions: SequenceCondition[];
private isDiscountable(screening: Screening): boolean {
return this.checkPeriodConditions(screening) || this.checkSequenceConditions(screening);
}
private checkPeriodConditions(screening: Screening): boolean {
return this.periodConditions.find((condition) => condition.isSatisfiedBy(screening))
? true
: false;
}
private checkSequenceConditions(screening: Screening): boolean {
return this.sequenceConditions.find((condition) => condition.isSatisfiedBy(screening))
? true
: false;
}
}
하지만 이 방법은 새로운 문제를 야기한다.
- Movie가 PeriodCondition과 SequenceCondition 모두에게 결합된다. 전체적인 결합도가 높아졌다.
- 새로운 할인 조건을 추가하기가 더 어려워졌다.
새로운 할인 조건을 담기 위한 인스턴스 변수를 추가하고, 메서드도 새로 추가해야 하며, isDiscountable도 수정해야 한다.
DiscountCondition의 내부 구현이 수정되면 Movie에게도 영향이 미치게 된다.
DiscountCondition 입장에서는 응집도가 높아졌지만, 변경과 캡슐화의 관점에서는 전체적으로 설계 품질이 나빠졌다.
POLYMORPHISM(다형성) 패턴
Movie의 입장에서 SequenceCondition과 PeriodCondition은 아무 차이도 없다.
둘 모두 할인 여부를 판단하는 동일한 책임을 수행하고 있다. 동일한 역할을 수행하는 것이다.
역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있다.
- 추상 클래스: 역할을 대체할 클래스들 사이에 구현을 공유해야 할 필요가 있을 때 사용
- 인터페이스: 구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 정의할 때 사용
SequenceCondition과 PeriodCondition이 구현을 공유할 필요는 없으므로 인터페이스를 이용하자.
interface DiscountCondition {
isSatisfiedBy(screening: Screening): boolean;
}
class PeriodCondition implements DiscountCondition { ... }
class SequenceCondition implements DiscountCondition { ... }
class Movie {
private discountConditions: DiscountCondition[];
private isDiscountable(screening: Screening): boolean {
return this.discountConditions.find((condition) => condition.isSatisfiedBy(screening))
? true
: false;
}
}
이제 Movie와 DiscountCondition 사이의 협력은 다형적이다.
객체의 타입에 따라 변하는 행동이 있다면, 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하자.
PROTECTED VARIATIONS(변경 보호) 패턴
변경을 캡슐화하도록 책임을 할당하자.
이제 두 개의 서로 다른 변경은 두 개의 서로 다른 클래스 안으로 캡슐화되었다.
DiscountCondition이라는 추상화가 구체적인 타입을 캡슐화한다.
클래스를 변경에 따라 분리하고 인터페이스로 변경을 캡슐화하는 것은 결합도와 응집도를 향상시킨다.
- 하나의 클래스가 여러 타입의 행동을 구현하고 있다면 클래스를 분해하고 다형성 패턴에 따라 책임을 분신시켜라.
- 예측 가능한 변경으로 여러 클래스들이 불안정해진다면 환경 보호 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하라.
Movie 클래스 개선하기
Movie 역시 금액 할인 정책 영화와 비율 할인 정책 영화의 두 타입을 하나의 클래스 안에 구현하고 있는 응집도가 낮은 클래스다.
AmountDiscountMovie, PercentDiscountMovie, NoneDiscountMovie로 클래스를 분해하고,
Movie의 경우에는 구현을 공유할 필요가 있으므로 추상 클래스를 이용해 역할을 구현하자.
abstract class Movie {
private title: string;
private runningTime: number;
private fee: Money;
private discountConditions?: DiscountCondition[];
constructor(
title: string,
runningTime: number,
fee: Money,
discountConditions?: DiscountCondition[]
) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountConditions = discountConditions;
}
public calculateMovieFee(screening: Screening): Money {
if (this.isDiscountable(screening)) {
return this.fee.minus(this.calculateDiscountAmount());
}
return this.fee;
}
private isDiscountable(screening: Screening): boolean {
return this.discountConditions &&
this.discountConditions.find((condition) => condition.isSatisfiedBy(screening))
? true
: false;
}
protected abstract calculateDiscountAmount(): Money;
protected getFee(): Money {
return this.fee;
}
}
기존의 discountAmount, discountPercent 변수들과 관련 메서드들은 자식 클래스로 옮겨진다.
할인 금액을 계산하는 로직은 추상 메서드로 선언함으로써 서브클래스들이 오버라이딩하게 된다.
class AmountDiscountMovie extends Movie {
private discountAmount: Money;
constructor(
title: string,
runningTime: number,
fee: Money,
discountAmount: Money,
...discountConditions: DiscountCondition[]
) {
super(title, runningTime, fee, discountConditions);
this.discountAmount = discountAmount;
}
protected calculateDiscountAmount(): Money {
return this.discountAmount;
}
}
class PercentDiscountMovie extends Movie {
private percent: number;
constructor(
title: string,
runningTime: number,
fee: Money,
percent: number,
...discountConditions: DiscountCondition[]
) {
super(title, runningTime, fee, discountConditions);
this.percent = percent;
}
protected calculateDiscountAmount(): Money {
return this.getFee() * (this.percent / 100);
}
}
class NoneDiscountMovie extends Movie {
constructor(title: string, runningTime: number, fee: Money) {
super(title, runningTime, fee);
}
protected calculateDiscountAmount(): Money {
return new Money(0).ZERO;
}
}
Move에서 기본 금액을 반환하는 메서드를 추가하자.
이 메스드는 서브클래스에서만 사용해야 하므로 protected로 제한하자.
abstract class Movie {
protected getFee(): Money {
return this.fee;
}
}
이제 모든 클래스의 내부 구현은 캡슐화돼 있고 모든 클래스는 변경의 이유를 오직 하나씩만 가진다.
각 클래스는 응집도가 높고 다른 클래스와 최대한 느슨하게 결합돼 있다. 책임은 적절하게 분배돼 있다.
데이터 중심 설계는 정반대다. 데이터가 아닌 책임을 중심으로 설계해야 한다.
변경과 유연성
변경에 대비하는 방법
- 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계
- 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 작성
할인 정책을 실행 중에 변경할 수 있어야 한다는 요구사항이 추가됐다고 가정해 보자.
현재 코드는 상속을 이용하고 있기 때문에,
새로운 할인 정책이 추가될 때마다 인스턴스를 생성하고, 상태를 복사하고, 식별자를 관리하는 코드를 추가해야 한다.
상속 대신 합성을 사용하자.
Movie의 상속 계층 안에 구현된 할인 정책을 독립적인 클래스로 분리한 후 Movie에 합성시키면 더 유연한 설계가 된다
이제 실행 중에 할인 정책을 바꾸는 일은 Movie에 연결된 DiscountPolicy의 인스턴스를 교체하는 단순한 작업이 된다.
const movie = new Movie(...);
movie.changeDiscountPolicy(new PercentDiscountMovie(...));
새로운 할인 정책이 추가되더라도 새로운 클래스를 추가하고 changeDiscountPolicy로 전달하면 된다.
유연성은 의존성 관리의 문제다. 요소들 사이의 의존성의 정도가 유연성의 정도를 결정한다.
4. 책임 주도 설계의 대안
일단은 기능을 수행하는 코드를 작성한 이후에 책임들을 올바른 위치로 이동시키는 것도 방법이다.
리팩터링: 이해하기 쉽고 수정하기 쉬운 소프트웨어로 개선하기 위해 겉으로 보이는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것
이전의 데이터 중심 설계를 리팩터링해보자.
메서드 응집도
영화 예매를 처리하는 모든 절차는 ReservationAgency에 집중돼 있었다.
ReservationAgency에 포함된 로직들을 적절한 객체의 책임으로 분배해보자.
// 이전 코드
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;
}
}
// 적절한 할인 정책에 따라 예매 요금 계산
let fee: Money;
if (discountable) {
let discountAmount: Money = new Money(0).ZERO;
switch (movie.getMovieType()) {
case "AMOUNT_DISCOUNT":
discountAmount = movie.getDiscountAmount();
break;
case "PERCENT_DISCOUNT":
discountAmount = movie.getFee().times(movie.getDiscountPercent());
break;
case "NONE_DISCOUNT":
discountAmount = new Money(0).ZERO;
}
fee = movie.getFee().minus(discountAmount);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
긴 메서드는 코드의 유지보수에 부정적인 영향을 미친다.
- 어떤 일을 수행하는지 한눈에 파악하기 어려움
- 변경이 필요할 때 수정해야 할 부분을 찾기 어려움
- 일부 로직만 수정하더라도 버그가 발생할 확률이 높음
- 로직의 일부만 재사용하는 것이 불가능함
- 코드를 재사용하는 유일한 방법은 코드를 복붙하는 것뿐이므로 코드 중복을 초래하기 쉬움
이런 메서드를 몬스터 메서드라 한다.
주석을 추가하는 대신 메서드를 작게 분해해서 각 메서드의 응집도를 높이자.
긴 메서드를 작고 응집도 높은 메서드로 분리하면 각 메서드를 적절한 클래스로 이동하기가 더 수월해진다.
class ReservationAgency {
public reserve(screening: Screening, customer: Customer, audienceCount: number): Reservation {
const discountable: boolean = this.checkDiscountable(screening);
const fee: Money = this.calculateFee(screening, discountable, audienceCount);
return this.createReservation(screening, customer, audienceCount, fee);
}
private checkDiscountable(screening: Screening): boolean {
return screening
.getMovie()
.getDiscountConditions()
.find((condition) => this.isDiscountable(condition, screening))
? true
: false;
}
private isDiscountable(condition: DiscountCondition, screening: Screening): boolean {
if (condition.getType() === "PERIOD") {
return this.isSatisfiedByPeriod(condition, screening);
}
return this.isSatisfiedBySequence(condition, screening);
}
private isSatisfiedByPeriod(condition: DiscountCondition, screening: Screening) {
return (
condition.getDayOfWeek() === screening.getWhenScreened().dayOfWeek &&
condition.getStartTime() <= screening.getWhenScreened().localTime &&
condition.getEndTime() >= screening.getWhenScreened().localTime
);
}
private isSatisfiedBySequence(condition: DiscountCondition, screening: Screening) {
return condition.getSequence() === screening.getSequence();
}
private calculateFee(screening: Screening, discountable: boolean, audienceCount: number) {
if (discountable) {
return (
screening.getMovie().getFee().minus(this.calculateDiscountedFee(screening.getMovie())) *
audienceCount
);
}
}
private calculateDiscountedFee(movie: Movie): Money {
switch (movie.getMovieType()) {
case "AMOUNT_DISCOUNT":
return this.calculateAmountDiscountedFee(movie);
case "PERCENT_DISCOUNT":
return this.calculatePercentDiscountedFee(movie);
case "NONE_DISCOUNT":
return this.calculateNoneDiscountedFee(movie);
}
throw new Error();
}
private calculateAmountDiscountedFee(movie: Movie) {
return movie.getDiscountAmount();
}
private calculatePercentDiscountedFee(movie: Movie) {
return movie.getDiscountPercent();
}
private calculateNoneDiscountedFee(movie: Movie) {
return new Money(0).ZERO;
}
private createReservation(
screening: Screening,
customer: Customer,
audienceCount: number,
fee: Money
) {
return new Reservation(customer, screening, fee, audienceCount);
}
}
클래스의 길이는 더 길어졌지만, 명확성의 가치가 클래스의 길이보다 더 중요하다.
작고 명확하며 한 가지 일에 집중하는 응집도 높은 메서드는 변경 가능한 설계를 이끌어 내는 기반이 된다.
메서드들의 응집도는 높아졌지만, ReservationAgency의 응집도는 여전히 낮다.
변경의 이유가 다른 메서드들을 적절한 위치로 분배해야 한다.
적절한 위치란 각 메서드가 사용하는 데이터를 정의하고 있는 클래스다.
객체를 자율적으로 만들자
자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만들어야 한다.
ReservationAgency의 isDiscountable을 보자.
class ReservationAgency {
private isDiscountable(condition: DiscountCondition, screening: Screening): boolean {
if (condition.getType() === "PERIOD") {
return this.isSatisfiedByPeriod(condition, screening);
}
return this.isSatisfiedBySequence(condition, screening);
}
private isSatisfiedByPeriod(condition: DiscountCondition, screening: Screening) {
return (
condition.getDayOfWeek() === screening.getWhenScreened().dayOfWeek &&
condition.getStartTime() <= screening.getWhenScreened().localTime &&
condition.getEndTime() >= screening.getWhenScreened().localTime
);
}
private isSatisfiedBySequence(condition: DiscountCondition, screening: Screening) {
return condition.getSequence() === screening.getSequence();
}
}
이 메서드들은 DiscountCondition에 속한 데이터를 주로 이용하고 있다. 이동시켜 보자.
class DiscountCondition {
private type: DiscountConditionType;
private sequence: number;
private dayOfWeek: number;
private startTime: number;
private endTime: number;
// 구현의 일부에서 퍼블릭 인터페이스의 일부로 바뀜
public isDiscountable(screening: Screening): boolean {
if (this.type === "PERIOD") {
return this.isSatisfiedByPeriod(screening);
}
return this.isSatisfiedBySequence(screening);
}
private isSatisfiedByPeriod(screening: Screening) {
return (
this.dayOfWeek === screening.getWhenScreened().dayOfWeek &&
this.startTime <= screening.getWhenScreened().localTime &&
this.endTime >= screening.getWhenScreened().localTime
);
}
private isSatisfiedBySequence(screening: Screening) {
return this.sequence === screening.getSequence();
}
}
- 내부 구현이 캡슐화되었다.
DiscountCondition 내 모든 접근자 메서드가 제거되었다. - 응집도가 높아졌다.
할인 조건을 계산하는 데 필요한 모든 로직이 모이게 되었다. - 결합도가 낮아졌다.
ReservationAgency는 내부 구현을 노출하는 접근자 메서드를 사용하지 않고 메시지를 통해서만 DiscountCondition과 협력한다.
여기에 다형성 패턴과 환경 보호 패턴을 차례대로 적용하면 더 나은 코드가 될 것이다.
동작하는 코드를 작성한 후 리팩터링하는 것도 훌륭한 결과물을 낳을 수 있다.