14장. 일관성 있는 협력


객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다.
하지만 재사용은 꽁짜로 얻어지지 않는다.
재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다

아래 예제를 통해 일관성 있는 협력 패턴이, 이해하기 쉽고 직관적이라는 것을 알아보자


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

기본 정책 확장

11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 아래와 같이 수정해보자

  • 고정요금 방식
    • ex) 10초당 18원
  • 시간대별 방식
    • ex) 00 ~ 19시 10초당 18원
    • ex) 19 ~ 24시 10초당 15원
  • 요일별 방식
    • ex) 평일 10초당 38원
    • ex) 공휴일 10초당 19원
  • 구간별 방식
    • ex) 초기 1분 10초당 50원
    • ex) 1분 이후 10초당 20원

조합 가능한 요금 계산 순서


위의 사진처럼 무수히 많은 조합이 나오기에 설계가 중요해졌다.

클래스 구조

고정요금 방식 구현하기

가장 간단한 고정요금이다. 일반요금제와 동일하다.

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

시간대별 방식 구현하기

시간대별 방식에 따라 요금을 계산하기 위해서는 통화 기간을 정해진 시간대별로 나눈후 시간대별로 서로 다른 계산 규칙을 적용해야 한다.
시간대별 방식의 통화 요금일 계산하기 위해서는 통화의 시작, 종료시간, 시작일자, 종료일자도 함께 고려되어야 한다.

시간대별 통화 시간을 관리하는 클래스 DateTimeInterval

public class DateTimeInterval {
    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)));
    }

    private DateTimeInterval(LocalDateTime from, LocalDateTime to) {
        this.from = from;
        this.to = to;
    }

    public Duration duration() {
        return Duration.between(from, to);
    }

    public LocalDateTime getFrom() {
        return from;
    }

    public LocalDateTime getTo() {
        return to;
    }

    public List<DateTimeInterval> splitByDay() {
        if (days() > 0) {
            return split(days());
        }
        return Arrays.asList(this);
    }

    private long days() {
        return Duration.between(from.toLocalDate().atStartOfDay(), to.toLocalDate().atStartOfDay()).toDays();
    }

    private List<DateTimeInterval> split(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 String toString() {
        return "[ " + from + " - " + to + " ]";
    }
}

요일별 방식 구현하기

요일별 방식은 요일별로 요금 규칙을 다르세 설정할 수 있다.
각 규칙은 요일의 목록, 단위 시간, 단위 요금이라는 3가지 요소로 구성된다.

public class DayOfWeekDiscountRule {
    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 {
    @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;
    }
}

잠시 지금까지 구현한 고정요금, 시간대별, 요일별 방식의 클래스를 다시 살펴보자.
겉으로 보기에는 문제가 없이 잘 구현된 것 같아 보인다. FixedFeePolicy, TimeOfDayDiscountPolicy, DayOfWeekDiscountPolicy
세 클래스는 통화 요금을 정확하게 계산하고 있고 응집도와 결합도 측면에서도 특별히 문제는 없어 보인다.
그러나 이 클래스들을 함께 모아놓고 보면 문제점이 보인다

문제는 이 클래스들이 유사한 문제를 해결하고 있음에도 설계의 일관성이 없다는 것이다


02. 설계에 일관성 부여하기

설계에 일관성을 부여하기 위해서는 다음과 같은 연습이 필요하다

  • 다양한 설계 경험 익히기
  • 디자인 패턴을 학습하고 변경이라는 문맥안에 적용해보기
  • 협력을 일관성 있게 만들기 위해 다음과 같은 기본 지침을 따르기
    • 변하는 개념을 변하지 않는 개념으로부터 분리
    • 변하는 개념을 캡슐화

조건 로직 대 객체 탐색

먼저, 조건 로직에 대해서 코드를 통해서 살펴보자.
4장에서 나왔던 ReservationAgency 예제이다.

public class ReservationAgency {
    public Reservation reserve(...) {
        for (DiscountCondition condition : movie,getDiscountConditions()) {
            if (condition.getType() == DiscountCondtionType.PERIDOD) {
                // 기간조건
            } else {
                // 회차조건
            }
        }

        if (discountable) {
            switch(movie.getMovieType)) {
                case AMOUNT_DISCOUNT: // 금액 할인 정책
                case PERCENT_DISCOUNT: // 비율 할인 정책
                case NONE_DISCOUNT: // 할인 정책 없음
            }
        } else {
            // 할인 정책이 불가능한 경우
        }
    }
}

