Chapter 14. 일관성 있는 협력

Minjae An·2023년 12월 15일

오브젝트

목록 보기
14/15

객체지향 설계의 목표는 적절한 책임을 수행하는 객체들의 협력을 기반으로 결합도가 낮고 재사용 가능한 코드 구조를 창조하는 것이다. 하지만 재사용은 공짜로 얻어지지 않는다. 재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다. 가능하면 유사한 기능을 구현하기 위해 유사한
협력 패턴을 사용하라. 일관성 있는 협력 패턴을 사용하면 코드가 이해하기 쉽고 유연해진다.

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

기본 정책 확장

11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 수정해야 한다고 가정하자. 이번 장에서는 기본 정책을 아래 표와 같이 확장할 것이며, 부가 정책에 대한 요구사항은 변화가 없다.

유형형식
고정요금 방식A초당 B원10초당 18원
시간대별 방식A시부터 B시까지 C초당 D원
B시부터 C시까지 C초당 E원00시부터 19시까지 10초당 1원
19시부터 24시까지 10초당 15원
요일별 방식평일에는 A초당 B원
공휴일에는 A초당 C원평일에는 10초당 38원
공휴일에는 10초당 19원
구간별 방식초기 A분 동안 B초당 C원

A분 ~ D분까지 B초당 D원
D분 초과 시 B초당 E원 | 초기 1분 동안 10초당 50원
초기 1분 이후 10초당 20원 |

기본 정책을 구성하는 4가지 방식에 대해 간단히 살펴보자.

  • 고정요금 방식 : 일정 시간 단위로 동일 요금을 부과하는 방식, 기존 ‘일반 요금제’와 동일
  • 시간대별 방식 : 하루 24시간을 특정한 시간 구간으로 나눈 후 각 구간별로 서로 다른 요금을
    부과하는 방식. 기존 ‘심야 할인 요금제’는 밤 10시를 기준으로 부과한 시간대별 방식
  • 요일별 방식 : 요일별로 요금을 차등 부과하는 방식
  • 구간별 방식 : 전체 통화 시간을 일정한 통화 시간에 따라 나누고 각 구간별로 요금을 차등
    부과하는 방식

이번 장에서 구현하게 될 클래스 구조를 그림으로 나타내보면 다음과 같다.

고정 요금 방식 구현하기

고정 요금 방식은 기존 일반요금제와 동일하기 때문에 RegularPolicy 의 이름을 FixedFeePolicy 로 수정하기만 하면 된다.

public class FixedFeePolicy extends BasicRatePolicy {
	private Money amount;
	private Duration seconds;

	public FixedFeePolicy(Money amount, Duration seconds) {
		this.amount = amount;
		this.seconds = seconds;
	}

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

시간대별 방식 구현하기

시간대별 방식에 따라 요금을 구현하기 위해 통화 기간을 정해진 시간대별로 나눈 후 각 시간대별로 서로 다른 계산 규칙을 적용해야 한다.

만약 통화가 여러 날에 걸쳐서 이뤄진다면 어떻게 될까? 아래 그림처럼 3일에 걸쳐 통화를 한
가입자가 있다고 가정해보자.

이 경우 시간대별 방식에 따라 요금을 구현하려면 규칙에 정의된 구간별로 통화를 구분해야 한다. 즉, 위 그림의 통화는 아래 그림과 같이 통화 구간을 분리한 후 각 구간에 대해 개별적으로 계산된
요금을 합해야 한다.

주목해야 할 것은 시간별 방식의 통화 요금을 계산하기 위해서는 통화의 시작 시간과 종료 시간뿐만 아니라 시작 일자와 종료 일자도 함께 고려해야 한다는 것이다.

시간대별 방식을 구현하는 데 있어 핵심은 규칙에 따라 통화 시간을 분할하는 방법을 결정하는
것이다. 이를 위해 기간을 편하게 관리할 수 있는 DateTimeInterval 클래스를 추가하자.

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

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

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

기존의 Call 클래스를 기간을 하나의 단위로 표현할 수 있는 DateTimeInterval 을 통해 재구성할 수 있다.

public class Call {
    private 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 DateTimeInterval getInterval() {
        return interval;
    }
}

앞선 그림처럼 전체 통화 시간을 일자와 시간 기준으로 분할해서 계산해보자. 요금 계산 로직을 다음 두 단계로 구분할 수 있다.

