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
를 상속해서 사용하도록 변경할 수도 있다.