Skip to content

Latest commit

 

History

History
1786 lines (1382 loc) · 48.7 KB

File metadata and controls

1786 lines (1382 loc) · 48.7 KB

Chapter 14. 일관성 있는 협력

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

📌 핵심 개념

이 장에서는 일관성 있는 협력을 다룹니다. 유사한 요구사항을 반복적으로 추가할 때, 일관된 협력 패턴을 유지함으로써 설계의 품질을 높이고 유지보수성을 향상시키는 방법을 학습합니다.

🎯 학습 목표

  • 일관성 있는 협력의 중요성 이해하기
  • 변하는 것과 변하지 않는 것을 분리하는 방법 파악하기
  • 변경을 캡슐화하는 다양한 기법 이해하기
  • 협력 패턴을 발견하고 적용하는 과정 학습하기
  • 조건 로직을 객체 탐색으로 전환하기
  • 개념적 무결성을 유지하는 설계 만들기

📖 목차

  1. 핸드폰 과금 시스템 변경하기
  2. 설계에 일관성 부여하기
  3. 일관성 있는 기본 정책 구현하기
  4. 핵심 정리

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

📂 코드: step01 - 비일관적 구현 / step02 - 일관성 있는 구현

1.1 기본 정책 확장

📋 요구사항: 4가지 기본 정책

유형 형식 예시
고정요금 A초당 B원 10초당 18원
시간대별 시간대마다 A초당 B원 0시19시: 10초당 18원
19시
24시: 10초당 15원
요일별 요일마다 A초당 B원 평일: 10초당 38원
공휴일: 10초당 19원
구간별 통화 구간마다 A초당 B원 초기 1분: 10초당 50원
1분 이후: 10초당 20원

🎯 부가 정책 (11장에서 구현)

기본 정책 + 세금 정책
기본 정책 + 요금 할인 정책
기본 정책 + 세금 정책 + 요금 할인 정책

→ 데코레이터 패턴으로 구현됨

📊 조합 가능한 경우의 수

4가지 기본 정책 × 3가지 부가 정책 조합
= 총 12가지 조합

┌──────────────────────────────────────┐
│         기본 정책 (4가지)               │
├──────────────────────────────────────┤
│  • FixedFeePolicy                    │
│  • TimeOfDayDiscountPolicy           │
│  • DayOfWeekDiscountPolicy           │
│  • DurationDiscountPolicy            │
└──────────────────────────────────────┘
         ↓
┌──────────────────────────────────────┐
│      부가 정책 (데코레이터)               │
├──────────────────────────────────────┤
│  • TaxablePolicy                     │
│  • RateDiscountablePolicy            │
│  • TaxablePolicy + RateDiscountable  │
└──────────────────────────────────────┘

1.2 고정요금 방식 구현

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

특징:

✅ 가장 단순한 구현
✅ 통화 시간을 단위 시간으로 나눠서 요금 계산
✅ 11장의 일반 요금제와 동일

1.3 시간대별 방식 구현

🎯 핵심 문제

시간대별 방식의 복잡성:

1. 통화는 여러 날에 걸쳐 이루어질 수 있다
   - 시작 일자 ≠ 종료 일자
   
2. 각 날짜마다 시간대가 다를 수 있다
   - 첫째 날: 23시~24시
   - 둘째 날: 0시~1시
   
3. 시간대별로 다른 요금 적용
   - 0시~19시: 10초당 18원
   - 19시~24시: 10초당 15원

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

    // 핵심 메서드: 일자별로 분리
    public List<DateTimeInterval> splitByDay() {
        if (days() > 0) {
            return split(days());
        }
        return Arrays.asList(this);
    }

    private List<DateTimeInterval> split(long days) {
        List<DateTimeInterval> result = new ArrayList<>();
        addFirstDay(result);      // 첫날: from ~ 자정
        addMiddleDays(result, days); // 중간날들: 0시 ~ 자정
        addLastDay(result);       // 마지막날: 자정 ~ to
        return result;
    }
}

동작 예시:

통화: 2024-01-05 23:00 ~ 2024-01-07 01:00

splitByDay() 결과:
[
  2024-01-05 23:00 ~ 2024-01-05 23:59,  // 첫날
  2024-01-06 00:00 ~ 2024-01-06 23:59,  // 중간날
  2024-01-07 00:00 ~ 2024-01-07 01:00   // 마지막날
]