  • 통화 기간을 일자별로 구현한다.
  • 일자별로 분리된 기간을 다시 시간대별 규칙에 따라 분리한 후 각 기간에 대해 요금을 계산한다.

첫째로 통화 기간을 일자 단위로 나누는 작업의 정보 전문가는 누구인가? 기간을 처리하는 방법에 대한 전문가는 DateTimeInterval 이다. 따라서 이 책임은 DateTimeInterval 에게 할당하고
Call이 분할을 요청하도록 협력을 설계하는 것이 적절하다.

두번째 작업인 시간대별로 분할하는 작업의 정보 전문가는 누구인가? 시간대별 기준을 잘 알고 있는 요금 정책이며 TimeOfDayDiscountPolicy 라는 이름의 클래스로 구현할 것이다. 전체 통화 시간을
분할하는 작업은 아래 그림과 같은 협력을 통해 진행된다.

통화 시간을 일자별로 구분하고, 시간대별 규칙에 따라 다시 나누어야 요금을 제대로 산정할 수 있으며 이 과정은 아래 그림과 같이 이뤄진다.

TimeOfDayDiscountPolicy 클래스에서 가장 중요한 것은 기간에 따라 서로 다른 요금 규칙을
정의하는 방법을 결정하는 것이다. 한 통화 시간대는 시작 시간, 종료 시간, 단위 시간, 단위 요금으로 구성된다. 시간대별 방식은 하나 이상의 시간대로 구성되기 때문에 이 4가지 요소가 하나 이상
존재해야 한다.

시간대별 방식을 담당한 개발자는 이 문제를 4개의 서로 다른 List 를 가지는 것으로 해결했다.
같은 규칙에 포함된 요소들은 List 안에서 동일 인덱스에 위치하게 된다.

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

다음은 전체 코드이다.

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()) {
            for(int loop=0; loop < starts.size(); loop++) {
                result.plus(amounts.get(loop).times(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();
    }
}

CallsplitByDay 메서드는 DateTimeInterval 에 요청을 전달하고 응답을 반환하는 위임
메서드이다.

public class Call {
	public List<DateTimeInterval> splitByDay() {
		return interval.splitByDay();
	}
}

DateTimeInterval 클래스의 splitByDay 메서드는 통화 기간을 일자별로 분할해서 반환한다.

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

요일별 방식 구현하기

요일별 방식은 요일별로 요금 규칙을 다르게 설정할 수 있다. 각 규칙은 요일의 목록, 단위 시간, 단위 요금이라는 세 요소로 구성된다. 요일별 방식을 개발하는 프로그래머는 규칙을 DayOfWeekDiscountRule 이라는 하나의 클래스로 구현하는 설계를 선택했다.

public class DayOfWeekDiscountRule {
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>();
    private Duration duration = Duration.ZERO;
    private Money amount = Money.ZERO;

    public DayOfWeekDiscountRule(List<DayOfWeek> dayOfWeeks, Duration duration, Money amount) {
        this.dayOfWeeks = dayOfWeeks;
        this.duration = duration;
        this.amount = amount;
    }

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

    public DayOfWeekDiscountPolicy(List<DayOfWeekDiscountRule> rules) {
        this.rules = rules;
    }

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

구간별 방식 구현하기

마지막 방식을 구현하기 전 현재까지 구현한 클래스들을 살펴보자. 모두 요금을 정확하게 계산하고 있으며 응집도와 결합도 측면에서 특별히 문제는 없어 보인다. 하지만 이 클래스들을 함께 모아놓고 보면 보이지 않던 문제가 눈에 들어온다.

그 문제점은 바로 해당 클래스들이 유사한 문제를 해결하고 있음에도 불구하고 설계에 일관성이
없다는 것이다. 기본 정책을 구현한다는 공통의 목적을 공유하지만 정책을 구현하는 방식이 완전히 다르다.비일관성은 두 가지 상황에서 발목을 잡는다. 하나는 새 구현을 추가해야 하는 상황이고, 또 다른 하나는 기존 구현을 이해해야 하는 상황이다.

새 구현을 추가할 경우 기본 정책 설계에서 가장 중요한 문제인 여러 개의 규칙을 설정하는 방법을 결정해야 한다. 이를테면 구간별 방식을 추가하는 개발자 입장이 되었다고 가정해보자. 구현에는TimeOfDayDiscountPolicy 처럼 각 요소를 저장하는 다수의 List 를 유지하는 방식과 DayOfWeekDiscountPolicy 처럼 규칙을 구현하는 독립적인 객체를 추가하는 방식이 존재한다.
두 방식 모두 구현하는 데는 문제가 없으나 전체적인 일관성 측면에서 보았을 때 어떤 방식을
따르더라도 문제가 더 커지게 된다.

한편, 서로 다른 구현 방식은 코드를 이해하는 데 상당히 방해가 된다. 대부분의 사람들은 유사한
요구사항을 구현하는 코드는 유사한 방식으로 구현될 것이라고 예상하기 때문이다.

결론적으로 유사한 기능은 유사한 방식으로 구현해야 한다.

앞선 가정으로 돌아가 구간별 방식을 마저 구현해보자. 여기서는 기존 방법과 전혀 다른 새 방법으로 구현하기로 결정하였다. 요일별 방식의 경우처럼 규칙을 정의하는 새 클래스를 추가하되 코드
재사용을 위해 FixedFeePolicy 를 상속하는 구조로 설계하였다.

public class DurationDiscountRule extends FixedFeePolicy {
    private Duration from;
    private Duration to;

    public DurationDiscountRule(Duration from, Duration to, Money amount, Duration seconds) {
        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;
        }

        // 부모 클래스의 calculateFee(phone)은 Phone 클래스를 파라미터로 받는다.
        // calculateFee(phone)을 재사용하기 위해 데이터를 전달할 용도로 임시 Phone을 만든다.
        Phone phone = new Phone(null);
        phone.call(new Call(call.getFrom().plus(from),
                            call.getDuration().compareTo(to) > 0 ? call.getFrom().plus(to) : call.getTo()));

        return super.calculateFee(phone);
    }
}

이제 여러 개의 DurationDiscountRule 을 통해 DurationDiscountPolicy 를 구현할 수 있다.

public class DurationDiscountPolicy extends BasicRatePolicy {
    private List<DurationDiscountRule> rules = new ArrayList<>();

    public DurationDiscountPolicy(List<DurationDiscountRule> rules) {
        this.rules = rules;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DurationDiscountRule rule: rules) {
            result.plus(rule.calculate(call));
        }
        return result;
    }
}

이제 이 일관성을 고려하지 않은 설계를 개선해보자.

참고로 코드 재사용을 위한 상속을 사용한 DurationDiscountRule의 코드 구조는 바람직하지 않다.

🧑‍🚀 설계에 일관성 부여하기

일관성 있는 설계를 만들기 위해서는 다양한 설계 경험을 익히고, 널리 알려진 디자인 패턴을
학습하고 변경이라는 문맥 안에서 그것을 적용해보는 것이 좋다. 부가적으로 ,협력을 일관성 있게
만들기 위해선 다음과 같은 지침을 따르는 것이 도움이 된다.

  • 변하는 개념을 변하지 않는 개념으로부터 분리하라.
  • 변하는 개념을 캡슐화하라.

조건 로직 대 객체 탐색

public class ReservationAgency {
	public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
		for(DiscountCondition condition : movie.getDiscountConditions()) {
			if(conditoin.getType() == DiscountConditionType.PERIOD) {
				// 기간 조건인 경우
			} else {
				// 회차 조건인 경우
			}
		}
		
		if(discountable) {
			switch(moive.getMovieType()) {
				case AMOUNT_DISCOUNT:
				
				case PERCENT_DISCOUNT:
				
				case NONE_DISCOUNT:
			}
		} else {
		
		}
	}
}

