[오브젝트] 일관성 있는 협력(chap14)

KwonMoYang·2025년 7월 2일

오브젝트 14장 - 일관성 있는 협력

📌 핵심 개념

"서비스를 개발하다 보면 유사한 요구사항을 반복적으로 추가하거나 수정하게 되는 경우가 있다. 객체지향 설계의 목표는 이러한 변경을 일관성 있는 협력 패턴으로 다루는 것이다."


1. 설계의 일관성이란?

개념적 무결성 (Conceptual Integrity)

정의: 시스템이 일관성 있는 몇 개의 협력 패턴으로 구성되어, 전체가 마치 한 사람이 설계한 것처럼 보이는 것

일관성이 주는 이점:

  • 시스템을 이해하기 쉬움
  • 수정과 확장에 필요한 시간과 노력 감소
  • 새로운 기능 추가 시 기존 패턴 재사용 가능

기존 핸드폰 과금 시스템의 문제

초기: 독립적인 구현들

// 고정 요금제
public class FixedFeePhone {
    private Money amount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Money calculateFee() {
        Money result = Money.ZERO;
        for(Call call : calls) {
            result = result.plus(amount.times(
                call.getDuration().getSeconds() / seconds.getSeconds()));
        }
        return result;
    }
}

// 심야 할인 요금제 - 완전히 다른 구조
public class NightlyDiscountPhone {
    private static final int LATE_NIGHT_HOUR = 22;
    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;
    private List<Call> calls = new ArrayList<>();

    public Money calculateFee() {
        Money result = Money.ZERO;
        for(Call call : calls) {
            if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                result = result.plus(nightlyAmount.times(
                    call.getDuration().getSeconds() / seconds.getSeconds()));
            } else {
                result = result.plus(regularAmount.times(
                    call.getDuration().getSeconds() / seconds.getSeconds()));
            }
        }
        return result;
    }
}

개선 시도 1: 중복 제거를 위한 상속

// 템플릿 메서드 패턴 적용
public abstract class Phone {
    private List<Call> calls = new ArrayList<>();
    
    public Money calculateFee() {
        Money result = Money.ZERO;
        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }
        return result;
    }
    
    protected abstract Money calculateCallFee(Call call);
}

public class RegularPhone extends Phone {
    private Money amount;
    private Duration seconds;
    
    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

개선 시도 2: 부가 정책 추가 (문제 시작)

// 세금 정책을 위해 또 다른 상속 계층 생성
public class TaxableRegularPhone extends RegularPhone {
    private double taxRate;
    
    @Override
    public Money calculateFee() {
        Money fee = super.calculateFee();
        return fee.plus(fee.times(taxRate));
    }
}

// 기본요금 할인을 위해 또 다른 클래스
public class RateDiscountableRegularPhone extends RegularPhone {
    private Money discountAmount;
    
    @Override  
    public Money calculateFee() {
        Money fee = super.calculateFee();
        return fee.minus(discountAmount);
    }
}

개선 시도 3: 조합 문제

// 세금 + 할인을 모두 적용하려면? 클래스 폭발!
public class TaxableAndRateDiscountableRegularPhone extends RegularPhone {
    private double taxRate;
    private Money discountAmount;
    
    @Override
    public Money calculateFee() {
        Money fee = super.calculateFee();
        fee = fee.minus(discountAmount);
        return fee.plus(fee.times(taxRate));
    }
}

개선 시도 4: 합성을 이용한 부분적 해결

// 일부는 합성으로 해결
public class TaxablePhone {
    private Phone phone;
    private double taxRate;
    