📞 Call 클래스

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 List<DateTimeInterval> splitByDay() {
        return interval.splitByDay();
    }

    public DateTimeInterval getInterval() {
        return interval;
    }
}

⏰ TimeOfDayDiscountPolicy

책임: 시간대별 요금 계산의 정보 전문가

public class TimeOfDayDiscountPolicy extends BasicRatePolicy {
    private List<LocalTime> starts = 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.splitByDay()) {
            // 각 시간대별 규칙 적용
            for (int loop = 0; loop < starts.size(); loop++) {
                result = 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();
    }
}

구조:

TimeOfDayDiscountPolicy
│
├─ starts:    [0:00, 19:00]
├─ ends:      [19:00, 24:00]
├─ durations: [10초, 10초]
└─ amounts:   [18원, 15원]

인덱스가 같은 요소들이 하나의 규칙을 구성

1.4 요일별 방식 구현

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

📊 DayOfWeekDiscountPolicy

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 = result.plus(rule.calculate(interval));
            }
        }

        return result;
    }
}

구조:

DayOfWeekDiscountPolicy
│
└─ rules: [
     Rule1: 평일(월~금), 10초당 38원
     Rule2: 주말(토~일), 10초당 19원
   ]

1.5 구간별 방식 구현

⏱️ DurationDiscountRule

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) {
        // 통화 시간이 구간 범위를 벗어나면 0원
        if (call.getDuration().compareTo(to) > 0) {
            return Money.ZERO;
        }
        if (call.getDuration().compareTo(from) < 0) {
            return Money.ZERO;
        }

        // 부모 클래스 재사용을 위한 임시 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);
    }
}

📊 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 = result.plus(rule.calculate(call));
        }

        return result;
    }
}

구조:

DurationDiscountPolicy
│
└─ rules: [
     Rule1: 0분~1분, 10초당 50원
     Rule2: 1분~∞,  10초당 20원
   ]

1.6 문제점: 비일관성

🚨 구현 방식의 차이

FixedFeePolicy:
- 단위 시간, 단위 요금만 인스턴스 변수로 보유
- 규칙이라는 개념 없음

TimeOfDayDiscountPolicy:
- 여러 List로 시간대 정보 관리
- 인덱스로 규칙 연결

DayOfWeekDiscountPolicy:
- DayOfWeekDiscountRule 객체로 규칙 표현
- 규칙 객체의 리스트로 관리

DurationDiscountPolicy:
- DurationDiscountRule 객체로 규칙 표현
- FixedFeePolicy 상속 (코드 재사용)

💥 비일관성의 문제

문제 1: 새로운 구현 추가 시
→ 어떤 방식을 따라야 할지 혼란
→ 새로운 방식으로 구현하면 일관성 더 무너짐

문제 2: 기존 구현 이해 시
→ 각 정책마다 다른 방식으로 이해해야 함
→ 학습 비용 증가

문제 3: 유지보수 시
→ 유사한 기능인데 수정 방법이 다름
→ 실수 가능성 증가

🎯 핵심 교훈

┌─────────────────────────────────────────────────────┐
│                                                     │
│  유사한 기능은 유사한 방식으로 구현해야 한다!                  │
│                                                     │
│  비일관성은 설계의 적!                                   │
│                                                     │
└─────────────────────────────────────────────────────┘

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

2.1 일관성 있는 설계를 만드는 방법

📚 첫 번째 조언: 다양한 설계 경험

일관성 있는 설계를 만드는 가장 좋은 방법:
→ 다양한 설계 경험을 익히는 것

하지만:
→ 단기간에 설계 경험을 쌓기는 어려움

🎨 두 번째 조언: 디자인 패턴 학습

디자인 패턴이란?
→ 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는
   경험 법칙을 모아놓은 설계 템플릿

장점:
1. 검증된 해결책 제공
2. 공통 어휘 제공 (의사소통 향상)
3. 변경에 대한 대응 방법 제시

2.2 협력을 일관성 있게 만드는 기본 지침

📋 두 가지 핵심 지침

┌─────────────────────────────────────────────────────┐
│                                                     │
│  1. 변하는 개념을 변하지 않는 개념으로부터 분리하라             │
│                                                     │
│  2. 변하는 개념을 캡슐화하라                              │
│                                                     │
└─────────────────────────────────────────────────────┘

연결:

이 두 지침은 모든 설계 원칙의 기반:

- 단일 책임 원칙 (SRP)
- 개방-폐쇄 원칙 (OCP)
- 의존성 역전 원칙 (DIP)
- 리스코프 치환 원칙 (LSP)
- 인터페이스 분리 원칙 (ISP)

모두 변경의 캡슐화를 목표로 함!

2.3 조건 로직 대 객체 탐색

❌ 조건 로직 기반 설계

나쁜 예: ReservationAgency (2장)

public class ReservationAgency {
    public Reservation reserve(Screening screening,
            Customer customer,
            int audienceCount) {
        Movie movie = screening.getMovie();

        boolean discountable = false;

        // 조건 로직 1: 할인 조건 판단
        for (DiscountCondition condition : movie.getDiscountConditions()) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                // 기간 조건인 경우
                // ... 복잡한 로직
            } else {
                // 회차 조건인 경우
                // ... 복잡한 로직
            }

            if (discountable) {
                break;
            }
        }

        Money fee;

        // 조건 로직 2: 할인 정책 판단
        if (discountable) {
            Money discountAmount = Money.ZERO;

            switch (movie.getMovieType()) {
                case AMOUNT_DISCOUNT:
                    // 금액 할인 정책
                    discountAmount = movie.getDiscountAmount();
                    break;
                case PERCENT_DISCOUNT:
                    // 비율 할인 정책
                    discountAmount = movie.getFee()
                            .times(movie.getDiscountPercent());
                    break;
                case NONE_DISCOUNT:
                    // 할인 없음
                    discountAmount = Money.ZERO;
                    break;
            }

            fee = movie.getFee()
                    .minus(discountAmount)
                    .times(audienceCount);
        } else {
            fee = movie.getFee().times(audienceCount);
        }

        return new Reservation(customer, screening, fee, audienceCount);
    }
}

문제점:

1. 변경의 주기가 다른 코드가 한 클래스에 뭉쳐있음
   - 할인 조건 추가
   - 할인 정책 추가
   - 예매 로직 변경
   
2. 새로운 타입 추가 시 기존 코드 수정 필요
   - OCP 위반
   
3. 코드 이해하기 어려움
   - 복잡한 중첩된 조건문
   
4. 오류 발생 확률 높음
   - 수정 범위가 넓음

✅ 객체 탐색 기반 설계

좋은 예: DiscountPolicy 계층 (2장 개선)

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

    public DiscountPolicy(DiscountCondition... conditions) {
        this.conditions = Arrays.asList(conditions);
    }

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

    abstract protected Money getDiscountAmount(Screening screening);
}
public class Movie {
    private DiscountPolicy discountPolicy;