위 코드에는 변경의 주기가 서로 다른 두 개의 조건 로직이 한 클래스 내에 존재한다. 새 할인 정책, 할인 조건을 추가하기 위해서 기존 코드를 수정해야 하기 때문에 오류 발생 가능성이 높다.
이 방식은 절차지향 프로그램에서 변경을 처리하는 전통적인 방법이다.

이와 다르게 객체지향에서 변경을 다루는 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다.

public class Movie {
	private DiscountPolicy discountPolicy;

	public Money calculateMovieFee(Screening screening) {
		return fee.minus(discountPolicy.calculateDiscountAmount(screening));
	}
}

다형성은 바로 이런 조건 로직을 객체 사이의 이동으로 바꾸기 위해 객체지향이 제공하는 설계 기법이다. 이는 DiscountPolicy 와 할인 조건을 구현하는 DiscountCondition 간 협력 역시 마찬가지다.

public abstract DiscountPolicy {
	private List<DiscountCondition> conditions = new ArrayList<>();

	public Money calculateDiscountAmount(Screening screenig) {
		for(DiscountCondition each : conditions) {
			if(each.isSatisfiedBy(screening)){
				return getDiscountAmount(screening);
			}
		}

		return screening.getMovieFee();
	}
} 

추상적인 수준에서의 협력은 다음 흐름을 보인다.