    public Money calculateFee() {
        Money fee = phone.calculateFee();
        return fee.plus(fee.times(taxRate));
    }
}

비상!! : 일관성 없는 협력 구조

문제점들:
1. 기본 정책: 템플릿 메서드 패턴 (상속)
2. 부가 정책 (첫 시도): 또 다른 상속 계층
3. 조합: 다중 상속 문제로 클래스 폭발
4. 부분적 해결: 일부는 상속, 일부는 합성

// 개발자 입장에서 혼란스러운 상황
// "새로운 할인 정책을 추가하려면?"

// 방법 1: Phone을 상속?
public class NewDiscountPhone extends Phone { }

// 방법 2: RegularPhone을 상속?  
public class NewDiscountRegularPhone extends RegularPhone { }

// 방법 3: 합성을 사용?
public class NewDiscountPhone {
    private Phone phone;
}

// 방법 4: 인터페이스를 만들어야 하나?
public interface DiscountPolicy { }

일관성이 없어서 생기는 실제 문제

1. 예측 불가능한 구조

// 어떤 곳은 이렇게
Phone phone = new TaxableRegularPhone();

// 어떤 곳은 이렇게  
Phone phone = new RegularPhone();
TaxablePhone taxablePhone = new TaxablePhone(phone);

// 또 어떤 곳은 이렇게
Phone phone = new TaxableAndRateDiscountableRegularPhone();

2. 확장의 어려움

// 새로운 요구사항: "약정 할인 정책 추가"
// 어떤 패턴을 따라야 하지?
// 1. Phone을 상속? 
// 2. 별도 클래스로 합성?
// 3. 기존 상속 계층에 추가?

3. 이해의 어려움

  • RegularPhone은 상속으로 확장
  • TaxablePhone은 합성으로 확장
  • 일부 조합은 다중 상속 문제로 별도 클래스
  • 각각은 나름의 이유가 있지만 전체적으로 일관성 없음

"문제는 이러한 설계가 나쁜 것이 아니라, 유사한 문제를 서로 다른 방식으로 해결하고 있다는 것이다"


2. 조건 로직 대 객체 탐색

핵심 원칙

"객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다"

절차적 방식의 문제

조건 로직을 사용하면 타입을 검사하고 그에 따라 분기하는 코드가 만들어진다:

// ❌ 절차적 방식 - 타입 검사와 분기
public class Phone {
    private enum PhoneType { REGULAR, NIGHTLY }
    private PhoneType type;

    public Money calculateFee() {
        Money result = Money.ZERO;

        for(Call call : calls) {
            if (type == PhoneType.REGULAR) {
                // 일반 요금 계산 로직
                result = result.plus(
                    amount.times(call.getDuration().getSeconds() / seconds.getSeconds())
                );
            } else {
                // 심야 할인 계산 로직
                if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
                    result = result.plus(
                        nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
                    );
                } else {
                    result = result.plus(
                        regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds())
                    );
                }
            }
        }
        return result;
    }
}

객체지향적 해결

조건 로직을 다형성으로 대체한다:

// ✅ 객체지향 방식 - 다형성 활용
public class Phone {
    private RatePolicy ratePolicy;
    private List<Call> calls = new ArrayList<>();

    public Phone(RatePolicy ratePolicy) {
        this.ratePolicy = ratePolicy;
    }

    public Money calculateFee() {
        return ratePolicy.calculateFee(this);
    }
}

public interface RatePolicy {
    Money calculateFee(Phone phone);
}

3. 캡슐화가 되었는가

캡슐화의 진정한 의미

"캡슐화란 변하는 어떤 것이든 감추는 것이다"

캡슐화의 종류

1. 데이터 캡슐화

public class Phone {
    private List<Call> calls = new ArrayList<>();  // 데이터 은닉

    public List<Call> getCalls() {
        return Collections.unmodifiableList(calls);  // 안전한 접근
    }
}

2. 메서드 캡슐화

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);  // 서브클래스만 접근
}

3. 객체 캡슐화 (합성)

public class Phone {
    private RatePolicy ratePolicy;  // 내부 객체 은닉

    public Money calculateFee() {
        return ratePolicy.calculateFee(this);  // 위임
    }
}

4. 서브타입 캡슐화

// 클라이언트는 구체적인 타입을 모름
RatePolicy policy = new RegularPolicy(...);  // 서브타입 은닉
Phone phone = new Phone(policy);

