BottleH Blog

Object - 14장 일관성 있는 협력

    Tags

  • java
Object - 14장 일관성 있는 협력 thumbnail

객체는 협력을 위해 존재한다.

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

🔖 14.1.1 기본 정책 확장

기본 정책을 구성하는 4가지 방식

  1. 고정요금 방식
  2. 시간대별 방식
  3. 요일별 방식
  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 설계에 일관성 부여하기

협력을 일관성 있게 만들기 위해 다음과 같은 기본 지침을 따르자.

  1. 변하는 개념을 변하지 않는 개념으로부터 분리하라.
  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 협력 패턴 설계하기

  1. 적용조건을 가장 잘 알고 있는 정보 전문가인 FeeCondition에게 할당
  2. 단위요금을 적용해서 요금을 계산하는 두 번째 작업은 요금기준의 정보 전문가인 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 패턴을 찾아라

애플리케이션에서 유사한 기능에 대한 변경이 지속적으로 발생하고 있다면 변경을 캡슐화할 수 있는 적절한 추상화를 찾은 후, 이 추상화에 변하지 않는 공통적인 책임을 할당하라.

협력을 일관성 있게 만든다는 것은 유사한 변경을 수용할 수 있는 협력 패턴을 발견하는 것과 동일하다.

Written by@BottleH
Back-End Developer

GitHub