실제 협력에 참여하는 구체적인 객체들에 따른 타입 계층은 다음 형태를 띈다.

할인 정책의 구체적인 종류는 메시지를 수신한 객체의 타입에 따라 달라지며 실행할 메서드를
결정하는 것은 순전히 메시지를 수신한 객체의 책임이다. 객체지향적인 코드는 조건을 판단하지
않는다. 단지 다음 객체로 이동할 뿐이다.

클래스를 분리하기 위해 가장 중요한 이유는 변경의 이유와 주기다. 간단히 말해 단일 책임 원칙을 따르도록 클래스를 분리해야 한다는 것이다. 따라서 협력을 일관성 있게 만들기 위한 첫 지침은
다음과 같다.

변하는 개념을 변하지 않는 개념으로부터 분리하라.

조건문을 개별적인 객체로 분리하고 이 객체들의 일관성 있는 협력을 위해 타입 계층을 구성했다.
이 타입 계층을 클라이언트로 분리하기 위해 역할을 도입하고, 최종적으로 이 역할을 추상 클래스와 인터페이스로 구현했다. 변하는 개념을 별도의 서브 타입으로 분리한 후 이것들을 클라이언트로부터 캡슐화한 것이다.

변하는 개념을 캡슐화하라.

핵심은 결합도를 낮출 수 있는 훌륭한 추상화를 찾아 추상화에 의존하도록 만드는 것이다.

캡슐화 다시 살펴보기

객체의 캡슐화에 대한 얘기를 들으면 반사적으로 데이터 은닉 을 보통 떠올린다. 데이터 은닉이란
오직 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한함으로써 객체 내부의
상태 구현을 숨기는 기법을 가리킨다. 그러나 캡슐화는 데이터 은닉 이상이다. GoF의 조언에 따르면 캡슐화는 단순히 데이터를 감추는 것이 아니다.

캡슐화란 변하는 어떤 것이든 감추는 것이다.

캡슐화의 가장 대표적인 예는 객체의 퍼블릭 인터페이스와 구현을 분리하는 것이다. 자주 변경되는 내부 구현을 안정적인 퍼블릭 인터페이스 뒤로 숨겨야 한다.

위 그림에는 다음과 같은 캡슐화가 존재한다.

  • 데이터 캡슐화 : Movie 의 인스턴스 변수 titleprivate 이기 때문에 외부에서 직접 접근할 수 없다.
  • 메서드 캡슐화 : DiscountPolicy 내부의 getDiscountAmount 메서드는 protected 다. 외부에서 이 메서드에 접근할 수 없고 클래스 내부에 서브클래스에서만 접근이 가능하다.
  • 객체 캡슐화 : MoviediscountPolicy 인스턴스 변수를 포함한다. 이 변수는 private 이기
    때문에 MovieDiscountPolicy 간 관계를 변경하더라도 외부에 영향을 미치지 않는다. 객체 캡슐화는 합성을 의미한다.
  • 서브타입 캡슐화 : MovieDiscountPolicy 에 대해 알고 있지만 그 구현체 클래스들에
    대해서는 알지 못한다. 하지만 런타임에 그 클래스들의 인스턴스들과 협력할 수 있다. 다시 말해 서브타입의 종류를 캡슐화하고 있는 것이다. 이는 다형성의 기반이 된다.

일반적으로 데이터 캡슐화와 메서드 캡슐화는 개별 객체에 대한 변경을 관리하기 위해 사용하고
객체 캡슐화와 서브타입 캡슐화는 협력에 참여하는 객체들의 관계에 대한 변경을 관리하기 위해
사용한다. 협력을 일관성 있게 만들기 위해 서브타입 캡슐화와 객체 캡슐화를 조합하며 서브타입
캡슐화는 인터페이스 상속을 사용하고, 객체 캡슐화는 합성을 사용한다.

서브타입 캡슐화와 객체 캡슐화를 적용하는 방법은 다음과 같다.

변하는 부분을 분리해서 타입 계층을 만든다.

변하는 부분들의 공통 행동을 추상 클래스, 인터페이스로 추상화하고 변하는 부분들이 이를
상속받게 만든다.

변하지 않는 부분의 일부로 타입 계층을 합성한다.