4. 협력 패턴 설계하기

변하는 것과 변하지 않는 것을 분리

원칙: 변하지 않는 부분은 추상화하고, 변하는 부분은 구체화한다

해결책: 일관된 협력 구조

// 모든 정책이 동일한 협력 구조를 따름
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 abstract class AdditionalRatePolicy implements RatePolicy {
    private RatePolicy next;
    
    public AdditionalRatePolicy(RatePolicy next) {
        this.next = next;
    }
    
    @Override
    public Money calculateFee(Phone phone) {
        Money fee = next.calculateFee(phone);      // 기본 계산
        return afterCalculated(fee);                // 부가 처리
    }
    
    // 변하는 부분 - 부가 처리 로직
    protected abstract Money afterCalculated(Money fee);
}

이제 개발자는 명확한 지침을 가지게 됩니다:

  • 기본 정책 추가: BasicRatePolicy 상속하여 calculateCallFee() 구현
  • 부가 정책 추가: AdditionalRatePolicy 상속하여 afterCalculated() 구현
  • 조합: 데코레이터 패턴으로 일관되게

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

일관된 협력 구조 정의

모든 요금 정책이 동일한 RatePolicy 인터페이스를 구현하도록 한다:

// 공통 인터페이스
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 abstract class AdditionalRatePolicy implements RatePolicy {
    private RatePolicy next;

    public AdditionalRatePolicy(RatePolicy next) {
        this.next = next;
    }

    @Override
    public Money calculateFee(Phone phone) {
        Money fee = next.calculateFee(phone);
        return afterCalculated(fee);
    }

    protected abstract Money afterCalculated(Money fee);
}

6. 구체적인 협력 구현하기

기본 정책 구현

일반 요금제

public class RegularPolicy extends BasicRatePolicy {
    private Money amount;
    private Duration seconds;

