목차
- ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한 방식을 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.
- 모든 이미지의 출처는 오브젝트(조용호 저) 책 입니다.
CHAPTER 14. 일관성 있는 협력
⚈ 애플리케이션을 개발하다 보면 유사한 요구사항을 반복적으로 추가하거나 수정하게 되는 경우가 있다.
- 이러한 상황에서 각 협력이 서로 다른 패턴을 따를 경우에는 전체적인 설계의 일관성이 서서히 무너지게 된다.
- 객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다. -> 재사용은 공짜로 얻어지지 않는다. 재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다.
- 과거의 해결 방법을 반복적으로 사용해서 유사한 기능을 구현하는 데 드는 시간과 노력을 대폭 줄일 수 있다. 또한 코드가 이해하기 쉬워진다.
⚈ 지금 보고 있는 코드가 얼마 전에 봤던 코드와 유사하다는 사실을 아는 순간 새로운 코드가 직관적인 모습으로 다가오는 것을 느끼게 될 것이다.
⚈ 14장의 주제 : 일관성 있는 협력 패턴을 적용하면 여러분의 코드가 이해하기 쉽고 직관적이며 유연해진다는 것
01 핸드폰 과금 시스템 변경하기
⚈ 기본 정책 확장
- 11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 수정 (11장 정리내용)
- 기존에는 기본 정책에 일본 요금제와 심야 할인 요금제 두 가지 종류가 있었음
- 14장에서는 기본 정책을 이하와 같이 확장할 것. 부가 정책은 변화 없음
⚈ 11장에서 조합의 폭발 얘기 나올 때 설명한 그림처럼 14장에서 새로운 기본 정책을 적용할 때 조합 가능한 모든 경우의 수 그림
⚈ 14장에서 구현하게 될 클래스 구조 (짙은 색이 새로운 기본 정책)
⚈ 구현된 코드
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());
}
}
---
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, 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 + " ]";
}
}
---
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;
}
public List<DateTimeInterval> splitByDay() {
return interval.splitByDay();
}
}
---
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();
}
}
---
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;
}
}
---
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);
}
}
---
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;
}
}
⚈ 문제점 - 비일관성
- 위 클래스들은 기본 정책을 구현한다는 공통의 목적을 공유한다. 하지만 정책을 구현하는 방식이 완전히 다르다. 다시 말해서 개념적으로는 연관돼 있지만 구현 방식에 있어서는 완전히 제각각이다.
- 비일관성은 두 가지 상황에서 발목을 잡는다. 하나는 새로운 구현을 추가해야 하는 상황이고, 또 다른 하나는 기존의 구현을 이해해야 하는 상황이다.
- 유사한 기능을 서로 다른 방식으로 구현해서는 안 된다.
02 설계에 일관성 부여하기
⚈ 일관성 있는 설계를 위한 조언
- 다양한 설계 경험을 익히라는 것 -> 하지만 이런 설계 경험을 단기간에 쌓아 올리는 것은 생각보다 어려운 일이다.
- 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 디자인 패턴을 적용해 보라는 것. (디자인 패턴 : 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는 경험 법칙을 모아놓은 일종의 설계 탬플릿)
⚈ 협력을 일관성 있게 만들기 위한 기본 지침
- 변하는 개념을 변하지 않는 개념으로부터 분리하라. (☆ 디자인 패턴 얘기!)
- 변하는 개념을 캡슐화하라.
⚈ 위 두 가지 지침은 훌륭한 구조를 설계하기 위해 따라야 하는 기본적인 원칙이기도 하다. 지금까지 이 책에서 설명했던 모든 원칙과 개념들 역시 대부분 변경의 캡슐화라는 목표를 향한다.
⚈ 객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다.
⚈ 캡슐화는 데이터 은닉(data hiding) 이상이다.
- 데이터 은닉 : 오직 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한함으로써 객체 내부의 상태 구현을 숨기는 기법
- 캡슐화 : 단순히 데이터를 감추는 것이 아니라 소프트웨어 안에서 변할 수 있는 모든 '개념'을 감추는 것이다. 즉, "캡슐화란 변하는 어떤 것이든 감추는 것이다"
- 캡슐화란 단지 데이터 은닉을 의미하는 것이 아니다. 코드 수정으로 인한 파급효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다.
03 일관성 있는 기본 정책 구현하기
⚈ 협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다.
- 변경을 캡슐화하는 가장 좋은 방법은 변하지 않는 부분으로부터 변하는 부분을 분리하는 것이다.
⚈ 구현된 코드
public interface FeeCondition {
List<DateTimeInterval> findTimeIntervals(Call call);
}
---
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));
}
}
---
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()));
}
}
---
public final 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));
}
}
---
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 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());
}
}
---
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()));
}
}
⚈ 일관성 있는 협력
- 이제 기본 정책을 추가하기 위해 규칙을 지키는 것보다 어기는 것이 더 어렵다.
- 일관성 있는 협력은 개발자에게 확장 포인트를 강제하기 때문에 정해진 구조를 우회하기 어렵게 만든다.
⚈ 지속적으로 개선하라
- 처음에는 일관성을 유지하는 것처럼 보이던 협력 패턴이 시간이 흐르면서 새로운 요구사항이 추가되는 과정에서 일관성의 벽에 조금씩 금이 가는 경우를 자주 보게 된다.
- 초기 단계에서는 모든 요구사항을 미리 예상할 수 없기 때문에 이것은 잘못이 아니며 꽤나 자연스러운 현상이다.
- 오히려 새로운 요구사항을 수용할 수 있는 협력 패턴을 향해 설계를 진화시킬 수 있는 좋은 신호로 받아들여야 한다.
- 협력은 고정된 것이 아니다. 만약 현재의 협력 패턴이 변경의 무게를 지탱하기 어렵다면 변경을 수용할 수 있는 협력패턴을 향해 과감하게 리팩터링하라.
⚈ 패턴을 찾아라
- 일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다.
- 협력을 일관성 있게 만드는 과정은 유사한 기능을 구현하기 위해 반복적으로 적용할 수 있는 협력의 구조를 찾아가는 기나긴 여정이다. 따라서 협력을 일관성 있게 만든다는 것은 유사한 변경을 수용할 수 있는 협력 패턴을 발견하는 것과 동일하다.
- 협력 패턴과 관련한 두 가지 개념 : 패턴, 프레임워크 (15장에서 할 얘기)
'Study > 오브젝트' 카테고리의 다른 글
[오브젝트] 책 내용 전체 정리 (0) | 2023.01.21 |
---|---|
[오브젝트] 15장. 디자인 패턴과 프레임워크 (0) | 2023.01.21 |
[오브젝트] 13장. 서브클래싱과 서브타이핑 (0) | 2023.01.14 |
[오브젝트] 12장. 다형성 (0) | 2023.01.14 |
[오브젝트] 11장. 합성과 유연한 설계 (0) | 2023.01.04 |
댓글