    public Money calculateMovieFee(Screening screening) {
        // 조건 로직 없음!
        // 단순히 메시지 전송
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

장점:

1. 타입 판단 로직 제거
   - Movie는 DiscountPolicy의 구체 타입을 몰라도 됨
   
2. 새로운 정책 추가 시 기존 코드 수정 불필요
   - OCP 만족
   
3. 코드 이해하기 쉬움
   - 단순한 메시지 전송
   
4. 변경의 영향 최소화
   - 각 정책은 독립적

🎯 다형성의 역할

다형성은
조건 로직을 객체 사이의 이동으로 바꾼다!

조건 로직:
if (type == A) { ... }
else if (type == B) { ... }
else if (type == C) { ... }

↓ 다형성 적용

객체 탐색:
object.operation();
→ 실제 타입에 따라 적절한 메서드 실행

시각화:

조건 로직 방식:
Movie
  │
  ├─ if (할인 조건)
  │    └─ switch (할인 정책)
  │         ├─ case 금액할인
  │         ├─ case 비율할인
  │         └─ case 할인없음
  └─ else ...

객체 탐색 방식:
Movie ─── message ──▶ DiscountPolicy
                           ↑
                    ┌──────┼──────┐
              AmountDiscount PercentDiscount NoneDiscount
                    (다형적으로 실행)

2.4 캡슐화 다시 살펴보기

🎯 캡슐화 ≠ 데이터 은닉

전통적 정의:

캡슐화 = 데이터 은닉

private 변수 + public getter/setter

진정한 의미:

┌─────────────────────────────────────────────────────┐
│                                                     │
│  캡슐화란 변하는 어떤 것이든 감추는 것이다!                   │
│                                                     │
│  "Encapsulate the concept that varies"              │
│                                                     │
└─────────────────────────────────────────────────────┘

📦 캡슐화의 종류

1. 데이터 캡슐화 (Data Encapsulation)

public class Movie {
    private String title;        // ✅ private 데이터
    private Duration runningTime;
    private Money fee;

    // public 메서드로만 접근
    public Money getFee() {
        return fee;
    }
}

목적:

내부 데이터 구조 변경 시
외부 영향 최소화

2. 메서드 캡슐화 (Method Encapsulation)

public class DiscountPolicy {
    // public - 외부에서 호출
    public Money calculateDiscountAmount(Screening screening) {
        for (DiscountCondition each : conditions) {
            if (each.isSatisfiedBy(screening)) {
                return getDiscountAmount(screening);
            }
        }
        return Money.ZERO;
    }

    // protected - 서브클래스에서만 오버라이드
    abstract protected Money getDiscountAmount(Screening screening);
}

목적:

메서드 구현 변경 시
클래스 외부에 영향 없음

3. 객체 캡슐화 (Object Encapsulation)

public class Movie {
    private DiscountPolicy discountPolicy;  // ✅ 합성

    public Movie(String title, Duration runningTime,
            Money fee, DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }
}

목적:

객체 사이의 관계 변경 시
외부에 영향 없음

= 합성 (Composition)

4. 서브타입 캡슐화 (Subtype Encapsulation)

public class Movie {
    private DiscountPolicy discountPolicy;  // 추상 타입

    public Money calculateMovieFee(Screening screening) {
        // 구체 타입을 알 필요 없음!
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

목적:

구체적인 서브타입 종류 캡슐화

Movie는 다음을 모름:
- AmountDiscountPolicy
- PercentDiscountPolicy
- NoneDiscountPolicy

= 다형성 (Polymorphism)

📊 캡슐화 종류별 적용 범위

캡슐화 종류 적용 대상 목적 기법
데이터 개별 객체 데이터 구조 변경 관리 private + getter
메서드 개별 객체 메서드 구현 변경 관리 protected, private
객체 협력 관계 객체 관계 변경 관리 합성
서브타입 협력 관계 타입 종류 변경 관리 다형성

💡 핵심 통찰

일반적으로:

데이터 캡슐화 + 메서드 캡슐화
→ 개별 객체의 변경 관리

객체 캡슐화 + 서브타입 캡슐화
→ 협력 참여 객체들의 관계 변경 관리

일관성 있는 협력을 만들기 위해:
→ 서브타입 캡슐화 + 객체 캡슐화 조합
  = 인터페이스 상속 + 합성

2.5 변경을 캡슐화하는 방법

📋 2단계 프로세스

1단계: 변하는 부분을 분리해서 타입 계층 만들기

변하지 않는 부분으로부터 변하는 부분을 분리

변하는 부분들의 공통적인 행동을
추상 클래스나 인터페이스로 추상화

변하는 부분들이
이 추상 클래스나 인터페이스를 상속/구현

→ 변하는 부분은 변하지 않는 부분의 서브타입이 됨

2단계: 변하지 않는 부분의 일부로 타입 계층 합성하기

앞에서 구현한 타입 계층을
변하지 않는 부분에 합성

변하지 않는 부분에서는
변경되는 구체적인 사항에 결합되면 안 됨

의존성 주입 등을 이용해
오직 추상화에만 의존

→ 변경이 캡슐화됨!

🎨 예시: DiscountPolicy

1단계: 타입 계층 만들기

// 추상화
public abstract class DiscountPolicy {
    abstract protected Money getDiscountAmount(Screening screening);
}

// 변하는 부분들 (서브타입)
public class AmountDiscountPolicy extends DiscountPolicy {
    @Override
    protected Money getDiscountAmount(Screening screening) {
        return discountAmount;
    }
}

public class PercentDiscountPolicy extends DiscountPolicy {
    @Override
    protected Money getDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(percent);
    }
}

2단계: 합성하기

// 변하지 않는 부분
public class Movie {
    private DiscountPolicy discountPolicy;  // 추상화에 의존!

    public Movie(String title, Duration runningTime,
            Money fee, DiscountPolicy discountPolicy) {
        // 의존성 주입
        this.discountPolicy = discountPolicy;
    }

    public Money calculateMovieFee(Screening screening) {
        // 구체 타입을 알 필요 없음
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

결과:

변경 캡슐화 완료!

새로운 할인 정책 추가:
1. DiscountPolicy 상속
2. getDiscountAmount() 구현
3. Movie 코드 수정 불필요!

Movie는 다음을 모름:
- 구체적인 할인 정책 종류
- 할인 정책 계산 방법
- 할인 정책 내부 구현

→ 완벽한 캡슐화!

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

📂 코드: step02 - 일관성 있는 최종 구현

3.1 변경 분리하기

🔍 공통점 찾기

4가지 기본 정책 분석:

고정요금:
규칙 = [단위시간]당 [요금]원

시간대별:
규칙 = [시작시간]~[종료시간]까지 [단위시간]당 [요금]원

요일별:
규칙 = [요일]별 [단위시간]당 [요금]원

구간별:
규칙 = [통화구간] 동안 [단위시간]당 [요금]원

💡 패턴 발견

공통 구조:
기본 정책 = 하나 이상의 "규칙"들의 집합

각 규칙:
규칙 = "적용조건" + "단위요금"

적용조건 (변하는 부분):
- 시간대
- 요일
- 통화 구간
- 항상 (고정요금)

단위요금 (변하지 않는 부분):
- [단위시간]당 [요금]원

🎯 변경 분리

변하는 것:
→ 적용조건
  - 시간대별, 요일별, 구간별, 고정

변하지 않는 것:
→ 규칙
→ 단위요금

시각화:

┌─────────────────────────────────────┐
│         기본 정책                     │
├─────────────────────────────────────┤
│                                     │
│  ┌───────────────────────────────┐  │
│  │        규칙 1                  │  │
│  ├───────────────────────────────┤  │
│  │ 적용조건   │  단위요금             │  │
│  │ (변함)    │  (변하지 않음)        │  │
│  └───────────────────────────────┘  │
│                                     │
│  ┌───────────────────────────────┐  │
│  │        규칙 2                  │  │
│  ├───────────────────────────────┤  │
│  │ 적용조건   │  단위요금             │  │
│  │ (변함)    │  (변하지 않음)        │  │
│  └───────────────────────────────┘  │
│                                     │
│  ...                                │
│                                     │
└─────────────────────────────────────┘

3.2 변경 캡슐화하기

🎨 도메인 모델

┌──────────────┐       ┌──────────────┐
│ BasicRate    │ 1   * │   FeeRule    │
│ Policy       ├───────│   (규칙)      │
└──────────────┘       └──────┬───────┘
                              │ 1
                              │
                    ┌─────────┴─────────┐
                    │                   │
                    │ 1                 │ 1
             ┌──────▼──────┐    ┌──────▼───────┐
             │FeeCondition │    │FeePerDuration│
             │(적용조건)     │    │(단위요금)      │
             └──────┬──────┘    └──────────────┘
                    ▲
                    │
        ┌───────────┼───────────┬───────────┐
        │           │           │           │
┌───────┴──┐ ┌──────┴───┐ ┌─────┴────┐ ┌────┴────┐
│TimeOfDay │ │DayOfWeek │ │Duration  │ │Fixed    │
│Condition │ │Condition │ │Condition │ │Condition│
└──────────┘ └──────────┘ └──────────┘ └─────────┘

핵심:

FeeRule은 추상화인 FeeCondition에만 의존
→ "적용조건"이라는 변경에 대해 캡슐화됨!

3.3 협력 패턴 설계하기

🎯 책임 할당

작업 1: 적용조건을 만족하는 구간 찾기

누구의 책임?
→ FeeCondition (적용조건의 정보 전문가)

메서드:
List<DateTimeInterval> findTimeIntervals(Call call)

작업 2: 구간에 단위요금 적용하기

누구의 책임?
→ FeeRule (규칙의 정보 전문가)

메서드:
Money calculateFee(Call call)

📊 협력 흐름

Phone
  │
  │ calculateFee()
  ▼
BasicRatePolicy
  │
  │ calculate(call)
  ▼
FeeRule
  │
  │ calculateFee(call)
  │
  ├──▶ FeeCondition.findTimeIntervals(call)
  │      │
  │      └─▶ List<DateTimeInterval> 반환
  │
  └──▶ FeePerDuration.calculate(interval) (각 구간마다)
         │
         └─▶ Money 반환

시각화:

┌─────────────┐
│   Phone     │
└──────┬──────┘
       │ calculateFee()
       ▼
┌─────────────────┐
│ BasicRatePolicy │
└──────┬──────────┘
       │ calculate(call)
       ▼
┌──────────────────────────────────┐
│          FeeRule                 │
├──────────────────────────────────┤
│                                  │
│  1. findTimeIntervals(call) ────▶│ FeeCondition
│     ← List<DateTimeInterval>     │
│                                  │
│  2. for each interval:           │
│     calculate(interval) ────────▶│ FeePerDuration
│     ← Money                      │
│                                  │
│  3. sum all Money                │
│                                  │
└──────────────────────────────────┘

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

🎯 FeeCondition 인터페이스

public interface FeeCondition {
    /**
     * 통화 기간 중 적용조건을 만족하는 구간들을 반환
     */
    List<DateTimeInterval> findTimeIntervals(Call call);
}

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

🎯 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));  // 합계
    }
}

🏛️ BasicRatePolicy 클래스

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

핵심:

✅ 추상적인 수준에서 협력 완성
✅ 구체적인 FeeCondition 구현체 없이도 동작 정의
✅ 확장 포인트는 FeeCondition 인터페이스

3.5 구체적인 협력 구현하기

⏰ 시간대별 정책

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

사용:

FeeRule rule1 = new FeeRule(
        new TimeOfDayFeeCondition(LocalTime.of(0, 0), LocalTime.of(19, 0)),
        new FeePerDuration(Money.wons(18), Duration.ofSeconds(10))
);

FeeRule rule2 = new FeeRule(
        new TimeOfDayFeeCondition(LocalTime.of(19, 0), LocalTime.of(24, 0)),
        new FeePerDuration(Money.wons(15), Duration.ofSeconds(10))
);

BasicRatePolicy policy = new BasicRatePolicy(rule1, rule2);

📅 요일별 정책

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

사용:

FeeRule weekdayRule = new FeeRule(
        new DayOfWeekFeeCondition(
                DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
                DayOfWeek.THURSDAY, DayOfWeek.FRIDAY
        ),
        new FeePerDuration(Money.wons(38), Duration.ofSeconds(10))
);

FeeRule weekendRule = new FeeRule(
        new DayOfWeekFeeCondition(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY),
        new FeePerDuration(Money.wons(19), Duration.ofSeconds(10))
);

BasicRatePolicy policy = new BasicRatePolicy(weekdayRule, weekendRule);

⏱️ 구간별 정책

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

사용:

FeeRule initialRule = new FeeRule(
        new DurationFeeCondition(Duration.ZERO, Duration.ofMinutes(1)),
        new FeePerDuration(Money.wons(50), Duration.ofSeconds(10))
);

FeeRule afterRule = new FeeRule(
        new DurationFeeCondition(Duration.ofMinutes(1), Duration.ofHours(10)),
        new FeePerDuration(Money.wons(20), Duration.ofSeconds(10))
);

BasicRatePolicy policy = new BasicRatePolicy(initialRule, afterRule);

💡 핵심: 일관성

이전 구현과의 차이:

step01 (비일관적):
- FixedFeePolicy: 단순 변수
- TimeOfDayDiscountPolicy: 여러 List
- DayOfWeekDiscountPolicy: Rule 객체
- DurationDiscountPolicy: Rule 객체 + 상속

step02 (일관적):
- 모두 FeeCondition 구현
- 모두 BasicRatePolicy + FeeRule 사용
- 동일한 협력 패턴

→ 새로운 정책 추가가 쉬워짐!
→ 코드 이해가 쉬워짐!

3.6 협력 패턴에 맞추기

🎯 고정요금 방식의 문제

고정요금 방식:
- "규칙"이라는 개념 불필요
- 단위요금 정보만 있으면 충분

하지만:
전체 협력 패턴은 "규칙 = 적용조건 + 단위요금"

어떻게 해결?

✅ 해결책: FixedFeeCondition

public class FixedFeeCondition implements FeeCondition {
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        // 전체 통화 시간을 그대로 반환
        return Arrays.asList(call.getInterval());
    }
}

