Screening (상영)
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Screening(Movie movie, int sequence, LocalDateTime whenScreened) {
this.movie = movie;
this.sequence = sequence;
this.whenScreened = whenScreened;
}
public LocalDateTime getStartTime() {
return whenScreened;
}
public boolean isSequence(int sequence) {
return this.sequence == sequence;
}
public Money getMovieFee() {
return movie.getFee();
}
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);
}
}
private, 메서드는 public클래스를 구현하거나 사용할 때 가장 중요한 것은 클래스 경계를 구분 짓는 것. 훌륭한 설계의 핵심은 어떤 부분을 공개하고 숨길지 결정하는 것. 경계의 명확성이 객체의 자율성을 보장한다.
Screening.reserve() 메서드는 예매 후 예매 정보를 가지는 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.cacluateFee() 메서드로 반환된 Money 객체가 times() 메서드를 통해 인원 수만큼의 가격을 곱한다.
Money 객체를 통해 돈과 관련된 로직의 중복 구현을 막고 도메인 의미를 풍부하게 표현할 수 있다.Reservation 클래스
public class Reservation {
private Customer customer; // 고객
private Screening Screening; // 상영 정보
private Money fee; // 예매 요금
private int audienceCount; // 인원 수
public Reservation(Customer customer, Screening Screening, Money fee, int audienceCount) {
this.customer = customer;
this.Screening = Screening;
this.fee = fee;
this.audienceCount = audienceCount;
}
}
Screening, Movie, Reservation 인스턴스들은 서로 호출하며 상호작용하는데 이를 ‘협력’이라 부른다.Screening → Reservation 생성Screening → Movie 사용 (calculateFee)Movie 클래스
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
// ...
}
caculateMovieFee 메서드는 discountPolicy에 메시지를 전송해 할인 요금을 반환 받는다.discountPolity에 메시지를 전송할 뿐이다.AmounDiscountPolicy와 PercentDiscountPolicy는 대부분 코드가 유사하고 요금을 계산하는 방식만 조금 다르다.
부모 클래스인 DiscountPolicy에 중복 코드를 두고 각 구현체가 이를 상속 받게 한다.
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public DiscountPolicy(DiscountCondition ... conditions) {
this.conditions = Arrays.asList(conditions);
}
public Money calculateDiscountAmount(Screening screening) {
return conditions.stream()
.filter(each -> each.isSatisfiedBy(screening))
.findFirst()
.map(each -> getDiscountAmount(screening))
.orElse(Money.ZERO);
}
abstract protected Money getDiscountAmount(Screening Screening);
}
DiscountCondition은 인터페이스로 선언되어 있다.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
각 할인 정책과 할인 조건 구현체의 구현 코드는 아래에서 확인할 수 있다.
public class SequenceCondition implements DiscountCondition { // 순번 조건
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) {
return screening.isSequence(sequence);
}
}
public class PeriodCondition implements DiscountCondition { // 기간 조건
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 screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
}
}
public class AmountDiscountPolicy extends DiscountPolicy { // 금액 할인 정책
private Money discountAmount;
public AmountDiscountPolicy(Money discountAmount, DiscountCondition... conditions) {
super(conditions);
this.discountAmount = discountAmount;
}
@Override
protected Money getDiscountAmount(Screening screening) {
return discountAmount;
}
}
public class PercentDiscountPolicy extends DiscountPolicy { // 비율 할인 정책
private double percent;
public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
super(conditions);
this.percent = percent;
}
@Override
protected Money getDiscountAmount(Screening screening) {
return screening.getMovieFee().times(percent);
}
}
영화에 대해선 단 하나의 할인 정책만 설정 가능
→ Movie 생성자에 하나의 정책만 받음
할인 정책에 여러 할인 조건을 적용 가능
→ DiscountPolicy 생성자에 여러 정책 받음
Movie avater = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(...),
new PeriodCondition(...))));
Movie 클래스 어디에도 할인 정책을 판단하지 않는다.Movie는 DiscountPolicy를 의존하지만, 실행 시간에는 구현체 중 하나를 의존하게 된다.부모 클래스와 다른 부분만 추가해서 새로운 클래스를 더 쉽고 빠르게 만드는 방법
→ 차이에 의한 프로그래밍(programming by differnce)
Movie에 메시지 → ‘할인 정책을 적용해 비용을 계산해라’AmoundDiscountPolicy.getDiscountAmount() or PercentDiscountPolicy.getDiscountAmount()DiscountPolicy는 추상 클래스를 사용해 구현 상속을 사용했다.DiscountCondition의 경우 구현 공유가 필요 없었기에 인터페이스를 사용했었다.할인 정책이 없는 경우는 어떻게 할까?
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
Movie 내부에서 결정하게 된다.책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력적인 설계 측면에서 대부분 좋지 않은 선택이다. (할인 금액 계산 책임의 위치가
Movie이냐,DiscoutPolicy냐가 조건문에 의해 나뉘고 있다.)
NoneDiscountPolicy를 통해 일관성을 지킬 수 있다.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
중요한 것은 Movie와 DiscountPolicy는 수정하지 않고 클래스를 추가하는 것만으로 기능을 확장했다는 것
NoneDiscountPolicy는 할인 조건이 없기 때문에 getDiscountAmount() 반환 값이 의미가 없다.
DiscountPolicy에서 할인 조건에 매칭되는 경우에만 getDiscountAmount()를 호출한다.DiscountPolicy와 NoneDiscountPolicy를 개념적으로 결합시킨다.
NoneDiscountPolicy를 만든 개발자는 getDiscountAmount()가 호출되지 않을 경우 DiscountPolicy가 0원을 반환할 것이라는 걸 가정하기 때문DiscountPolicy는 할인 조건을 탐색하는 공통 로직이 존재하는데 NoneDiscountPolicy에게는 필요 없는 절차이다.DiscountPolicy를 인터페이스로 변경하자
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
// NoneDiscountPolicy는 DiscountPolicy를 바로 구현
public abstract class DefaultDiscountPolicy implements DiscountPolicy { // 할인 조건 탐색이 필요한 경우
private List<DiscountCondition> conditions = new ArrayList<>();
public DefaultDiscountPolicy(DiscountCondition... conditions) {
this.conditions = Arrays.asList(conditions);
}
@Override
public Money calculateDiscountAmount(Screening screening) {
// 할인 정책 탐색 코드 존재
// ...
}
abstract protected Money getDiscountAmount(Screening Screening);
}
// AmountDiscoutPolicy와 PercentDiscountPolicy는 DefaultDiscountPolicy를 상속
모든 구현과 관련된 것들은 트레이드 오프의 대상이다. 사소한 결정이라도 트레이드오프를 통해 얻어진 결론과 그렇지 않은 결론의 차이는 크다. 고민하고, 트레이드오프하라.
Movie가 DiscountPolicy의 코드를 재사용하는 방법이 바로 합성Movie가 DiscountPolicy를 상속해서 사용하도록 변경할 수도 있다.