14장 : 일관성 있는 협력

객체는 협력을 위해 존재한다. 잘 설계된 애플리케이션은 이해하기 쉽고, 수정이 용이하며, 재사용 가능한 협력의 모임이다.

가능하면 유사한 기능을 구현하기 위해 유사한 협력 패턴을 사용하라.

일관성 있는 협력 패턴을 적용하면 여러분의 코드가 이해하기 쉽고 직관적이며 유연해진다는 것이 이번 장의 주제다.

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

기본 정책 확장

짙은 색으로 표현된 클래스들이 새로운 기본 정책을 구현한 클래스들이다.

고정요금 방식 구현하기

public class FixedFeePolicy extends BiasicPolicy {
	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());
	}
}

시간대별 방식 구현하기

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

  • 0시부터 19시까지의 통화에 대해서는 10초당 18원의 요금을 부과하고, 19시부터 24시까지는 10초당 15원의 요금을 부과한다.

  • 시간대별 방식의 통화 요금을 계산하기 위해서는 통화의 시작 시간과 종료 시간과 더불어 시작 일자와 종료 일자도 함께 고려해야 한다.

      → 이런 부분이 까다로움

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

DateTimeInterval

  • 시작 시간(from)종료 시간(to)인스턴스 변수로 포함

  • 객체 생성을 위한 정적 메서드of, toMidnight, fromMidnight, during을 제공

public class DateTimeInterval {
	private LocalDateTime from;
	private LocalDateTime to;

	// of
	public static DateTimeInterval of (LocalDateTime from, LocalDateTime to) {
		return new DateTimeInterval(from, to);
	}

	// toMidnight
	public static DateTimeInterval toMidnight(LocalDateTime from) {
		return new DateTimeInterval (
			from,
			LocalDateTime.of(from.toLocalDate(), LocalTime.of(23, 59, 59, 999_999_999))
		);
	}

	// fromMidnight
	public static DateTimeInterval fromMidnight(LocalDateTime to) {
		return new DateTimeInterval(
			LocalDateTime.of(to.toLocalDate(), LocalTime.of(0, 0)), 
			to
		);
	}

	// during
	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 DateTimeInteval(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;
	}
}

기간을 하나의 단위로 표현할 수 있는 DateTimeInterval 타입을 사용할 수 있으므로 from과 to를 interval이라는 하나의 인스턴스 변수로 묶을 수 있다.

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 DateTieInterval getInterval() {
		return interval;
	}
}

전체 통화 시간을 일자와 시간 기준으로 분할해서 계산하자. 이를 위해 요금 계산 로직을 다음과 같이 두 개의 단계로 나눠 구현한다.

  1. 통화 기간을 일자별로 분리한다.

  2. 일자별로 분리된 기간을 다시 시간대별 규칙에 따라 분리한 후 각 기간에 대해 요금을 계산했다.

두 작업을 객체의 책임으로 할당해보다. 기간을 처리하는 방법에 대한 전문가는 바로 DateTimeInterval이다.

  • 통화 기간을 일자 단위로 나누는 책임은 DateTimeInterval에게 할당하고

  • Call이 DateTimeInterval에게 분할을 요청하도록 협력을 설계하자.

  • 시간대별로 분할하는 작업은 시간대별 기준을 잘 알고 있는 TimeOfDatDiscountPolicy라는 이름의 클래스로 구현할 것

  1. TimeOfDayDiscountPolicy는 통화 기간을 알고 있는 Call에게 일자별로 통화 기간을 분리할 것 을 요청한다.

  2. Call은 이 요청을 DateTimeInterval에게 위임한다.

  3. DateTimeInterval은 기간을 일자 단위로 분할한 후 분할된 목록을 반활한 목록을 반환한다.

  4. Call은 반환된 목록을 그대로 TimeOfDaayDiscountPolicy에게 반환한다.

  5. TimeOfDatDiscountPolicy는 일자별 기간의 목록을 대상으로 루프를 돌리면서 각 시간대별 기준에 맞는 시작시간(from)과 종료시간(to)을 얻는다.