사용:

FeeRule rule = new FeeRule(
        new FixedFeeCondition(),  // 항상 만족하는 조건
        new FeePerDuration(Money.wons(18), Duration.ofSeconds(10))
);

BasicRatePolicy policy = new BasicRatePolicy(rule);

의미:

"적용조건이 항상 만족됨"을 표현

→ 전체 통화 시간이 조건을 만족하는 구간

협력 패턴을 깨지 않고
고정요금 방식을 표현!

💡 패턴에 맞추기의 중요성

고정요금 방식만 다르게 구현했다면?

새로운 개발자가 코드를 볼 때:
"왜 고정요금만 다른 방식이지?"
"어떤 방식이 더 좋은 거지?"
"새로운 정책은 어떤 방식으로 구현하지?"

→ 혼란!

일관성 있게 구현했다면:

"아, 모든 정책이 FeeCondition을 구현하는구나"
"새로운 정책도 FeeCondition을 구현하면 되겠네"

→ 명확!

3.7 지속적으로 개선하라

⚠️ 주의사항

일관성 있는 협력 패턴을 찾았다고 해서
끝이 아니다!

처음부터 완벽한 협력 패턴을 찾기는 어렵다.

새로운 요구사항이 추가되면서
현재 협력 패턴의 한계가 드러날 수 있다.