    public RegularPolicy(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 NightlyDiscountPolicy extends BasicRatePolicy {
    private static final int LATE_NIGHT_HOUR = 22;

    private Money nightlyAmount;
    private Money regularAmount;
    private Duration seconds;

    public NightlyDiscountPolicy(Money nightlyAmount, Money regularAmount, Duration seconds) {
        this.nightlyAmount = nightlyAmount;
        this.regularAmount = regularAmount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call) {
        if (call.getFrom().getHour() >= LATE_NIGHT_HOUR) {
            return nightlyAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
        return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

부가 정책 구현

세금 정책

public class TaxablePolicy extends AdditionalRatePolicy {
    private double taxRatio;

    public TaxablePolicy(double taxRatio, RatePolicy next) {
        super(next);
        this.taxRatio = taxRatio;
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee.plus(fee.times(taxRatio));
    }
}

기본 요금 할인 정책

public class RateDiscountablePolicy extends AdditionalRatePolicy {
    private Money discountAmount;

    public RateDiscountablePolicy(Money discountAmount, RatePolicy next) {
        super(next);
        this.discountAmount = discountAmount;
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee.minus(discountAmount);
    }
}

7. 협력 패턴에 맞추기

새로운 요구사항 추가

기존 패턴을 따라 새로운 정책을 쉽게 추가할 수 있다:

시간대별 요금제 (새로운 기본 정책)

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

기본료 부과 정책 (새로운 부가 정책)

public class FeePerBillPolicy extends AdditionalRatePolicy {
    private Money feePerBill;

    public FeePerBillPolicy(Money feePerBill, RatePolicy next) {
        super(next);
        this.feePerBill = feePerBill;
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee.plus(feePerBill);
    }
}

정책 조합하기

일관된 방식으로 다양한 정책을 조합할 수 있다:

// 일반 요금제
Phone regularPhone = new Phone(
    new RegularPolicy(Money.wons(10), Duration.ofSeconds(10))
);

// 일반 요금제 + 세금
Phone taxablePhone = new Phone(
    new TaxablePolicy(0.1,
        new RegularPolicy(Money.wons(10), Duration.ofSeconds(10)))
);

// 심야 할인 요금제 + 기본 요금 할인 + 세금
Phone complexPhone = new Phone(
    new TaxablePolicy(0.1,
        new RateDiscountablePolicy(Money.wons(1000),
            new NightlyDiscountPolicy(
                Money.wons(5),
                Money.wons(10),
                Duration.ofSeconds(10))))
);

// 시간대별 요금제 + 기본료 + 세금
Phone timeBasedPhone = new Phone(
    new TaxablePolicy(0.1,
        new FeePerBillPolicy(Money.wons(30),
            new TimeOfDayDiscountPolicy(...)))
);

8. 패턴을 찾아라

일관성 있는 협력의 핵심 패턴

1. 템플릿 메서드 패턴 (기본 정책)

  • 목적: 전체 알고리즘은 동일하지만 일부 단계가 다를 때
  • 적용: BasicRatePolicy에서 전체 통화 요금 계산 흐름 정의

2. 데코레이터 패턴 (부가 정책)

  • 목적: 객체의 책임을 동적으로 추가할 때
  • 적용: AdditionalRatePolicy에서 기본 정책에 부가 기능 추가

일관성이 가져오는 가치

public class PolicyExample {
    public void demonstrateConsistency() {
        // 1. 일관된 생성
        RatePolicy regular = new RegularPolicy(Money.wons(10), Duration.ofSeconds(10));
        RatePolicy nightly = new NightlyDiscountPolicy(...);
        RatePolicy timeOfDay = new TimeOfDayDiscountPolicy(...);

        // 2. 일관된 조합
        RatePolicy taxableRegular = new TaxablePolicy(0.1, regular);
        RatePolicy discountableNightly = new RateDiscountablePolicy(Money.wons(1000), nightly);

        // 3. 일관된 사용
        Phone phone1 = new Phone(regular);
        Phone phone2 = new Phone(taxableRegular);

        Money fee1 = phone1.calculateFee();
        Money fee2 = phone2.calculateFee();
    }
}

💡 마무리 정리

1. 개념적 일관성

  • 모든 요금 계산이 "정책"이라는 동일한 개념으로 다뤄짐
  • 기본 정책과 부가 정책의 명확한 구분

2. 구조적 일관성

  • 모든 정책이 RatePolicy 인터페이스 구현
  • 기본 정책은 BasicRatePolicy 상속
  • 부가 정책은 AdditionalRatePolicy 상속

3. 행위적 일관성

  • 모든 정책이 calculateFee() 메서드로 요금 계산
  • 기본 정책은 calculateCallFee() 구현
  • 부가 정책은 afterCalculated() 구현

4. 협력의 일관성

  • Phone → RatePolicy → 구체적인 정책의 협력 구조
  • 데코레이터 패턴을 통한 일관된 조합 방식

원칙

  1. 조건 로직을 객체로 대체하라

    • 타입 검사와 분기를 다형성으로 해결
  2. 변경을 캡슐화하라

    • 데이터, 메서드, 객체, 타입을 적절히 캡슐화
  3. 일관된 협력 패턴을 사용하라

    • 유사한 기능은 유사한 방식으로 구현
  4. 패턴을 통해 일관성을 달성하라

    • 검증된 디자인 패턴 활용

결과

"개발자는 하나의 협력 패턴만 이해하면 전체 시스템을 이해할 수 있게 되었다"

결국 도메인 요구사항이 변화하면서 기존의 설계 방식으로 한계가 생길 수 있고 그에 맞게 구조를 변경하여 일관성을 맞춰가는 것, 이것이 일관성 있는 협력이 주는 가장 큰 가치가 아닐까 싶다. 새로운 요구사항이 추가되더라도 기존 패턴을 따라 구현하면 되므로, 시스템은 점진적으로 성장하면서도 일관성을 유지할 수 있게 되는 아름다운 구조

profile
Dot Your moment.

0개의 댓글