TIL

14 일관성 있는 협력

01 핸드폰 과금 시스템 변경하기

기본 정책 확장

Phone -----> RatePolicy <-----------
                  ↑                |
              ---- -------         |
BasicRatePolicy           AdditionalRatePolicy
      ↑                                ↑
FixedFeePolicy                 RateDiscountablePolicy
TimeOfDayDiscountPolicy        TaxablePolicy
DayOfWeekDiscountPolicy
DurationDiscountPolicy

고정 요금 방식 구현하기

public class FixedFeePolicy extends BasicRatePolicy {
    private Money amount;
    private Duration seconds;
    // ...
    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

시간대별 방식 구현하기

public class Call {
	private DateTimeInterval interval;

	public Call(LocalDateTime from, LocalDateTime to) {
		this.interval = DateTimeInterval.of(from, to);
	}
	// ...
}
public class TimeOfDayDiscountPolicy extends BasicRatePolicy {
    private List<LocalTime> starts = new ArrayList<LocalTime>();
    private List<LocalTime> ends = new ArrayList<LocalTime>();
    private List<Duration> durations = new ArrayList<Duration>();
    private List<Money>  amounts = new ArrayList<Money>();

    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DateTimeInterval interval : call.splitByDay()) { // call에게 일자별 통화 기간 분리를 요청
            for(int loop=0; loop < starts.size(); loop++) {
                LocalTime from = from(interval, starts.get(loop));
                LocalTime to = to(interval, ends.get(loop));

                long intervalSeconds = Duration.between(from, to).getSeconds();
                long criteriaSeconds = durations.get(loop).getSeconds();

                Money intervalFee = amounts.get(loop);
                result.plus(intervalFee.times(intervalSeconds / criteriaSeconds));
            }
        }
        return result;
    }

    private LocalTime from(DateTimeInterval interval, LocalTime from) {
        LocalTime intervalFrom = interval.getFrom().toLocalTime();
        return intervalFrom.isBefore(from) ? from : intervalFrom;
    }

    private LocalTime to(DateTimeInterval interval, LocalTime to) {
        LocalTime intervalTo = interval.getTo().toLocalTime();
        return intervalTo.isAfter(to) ? to : intervalTo;

요일별 방식 구현하기

public class DayOfWeekDiscountRule {
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>();
    private Duration duration = Duration.ZERO;
    private Money amount = Money.ZERO;
    // 생성자
    public Money calculate(DateTimeInterval interval) {
        if (dayOfWeeks.contains(interval.getFrom().getDayOfWeek())) {
            return amount.times(interval.duration().getSeconds() / duration.getSeconds());
        }
        return Money.ZERO;
    }
}

public class DayOfWeekDiscountPolicy extends BasicRatePolicy {
    private List<DayOfWeekDiscountRule> rules = new ArrayList<>();
    // 생성자
    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DateTimeInterval interval : call.getInterval().splitByDay()) {
            for(DayOfWeekDiscountRule rule: rules) { 
                result.plus(rule.calculate(interval));
            }
        }
        return result;
    }
}

구간별 방식 구현하기 전 기존 방식 문제점

02 설계에 일관성 부여하기

조건 로직 대 객체 탐색

캡슐화 다시 살펴보기

03 일관성 있는 기본 정책 구현하기

변경 분리하기

변경 캡슐화하기

협력 패턴 설계하기

  1. BasicRatePolicy가 calculateFee 메시지 수신
  2. BasicRatePolicy는 전체 Call에 대해 요금을 계산
  3. 각 Call 별로 FeeRule의 calculateFee 메서드를 실행
  4. FeeCondition - 전체 통화 시간을 각 ‘규칙’의 ‘적용조건’을 만족하는 구간들로 나누기
  5. FeeRule - 분리된 통화 구간에 ‘단위요금’을 적용하여 요금 계산

추상화 수준에서 협력 패턴 구현하기

// Call의 통화 기간 중 '적용조건'을 만족하는 기간의 목록을 반환
public interface FeeCondition {
    List<DateTimeInterval> findTimeIntervals(Call call);
}

public class FeeRule {
    private FeeCondition feeCondition;
    private FeePerDuration feePerDuration;
    // ...
    public Money calculateFee(Call call) {
        return feeCondition.findTimeIntervals(call)
                .stream()
                .map(each -> feePerDuration.calculate(each)) // 단위요금 계산
                .reduce(Money.ZERO, (first, second) -> first.plus(second));
    }
}

public final class BasicRatePolicy implements RatePolicy {
    private List<FeeRule> feeRules = new ArrayList<>();
    // ...
    @Override
    public Money calculateFee(Phone phone) {
        return phone.getCalls()
                .stream()
                .map(call -> calculate(call))
                .reduce(Money.ZERO, (first, second) -> first.plus(second));
    }

    private Money calculate(Call call) {
        return feeRules
                .stream()
                .map(rule -> rule.calculateFee(call))
                .reduce(Money.ZERO, (first, second) -> first.plus(second));
    }
}

구체적인 협력 구현하기

// 시간대별 정책
public class TimeOfDayFeeCondition implements FeeCondition {
    private LocalTime from;
    private LocalTime to;
    // ...
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval().splitByDay()
                .stream()
                .filter(each -> from(each).isBefore(to(each)))
                .map(each -> DateTimeInterval.of(
                                LocalDateTime.of(each.getFrom().toLocalDate(), from(each)),
                                LocalDateTime.of(each.getTo().toLocalDate(), to(each))))
                .collect(Collectors.toList());
    }
    // ...
}

// 요일별 정책
public class DayOfWeekFeeCondition implements FeeCondition {
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>();
    // ...
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval()
                .splitByDay()
                .stream()
                .filter(each ->
                        dayOfWeeks.contains(each.getFrom().getDayOfWeek()))
                .collect(Collectors.toList());
    }
}

// 구간별 정책
public class DurationFeeCondition implements FeeCondition {
    private Duration from;
    private Duration to;
    // ...
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        if (call.getInterval().duration().compareTo(from) < 0) {
            return Collections.emptyList();
        }
        return Arrays.asList(DateTimeInterval.of(
                call.getInterval().getFrom().plus(from),
                call.getInterval().duration().compareTo(to) > 0 ?
                        call.getInterval().getFrom().plus(to) :
                        call.getInterval().getTo()));
    }
}

협력 패턴에 맞추기

public class FixedFeeCondition implements FeeCondition {
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return Arrays.asList(call.getInterval()); // 적용조건이 의미 없기에 call의 전체 기간을 반환
    }
}