🔄 개선 프로세스

1. 새로운 요구사항 발생
   ↓
2. 현재 협력 패턴으로 수용 가능한지 평가
   ↓
3-a. 가능 → 기존 패턴 유지
   ↓
3-b. 불가능 → 리팩터링 고려
   ↓
4. 새로운 협력 패턴 탐색
   ↓
5. 점진적 리팩터링
   ↓
6. 반복

💡 핵심 원칙

┌─────────────────────────────────────────────────────┐
│                                                     │
│  협력은 고정된 것이 아니다!                               │
│                                                     │
│  현재 협력 패턴이 변경의 무게를 지탱하기 어렵다면               │
│  변경을 수용할 수 있는 협력 패턴을 향해                      │
│  과감하게 리팩터링하라                                    │
│                                                     │
│  중요한 것:                                           │
│  현재 설계에 맹목적으로 일관성을 맞추는 것이 아니라             │
│  변경의 방향에 맞춰 지속적으로 개선하려는 의지                 │
│                                                     │
└─────────────────────────────────────────────────────┘

3.8 패턴을 찾아라

🎯 일관성의 핵심

일관성 있는 협력의 핵심:
→ 변경을 분리하고 캡슐화하는 것

협력을 일관성 있게 만드는 과정:
→ 유사한 기능 구현을 위해
  반복적으로 적용할 수 있는
  협력의 구조를 찾아가는 여정

