14장: 일관성 있는 협력

변경을 분리하고 캡슐화하기

  • 유사한 요구사항에 대해 각 협력이 서로 다른 패턴으로 구현되어 있다면 전체적인 설계의 일관성이 무너지고 비용이 증가한다.

  • 유사한 기능을 구현하기 위해 일관성 있는 유사한 협력 패턴을 사용하면 시스템을 이해하고 확장하기 위해 요구되는 정신적인 부담을 크게 줄일 수 있다.

  • 일관성 있는 설계를 만드려면 다양한 설계 경험을 익히고 널리 알려진 디자인 패턴을 학습하여 적용해보는 것이 필요하다.

  • 늘 강조해왔듯, 변하는 개념과 변하지 않는 개념을 분리하고 변하는 개념은 캡슐화해야 한다. 여기서 캡슐화란 단순히 데이터를 감추는 것이 아니라 소프트웨어 안에서 변할 수 있는 모든 ‘개념’을 감추는 것이다.

  • 캡슐화에는 클래스 내부 데이터를 캡슐화하는 데이터 캡슐화, 클래스의 내부 행동을 캡슐화하는 메서드 캡슐화, 합성을 사용해 객체를 캡슐화하는 객체 캡슐화, 실행 시점에 어떤 객체가 올 지 확인하도록 하는 서브타입 캡슐화가 존재한다.

  • 서브타입 캡슐화와 객체 캡슐화는 다음과 같은 순서로 적용할 수 있다.

    • 변하는 부분을 분리한 후 공통적인 행동을 추상 클래스/인터페이스로 정의하고 이를 상속받도록 한다.

    • 변하지 않는 부분에 타입 계층을 합성(composite)하여 추상화에 의존하게 만든다.

  • 변경의 이유에 따라 캡슐화할 수 있는 다양한 방법을 디자인 패턴에서 제시하므로 공부해보는 게 좋다.

  • 다형성은 조건 로직을 객체 사이의 이동으로 바꾸기 위해 객체지향이 제공하는 설계 기법이다.

협력 패턴 구현하기

요구사항

  • 여러가지 통화 요금 책정 방식이 존재할 수 있다.

  • 아래는 그 중 한 방식인 시간대별 방식을 그림으로 나타낸 것이다. 1월1일 0시부터 1월3일 24시까지 통화가 이뤄졌고, 이 통화 내역을 시간대별로 분리하여 각각 다른 요금을 부과해야 한다.

설계

  • 모든 작업을 객체의 책임으로 생각하고, 아래와 같이 객체를 설계한다.

    • BasicRatePolicy

      • 사용자로부터 통화 목록이 담긴 핸드폰을 전달받으면 해당 핸드폰의 통화 요금을 반환한다.

    • FeeRule

      • 시간대별로 분리된 통화 내역을 기반으로 가격을 계산한다.

    • FeeCondition

      • 적용 조건을 표현하는 인터페이스

      • 통화 내역을 전달받고 적용 조건을 만족하는 기간만 잘라 DateTimeInterval의 List로 반환하는 메서드가 있다.

    • FeePerDuration

      • 특정 기간만큼의 통화 요금을 계산해 반환한다.

구현

  • BasicRatePolicy

    • 하나의 통화 기록 당 통화 내역을 분리해 각각 요금을 계산하여 전체 요금을 반환하도록 한다.

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

    • 하나의 통화 내역을 전달받고 적용 조건을 만족하는 기간만 잘라 DateTimeInterval의 List로 반환한다.

    • 시간대별 정책, 요일별 정책, 구간별 정책 등이 이 인터페이스를 구현하는 방식으로 표현될 것이다.

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

    • 하나의 통화 내역을 구간별로 잘라 적용 조건에 따른 요금을 계산하여 반환한다.

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, (first, second) -> first.plus(second));
	}
}
  • FeePerDuration

    • 얼마동안 통화할경우 얼마만큼의 요금이 나올 지 정의하여

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(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
	}
}

요금 부과 정책 구현

  • 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()
			.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();
	}
}
  • 아래는 구간별 정책의 적용 조건을 구현하는 클래스로, 특정 구간을 담아두고 통화 내역이 입력되면 구간에 만족하는 통화 기록 조각을 반환한다.

    • 개념적 무결성을 무너뜨리는 것보다는 약간의 부조화를 수용하는 편이 더 낫다.

    개념적 무결성: 일관성과 유사한 의미로, 시스템이 일관성 있는 몇 개의 협력 패턴으로 구성된다면 이해, 수정, 확장에 필요한 노력과 시간을 아낄 수 있다. 좋은 기능들이 서로 독립적이고 조화되지 못한 아이디어들을 담고 있는 시스템보다는 여러가지 다양한 기능이나 갱신된 내용은 비록 빠졌더라도 하나로 통합된 설계 패턴을 반영하는 시스템이 훨씬 좋다.

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()));
	}
}
  • 협력은 고정된 것이 아니다. 만약 현재의 협력 패턴이 변경을 수용하기 어렵다면 변경의 방향에 맞춰 최선의 협력 패턴을 만들도록 리팩토링해야 한다.

  • 유사한 기능에 대한 변경이 지속적으로 발생하고 있다면 변경을 캡슐화할 수 있는 적절한 추상화를 찾아 반복적으로 적용할 수 있는 협력 구조를 도출하고, 이 추상화에 변하지 않는 공통적인 책임을 할당해야 한다.

Last updated