올바른 도메인 모델이란 존재하지 않는다. 위의 영화 시스템 도메인 모델도 2장에서 설명한 모델과 다르다. 올바른 구현을 이끌어낼 수 있다면 정답을 둘 다 올바르다. 이번 장의 마지막에 이르면 도메인 모델이 2장과 동일하게 변경되는데 이는 유연성과 재사용성을 고려하며 얻게 되는 통찰이 역으로 도메인에 대한 개념을 바꾸기 때문이다. 중요한 것은 도메인을 그대로 투영한 모델이 아닌 구현에 도움이 되는 모델이다.
상영
도메인은 영화의 정보와 상영 시간 등의 정보를 알고 있기에 ‘예매하라’ 책임을 할당하기 적합하다.INFORMATION EXPERT 패턴이란 객체가 자신이 소유한 정보와 관련된 작업을 수행한다는 일반적인 직관을 표현한 패턴이다. 다만 ‘정보’ ‘데이터’는 다른데 객체가 정보를 알고 있다고 해서 그 정보를 ‘저장’할 필요는 없기 때문이다. 정보를 굳이 저장하지 않고 계산 등을 통해서도 도출할 수 있다.
상영
을 이를 가지고 있는 정보로 계산할 것이다.상영
이 메시지 송신자로서 새로운 메시지인 ‘계산하라’를 도출하게 된다.영화
도메인에게 ‘계산하라’가 할당될 것이다.영화
의 구현을 개략적으로 생각했을 때 할인이 가능한지를 판단해야 하는데 이 또한 외부에 도움의 필요하다.할인 조건
이다.할인 조건
은 할인 여부 판단에 대해 외부 정보를 필요로하지 않기에 메시지를 전송하지는 않는다.이처럼 INFORMATION EXPERT 패턴을 통해 자율성이 높은 객체들로 구성된 협력 공동체를 구축할 가능성이 높아진다.
'예매하라'
|
Screening - '계산하라' -> Movie
|
'할인 여부를 판단하라' -> DiscountCondition
INFORMATION EXPERT 패턴 설계의 대안으로 영화 예제에서 다른 설계를 고려할 수도 있다.
'예매하라'
|
Screening - '계산하라' -> Movie
|
'할인 여부를 판단하라' -> DiscountCondition
영화
가 할인 조건
과 협력하는 것이 아닌 상영
이 ‘할인 조건’과 협력할 수도 있다.할인 조건
과 협력하는 객체가 영화
가 아니라 상영
이라는 것영화
는 이미 할인 조건
의 목록을 속성으로 포함하고 있다.상영
에 할인 조건
결합을 추가하지 않고도 협력을 완성할 수 있다.영화
가 할인 조건
과 협력하는 것이 더 나은 설계 대안이다.상영
의 핵심 책임은 예매하는 것이다.할인 조건
과 협력한다면 요금 계산과 관련된 책임 일부를 맡아야 한다.상영
도 함께 변경해야 한다.영화
는 원래부터 요금 계산이 핵심 책임이기에 할인 조건 판단을 위해 할인 조건
과 협력하는 것은 응집에 해를 끼치지 않는다.Reservation
인스턴스를 생성하는 것이고 Screening
이 그 책임을 맡았다.CREATOR 패턴의 의도는 어떤 방식으로든 생성되는 객체와 연결되거나 관련될 필요가 있는 객체에 해당 객체 생성 책임을 맡기는 것. 이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 전체적인 결합도에 영향을 미치지 않는다.
Screening
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
// '예매하라' 메시지 시그니처
// Reservation 인스턴스 생성
public Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
}
private Money calculateFee(int audienceCount) {
return movie.calculateMovieFee(this).times(audienceCount);
}
}
Movie
에 ‘계산하라’ 메시지 시그니처로 calculateMovieFee(Screening screening)
선언
Movie
내부 구현을 고려하지 않고 메시지로 캡슐화Movie
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
private MovieType movieType;
private Money discountAmount;
private double discountPercent;
// '계산하라' 메시지 시그니처
public Money calculateMovieFee(Screening screening) {
if (isDiscountable(screening)) {
return fee.minus(calculateDiscountAmount());
}
return fee;
}
// 할인 여부 판단
private boolean isDiscountable(Screening screening) {
return discountConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
private Money calculateDiscountAmount() {
switch(movieType) { // MovieType에 따라 할인 정책 적용
case AMOUNT_DISCOUNT:
return calculateAmountDiscountAmount();
case PERCENT_DISCOUNT:
return calculatePercentDiscountAmount();
case NONE_DISCOUNT:
return calculateNoneDiscountAmount();
}
throw new IllegalStateException();
}
private Money calculateAmountDiscountAmount() {
return discountAmount;
}
private Money calculatePercentDiscountAmount() {
return fee.times(discountPercent);
}
private Money calculateNoneDiscountAmount() {
return Money.ZERO;
}
}
DiscountCondition
에 ‘할인 여부를 판단하라’ 메시지 시그니처로 isSatisfiedBy(Screening screening)
선언DiscountCondition
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public boolean isSatisfiedBy(Screening screening) {
if (type == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(screening);
}
return isSatisfiedBySequence(screening);
}
private boolean isSatisfiedByPeriod(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0;
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
DiscountCondition
은 할인 조건 판단을 위해 Screening
상영 시간과 상영 순번을 알아야 하기에 Screening
이 이를 제공해야 한다.
Screening
에 getWhenScreend()
와 getSequence()
추가 필요DiscountCondition
은 다음 서로 다른 세 가지 이유로 변경될 수 있다.
DiscountCondition
의 가장 큰 문제는 두 독립적 타입이 한 클래스에 공존하는 것
각 조건을 SequenceCondition
과 PeriodCondition
두 클래스로 분리할 수 있다.
public class SequenceCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return sequence == screening.getSequence();
}
}
public class PeriodCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
public boolean isSatisfiedBy(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
}
}
Condition
은 자신의 모든 인스턴스 변수를 함께 초기화할 수 있다.Movie
와 협력하는 클래스는 DiscountCondition
하나 뿐이었기에 위 두 클래스와 모두 협력할 수 있어야 한다.Movie
클래스 안에 SequenceCondition
, PeriodCondition
목록을 따로 유지하는 방법이 있다.
public class Movie {
// ...
private List<PeriodCondition> periodConditions;
private List<SequenceCondition> sequenceConditions;
// ...
private boolean checkPeriodConditions(Screening screening) {
return periodConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
private boolean checkSequenceConditions(Screening screening) {
return sequenceConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
Movie
가 SequenceCondition
, PeriodCondition
양쪽 모두에게 결합되는 문제List
변수를 추가해야 하고 메서드도 추가해야한다.Movie
입장에선 SequenceCondition
, PeriodCondition
은 동일한 책임을 수행한다.Movie
가 DiscountCondition
역할 의존하도록 변경
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class Movie {
// ...
private List<DiscountCondition> discountConditions;
// ...
public Money calculateMovieFee(Screening screening) {
if (isDiscountable(screening)) {
return fee.minus(calculateDiscountAmount());
}
return fee;
}
private boolean isDiscountable(Screening screening) {
return discountConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
Movie
는 구체 타입을 몰라도 역할이 올바른 메시지를 받아들일 수 있다는 사실만 알아도 충분하다.DiscountCondition
의 두 서브클래스는 서로 다른 이유로 변경된다.
Movie
클래스 또한 금액 할인 정책과 비율 할인 정책 영화라는 두 타입을 한 클래스에서 구현하고 있기에 응집도가 낮다.Movie
의 경우 구현을 공유해야 해서 추상 클래스를 사용
public abstract class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
public Movie(String title, Duration runningTime, Money fee, DiscountCondition... discountConditions) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountConditions = Arrays.asList(discountConditions);
}
public Money calculateMovieFee(Screening screening) {
if (isDiscountable(screening)) {
return fee.minus(calculateDiscountAmount());
}
return fee;
}
private boolean isDiscountable(Screening screening) {
return discountConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
protected Money getFee() {
return fee;
}
abstract protected Money calculateDiscountAmount();
}
discountAmount
, discountPercent
변수가 사라졌다.AmountDiscountMovie
와 PercentDiscountMovie
구체 타입에서 각 변수와 메서드를 추가 또는 오버라이딩할 수 있다.도메인 구조가 코드의 구조를 이끈다. 도메인 모델은 단순히 설계에 필요한 용어를 제공하는 것을 넘어 구조에 영향을 미친다. 변경 역시 도메인 모델의 일부이다. 도메인 모델에는 도메인 안에서 변하는 개념과 이들 사이 관계가 투영되어 있어야 한다. 위 도메인 모델에서도 할인 정책과 조건이 변경될 수 있다는 직관이 반영되어 있다.
Movie
의 문제점
Movie
상속 계층 안에 구현된 할인 정책을 독립적인 DiscountPolicy
로 분리 후 Movie
에 합성
Movie movie = new Movie("타이타닉",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(...));
movie.changeDiscountPolicy(new PercentDiscountPolicy(...));
합성을 사용하면 새 정책이 추가되더라도 정책 변경 시 추가적인 코드를 작성할 필요가 없다.