올바른 도메인 모델이란 존재하지 않는다. 위의 영화 시스템 도메인 모델도 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(...));
합성을 사용하면 새 정책이 추가되더라도 정책 변경 시 추가적인 코드를 작성할 필요가 없다.