"유사한 기능을 구현하기 위해 유사한 협력 패턴을 사용하라."
이 장에서는 일관성 있는 협력을 다룹니다. 유사한 요구사항을 반복적으로 추가할 때, 일관된 협력 패턴을 유지함으로써 설계의 품질을 높이고 유지보수성을 향상시키는 방법을 학습합니다.
- 일관성 있는 협력의 중요성 이해하기
- 변하는 것과 변하지 않는 것을 분리하는 방법 파악하기
- 변경을 캡슐화하는 다양한 기법 이해하기
- 협력 패턴을 발견하고 적용하는 과정 학습하기
- 조건 로직을 객체 탐색으로 전환하기
- 개념적 무결성을 유지하는 설계 만들기
📂 코드: step01 - 비일관적 구현 / step02 - 일관성 있는 구현
| 유형 | 형식 | 예시 |
|---|---|---|
| 고정요금 | A초당 B원 | 10초당 18원 |
| 시간대별 | 시간대마다 A초당 B원 | 0시 19시 |
| 요일별 | 요일마다 A초당 B원 | 평일: 10초당 38원 공휴일: 10초당 19원 |
| 구간별 | 통화 구간마다 A초당 B원 | 초기 1분: 10초당 50원 1분 이후: 10초당 20원 |
기본 정책 + 세금 정책
기본 정책 + 요금 할인 정책
기본 정책 + 세금 정책 + 요금 할인 정책
→ 데코레이터 패턴으로 구현됨
4가지 기본 정책 × 3가지 부가 정책 조합
= 총 12가지 조합
┌──────────────────────────────────────┐
│ 기본 정책 (4가지) │
├──────────────────────────────────────┤
│ • FixedFeePolicy │
│ • TimeOfDayDiscountPolicy │
│ • DayOfWeekDiscountPolicy │
│ • DurationDiscountPolicy │
└──────────────────────────────────────┘
↓
┌──────────────────────────────────────┐
│ 부가 정책 (데코레이터) │
├──────────────────────────────────────┤
│ • TaxablePolicy │
│ • RateDiscountablePolicy │
│ • TaxablePolicy + RateDiscountable │
└──────────────────────────────────────┘
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. 통화는 여러 날에 걸쳐 이루어질 수 있다
- 시작 일자 ≠ 종료 일자
2. 각 날짜마다 시간대가 다를 수 있다
- 첫째 날: 23시~24시
- 둘째 날: 0시~1시
3. 시간대별로 다른 요금 적용
- 0시~19시: 10초당 18원
- 19시~24시: 10초당 15원
책임: 기간 처리의 정보 전문가
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 // 마지막날
]
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;
}
}책임: 시간대별 요금 계산의 정보 전문가
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원]
인덱스가 같은 요소들이 하나의 규칙을 구성
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 = result.plus(rule.calculate(interval));
}
}
return result;
}
}구조:
DayOfWeekDiscountPolicy
│
└─ rules: [
Rule1: 평일(월~금), 10초당 38원
Rule2: 주말(토~일), 10초당 19원
]
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);
}
}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원
]
FixedFeePolicy:
- 단위 시간, 단위 요금만 인스턴스 변수로 보유
- 규칙이라는 개념 없음
TimeOfDayDiscountPolicy:
- 여러 List로 시간대 정보 관리
- 인덱스로 규칙 연결
DayOfWeekDiscountPolicy:
- DayOfWeekDiscountRule 객체로 규칙 표현
- 규칙 객체의 리스트로 관리
DurationDiscountPolicy:
- DurationDiscountRule 객체로 규칙 표현
- FixedFeePolicy 상속 (코드 재사용)
문제 1: 새로운 구현 추가 시
→ 어떤 방식을 따라야 할지 혼란
→ 새로운 방식으로 구현하면 일관성 더 무너짐
문제 2: 기존 구현 이해 시
→ 각 정책마다 다른 방식으로 이해해야 함
→ 학습 비용 증가
문제 3: 유지보수 시
→ 유사한 기능인데 수정 방법이 다름
→ 실수 가능성 증가
┌─────────────────────────────────────────────────────┐
│ │
│ 유사한 기능은 유사한 방식으로 구현해야 한다! │
│ │
│ 비일관성은 설계의 적! │
│ │
└─────────────────────────────────────────────────────┘
일관성 있는 설계를 만드는 가장 좋은 방법:
→ 다양한 설계 경험을 익히는 것
하지만:
→ 단기간에 설계 경험을 쌓기는 어려움
디자인 패턴이란?
→ 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는
경험 법칙을 모아놓은 설계 템플릿
장점:
1. 검증된 해결책 제공
2. 공통 어휘 제공 (의사소통 향상)
3. 변경에 대한 대응 방법 제시
┌─────────────────────────────────────────────────────┐
│ │
│ 1. 변하는 개념을 변하지 않는 개념으로부터 분리하라 │
│ │
│ 2. 변하는 개념을 캡슐화하라 │
│ │
└─────────────────────────────────────────────────────┘
연결:
이 두 지침은 모든 설계 원칙의 기반:
- 단일 책임 원칙 (SRP)
- 개방-폐쇄 원칙 (OCP)
- 의존성 역전 원칙 (DIP)
- 리스코프 치환 원칙 (LSP)
- 인터페이스 분리 원칙 (ISP)
모두 변경의 캡슐화를 목표로 함!
나쁜 예: 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
(다형적으로 실행)
전통적 정의:
캡슐화 = 데이터 은닉
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 |
| 객체 | 협력 관계 | 객체 관계 변경 관리 | 합성 |
| 서브타입 | 협력 관계 | 타입 종류 변경 관리 | 다형성 |
일반적으로:
데이터 캡슐화 + 메서드 캡슐화
→ 개별 객체의 변경 관리
객체 캡슐화 + 서브타입 캡슐화
→ 협력 참여 객체들의 관계 변경 관리
일관성 있는 협력을 만들기 위해:
→ 서브타입 캡슐화 + 객체 캡슐화 조합
= 인터페이스 상속 + 합성
1단계: 변하는 부분을 분리해서 타입 계층 만들기
변하지 않는 부분으로부터 변하는 부분을 분리
변하는 부분들의 공통적인 행동을
추상 클래스나 인터페이스로 추상화
변하는 부분들이
이 추상 클래스나 인터페이스를 상속/구현
→ 변하는 부분은 변하지 않는 부분의 서브타입이 됨
2단계: 변하지 않는 부분의 일부로 타입 계층 합성하기
앞에서 구현한 타입 계층을
변하지 않는 부분에 합성
변하지 않는 부분에서는
변경되는 구체적인 사항에 결합되면 안 됨
의존성 주입 등을 이용해
오직 추상화에만 의존
→ 변경이 캡슐화됨!
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는 다음을 모름:
- 구체적인 할인 정책 종류
- 할인 정책 계산 방법
- 할인 정책 내부 구현
→ 완벽한 캡슐화!
📂 코드: step02 - 일관성 있는 최종 구현
4가지 기본 정책 분석:
고정요금:
규칙 = [단위시간]당 [요금]원
시간대별:
규칙 = [시작시간]~[종료시간]까지 [단위시간]당 [요금]원
요일별:
규칙 = [요일]별 [단위시간]당 [요금]원
구간별:
규칙 = [통화구간] 동안 [단위시간]당 [요금]원
공통 구조:
기본 정책 = 하나 이상의 "규칙"들의 집합
각 규칙:
규칙 = "적용조건" + "단위요금"
적용조건 (변하는 부분):
- 시간대
- 요일
- 통화 구간
- 항상 (고정요금)
단위요금 (변하지 않는 부분):
- [단위시간]당 [요금]원
변하는 것:
→ 적용조건
- 시간대별, 요일별, 구간별, 고정
변하지 않는 것:
→ 규칙
→ 단위요금
시각화:
┌─────────────────────────────────────┐
│ 기본 정책 │
├─────────────────────────────────────┤
│ │
│ ┌───────────────────────────────┐ │
│ │ 규칙 1 │ │
│ ├───────────────────────────────┤ │
│ │ 적용조건 │ 단위요금 │ │
│ │ (변함) │ (변하지 않음) │ │
│ └───────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ 규칙 2 │ │
│ ├───────────────────────────────┤ │
│ │ 적용조건 │ 단위요금 │ │
│ │ (변함) │ (변하지 않음) │ │
│ └───────────────────────────────┘ │
│ │
│ ... │
│ │
└─────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐
│ BasicRate │ 1 * │ FeeRule │
│ Policy ├───────│ (규칙) │
└──────────────┘ └──────┬───────┘
│ 1
│
┌─────────┴─────────┐
│ │
│ 1 │ 1
┌──────▼──────┐ ┌──────▼───────┐
│FeeCondition │ │FeePerDuration│
│(적용조건) │ │(단위요금) │
└──────┬──────┘ └──────────────┘
▲
│
┌───────────┼───────────┬───────────┐
│ │ │ │
┌───────┴──┐ ┌──────┴───┐ ┌─────┴────┐ ┌────┴────┐
│TimeOfDay │ │DayOfWeek │ │Duration │ │Fixed │
│Condition │ │Condition │ │Condition │ │Condition│
└──────────┘ └──────────┘ └──────────┘ └─────────┘
핵심:
FeeRule은 추상화인 FeeCondition에만 의존
→ "적용조건"이라는 변경에 대해 캡슐화됨!
작업 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 │
│ │
└──────────────────────────────────┘
public interface FeeCondition {
/**
* 통화 기간 중 적용조건을 만족하는 구간들을 반환
*/
List<DateTimeInterval> findTimeIntervals(Call call);
}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 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 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 인터페이스
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 사용
- 동일한 협력 패턴
→ 새로운 정책 추가가 쉬워짐!
→ 코드 이해가 쉬워짐!
고정요금 방식:
- "규칙"이라는 개념 불필요
- 단위요금 정보만 있으면 충분
하지만:
전체 협력 패턴은 "규칙 = 적용조건 + 단위요금"
어떻게 해결?
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을 구현하면 되겠네"
→ 명확!
일관성 있는 협력 패턴을 찾았다고 해서
끝이 아니다!
처음부터 완벽한 협력 패턴을 찾기는 어렵다.
새로운 요구사항이 추가되면서
현재 협력 패턴의 한계가 드러날 수 있다.
1. 새로운 요구사항 발생
↓
2. 현재 협력 패턴으로 수용 가능한지 평가
↓
3-a. 가능 → 기존 패턴 유지
↓
3-b. 불가능 → 리팩터링 고려
↓
4. 새로운 협력 패턴 탐색
↓
5. 점진적 리팩터링
↓
6. 반복
┌─────────────────────────────────────────────────────┐
│ │
│ 협력은 고정된 것이 아니다! │
│ │
│ 현재 협력 패턴이 변경의 무게를 지탱하기 어렵다면 │
│ 변경을 수용할 수 있는 협력 패턴을 향해 │
│ 과감하게 리팩터링하라 │
│ │
│ 중요한 것: │
│ 현재 설계에 맹목적으로 일관성을 맞추는 것이 아니라 │
│ 변경의 방향에 맞춰 지속적으로 개선하려는 의지 │
│ │
└─────────────────────────────────────────────────────┘
일관성 있는 협력의 핵심:
→ 변경을 분리하고 캡슐화하는 것
협력을 일관성 있게 만드는 과정:
→ 유사한 기능 구현을 위해
반복적으로 적용할 수 있는
협력의 구조를 찾아가는 여정
결론:
협력을 일관성 있게 만든다
= 유사한 변경을 수용할 수 있는 협력 패턴을 발견한다
처음:
각 정책마다 다른 방식으로 구현
→ 비일관적
변경 분석:
공통점과 차이점 파악
→ 변하는 것과 변하지 않는 것 분리
추상화:
변하는 부분을 인터페이스로 추상화
→ FeeCondition
협력 패턴:
추상화를 중심으로 한 협력 구조
→ BasicRatePolicy - FeeRule - FeeCondition
확장:
새로운 정책 추가가 쉬워짐
→ FeeCondition 구현만 추가
패턴 (Pattern):
반복적으로 발생하는 문제와
그 해결책의 쌍
특징:
- 검증된 해결책
- 재사용 가능
- 문서화된 경험
- 공통 어휘 제공
프레임워크 (Framework):
애플리케이션의 아키텍처를
구현 코드의 형태로 제공
특징:
- 실행 가능한 코드
- 제어의 역전 (IoC)
- 확장 포인트 제공
- 협력 패턴 강제
"객체지향 설계는
객체의 행동과 그것을 지원하기 위한 구조를
계속 수정해 나가는 작업을 반복해 나가면서
다듬어진다.
협력자들 간에 부하를 좀 더 균형 있게 배분하는
방법을 새로 만들어내면 나눠줄 책임이 바뀌게 된다.
만약 객체들이 서로 통신하는 방법을 개선해냈다면
이들 간의 상호작용은 재정의돼야 한다.
이 같은 과정을 거치면서
객체들이 자주 통신하는 경로는 더욱 효율적이게 되고,
주어진 작업을 수행하는 표준 방안이 정착된다.
협력 패턴이 드러나는 것이다!"
┌───────────────────────────────────────────────┐
│ │
│ 일관성 있는 협력의 장점: │
│ │
│ 1. 이해하기 쉬움 │
│ - 유사한 기능 = 유사한 구조 │
│ - 한 번 이해하면 다른 것도 쉽게 이해 │
│ │
│ 2. 수정하기 쉬움 │
│ - 변경 포인트가 명확 │
│ - 영향 범위 예측 가능 │
│ │
│ 3. 확장하기 쉬움 │
│ - 새로운 기능 추가 방법 명확 │
│ - 기존 코드 수정 최소화 │
│ │
│ 4. 재사용하기 쉬움 │
│ - 검증된 협력 패턴 재사용 │
│ - 설계 시간 단축 │
│ │
└────────────────────────────────────────────────┘
애플리케이션에서 달라지는 부분을 찾아내고,
달라지지 않는 부분으로부터 분리시킨다.
= 변경의 캡슐화
바뀌는 부분을 따로 뽑아서 캡슐화한다.
그렇게 하면 나중에
바뀌지 않는 부분에는 영향을 미치지 않고
그 부분만 고치거나 확장할 수 있다.
if-else, switch 문
→ 다형성 활용
타입 판단 코드 제거
→ 메시지 전송
인터페이스 상속 + 합성
변하는 부분: 타입 계층으로 분리
변하지 않는 부분: 추상화에 의존
캡슐화 ≠ 데이터 은닉
캡슐화 = 변하는 어떤 것이든 감추는 것
4가지 캡슐화:
1. 데이터 캡슐화
2. 메서드 캡슐화
3. 객체 캡슐화 (합성)
4. 서브타입 캡슐화 (다형성)
유사한 기능을 구현하기 위해
반복적으로 적용할 수 있는
협력의 구조
= 변경을 수용할 수 있는 설계 템플릿
시스템이 일관성 있는
몇 개의 협력 패턴으로 구성될 때
얻어지는 품질
→ 이해, 수정, 확장 용이
| 측면 | 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: 디자인 패턴과 프레임워크
- 협력 패턴의 구체화
- 재사용 가능한 설계
- 프레임워크의 역할