위와 같이 조건 로직으로 구현한다면 변경에 취약하고 유지보수의 어려움성이 생긴다.
조건로직이란 위와 같이 'if ~ else' 를 통해 비즈니스 로직에 덕지덕지 붙여 놓은 것이다.

우리는 추상화와 다형성이라는 것을 배웠기에 인터페이스를 이용해 객체 탐색으로 변경할 수 있다.

public class Movie {
    private DiscountPolicy discountPolicy;
    public Money calculteMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

이처럼 다형성은 조건 로직을 객체 사이의 이동으로 바꾸어 준다.
따라서, 할인 조건에 맞는 메시지만 주고 받으면 되기 때문에 변경이 용이해진다


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

변경 분리하기

일관성 있는 협력을 만들기 위한 첫 단계는 변하는 개념과 변치 않는 개념을 분리하는 것이다
앞에서 본 핸드폰 과금 시스템의 기본 정책에서 변하는 부분과 변하지 않는 부분을 다시 살펴보자


단위요금이 시간당 요금을 계산하는 반면, 적용조건은 형식이 다르다른 것을 알 수 있다.
이것으로 적용조건은 변하는 부분이고, 단위요금은 변치 않는 다는 것으로 분리할 수 있다는 것을 유추할 수 있다.

변경 캡슐화하기

협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다.
여기서 변하지 않는 것은 ‘규칙’이다. 변하는 것은 ‘적용조건’이다.
따라서 규칙으로부터 적용조건을 분리하여 추상화한 후 시간대별, 요일별, 구간별 방식을 이 추상화의 서브타입으로 만들어야 한다.
그 후에 규칙이 적용조건을 표현하는 추상화를 합성관계로 연결하는 것이 객체의 캡슐화이다.

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

먼저, ‘적용조건’을 표현하는 추상화인 FeeCondtion

public interface FeeCondition {
    List<DateTimeInterval> findTimeIntervals(Call call);
}

다음으로 FeeFule, 단위요금과 적용조건을 인스턴스변수로 사용한다

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));
    }
}

FeePerDuartion클래스는 단위 시간당 요금이라는 개념을 표현한다.
또한, 이정보를 이용해 일정기간의 요금을 계산하는 calculate 메서드를 구현한다

public class FeePerDuration {
    private Money fee;
    private Duration duration;

    public Money calculate(DateTimeInterval interval) {
        return fee.times(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
    }
}

구체적인 협력 구현하기

이제 맨 처음 앞서 구현한 구체적인 협력 클래스를 인터페이스를 이용해 구현해보자
TimeOfDayFeeCondition 시간대별 정책

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());
    }

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

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

DayOfWeekFeeCondition, 요일별정책

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());
    }
}

DurationFeeCondition 구간별정책

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()));
    }
}

맨처음 짯던 정책들과 비교를 해보자. 각 클래스마다 일관성이 생기게 되었다.
우리는 변하는 개념과 변치 않는 개념을 분리해서 변경을 캡슐화 하였다.
이처럼 변경을 캡슐화해 협력을 일관성 있게 만들면 어떤 장점을 얻을 수 있는지 명확하게 보여준다


마찬가지로, 추가적인 정책이 필요하다면 FeeCondition으로부터 구현을 해주면 된다.

일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다.
변경을 캡슐화 하는 방법이 협력에 참여하는 객체들의 역할과 책임을 결정하고 이렇게 결정된 협력이 코드의 구조를 결정한다.
따라서, 변경의 방향을 파악할 수 있는 감각을 기르는 것이 중요하다.


참조

  • 조영호님의 오브젝트 '일관성 있는 협력'

'Books > Object' 카테고리의 다른 글

[Object] 일관성 있는 협력  (0) 2021.08.16
[Object] 다형성  (0) 2021.08.12
[OBJECT] 합성과 유연한 설계  (2) 2021.08.08
[Object] 상속과 코드 재사용  (1) 2021.08.03
[Object] 유연한 설계  (0) 2021.06.28
[Object] 의존성 관리하기  (0) 2021.06.10
블로그 이미지

사용자 yhmane

댓글을 달아 주세요