결론:
협력을 일관성 있게 만든다 
= 유사한 변경을 수용할 수 있는 협력 패턴을 발견한다

🎨 협력 패턴의 진화

처음:
각 정책마다 다른 방식으로 구현
→ 비일관적

변경 분석:
공통점과 차이점 파악
→ 변하는 것과 변하지 않는 것 분리

추상화:
변하는 부분을 인터페이스로 추상화
→ FeeCondition

협력 패턴:
추상화를 중심으로 한 협력 구조
→ BasicRatePolicy - FeeRule - FeeCondition

확장:
새로운 정책 추가가 쉬워짐
→ FeeCondition 구현만 추가

📚 관련 개념

패턴 (Pattern):

반복적으로 발생하는 문제와
그 해결책의 쌍

특징:
- 검증된 해결책
- 재사용 가능
- 문서화된 경험
- 공통 어휘 제공

프레임워크 (Framework):

애플리케이션의 아키텍처를
구현 코드의 형태로 제공

특징:
- 실행 가능한 코드
- 제어의 역전 (IoC)
- 확장 포인트 제공
- 협력 패턴 강제

💡 Kent Beck의 인용

"객체지향 설계는
객체의 행동과 그것을 지원하기 위한 구조를
계속 수정해 나가는 작업을 반복해 나가면서
다듬어진다.

협력자들 간에 부하를 좀 더 균형 있게 배분하는
방법을 새로 만들어내면 나눠줄 책임이 바뀌게 된다.

만약 객체들이 서로 통신하는 방법을 개선해냈다면
이들 간의 상호작용은 재정의돼야 한다.

이 같은 과정을 거치면서
객체들이 자주 통신하는 경로는 더욱 효율적이게 되고,
주어진 작업을 수행하는 표준 방안이 정착된다.

협력 패턴이 드러나는 것이다!"

4. 핵심 정리

🎯 일관성 있는 협력의 가치

