11장에서 핸드폰 과금 시스템은 상속을 걷어내고 합성으로 정착했다. 요금 계산 정책은 RatePolicy 인터페이스 뒤에 숨고, 기본 정책(BasicRatePolicy)과 부가 정책(AdditionalRatePolicy)이 서로를 감싸며 협력한다.
public interface RatePolicy {
Money calculateFee(Phone phone);
}
public abstract class BasicRatePolicy implements RatePolicy {
@Override
public Money calculateFee(Phone phone) {
Money result = Money.ZERO;
for (Call call : phone.getCalls()) {
result = result.plus(calculateCallFee(call));
}
return result;
}
protected abstract Money calculateCallFee(Call call);
}
public class Phone {
private RatePolicy ratePolicy; // 정책은 주입된다
}RegularPolicy, NightlyDiscountPolicy가 BasicRatePolicy를 상속하고, TaxablePolicy, RateDiscountablePolicy가 AdditionalRatePolicy로 다른 정책을 감싼다. 여기까지는 깔끔하다. 문제는 새로운 종류의 요금제가 밀려들 때 시작된다.
밀려드는 네 개의 새 요금제
기획은 네 종류의 요금제를 한꺼번에 요구한다. 각각을 따로 보면 요구는 단순하다.
- 고정요금제: 통화 시간에 비례해 일정 단가를 부과한다. 10초당 얼마 식이다. 가장 단순한 형태다.
- 시간대별 요금제: 하루를 시간 구간으로 나눠 단가를 다르게 매긴다. 예컨대 0
19시는 10초당 18원, 1924시는 10초당 15원. 한 통화가 두 구간에 걸치면 걸친 만큼 나눠 계산해야 한다. - 요일별 요금제: 평일과 주말에 다른 단가를 적용한다. 조건이 시간이 아니라 요일이라는 점만 다르다.
- 구간별 요금제: 통화 시간 자체를 구간으로 나눠, 처음 1분은 비싸게 이후는 싸게 매기는 식이다. 조건이 “몇 번째 구간의 통화인가”로 바뀐다.
이 넷을 각각의 담당자가 독립적으로 구현하면 어떻게 될까. 셋은 모두 “통화가 어떤 조건에 걸리는지 판단하고, 걸린 부분에 해당 단가를 곱한다”는 같은 일을 하는데, 조건의 종류(시간·요일·통화 구간)만 다르다. 그런데 각자 알아서 짜면 이 공통 구조가 코드에 드러나지 않는다.
판단 기준: 여러 요구가 한꺼번에 들어왔다면, 개별 구현에 뛰어들기 전에 “이들이 공유하는 뼈대가 무엇인가”를 먼저 찾는다. 함정: 요구를 하나씩 순서대로 처리하면, 뒤늦게 공통점을 발견해도 이미 서로 다르게 굳은 코드를 되돌리기 어렵다.
유사한 요구가 제각각 구현되는 문제
고정요금제, 시간대별 요금제, 요일별 요금제, 구간별 요금제 — 새 요구사항이 줄줄이 들어온다. 이들은 본질적으로 같은 일을 한다. “통화가 어떤 조건에 속하는지 판단하고, 그 조건에 맞는 단가를 적용한다.” 하지만 각 정책을 담당한 개발자가 제각각 구현하면, 같은 개념이 매번 다른 코드 모양으로 흩어진다.
판단 기준: 새 기능들이 개념적으로 유사하다면, 구현도 유사한 형태를 공유해야 한다. 유사한 요구가 서로 다른 코드 구조로 풀리고 있다면 그것은 설계 실패의 신호다. 함정: 각 클래스만 따로 보면 다 정상이다. 문제는 전체를 나란히 놓고 봐야 드러난다 — “왜 시간대별과 요일별이 완전히 다른 방식으로 짜였지?”
설계에 일관성을 부여하는 세 단계
흩어진 구현을 일관된 협력으로 바꾸는 데는 정해진 절차가 있다. 요령이 아니라 반복 가능한 세 단계다.
- 변하는 개념과 변하지 않는 개념을 구분한다. 네 요금제를 관통하는 불변의 절차는 “조건에 걸리는 통화 구간을 찾아, 그 구간에 단가를 곱해 합산한다”이다. 변하는 것은 오직 “무엇이 조건인가”뿐이다 — 시간대인지, 요일인지, 통화 구간인지.
- 변하는 개념을 캡슐화한다. 변하는 “조건”을
FeeCondition이라는 독립된 타입 뒤로 숨긴다. 조건이 어떻게 판단되는지는 각 구현체 안에 갇히고, 바깥에서는 “조건에 맞는 구간을 돌려줘”라는 하나의 메시지만 본다. - 캡슐화한 개념끼리 협력하는 패턴을 통일한다. 어떤 조건이든
FeeRule이 똑같은 방식으로 다룬다 — 조건에게 구간을 물어보고, 각 구간에 단가를 곱해 합한다. 모든 요금제가 이 하나의 협력 형태를 강제로 공유한다.
핵심은 3단계다. 1·2단계까지는 흔한 캡슐화지만, 캡슐화한 것들이 서로 다른 방식으로 협력하면 일관성은 다시 무너진다. 협력의 형태까지 통일해야 비로소 설계가 예측 가능해진다.
판단 기준: 변하는 것을 객체로 뽑아낸 뒤, 그 객체들이 클라이언트와 맺는 협력 방식이 모두 동일한지 확인한다. 함정: 인터페이스만 통일하고 협력 흐름은 제각각이면, 타입은 같아도 사용법이 달라 일관성의 이득을 잃는다.
변하는 것과 변하지 않는 것을 가른다
아래 스텝은 이 흩어짐을 일관된 하나의 구조로 수렴시키는 과정이다. 핵심은 시간대별·요일별·구간별 요금제를 관통하는 공통 뼈대를 찾는 것이다 — 변하지 않는 것은 “조건을 판단하고 단가를 곱한다”는 절차이고, 변하는 것은 “무엇이 조건인가”뿐이다.
public class TimeOfDayDiscountPolicy extends BasicRatePolicy { private List<LocalTime> starts; private List<LocalTime> ends; private List<Money> amounts; @Override protected Money calculateCallFee(Call call) { Money result = Money.ZERO; for (int i = 0; i < starts.size(); i++) { if (call.getFrom().toLocalTime().isAfter(starts.get(i))) { result = result.plus(amounts.get(i)); // 시간대 판단 로직이 여기 박힘 } } return result; }}public class DayOfWeekDiscountPolicy extends BasicRatePolicy { private List<DayOfWeek> days; // 요일별은 또 다른 필드 구성 private List<Money> amounts; // ... calculateCallFee 를 완전히 다른 방식으로 다시 구현 // 시간대별과 요일별은 "같은 일"인데 코드에는 공통점이 없다.}
이제 새 요금제를 추가하는 일은 FeeCondition을 하나 구현하는 것으로 끝난다. FeeRule이 “조건에 맞는 구간을 찾아 단가를 곱해 합산한다”는 절차를 한 번만 정의하고, 각 조건은 “무엇이 걸리는가”만 다르게 답한다. 변하지 않는 협력 구조 위에서 변하는 부분만 갈아 끼우는 것 — 이것이 일관성 있는 협력이다.
판단 기준: 유사한 기능들이 공유해야 할 것은 “협력의 구조”다. 각자 자유롭게 구현하도록 두지 말고, 개념적으로 유사한 것은 동일한 협력 패턴을 따르도록 강제한다. 함정: 처음부터 완벽한 추상 구조를 설계하려 들면 과설계가 된다. 두세 개의 유사 사례가 실제로 쌓인 뒤, 그 공통 뼈대를 추출하는 편이 안전하다.
시간대별 정책의 진짜 어려움 — 구간 분할
FeeCondition이 반환하는 List<DateTimeInterval>는 이 설계에서 가장 까다로운 부분을 감춘다. 통화 하나가 조건 구간에 깔끔하게 들어맞는 경우는 드물다. 22시 40분에 시작해 23시 20분에 끝난 통화를, “19~24시” 시간대 조건에 맞춰 잘라 내는 것은 쉽다. 문제는 통화가 자정을 넘길 때다.
23시 50분에 시작해 다음 날 0시 30분에 끝난 통화가 있고, 조건이 “0~19시”라면 어떻게 되는가. 이 통화는 두 날에 걸쳐 있으므로, 먼저 날짜 경계에서 통화를 쪼갠 뒤 각 조각을 시간대 조건과 대조해야 한다. DateTimeInterval의 분할 로직은 그래서 두 단계를 밟는다.
public class DateTimeInterval {
private LocalDateTime from;
private LocalDateTime to;
// 1) 자정을 넘기면 날짜 단위로 먼저 쪼갠다
public List<DateTimeInterval> splitByDay() {
if (days() > 0) {
return splitByDay(days()); // [23:50~24:00], [00:00~00:30] 처럼 하루 경계로 분리
}
return List.of(this); // 자정을 넘지 않으면 그대로
}
private long days() {
return Duration.between(from.toLocalDate().atStartOfDay(),
to.toLocalDate().atStartOfDay()).toDays();
}
}
// 시간대 조건은 "날짜로 쪼갠 각 조각"을 다시 [from,to] 시간창과 교차시킨다
public class TimeOfDayFeeCondition implements FeeCondition {
private LocalTime from;
private LocalTime to;
@Override
public List<DateTimeInterval> findTimeIntervals(Call call) {
return call.getInterval().splitByDay().stream() // 먼저 자정 경계로 분리
.flatMap(interval -> interval.intersectWith(from, to).stream()) // 그 다음 시간창 교차
.collect(Collectors.toList());
}
}이 자정 처리 로직이 각 요금제마다 흩어져 있었다면 재앙이었을 것이다. DateTimeInterval이 “구간을 다룬다”는 책임을 온전히 쥐고 있기에, 시간대별이든 요일별이든 이 어려운 계산을 한 곳에서 공유한다. 변하지 않는 것을 한 객체에 모아 둔 설계가, 가장 위험한 로직의 중복을 원천에서 막는다.
판단 기준: 여러 조건이 공통으로 필요로 하는 까다로운 계산(날짜·구간 분할 등)은 조건 바깥의 값 객체(DateTimeInterval)에 두어 단 한 번만 구현한다. 함정: 자정 걸침 같은 경계 사례를 각 조건 구현체 안에서 처리하면, 조건이 늘 때마다 같은 버그를 다시 만들 위험이 커진다.
개념적 무결성 — 하나의 정신이 관통해야 한다
일관성 있는 협력이 궁극적으로 추구하는 것은 프레더릭 브룩스가 『맨먼스 미신』에서 말한 **개념적 무결성(conceptual integrity)**이다. 브룩스는 이렇게 못 박았다 — “개념적 무결성이야말로 시스템 설계에서 가장 중요한 고려사항이다.” 시스템의 여러 부분이 서로 다른 정신(설계 원칙)으로 만들어지면, 각 부분이 아무리 잘 만들어졌어도 전체는 이해하기 어려운 잡동사니가 된다.
과금 시스템에서 개념적 무결성은 “모든 요금제가 같은 방식으로 계산된다”는 데서 나온다. 사용자가 시간대별 요금제 코드를 읽고 그 정신을 이해했다면, 요일별·구간별도 같은 정신으로 읽힌다. 브룩스가 강조한 것처럼, 이런 일관성은 여러 설계자의 아이디어를 나열해서가 아니라 하나의 관통하는 원칙을 소수의 설계자가 관철할 때 생긴다. FeeRule–FeeCondition 협력 구조는 그 관통하는 원칙을 코드에 심은 것이다.
판단 기준: 시스템을 처음 접한 사람이 한 부분을 이해하면 나머지도 같은 방식으로 이해되는가 — 그렇다면 개념적 무결성이 있다. 함정: 부분마다 다른 설계 사상이 섞이면, 개별 부분의 품질과 무관하게 전체 학습 비용이 폭증한다.
왜 일관성이 이득인가
일관된 협력 패턴은 코드를 예측 가능하게 만든다. 시간대별 요금제를 이해한 개발자는 요일별·구간별 요금제도 곧바로 이해한다 — 구조가 같고 FeeCondition만 다르기 때문이다. 새 요구사항이 왔을 때 “이건 어디에 어떻게 끼워야 하지”를 고민할 필요 없이, 이미 정해진 자리에 FeeCondition 하나를 놓으면 된다. 설계의 응집도가 개별 클래스가 아니라 협력 전체에서 관리된다.
설계가 지도가 되는 순간 — 구간별 요금제
마지막 요구인 구간별 요금제가 도착했다고 하자. 이번 조건은 시간도 요일도 아니라 “통화 시작 후 몇 초가 지났는가”다. 일관된 협력 패턴이 없었다면 이 요구는 또 하나의 낯선 구현을 낳았을 것이다. 하지만 이제 설계 자체가 “어디에 무엇을 놓으라”고 알려 준다. FeeCondition을 하나 더 구현하면 된다는 것을 설계가 지도처럼 가리킨다.
public class DurationFeeCondition implements FeeCondition {
private Duration from; // 통화 시작 후 이 지점부터
private Duration to; // 이 지점까지
@Override
public List<DateTimeInterval> findTimeIntervals(Call call) {
// 통화 구간 중 [from, to] 경과 시간에 해당하는 부분만 잘라 반환
return call.getInterval().splitByDuration(from, to);
}
}
// FeeRule 도, Phone 도, 다른 어떤 코드도 바뀌지 않는다.
// 새 조건 클래스 하나가 이미 열려 있던 자리에 그대로 꽂힌다.FeeRule은 손대지 않는다. Phone도, 기존 조건들도 그대로다. 새 개념이 들어올 자리가 설계 안에 미리 마련돼 있고, 개발자는 그 자리에 부품 하나를 끼우기만 한다. 일관성 있는 협력이란 결국 이런 것이다 — 미래의 변경이 어디로 올지 예측해 그 지점을 열어 두고, 나머지는 닫아 두는 것. 잘 만든 협력 구조는 다음 요구가 왔을 때 개발자를 헤매게 하지 않고 정확한 위치로 안내하는 지도가 된다.
판단 기준: 새 요구를 받았을 때 “무엇을 어디에 추가할지”가 설계만 보고 곧바로 정해진다면, 협력 패턴이 지도 역할을 하고 있는 것이다. 함정: 새 요구마다 여러 클래스를 함께 수정해야 한다면, 겉보기에 일관돼 보여도 변경의 자리가 제대로 열려 있지 않은 것이다.
정리
- 유사한 요구가 제각각 구현되면, 개념은 하나인데 코드 모양은 여럿이 되어 이해와 확장이 어려워진다.
- 해법은 변하는 것(요금 조건)과 변하지 않는 것(조건 적용 절차)을 분리하는 것이다.
FeeRule이 불변의 협력 절차를 쥐고,FeeCondition이 변하는 조건을 위임받는다. 새 요금제는FeeCondition하나로 끝난다.- 일관성은 개별 클래스가 아니라 협력 전체에서 관리되어야 한다 — 유사한 것은 유사하게, 다른 것은 다르게.
- 추상 구조는 유사 사례가 실제로 쌓인 뒤 추출한다. 선제적 완벽 설계는 과설계로 흐른다.
다음장으로 15장