객체는 협력을 위해 존재한다.
📖 14.1 핸드폰 과금 시스템 변경하기
🔖 14.1.1 기본 정책 확장
기본 정책을 구성하는 4가지 방식
- 고정요금 방식
- 시간대별 방식
- 요일별 방식
- 구간별 방식
🔖 14.1.2 고정요금 방식 구현하기
@RequiredArgsConstructor
public class FixedFeePolicy extends BasicRatePolicy {
private final Money amount;
private final Duration seconds;
@Override
protected Money calculateCallFee(Call call) {
return amount.times((double) call.getDuration().getSeconds() / seconds.getSeconds());
}
}
🔖 14.1.3 시간대별 방식 구현하기
@Getter
@RequiredArgsConstructor
public class DateTimeInterval {
private final LocalDateTime from;
private final LocalDateTime to;
public static DateTimeInterval of(LocalDateTime from, LocalDateTime to) {
return new DateTimeInterval(from, to);
}
public static DateTimeInterval toMidnight(LocalDateTime from) {
return new DateTimeInterval(
from,
LocalDateTime.of(from.toLocalDate(), LocalTime.of(23, 59, 59, 999_999_999)));
}
public static DateTimeInterval fromMidnight(LocalDateTime to) {
return new DateTimeInterval(
LocalDateTime.of(to.toLocalDate(), LocalTime.of(0, 0)),
to);
}
public static DateTimeInterval during(LocalDate date) {
return new DateTimeInterval(
LocalDateTime.of(date, LocalTime.of(0, 0)),
LocalDateTime.of(date, LocalTime.of(23, 59, 59, 999_999_999)));
}
public Duration duration() {
return Duration.between(from, to);
}
public List<DateTimeInterval> splitByDay() {
if (days() > 0) {
return splitByDay(days());
}
return List.of(this);
}
private long days() {
return Duration.between(from.toLocalDate().atStartOfDay(), to.toLocalDate().atStartOfDay()).toDays();
}
private List<DateTimeInterval> splitByDay(long days) {
List<DateTimeInterval> result = new ArrayList<>();
addFirstDay(result);
addMiddleDays(result, days);
addLastDay(result);
return result;
}
private void addFirstDay(List<DateTimeInterval> result) {
result.add(DateTimeInterval.toMidnight(from));
}
private void addMiddleDays(List<DateTimeInterval> result, long days) {
for (int loop = 1; loop < days; loop++) {
result.add(DateTimeInterval.during(from.toLocalDate().plusDays(loop)));
}
}
private void addLastDay(List<DateTimeInterval> result) {
result.add(DateTimeInterval.fromMidnight(to));
}
}
public class Call {
@Getter
private final DateTimeInterval interval;
public Call(LocalDateTime from, LocalDateTime to) {
this.interval = DateTimeInterval.of(from, to);
}
public Duration getDuration() {
return interval.duration();
}
public LocalDateTime getFrom() {
return interval.getFrom();
}
public LocalDateTime getTo() {
return interval.getTo();
}
public List<DateTimeInterval> splitByDay() {
return interval.splitByDay();
}
}
public class TimeOfDayDiscountPolicy extends BasicRatePolicy {
private List<LocalTime> starts = new ArrayList<>();
private List<LocalTime> ends = new ArrayList<>();
private List<Duration> durations = new ArrayList<>();
private List<Money> amounts = new ArrayList<>();
@Override
protected Money calculateCallFee(Call call) {
Money result = Money.ZERO;
for (DateTimeInterval interval : call.splitByDay()) {
for (int loop = 0; loop < starts.size(); loop++) {
result.plus(amounts.get(loop).times(
(double) Duration.between(from(interval, starts.get(loop)), to(interval, ends.get(loop)))
.getSeconds() / durations.get(loop).getSeconds()));
}
}
return result;
}
private LocalTime from(DateTimeInterval interval, LocalTime from) {
return interval.getFrom().toLocalTime().isBefore(from) ? from : interval.getFrom().toLocalTime();
}
private LocalTime to(DateTimeInterval interval, LocalTime to) {
return interval.getTo().toLocalTime().isAfter(to) ? to : interval.getTo().toLocalTime();
}
}
🔖 14.1.4 요일별 방식 구현하기
@RequiredArgsConstructor
public class DayOfWeekDiscountRule {
private final List<DayOfWeek> dayOfWeeks = new ArrayList<>();
private final Duration duration = Duration.ZERO;
private final Money amount = Money.ZERO;
public Money calculate(DateTimeInterval interval) {
if (dayOfWeeks.contains(interval.getFrom().getDayOfWeek())) {
return amount.times((double) interval.duration().getSeconds() / duration.getSeconds());
}
return Money.ZERO;
}
}
@RequiredArgsConstructor
public class DayOfWeekDiscountPolicy extends BasicRatePolicy {
private final 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;
}
}
🔖 14.1.5 구간별 방식 구현하기
public class DurationDiscountRule extends FixedFeePolicy {
private final Duration from;
private final Duration to;
public DurationDiscountRule(Money amount, Duration seconds, Duration from, Duration to) {
super(amount, seconds);
this.from = from;
this.to = to;
}
public Money calculate(Call call) {
if (call.getDuration().compareTo(to) > 0) {
return Money.ZERO;
}
if (call.getDuration().compareTo(from) < 0) {
return Money.ZERO;
}
return super.calculateCallFee(new Call(call.getFrom().plus(from),
call.getDuration().compareTo(to) > 0 ? call.getFrom().plus(to) : call.getTo()));
}
}
- 상속을 잘못 사용했다! 예시코드를 바꿔서 넣어봄.
@RequiredArgsConstructor
public class DurationDiscountPolicy extends BasicRatePolicy {
private final List<DurationDiscountRule> rules = new ArrayList<>();
@Override
protected Money calculateCallFee(Call call) {
Money result = Money.ZERO;
for (DurationDiscountRule rule : rules) {
result.plus(rule.calculate(call));
}
return result;
}
}
- 이해하기도 힘들고 설계 개선과 새로운 기능의 추가를 방해한다.
- 코드 재사용을 위한 상속은 해롭다.
📖 14.2 설계에 일관성 부여하기
협력을 일관성 있게 만들기 위해 다음과 같은 기본 지침을 따르자.
- 변하는 개념을 변하지 않는 개념으로부터 분리하라.
- 변하는 개념을 캡슐화하라.
🔖 14.2.1 조건 로직 대 객체 탐색
public abstract class DiscountPolicy {
private final List<DiscountCondition> conditions;
protected DiscountPolicy(DiscountCondition... conditions) {
this.conditions = Arrays.asList(conditions);
}
public Money calculateDiscountAmount(Screening screening) {
for (DiscountCondition condition : conditions) {
if (condition.isSatisfiedBy(screening)) {
return getDiscountAmount(screening);
}
}
return Money.ZERO;
}
protected abstract Money getDiscountAmount(Screening screening);
}
@AllArgsConstructor
public class Movie {
/**
* 제목
*/
private String title;
/**
* 상영시간
*/
private Duration runningTime;
/**
* 기본요금
*/
@Getter
private Money fee;
/**
* 할인 정책
*/
private DiscountPolicy discountPolicy;
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
public void changeDiscountPolicy(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
- 실제로 협력에 참여하는 주체는 구체적인 객체다.
- 변경에 초점을 맞추고 캡슐화의 관점에서 설계를 바라보면 일관성 있는 협력 패턴을 얻을 수 있다.
🔖 14.2.2 캡슐화 다시 살펴보기
데이터 은닉
- 오직 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한함으로써 객체 내부의 상태 구현을 숨기는 기법
- 해당 클래스의 메서드만이 인스턴스 변수에 접근할 수 있어야 한다.
- 캡슐화는 데이터 은닉 이상이다.
캡슐화란 변하는 어떤 것이든 감추는 것이다.
- 데이터 캡슐화
- 메서드 캡슐화
- 객체 캡슐화
- 서브타입 캡슐화
변경을 캡슐화할 수 있는 다양한 방법이 존재하지만 협력을 일관성 있게 만들기 위해 가장 일반적으로 사용하는 방법은 서브타입 캡슐화와 객체 캡슐화를 조합하는 것이다.
- 변하는 부분을 분리해서 타입 계층을 만든다.
- 변하지 않는 부분의 일부로 타입 계층을 합성한다.
📖 14.3 일관성 있는 기본 정책 구현하기
🔖 14.3.1 변경 분리하기
- 고정요금 방식
- 단위시간당 요금
- 시간대별 방식
- 시작시간 ~ 종료시간까지 단위시간당 요금
- 요일별 방식
- 요일별 단위시간당 요금
- 구간별 방식
- 통화구간동안 단위시간당 요금
🔖 14.3.2 변경 캡슐화하기
- 변하는 것: 적용조건
- 변하지 않는 것: 규칙
규칙으로부터 적용조건을 분리해서 추상화한 후 시간대별, 요일별, 구간별 방식을 이 추상화의 서브타입으로 만든다.
🔖 14.3.3 협력 패턴 설계하기
- 적용조건을 가장 잘 알고 있는 정보 전문가인
FeeCondition에게 할당 - 단위요금을 적용해서 요금을 계산하는 두 번째 작업은 요금기준의 정보 전문가인
FeeRule이 담당
🔖 14.3.4 추상화 수준에서 협력 패턴 구현하기
public interface FeeCondition {
List<DateTimeInterval> findTimeIntervals(Call call);
}
@RequiredArgsConstructor
public class FeePerDuration {
private final Money fee;
private final Duration duration;
public Money calculate(DateTimeInterval interval) {
return fee.times(Math.ceil((double) interval.duration().toNanos() / duration.toNanos()));
}
}
@RequiredArgsConstructor
public class FeeRule {
private final FeeCondition feeCondition;
private final FeePerDuration feePerDuration;
public Money calculateFee(Call call) {
return feeCondition.findTimeIntervals(call)
.stream()
.map(feePerDuration::calculate)
.reduce(Money.ZERO, Money::plus);
}
}
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class BasicRatePolicy implements RatePolicy {
private final List<FeeRule> feeRules;
@Override
public Money calculateFee(Phone phone) {
return phone.getCalls()
.stream()
.map(this::calculate)
.reduce(Money.ZERO, Money::plus);
}
private Money calculate(Call call) {
return feeRules.stream()
.map(rule -> rule.calculateFee(call))
.reduce(Money.ZERO, Money::plus);
}
protected abstract Money calculateCallFee(Call call);
}
- 변하지 않는 요소와 추상적인 요소만으로도 요금 계산에 필요한 전체적인 협력 구조를 설명할 수 있다.
🔖 14.3.5 구체적인 협력 구현하기
🛠️ 시간대별 정책
@RequiredArgsConstructor
public class TimeOfDayFeeCondition implements FeeCondition {
private final LocalTime from;
private final 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(toList());
}
public LocalTime from(DateTimeInterval interval) {
return interval.getFrom().toLocalTime().isBefore(from) ? from : interval.getFrom().toLocalTime();
}
public LocalTime to(DateTimeInterval interval) {
return interval.getTo().toLocalTime().isAfter(to) ? to : interval.getTo().toLocalTime();
}
}
🛠️ 요일별 정책
@RequiredArgsConstructor
public class DayOfWeekFeeCondition implements FeeCondition {
private final List<DayOfWeek> dayOfWeeks;
@Override
public List<DateTimeInterval> findTimeIntervals(Call call) {
return call.getInterval()
.splitByDay()
.stream()
.filter(each -> dayOfWeeks.contains(each.getFrom().getDayOfWeek()))
.collect(toList());
}
}
🛠️ 구간별 정책
@RequiredArgsConstructor
public class DurationFeeCondition implements FeeCondition {
private final Duration from;
private final Duration to;
@Override
public List<DateTimeInterval> findTimeIntervals(Call call) {
if (call.getInterval().duration().compareTo(from) < 0) {
return Collections.emptyList();
}
return List.of(DateTimeInterval.of(
call.getInterval().getFrom().plus(from),
call.getInterval().duration().compareTo(to) > 0 ?
call.getInterval().getFrom().plus(to) :
call.getInterval().getTo()));
}
}
- 유사한 기능에 대해 유사한 협력 패턴을 적용하는 것은 객체지향 시스템에서 개념적 무결성을 유지할 수 있는 가장 효과적인 방법이다.
🔖 14.3.6 협력 패턴에 맞추기
@RequiredArgsConstructor
public class FixedFeeCondition implements FeeCondition {
@Override
public List<DateTimeInterval> findTimeIntervals(Call call) {
return Collections.singletonList(call.getInterval());
}
}
- 개념적 무결성을 무너뜨리는 것보다는 약간의 부조화를 수용하는 편이 더 낫다.
🔖 14.3.7 패턴을 찾아라
애플리케이션에서 유사한 기능에 대한 변경이 지속적으로 발생하고 있다면 변경을 캡슐화할 수 있는 적절한 추상화를 찾은 후, 이 추상화에 변하지 않는 공통적인 책임을 할당하라.
협력을 일관성 있게 만든다는 것은 유사한 변경을 수용할 수 있는 협력 패턴을 발견하는 것과 동일하다.