┌───────────────────────────────────────────────┐
│                                               │
│  일관성 있는 협력의 장점:                           │
│                                               │
│  1. 이해하기 쉬움                                 │
│     - 유사한 기능 = 유사한 구조                     │
│     - 한 번 이해하면 다른 것도 쉽게 이해              │
│                                               │
│  2. 수정하기 쉬움                                │
│     - 변경 포인트가 명확                          │
│     - 영향 범위 예측 가능                         │
│                                               │
│  3. 확장하기 쉬움                                │
│     - 새로운 기능 추가 방법 명확                    │
│     - 기존 코드 수정 최소화                        │
│                                               │
│  4. 재사용하기 쉬움                               │
│     - 검증된 협력 패턴 재사용                       │
│     - 설계 시간 단축                              │
│                                                │
└────────────────────────────────────────────────┘

📏 일관성을 위한 지침

1. 변하는 것과 변하지 않는 것 분리

애플리케이션에서 달라지는 부분을 찾아내고,
달라지지 않는 부분으로부터 분리시킨다.

= 변경의 캡슐화

2. 변하는 개념을 캡슐화

바뀌는 부분을 따로 뽑아서 캡슐화한다.

그렇게 하면 나중에
바뀌지 않는 부분에는 영향을 미치지 않고
그 부분만 고치거나 확장할 수 있다.

3. 조건 로직을 객체 탐색으로 전환

if-else, switch 문
→ 다형성 활용

타입 판단 코드 제거
→ 메시지 전송

4. 서브타입 캡슐화 + 객체 캡슐화

인터페이스 상속 + 합성

변하는 부분: 타입 계층으로 분리
변하지 않는 부분: 추상화에 의존

🔑 핵심 개념

캡슐화의 진정한 의미

캡슐화 ≠ 데이터 은닉

캡슐화 = 변하는 어떤 것이든 감추는 것

4가지 캡슐화:
1. 데이터 캡슐화
2. 메서드 캡슐화
3. 객체 캡슐화 (합성)
4. 서브타입 캡슐화 (다형성)

협력 패턴

유사한 기능을 구현하기 위해
반복적으로 적용할 수 있는
협력의 구조

= 변경을 수용할 수 있는 설계 템플릿

개념적 무결성 (Conceptual Integrity)

시스템이 일관성 있는
몇 개의 협력 패턴으로 구성될 때
얻어지는 품질

→ 이해, 수정, 확장 용이

📊 step01 vs step02 비교

측면 step01 (비일관적) step02 (일관적)
구조 각 정책마다 다름 모두 동일한 협력 패턴
추가 난이도 높음 (어떤 방식?) 낮음 (FeeCondition 구현)
이해 난이도 높음 (각각 학습) 낮음 (한 번 이해)
변경 영향 예측 어려움 명확함
재사용성 낮음 높음

💡 핵심 교훈

1. 유사한 기능은 유사한 방식으로 구현하라
   - 일관성 = 이해도 향상
   
2. 변경을 분리하고 캡슐화하라
   - 변하는 것과 변하지 않는 것 분리
   - 추상화로 캡슐화
   
3. 조건 로직을 객체 탐색으로 바꿔라
   - 다형성 활용
   - 타입 판단 제거
   
4. 협력 패턴을 발견하라
   - 반복되는 구조 찾기
   - 재사용 가능한 템플릿 만들기
   
5. 지속적으로 개선하라
   - 완벽한 패턴은 처음부터 나오지 않음
   - 요구사항 변화에 맞춰 진화

🎓 설계 원칙과의 연결

일관성 있는 협력은
여러 설계 원칙의 조화:

SRP (단일 책임):
- 변경 이유별로 클래스 분리
- FeeCondition, FeePerDuration 분리

OCP (개방-폐쇄):
- 새로운 FeeCondition 추가
- 기존 코드 수정 불필요

LSP (리스코프 치환):
- 모든 FeeCondition 구현체
- 동일하게 사용 가능

ISP (인터페이스 분리):
- FeeCondition 인터페이스
- 필요한 메서드만 정의

DIP (의존성 역전):
- BasicRatePolicy
- 추상화(FeeCondition)에 의존

🔗 연결고리

이전 장과의 연결

  • Chapter 11: 합성으로 조합 폭발 해결 → 일관성 있는 협력으로 확장
  • Chapter 13: 올바른 상속 (서브타이핑) → 일관성 있는 타입 계층
  • 변경의 캡슐화 → 협력 패턴

다음 장 예고

  • Chapter 15: 디자인 패턴과 프레임워크
    • 협력 패턴의 구체화
    • 재사용 가능한 설계
    • 프레임워크의 역할