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, NightlyDiscountPolicyBasicRatePolicy를 상속하고, TaxablePolicy, RateDiscountablePolicyAdditionalRatePolicy로 다른 정책을 감싼다. 여기까지는 깔끔하다. 문제는 새로운 종류의 요금제가 밀려들 때 시작된다.

유사한 요구가 제각각 구현되는 문제

고정요금제, 시간대별 요금제, 요일별 요금제, 구간별 요금제 — 새 요구사항이 줄줄이 들어온다. 이들은 본질적으로 같은 일을 한다. “통화가 어떤 조건에 속하는지 판단하고, 그 조건에 맞는 단가를 적용한다.” 하지만 각 정책을 담당한 개발자가 제각각 구현하면, 같은 개념이 매번 다른 코드 모양으로 흩어진다.

판단 기준: 새 기능들이 개념적으로 유사하다면, 구현도 유사한 형태를 공유해야 한다. 유사한 요구가 서로 다른 코드 구조로 풀리고 있다면 그것은 설계 실패의 신호다. 함정: 각 클래스만 따로 보면 다 정상이다. 문제는 전체를 나란히 놓고 봐야 드러난다 — “왜 시간대별과 요일별이 완전히 다른 방식으로 짜였지?”

변하는 것과 변하지 않는 것을 가른다

아래 스텝은 이 흩어짐을 일관된 하나의 구조로 수렴시키는 과정이다. 핵심은 시간대별·요일별·구간별 요금제를 관통하는 공통 뼈대를 찾는 것이다 — 변하지 않는 것은 “조건을 판단하고 단가를 곱한다”는 절차이고, 변하는 것은 “무엇이 조건인가”뿐이다.

Refactoring Step 제각각 구현 — 유사 기능이 서로 다른 모양으로 흩어진다
1/3
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만 다르기 때문이다. 새 요구사항이 왔을 때 “이건 어디에 어떻게 끼워야 하지”를 고민할 필요 없이, 이미 정해진 자리에 FeeCondition 하나를 놓으면 된다. 설계의 응집도가 개별 클래스가 아니라 협력 전체에서 관리된다.

정리

  • 유사한 요구가 제각각 구현되면, 개념은 하나인데 코드 모양은 여럿이 되어 이해와 확장이 어려워진다.
  • 해법은 변하는 것(요금 조건)과 변하지 않는 것(조건 적용 절차)을 분리하는 것이다.
  • FeeRule이 불변의 협력 절차를 쥐고, FeeCondition이 변하는 조건을 위임받는다. 새 요금제는 FeeCondition 하나로 끝난다.
  • 일관성은 개별 클래스가 아니라 협력 전체에서 관리되어야 한다 — 유사한 것은 유사하게, 다른 것은 다르게.
  • 추상 구조는 유사 사례가 실제로 쌓인 뒤 추출한다. 선제적 완벽 설계는 과설계로 흐른다.

다음장으로 15장