앞서 구현한 타입 계층을 변하지 않는 부분에 합성한다. 의존성 주입과 같이 결합도를 느슨하게
유지할 수 있는 방법을 이용해 오직 추상화에만 의존하게 만든다.

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

변경 분리하기

일관성 있는 협력을 만들기 위한 첫 단계는 변하는 개념, 변하지 않는 개념을 분리하는 것이다.

먼저 시간대별, 요일별, 구간별 방식의 공통점은 각 기본 정책을 구성하는 방식이 유사하다는 점이다.

  • 기본 정책은 한 개 이상의 ‘규칙’으로 구성된다.
  • 하나의 ‘규칙’은 ‘적용조건’과 ‘단위요금’의 조합이다.

방식의 차이점은 각 기본 정책별로 요금을 계산하는 ‘적용조건’의 형식이 다르다는 것이다. 조건의
세부 내용이 바로 변화에 해당한다. 따라서 변하지 않는 ‘규칙’으로부터 변하는 ‘적용조건’을
분리해야 한다.

변경 캡슐화하기

변하지 않는 것은 ‘규칙이고’, 변하는 것은 ‘적용조건’이다. 따라서 ‘규칙’으로부터 ‘적용조건’을
추상화한 후 각 방식을 이 추상화의 서브타입으로 만든다. 이것이 서브타입 캡슐화다. 그 후 규칙이 적용조건을 표현하는 추상화를 합성 관계로 표현한다. 이것이 객체 캡슐화다.

아래 그림은 개별 ‘규칙’을 구성하는 데 필요한 클래스들의 관계를 나타낸 것이다. 하나의 기본
정책은 하나 이상의 ‘규칙’들로 구성된다.

FeeRule 은 오직 FeeCondition 추상화에만 의존하기 때문에 ‘적용조건’이 변하더라도 영향을 받지 않는다. 즉 ‘적용조건’에 대해 캡슐화되어 있다.

협력 패턴 설계하기

앞선 그림에서 변하지 않는 추상화만을 남기면 첫 그림과 같은 협력 패턴이 선명히 드러난다.
추상화들이 참여하는 협력은 그 밑 그림과 같이 나타난다.

FeeRule 은 하나의 Call 에 대해 요금을 계산하는 책임을 수행한다. 하나는 전체 통화 시간을 각 ‘규칙’의 ‘적용조건’을 만족하는 구간들로 나누는 것이다. 다른 하나는 이렇게 분리된 통화 구간에
‘단위 요금’을 적용해서 요금을 계산하는 것이다.

객체지향에서는 모든 작업을 객체의 책임으로 생각하므로 이 두 책임을 객체에 할당하자. 전체 통화 시간을 각 ‘규칙’의 ‘적용조건’을 만족하는 구간들로 나누는 작업은 FeeCondition 에게 할당하는
것이 적절하다. 분리된 통화 구간에 ‘단위요금’을 적용해 요금을 계산하는 작업은 FeeRule
담당하는 것이 적절하다.

만약 기간별 방식으로 요금을 계산하고 싶다면 TimeOfDayFeeCondition 인스턴스가
FeeCondition 의 자리를 대체할 것이다.

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

FeeCondition 부터 시작하자. findTimeIntervals 오퍼레이션은 Call 의 통화 기간 중에서
‘적용조건’을 만족하는 기간을 구한 후 List 에 담아 반환한다.

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

FeeRulecalculateFee 메서드는 FeeCondition 에게 findTimeIntervals 메시지를 전송해
조건을 만족하는 시간 목록을 반환받은 후 feePerDuration 의 값을 이용해 요금을 계산한다.

public class FeeRule {
    private FeeCondition feeCondition;
    private FeePerDuration feePerDuration;

    public FeeRule(FeeCondition feeCondition, FeePerDuration feePerDuration) {
        this.feeCondition = feeCondition;
        this.feePerDuration = feePerDuration;
    }

    public Money calculateFee(Call call) {
        return feeCondition.findTimeIntervals(call)
                .stream()
                .map(each -> feePerDuration.calculate(each))
                .reduce(Money.ZERO, Money::plus);
    }
}

FeePerDuration 은 일정 기간 동안의 요금을 계산하는 calculate 메서드를 구현한다.

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

    public FeePerDuration(Money fee, Duration duration) {
        this.fee = fee;
        this.duration = duration;
    }

    public Money calculate(DateTimeInterval interval) {
        return fee.times(interval.duration().getSeconds() / duration.getSeconds());
    }
}