public class TimeOfDayDiscountPolicy extend BasicRatePolicy {
	private List<LocalTime> start = 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.aplitByDay()) {
			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(loops)))
							.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 from) {
		return interval.getTo().toLocalTime().isAfter(to) ? 
					to : 
					interval.getTo().toLocalTime();
	}
}

같은 규칙에 속하는 요소들이 시작 시간의 Lists인 starts, 종료 시간의 List인 ends, 단위 시간의 List인 durations, 단위 요금의 List인 amount 안에서 같은 인덱스에 위치한다는 것을 알 수 있다.

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

		return Arrays.asList(this);
	} 

	private long days() {
		return Duration.between(from.toLocalDate().atStartOfDay(), to.toLocalDate().arStartDay())
											.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 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;
	}
}

요일별 방식 역시 여러 날에 걸쳐있을 수 있기때문에 통화 기간을 날짜 경계로 분리하고 분리된 각 통화 기간을 요일별로 설정된 요금 정책에 따라 적절하게 계산해야 한다.

→ 까다로운 부분2

public class DayOfWeekDiscountPolicy extend 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 (DateTimeIntervall interval : call.getInterval().aplitByDay()) {
			for (DayOfWeekDiscountRule rule : rules) {
				result.plus(rule.calculate(interval));
			}
		}
		return result;
	}
}

구간별 방식 구현하기

FixedFeePolicy, TimeOfDayDiscountPolicy, DayOfWeekDiscountPolicy의 세 클래스읭 가장 큰 문제점은 이 클래스들이 유사한 문제를 해결하고 있음에도 불구하고 설계에 일관성이 없다는 것이다.

일관성 없는 코드의 문제점

  • 새로운 구현을 추가해야 한다.

  • 기존의 구현을 이해해야 한다.

구간별 방식의 구현을 기존 방법과 새로운 방법으로 구현해보자.

public class DurationDiscountRule extends FixedFeePolicy {

}
public class DurationDiscountPolicy extends BasicRatePolicy {

}

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

일관성 있는 설계를 위한 조언

  • 다양한 설계 경험을 익혀라

  • 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 디자인 패턴을 적용해 보라

        → 디자인 패턴 ..?

협력을 일관성 있게 만들기 위한 지침

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

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

조건 로직 대 객체 탐색

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

아래 코드를 보면 Movie는 현재의 할인 정책이 어떤 종류인지 확인하지 않는다. 단순히 현재의 할인 정책을 나타내는 discountPolicy에 필요한 메시지를 전송할 뿐이다. 할인 정책의 종류를 체크하던 조건문이 discountPolicy로의 객체 이동으로 대체된 것이다.

public class Movie {
	private DiscountPolicy discountPolicy;

	public Mmoney calculateMovieFee(Screening screening) {
		return fee.minus(discountPolicy.calculateDiscountAmount(screeing));
	}
}
public abstract class DiscountPolicy {
	private List<DiscountCondition> conditions = new ArrayList<>();

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

핵심은 훌륭한 추상화를 찾아 추상화에 의존하도록 만드는 것이다. 추상화에 대한 의존은 결합도를 낮추고 결과적으로 대체 가능한 역할로 구성된 협력을 설계할 수 있게 해준다.

타입을 캡슐화하고 낮은 의존성을 유지하기 위해서는 지금까지 살펴본 다양한 기법들이 필요하다.

  • 6장에서 살펴본 인터페이스 설계 원칙들을 적용하면 구현을 효과적으로 캡슐화하는 코드를 구현할 수 있다.

  • 8장과 0장에서 설명한 의존성 관리 기법은 타입을 캡슐화하기 위해 낮은 결합도를 유지할 수 있는 방법을 보여준다.

