"서비스를 개발하다 보면 유사한 요구사항을 반복적으로 추가하거나 수정하게 되는 경우가 있다. 객체지향 설계의 목표는 이러한 변경을 일관성 있는 협력 패턴으로 다루는 것이다."
정의: 시스템이 일관성 있는 몇 개의 협력 패턴으로 구성되어, 전체가 마치 한 사람이 설계한 것처럼 보이는 것
일관성이 주는 이점:
// 고정 요금제
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;
}
}
// 템플릿 메서드 패턴 적용
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());
}
}
// 세금 정책을 위해 또 다른 상속 계층 생성
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);
}
}
// 세금 + 할인을 모두 적용하려면? 클래스 폭발!
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));
}
}
// 일부는 합성으로 해결
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 { }
// 어떤 곳은 이렇게
Phone phone = new TaxableRegularPhone();
// 어떤 곳은 이렇게
Phone phone = new RegularPhone();
TaxablePhone taxablePhone = new TaxablePhone(phone);
// 또 어떤 곳은 이렇게
Phone phone = new TaxableAndRateDiscountableRegularPhone();
// 새로운 요구사항: "약정 할인 정책 추가"
// 어떤 패턴을 따라야 하지?
// 1. Phone을 상속?
// 2. 별도 클래스로 합성?
// 3. 기존 상속 계층에 추가?
"문제는 이러한 설계가 나쁜 것이 아니라, 유사한 문제를 서로 다른 방식으로 해결하고 있다는 것이다"
"객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다"
조건 로직을 사용하면 타입을 검사하고 그에 따라 분기하는 코드가 만들어진다:
// ❌ 절차적 방식 - 타입 검사와 분기
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);
}
"캡슐화란 변하는 어떤 것이든 감추는 것이다"
public class Phone {
private List<Call> calls = new ArrayList<>(); // 데이터 은닉
public List<Call> getCalls() {
return Collections.unmodifiableList(calls); // 안전한 접근
}
}
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; // 내부 객체 은닉
public Money calculateFee() {
return ratePolicy.calculateFee(this); // 위임
}
}
// 클라이언트는 구체적인 타입을 모름
RatePolicy policy = new RegularPolicy(...); // 서브타입 은닉
Phone phone = new Phone(policy);
원칙: 변하지 않는 부분은 추상화하고, 변하는 부분은 구체화한다
// 모든 정책이 동일한 협력 구조를 따름
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);
}
이제 개발자는 명확한 지침을 가지게 됩니다:
모든 요금 정책이 동일한 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);
}
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);
}
}
기존 패턴을 따라 새로운 정책을 쉽게 추가할 수 있다:
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(...)))
);
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();
}
}
조건 로직을 객체로 대체하라
변경을 캡슐화하라
일관된 협력 패턴을 사용하라
패턴을 통해 일관성을 달성하라
"개발자는 하나의 협력 패턴만 이해하면 전체 시스템을 이해할 수 있게 되었다"
결국 도메인 요구사항이 변화하면서 기존의 설계 방식으로 한계가 생길 수 있고 그에 맞게 구조를 변경하여 일관성을 맞춰가는 것, 이것이 일관성 있는 협력이 주는 가장 큰 가치가 아닐까 싶다. 새로운 요구사항이 추가되더라도 기존 패턴을 따라 구현하면 되므로, 시스템은 점진적으로 성장하면서도 일관성을 유지할 수 있게 되는 아름다운 구조