이제 BasicRatePolicyFeeRule 의 컬렉션을 통해 전체 통화 요금을 계산하도록 할 수 있다.

public class BasicRatePolicy implements RatePolicy {
    private List<FeeRule> feeRules = new ArrayList<>();

    public BasicRatePolicy(FeeRule ... feeRules) {
        this.feeRules = Arrays.asList(feeRules);
    }

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

이제 추상적인 수준에서 협력을 완성했다. 이어서 구체적이고 살아있는 컨텍스트로 확장하여 협력이 동작하게 해야한다.

구체적인 협력 구현하기

현재의 요금제가 어떤 정책인지 결정하는 기준은 FeeCondition 을 대체하는 객체의 타입이
무엇인가에 달려있다. FeeCondition 인터페이스를 실체화하는 클래스에 따라 기본 정책의 종류가 달라진다.

시간대별 정책

시간대별 정책의 적용조건은 “시작시간부터 종료시간까지” 이기 때문에 시작시간과 종료시간을
인스턴스 변수로 포함한다.

public class TimeOfDayFeeCondition implements FeeCondition {
    private LocalTime from;
    private LocalTime to;

    public TimeOfDayFeeCondition(LocalTime from, LocalTime to) {
        this.from = from;
        this.to = to;
    }

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval().splitByDay()
                .stream()
                .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();
    }
}

요일별 정책

이 정책은 여러 요일을 하나의 단위로 관리할 수 있게 DayOfWeek 컬렉션을 인스턴스 변수로
포함하며 findTimeIntervals 메서드는 Call 의 기간 중에서 요일에 해당하는 기간만을 추출해
반환하면 된다.

public class DayOfWeekFeeCondition implements FeeCondition {
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>();

    public DayOfWeekFeeCondition(DayOfWeek... dayOfWeeks) {
        this.dayOfWeeks = Arrays.asList(dayOfWeeks);
    }

    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval()
                .splitByDay()
                .stream()
                .filter(each -> dayOfWeeks.contains(each.getFrom().getDayOfWeek()))
                .collect(Collectors.toList());
    }
}

구간별 정책

처음 설계에서 새 기본 정책을 추가를 위한 지침이 없었기 때문에 협력의 일관성을 유지하지 못하는 방식으로 새 정책을 구현할 수 있었다. 하지만 FeeCondition 이라는 인터페이스를 통해 협력을
일관성 있게 만들었다.

public class DurationFeeCondition implements FeeCondition {
    private Duration from;
    private Duration to;

    public DurationFeeCondition(Duration from, Duration to) {
        this.from = from;
        this.to = 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());
    }
}

아래는 설계한 전체 클래스 다이어그램이다. 추상화와 함께 합성, 상속이 어떻게 변경을 캡슐화하고 있는지 살펴볼 수 있다.

지속적으로 개선하라

새로운 요구사항에 추가되는 과정에서 유지되던 일관성이 꺠지는 모습을 자주 보게 된다. 협력을
설계하는 초기 단계에서 모든 요구사항을 미리 예상할 수 없기에 자연스러운 현상이다. 새
요구사항을 수용할 수 있는 협력 패턴을 고안해 설계를 진화시킬 수 있는 기회이다.

협력은 고정된 것이 아니다. 요구사항의 변경에 따라 협력 역시 지속적으로 개선해야 한다.
중요한 것은 현재 설계에 맹목적으로 일관성을 맞추는 것이 아닌 변경 방향에 맞춰 코드를
개선하려는 태도이다.

패턴을 찾아라

일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다. 변경을 캡슐화하는 방법이 협력 참여 객체들의 역할과 책임을 결정하고 이렇게 결정된 협력이 코드의 구조를 결정한다.

객체지향 설계는 객체의 행동과 그것을 지원하기 위한 구조를 계속 수정해 나가는 작업을 반복해 나가면서 다듬어진다. 객체, 역할, 책임은 계속 진화해 나가는 것이다. 협력자들 간에 부하를 좀 더 균형 있게 배분하는 방법을 새로 만들어내면 나눠줄 책임이 바뀌게 된다. 만약 객체들이 서로 통신하는 방법을 개선해냈다면 이들 간의 상호작용은 재정의돼야 한다. 이 같은 과정을 거치면서 자주 통신하는 경로는 더욱 효율적이게 되고, 주어진 작업을 수행하는 표준 방안이 정착된다. 협력 패턴이 드러나는 것이다.

profile
도전을 성과로

0개의 댓글