  • 11장에서 설명한 상속 대신 합성을 사용하는 것도 캡슐화를 보장할 수 있는 훌륭한 방법이다.

  • 13장에서 설명한 원칙을 따르면 리스코프 치환 원칙(이라 쓰고 행동 호환성이라 부른다)을 준수하는 타입 계층을 구현하는 데 상속을 이용할 수 있다.

캡슐화 다시 살펴보기

캡술화는 데이터 은닉 이상이다. 캡슐화는 변하는 어떤 것이든 감추는 것이다.

  • 데이터 캡슐화

  • 메서드 캡슐화

  • 객체 캡슈로하

  • 서브타입 캡슐화

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

변경 분리하기

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

변경 캡슐화하기

협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다. 변경을 캡슐화하는 가장 좋은 방법은 변하지 않는 부분으로부터 변하는 부분을 분리하는 것이다. 변하는 부분의 공통점을 추상화하는 것을 잊어서는 안 된다. 이제 변하지 않는 부분이 오직 이 추상화에만 의존하도록 관계를 제한하면 변경을 캡슐화할 수 있게 된다.

앞선 휴대폰 요금 계산하기 예제에서 변하지 않는 것은 ‘규칙’이고, 변하는 것은 ‘적용 조건’이다.

협력 패턴 설계하기

변하는 부분과 변하지 않는 부분을 분리하고, 변하는 부분을 적절히 추상화하고 나면 변하는 부분을 생략한 채 변하지 않는 부분만을 이용해 객체 사이의 협력을 이야기할 수 있다. 추상화만으로 구성한 협력은 추상화를 구체적인 사례로 대체함으로써 다양한 상황으로 확장할 수 있게 된다. 재사용 가능한 협력 패턴이 선명하게 드러나는 것이다.

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

public interface FeeConditoin {
	List<DateTimeInterval> findTimeIntervals(Call call);
}
public class ReeRule {
	private FeeCondtion feeCondtion;
	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, (first, second) -> first.plus(second));
	}
}
public class FeerPerDuration {
	private Money fee;
	private Duration durtion;

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

	public Money calculate(DateTimeInterval interval) {
		return fe..times(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
	}
}
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) -> fist.plus(second));
	}

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

구체적인 협력 구현하기

현재의 요금제가 시간대별 정책인지, 요일별 정책인지, 구간별 정책인지를 결정하는 기준은 FeeCondition을 대체하는 객체의 타입이 무엇인가에 달려있다.

시간대별 정책

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

	public TimeOfDayFeeCondition(LocalTime from, LocalTime to) {
		this.from = from;
		this.to = to;
	}
}
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()
					.filter(each -> from(each).isBefore(to(each)))
					.map(each -> DateTimeInterval.of(
												LocalDateTime.of(each.getFrom().toLocalDate(), from(each)),
												LocalDateTime.of(each.getTo))))
					.collect(Collectors.toList());
	}
}

요일별 정책

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

	public DayOfWeekFeeCondition(DayOfWeek ... dayOfWeeks) {
		this.dayOfWeeks = Arrays.asList(dayOfWeeks);
	}
}
public class DayOfWeekFeeCondition implements FeeCondition {
	private List<DayOfWeek> dayOfWeeks = new ArrayList<>();

	public DayOfWeekFeeCondition(DayofWeek ... dayOfWeeks) {
		this.daysOfWeeks = 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());
	}
}

구간별 정책

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> findTimeIntercals(Call call) {
		if (call.getInterval().duration.compareTo(from) < 0) {
			return Collections.emptyList();
		}
	
		return Arrays.asList(DateTimeInterval.of(
								call.getInterval().getFrom().plus()from),
								call.getIntercal().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());
	}
}

패턴을 찾아라

일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다.

협력 패턴과 관련해서 언급할 가치가 있는 두 가지 개념이 있는데, 하나는 패턴이고 다른 하나는 프레임워크다.

Last updated