devseop08 님의 블로그

[오브젝트] 합성과 유연한 설계 본문

Architecture/객체지향설계

[오브젝트] 합성과 유연한 설계

devseop08 2025. 7. 21. 01:35
  • 상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만
  • 합성에서 두 객체 사이의 의존성은 런타임에 해결된다.
  • 상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야 한다.
  • 자식 클래스와 부모 클래스의 결합도가 높아질 수 밖에 없다.
  • 합성은 구현에 의존하지 않는다.
  • 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다.
  • 합성을 이용하면 변경에 더 안정적인 코드를 얻을 수 있다.
  • 합성 관계는 객체 사이의 동적인 관계다. 상속 관계는 클래스 사이의 정적인 관계다
  • 변경에 유연하게 대처할 수 있는 설계가 대부분 정답
  • 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경할 수 있다.
  • 클래스 사이의 높은 결합도를 객체 사이의 낮은 결합도록 대체할 수 있다.
  • 1. 상속을 합성으로 변경하기
  • 상속 남용 시의 발생 가능한 세 가지 문제
      1. 불필요한 인터페이스 상속 문제
      1. 메서드 오버라이딩의 오작용 문제
      1. 부모 클래스와 자식 클래스의 동시 수정 문제

불필요한 인터페이스 상속 문제: java.util.Properties와 java.util.Stack

  • 먼저 Hashtable 클래스와 Properties 클래스 사이의 상속 관계를 합성 관계로 변경
  • Properties 클래스에서 상속 관계를 제거, Hashtable을 Properties의 인스턴스 변수로 포함시키면 합성 관계로 변경 가능
public class Properties {
    private Hashtable<String, String> properties = new Hashtable<>();

    public String setProperty(String key, String value) {
        return properties.put(key, value);
    }

    public String getProperty(String key) {
        return properties.get(key);
    }
}
  • 이제 더 이상 불필요한 Hashtable의 오퍼레이션들이 Properties 클래스의 퍼블릭 인터페이스를 오염시키지 않는다.
  • Properties의 클라이언트는 모든 타입의 키와 값을 저장할 수 있는 Hashtable의 오퍼레이션을 사용할 수 없기 때문에 String 타입의 키와 값만 허용하는 Properties의 규칙을 어길 위험성은 사라진다.
  • 합성으로 변경한 Properties는 Hashtable의 내부 구현에 관해 알지 못한다. 단지 get과 set 오퍼레이션이 포함된 퍼블릭 인터페이스를 통해서만 Hashtable과 협력할 수 있을 뿐이다.
  • Stack 역시 Vector의 인스턴스 변수를 Stack 클래스의 인스턴스 변수로 선언함으로써 합성 관계로 변경할 수 있다.
public class Stack<E> {
    private Vector<E> elements = new Vector<>();

    public E push(E item) {
        elements.addElement(item);
        return item;
    }

    public E pop() {
        if(elements.isEmpty()) {
            throw new EmptyStackException();
        }

        return elements.remove(elements.size() - 1);
    }
}
  • 이제 Stack의 퍼블릭 인터페이스에는 불필요한 Vector의 오퍼레이션들이 포함되지 않는다.
  • 클라이언트는 더 이상 임의의 위치에 요소를 추가하거나 삭제할 수 없다.

메서드 오버라이딩의 오작용 문제:InstrumentedHashSet

  • InstrumentedHashSet도 같은 방법을 사용해서 합성 관계로 변경할 수 있다.
public class InstrumentedHashSet<E> {
    private int addCount = 0;
    private Set<E> set;

    public InstrumentedHashSet(Set<E> set) {
        this.set = set;
    }

    public boolean add(E e) {
        addCount++;
        return set.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}
  • Properties와 Stack을 합성으로 변경한 이유는 불필요한 오퍼레이션들이 자신의 퍼블릭 인터페이스에 스며드는 것을 방지하기 위해서다.
  • 하지만 InstrumentedHashSet의 경우에는 HashSet이 제공하는 퍼블릭 인터페이스를 그대로 제공해야하는 상황이다.
  • HashSet에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스를 그대로 상속받을 수 있는 방법은 자바의 인터페이스를 사용하는 것이다.
  • HashSet은 Set 인터페이스를 실체화하는 구현체 중 하나이다.
public class InstrumentedHashSet<E> implements Set<E> {
    private int addCount = 0;
    private Set<E> set;

    public InstrumentedHashSet(Set<E> set) {
        this.set = set;
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return set.add(e); // 포워딩
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c); // 포워딩
    }

    public int getAddCount() {
        return addCount;
    }

    @Override public boolean remove(Object o) { return set.remove(o); }
    @Override public void clear() { set.clear(); }
    @Override public boolean equals(Object o) { return set.equals(o); }
    ...
}

포워딩 기법 : 클래스를 상속하지 않으면서 퍼블릭 인터페이스는 상속받을 수 있는 기법(자바 인터페이스를 구현하고 구현한 메서드 내에서 클래스에 합성한 객체로부터 동일한 메서드를 호출)

  • InstrumentedHashSet의 코드를 보면 Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달한다는 것을 알 수 있다.
  • 이를 포워딩이라 부르고 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드라고 부른다.
  • 포워딩은 기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합없이 일부 작동 방식을 변경하고 싶은 경우에 사용할 수 있는 기법이다.

부모 클래스와 자식 클래스의 동시 수정 문제: PersonalPlaylist

  • Playlist의 경우에는 합성으로 변경하더라도 가수별 노래 목록을 유지하기 위해 Playlist와 PersonalPlaylist를 함께 수정해야 하는 문제가 해결되지는 않지만
  • 여전히 상속보다는 합성을 사용하는 게 더 좋다.
  • 향후에 Playlist의 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlaylist 내부로 캡슐화할 수 있기 때문이다.
  • 대부분의 경우 구현에 대한 결합보다는 퍼블릭 인터페이스에 대한 결합이 더 좋다는 사실을 기억해야 한다.

2. 상속으로 인한 조합의 폭발적인 증가

  • 상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다.
  • 가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같은 두 가지 문제점이 있다.
      1. 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
      1. 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.
  • 합성을 사용하면 상속으로 인해 발생하는 클래스의 증가와 중복 코드 문제를 간단하게 해결할 수 있다.

기본 정책과 부가 정책 조합하기

  • 핸드폰 과금 시스템에 새로운 요구사항을 추가해본다.
  • 현재 시스템에서는 일반 요금제와 심야 할인 요금제라는 두 가지 종류의 요금제가 존재
  • 새로운 요구사항은 이 두 요금제에 부가 정책을 추가하는 것이다. 핸드폰 요금제가 기본 정책과 부가 정책을 조합해서 구성한다고 가정한다.
  • 기본 정책은 가입자의 한 달 통화량을 기준으로 부과할 요금을 계산한다.
  • 일반 요금제와 심야 할인 요금제는 통화량을 기준으로 요금을 계산하므로 기본 정책으로 분류된다.
  • 부가 정책은 통화량과 무관하게 기본 정책에 선택적으로 추가할 수 있는 요금 방식을 의미한다.
  • 부가 정책에는 세금 정책과 기본 요금 할인 정책이 존재한다.
  • 부가 정책의 특성
    • 기본 정책의 계산 결과에 적용된다.
    • 선택적으로 적용할 수 있다.
    • 조합 가능하다.
    • 부가 정책은 임의의 순서로 적용 가능하다.
  • 현재 요구사항을 구현하는 데 가장 큰 장벽은 기본 정책과 부가 정책의 조합 가능한 수가 매우 많다는 것이다.
  • 기본 정책 하나에 대해서 부가 정책을 하나도 선택하지 않아도 되고 둘 중 하나만 선택해도 되고 둘 다 선택해도 될 뿐더러 순서도 다르게 선택할 수 있기 때문에 조합 가능한 수가 더욱 늘어나게 됐다.
  • 설계는 다양한 조합을 수용할 수 있도록 유연해야 한다.

상속을 이용해서 기본 정책 구현하기

  • 기본 정책은 Phone 추상 클래스를 루트로 삼는 기존의 상속 계층을 그대로 이용한다.
public abstract class Phone {
    private double taxRate;
    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;


    public RegularPhone(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 NightlyDiscountPhone extends Phone {
    private static final int LATE_NIGHT_HOUR = 22;

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

    public NightlyDiscountPhone(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()
                    );

    }
}

기본 정책에 세금 정책 조합하기

  • 일반 요금제에 세금 정책을 조합하기 위해 RegularPhone 클래스를 상속받은 TaxableRegularPhone 클래스를 추가
public class TaxableRegularPhone extends RegularPhone {
    private double taxRate;

    public RegularPhone(Money amount, Duration seconds, double taxRate) {
        super(amount, seconds);
        this.taxRate = taxRate;
    }

    @Override
    public Money calculateFee(){
        Money fee = super.calculateFee();
        return fee.plus(fee.times(taxRate));
    }
}
  • 부모 클래스의 메서드를 재사용하기 위해 super 호출을 사용하면 원하는 결과를 쉽게 얻을 수는 있지만 자식 클래스와 부모 클래스 사이의 결합도가 높아지고 만다.
  • 결합도를 낮추는 방법은 자식 클래스가 부모 클래스의 메서드를 호출하지 않도록 부모 클래스에 추상 메서드를 제공하는 것이다.
  • 부모 클래스 자신이 정의한 추상 메서드를 호출하고 자식 클래스가 이 메서드를 오버라이딩해서 부모 클래스가 원하는 로직을 제공하도록 수정하면 부모 클래스와 자식 클래스 사이의 결합도를 느슨하게 만들 수 있다.
  • Phone 클래스에 새로운 추상 메서드인 afterCalculated를 추가
public abstract class Phone {
    private double taxRate;
    private List<Call> calls = new ArrayList<>();

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

        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return afterCalculated(result); // 자신이 정의한 추상 메서드를 호출
    }

    protected abstract Money calculateCallFee(Call call);
    protected abstract Money afterCalculated(Money fee);
}
  • 자식 클래스는 afterCalculated 메서드를 오버라이딩하여 계산된 요금에 적용할 작업을 추가한다.
public class RegularPhone extends Phone {
    private Money amount;
    private Duration seconds;


    public RegularPhone(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }

    @Override
    protected Money calculateCallFee(Call call){
        return amount.times(
                        call.getDuration().getSeconds() / seconds.getSeconds()
                        )
    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee;
    }
}
public class NightlyDiscountPhone extends Phone {
    private static final int LATE_NIGHT_HOUR = 22;

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

    public NightlyDiscountPhone(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()
                    );

    }

    @Override
    protected Money afterCalculated(Money fee) {
        return fee;
    }
}
  • 부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩해야 하는 문제가 발생한다.
  • 모든 추상 메서드의 구현이 현재는 동일하다는 사실에 주목한다.
  • 유연성은 유지하면서도 중복 코드를 제거할 수 있는 방법은 Phone에서 afterCalculated 메서드에 대한 기본 구현을 함께 제공하는 것이다.
public abstract class Phone {
    private double taxRate;
    private List<Call> calls = new ArrayList<>();

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

        for(Call call : calls) {
            result = result.plus(calculateCallFee(call));
        }

        return afterCalculated(result); // 자신이 정의한 메서드를 호출
    }

    protected Money afterCalculated(Money fee){ // 훅 메서드 afterCalculated
        return fee;  // 
    }

    protected abstract Money calculateCallFee(Call call);
}

추상 메서드와 훅 메서드

  • 개방-폐쇄 원칙을 만족하는 설계를 만들 수 있는 한 가지 방법은 부모 클래스에 새로운 추상 메서드를 추가하고 부모 클래스의 다른 메서드 안에서 호출하는 것이다.
  • 자식 클래스는 그 추상 메서드를 오버라이딩하고 자신만의 로직을 구현해서 부모 클래스에서 정의한 플로우에 개입할 수 있게 된다.
  • 추상 메서드의 단점은 상속 계층에 속하는 모든 자식 클래스가 추상 메서드를 오버라이딩해야 한다는 것이다.
  • 대부분의 자식 클래스가 추상 메서드를 동일한 방식으로 구현한다면 상속 계층 전반에 걸쳐 중복 코드가 존재하게 될 것이다.
  • 해결 방법은 메서드에 기본 구현을 제공하는 것이다.
  • 추상 메서드와 동일하게 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공하는 메서드를 훅 메서드라고 한다.
public class TaxableRegularPhone extends RegularPhone {
    private double taxRate;

    public RegularPhone(Money amount, Duration seconds, double taxRate) {
        super(amount, seconds);
        this.taxRate = taxRate;
    }

    @Override
    public Money afterCalculated(Money fee){
        return fee.plus(fee.times(taxRate));
    }
}
public class TaxableNightlyDiscountPhone extends NightlyDiscountPhone {
    private double taxRate;

    public TaxableNightlyDiscountPhone(Money nightlyAmount,Money regularAmount, 
                                       Duration seconds, double taxRate) {
        super(nightlyAmount, regularAmount, seconds);
        this.taxRate = taxRate;
    }

    @Override
    public Money afterCalculated(Money fee){
        return fee.plus(fee.times(taxRate));
    }
}
  • 문제는 TaxableRegularPhone과 TaxableNightlyDiscountPhone 사이에 코드를 중복했다는 것이다.
  • 두 클래스의 코드를 살펴보면 부모 클래스의 이름을 제외하면 대부분의 코드가 거의 동일함을 알 수 있다.
  • 사실 자바를 비롯한 대부분의 객체지향 언어는 단일 상속만 지원하기 때문에상속으로 인해 발생하는 중복 코드 문제를 해결하기가 쉽지 않다.

기본 정책에 기본 요금 할인 정책 조합하기

  • 두 번째 부가 정책인 기본 요금 할인 정책을 Phone의 상속 계층에 추가해본다.
public class RateDiscountableRegularPhone extends RegularPhone {
    private Money discountAmount;

    public RateDiscountableRegularPhone(Money amount, Duration seconds, 
                                        Money discountAmount) {
        super(amount, seconds);
        this.discountAmount = discountAmount;
    }

    @Override
    public Money afterCalculated(Money fee){
        return fee.minus(discountAmount);
    }
}
public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone {
    private Money discountAmount;

    public RateDiscountableNightlyDiscountPhone(Money nightlyAmount,
                                                Money regularAmount, 
                                                Duration seconds, 
                                                Money dicountAmount) {
        super(nightlyAmount, regularAmount, seconds);
        this.discountAmount = discountAmount;
    }

    @Override
    public Money afterCalculated(Money fee){
        return fee.minus(discountAmount);
    }
}
  • 이번에도 부가 정책을 구현한 RateDiscountableRegularPhone 클래스와 RateDiscountableNightlyDiscountPhone 클래스 사이에 중복 코드를 추가했다.

중복 코드의 덫에 걸리다

  • 부가 정책은 자유롭게 조합될 수 있어야 하고 적용되는 순서 역시 임의로 결정할 수 있어야 한다.
  • 상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스르 하나씩 추가하는 것이다.
  • 일반 요금제 계산 결과에 세금 정책을 조합한 후 기본 요금 할인 정책을 추가하려면 TaxableRegularPhone을 상속받는 새로운 자식 클래스인 TaxableAndRateDiscountableRegularPhone을 추가해야 한다.
public class TaxableAndRateDiscountableRegularPhone extends TaxableRegularPhone {
    private Money discountAmount;

    public TaxableAndRateDiscountableRegularPhone(Money amount, Duration seconds,
                                                  double taxRate, 
                                                  Money discountAmount) {
        super(amount, seconds, taxRate);
        this.discountAmount = discountAmount;
    }

    @Override
    public Money afterCalculated(Money fee){
        return super.afterCalculated(fee).minus(discountAmount);
    }
}
  • 부가 정책으로 기본 요금 할인 정책을 먼저 적용한 후 세금을 나중에 부과하고 싶다면 RateDiscountableRegularPhoone을 상속받는 RateDiscountableAndTaxableRegularPhone 클래스를 추가한다.
public class RateDiscountableAndTaxableRegularPhone 
                                        extends RateDiscountableRegularPhoone {
    private double taxRate;

    public RateDiscountableAndTaxableRegularPhone(Money amount, Duration seconds, 
                                                  Money discountAmount,
                                                  double taxRate) {
        super(amount, seconds, discountAmouont);
        this.taxRate = taxRate;
    }

    @Override
    public Money afterCalculated(Money fee){
        return super.afterCalculated(fee).plus(fee.times(taxRate));
    }
}
  • TaxableAndRateDiscountableNightlyDiscountPhone 클래스는 심야 할인 요금제의 계산 결과에 세금 정책을 적용한 후 기본 요금 할인 정책을 적용하는 케이스를 구현한다.
public class TaxableAndRateDiscountableNightlyDiscountPhone 
                                extends TaxableNightlyDiscountPhone {
    private Money discountAmount;

    public RateDiscountableNightlyDiscountPhone(Money nightlyAmount,
                                                Money regularAmount, 
                                                Duration seconds,
                                                double taxRate, 
                                                Money dicountAmount) {
        super(nightlyAmount, regularAmount, seconds, taxRate);
        this.discountAmount = discountAmount;
    }

    @Override
    public Money afterCalculated(Money fee){
        return super.afterCalculated(fee).minus(discountAmount);
    }
}
  • 마지막으로 RateDiscountableAndTaxableNightlyDiscountPhone 클래스는 심야 할인 요금제의 계산 결과에 기본 요금 할인 정책을 적용한 후 세금 정책을 적용하는 케이스를 구현한다.
public class RateDiscountableAndTaxableNightlyDiscountPhone 
                                    extends RateDiscountableNightlyDiscountPhone {
    private double taxRate;

    public RateDiscountableAndTaxableNightlyDiscountPhone(Money nightlyAmount,
                                                          Money regularAmount, 
                                                          Duration seconds, 
                                                          Money discountAmount,
                                                          double taxRate) {
        super(nightlyAmount, regularAmount, seconds, discountAmount);
        this.taxRate = taxRate;
    }

    @Override
    public Money afterCalculated(Money fee){
        return super.afterCalculated(fee).plus(fee.times(taxRate));
    }
}
  • 현재까지 구현된 상속 계층을 그림으로 표현

  • 복잡성보다 더 큰 문제는 새로운 정책을 추가하기가 어렵다는 것이다.
  • 현재의 설계에 새로운 정책을 추가하기 위해서는 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 한다.
  • 현재 상속 계층에 새로운 기본 정책을 추가한다고 가정해본다.
  • 추가할 기본 정책은 '고정 요금제'로 FixedRatePhone이라는 클래스로 구현
  • 모든 부가 정책은 기본 정책에 적용 가능해야 하며 조합 순서 역시 자유로워야 한다.
  • 따라서 새로운 기본 정책을 추가하면 그에 따라 조합 가능한 부가 정책의 수만큼 새로운 클래스를 추가해야 한다.

  • 고정 요금제 하나를 추가하기 위해 5개의 새로운 클래스를 추가해야 한다.
  • 만약 여기서 새로운 부가 정책으로 '약정 할인 정책'을 추가한다고 하면, 추가해야될 클래스가 급격하게 많아진다.
  • 이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발 문제 또는 조합 폭발 문제라고 부른다.
  • 컴파일 타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.
  • 클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 때도 문제가 된다.
  • 만약 세금 정책을 변경해야 한다면 세금 정책과 관련된 코드가 여러 클래스 안에 중복돼 있기 때문에 세금 정책과 관련된 모든 클래스를 찾아 동일한 방식으로 수정해야 할 것이다.
  • 3. 합성 관계로 변경하기
  • 상속 관계는 컴파일타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다.
  • 합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있다.
  • 합성을 사용하면 컴파일타임 의존성과 런타임 의존성을 다르게 만들 수 있다.
  • 대부분의 의존성 관리 기법은 상속이 아닌 합성을 기반으로 한다.
  • 클래스 폭발 문제를 해결하기 위해 합성을 사용하는 이유는 런타임에 객체 사이의 의존성을 자유롭게 변경할 수 있기 때문.
  • 합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다.
  • 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용한다.
  • 상속은 조합의 결과를 개별 클래스 안으로 밀어넣는 방법, 컴파일타임에 형태가 고정된다.
  • 컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점인 것이다.

기본 정책 합성하기

  • 가장 먼저 해야 할 일은 각 정책을 별도의 클래스로 구현하는 것이다.
  • 분리된 정책들을 연결할 수 있도록 합성 관계를 이용해서 구조를 개선하면 된다.
  • 핸드폰이라는 개념으로부터 요금 계산 방법이라는 개념을 분리해야 한다.
public interface RatePolity {
    Money calculateFee(Phone phone);
}
  • 기본 정책을 구성하는 일반 요금제와 심야 할인 요금제는 개별 통화 건 당 요금을 계산하는 방식을 제외한 전체 처리 로직이 거의 동일하다.
  • 이 중복 코드를 담을 추상 클래스 BasicRatePolicy를 추가한다.
public abstract class BasicRatePolicy implements RatePoliicy {
    @Override 
    public Money calculateFee(Phone phone) {
        Money result = Money.ZERO;

        for(Call call: phone.getCalls()) {
            result.plus(calculateCallFee(call));
        }
        return result;
    }
    protected abstract Money calculateCallFee(Call call);
}
  • 일반 요금제
public class RegularPolicy extends BasicRatePolicy {
    private Money amount;
    private Duration seconds;

    public RegularPolicy(Money amount, Duration duration) {
        this.amount = amount;
        this.seconds = secondes;
    }

    @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 NightlyDiscountPhone(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()
                    );

    }
}
  • 기본 정책을 이용해 요금을 계산할 수 있도록 Phone을 수정
public class Phone {
    private RatePolicy ratePolicy; // 합성 
    private List<Call> calls = new ArrayList<>();

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

    public List<Call> getCalls() {
        return Collections.unmodifiableList(calls);
    }

    pubilc Money calculateFee() {
        return ratePolicy.calculateFee(this);
    }
}
  • Phone이 다양한 요금 정책과 협력할 수 있어야 하므로 요금 정책의 타입이 RatePolicy라는 인터페이스로 정의됐다.
  • Phone은 이 컴파일타임 의존성을 구체적인 런타임 의존성으로 대체하기 위해 생성자를 통해 RatePolicy의 인스턴스에 대한 의존성을 주입받는다.

부가 정책 적용하기

  • 일반 요금제를 적용한 경우에 생성된 인스턴스 간의 관계

  • 컴파일 시점의 Phone 클래스와 RatePolicy 인터페이스 사이의 관계가 런타임에 Phone 인스턴스와 RegularPolicy 인스턴스 사이의 관계로 대체된다.
  • 이제 여기에 부가 정책을 추가해야 한다. 부가 정책은 기본 정책에 대한 계산이 끝난 후에 적용된다는 것을 기억해야 한다.
  • 만약 일반 요금제에 세금 정책을 추가한다면 세금 정책은 RegularPolicy의 계산이 끝나고 Phone에게 반환되기 전에 적용돼야 한다.
  • 따라서 RegularPolicy와 Phone 사이에 세금 정책을 구현하는 TaxablePolicy 인스턴스를 연결해야 한다.

  • 만약 일반 요금제에 기본 요금 할인 정책을 적용한 후에 세금 정책을 적용해야 한다면 다음과 같은 순서로 인스턴스들을 연결해야 한다.

  • 따라서 부가 정책은 결국 기본 정책과 동일하게 RatePolicy 인터페이스를 구현해야 하며, 내부에 또 다른 RatePolicy 인스턴스를 합성할 수 있어야 한다.
public abstract class AdditionalRatePolicy implements RatePolicy {
    private RatePolicy next;

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

    @Override
    public Money calculateFee(Phone phone) {
        Money fee = next.calculateFee(phone);
        return afterCalculated(fee); // 훅 메서드
    }

    abstract protected afterCalculated(Money fee);
}
  • AdditionalRatePolicy의 calculateFee 메서드는 먼저 next가 참조하고 있는 인스턴스에게 calculateFeel 메시지를 전송한다.
  • 그 후 반환된 요금에 부가 정책을 적용하기 위해 afterCalculated 메서드를 호출한다.
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);
    }
}

기본 정책과 부가 정책 합성하기

  • 일반 요금제에 세금 정책을 조합할 경우 Phone 인스턴스 생성
Phone phone = new Phone(
    new Taxable(0.05, new RegularPolicy(...));
)
  • 일반 요금제에 기본 요금 할인 정책을 조합한 결과에 세금 정책을 조합
Phone phone = new Phone(
    new TaxablePolicy(
        0.05,
        new RateDiscountablePolicy(
            Money.wons(1000),
            new RegularPolicy(...)
        )
    )
);
  • 세금 정책과 기본 요금 할인 정책의 순서를 바꾸기
Phone phone = new Phone(
    new RateDiscountablePolicy(
        Money.wons(1000),
        new TaxablePolicy(
            0.05,
            new RegularPolicy(...)
        )
    )
);
  • 동일한 부가 정책을 심야 할인 요금제에 적용
Phone phone = new Phone(
    new RateDiscountablePolicy(
        Money.wons(1000),
        new TaxablePolicy(
            0.05,
            new NightlyDiscountPolicy(...)
        )
    )
);

새로운 정책 추가하기

  • 고정 요금제 추가: 클래스 하나만 추가하면 된다.

  • 약정 할인 정책 추가: 클래스 하나만 추가하면 된다.

요구사항을 변경할 때도 오직 하나의 클래스만 수정해도 된다.

  • 세금 정책을 다루는 코드가 상속 계층 여기저기에 중복돼 있던 경우에서는 세금 정책을 변경하기 위해 한 번에 여러 클래스를 수정해야 한다.
  • 합성을 이용한 경우엔 TaxablePolicy 클래스 하나만 변경하면 된다.
  • 변경 후의 설계는 단일 책임 원칙을 준수한다.

객체 합성이 클래스 상속보다 더 좋은 방법이다.

  • 코드를 재사용하면서도 건전한 결합도를 유지할 수 있는 더 좋은 방법은 합성을 이용하는 것이다.
  • 상속이 구현을 재사용하는 데 비해 합성은 객체의 인터페이스를 재사용한다.
  • 상속은 구현 상속과 인터페이스 상속 두 가지로 나눠야 한다.
  • 그래야 상속을 효과적이고 올바르게 사용할 수 있는 경우를 판단할 수 있다.

믹스인

  • 구체적인 코드를 재사용하면서도 낮은 결합도를 유지할 수 있는 유일한 방법은 재사용에 적합한 추상화를 도입하는 것이다.
  • 믹스인은 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법을 가리키는 용어다.
  • 합성은 실행 시점에 객체를 조합하는 재사용 방법, 믹스인은 컴파일 시점에 필요한 코드 조각을 조합하는 재사용 방법
  • 믹스인은 상속과 다르다.
  • 믹스인은 말그대로 코드를 다른 코드 안에 섞어 넣기 위한 방법이다.
  • 믹스인은 코드 재사용에 특화된 방법이면서도 상속과 같은 결합도 문제를 초래하지 않는다.
  • 믹스인은 합성처럼 유연하면서도 상속처럼 쉽게 코드를 재사용할 수 있는 방법이다.

기본 정책 구현하기

  • Scala 언어에서 제공하는 트레이트를 이용해 믹스인을 구현해본다.
  • 우선 Scala로 기본 정책을 구현해본다.
  • 기본 정책을 구현한 후 부가 정책과 관련된 코드를 기본 정책에 어떻게 믹스인을 할 수 있는지 를 고민해야 한다.
abstract class BasicRatePolicy {
    def calculateFee(phone: Phone): Money =
        phone.calls.mep(calculateCallFee(_)).reduce(_+_)

    protected def calculateCallFee(call: Call): Money;

}
  • 표준 요금제를 구현하는 RegularPolicy는 BasicRatePolicy를 상속받아 개별 Call의 요금을 계산하는 calculateCallFee 메서드를 오버라이딩한다.
class RegularPolicy(val amount: Money, val seconds: Duration) 
                                                    extends BasicRatePolicy {
    override protected def calculateCallFee(call: Call): Money =
            amount + (call.duration.getSeconds / seconds.getSeconds)                                                    
}
  • 심야 할인 요금제를 구현하기 위한 NightlyDiscountPolicy 역시 BasicRatePolicy를 상속받아 calculateCallFee 메서드를 오버라이딩한다.
class NightlyDiscountPolicy(
    val nightlyAmount: Money,
    val regularAmount: Money,
    val seconds: Duration
) extends BasicRatePolicy {
    override protected def calculateCallFee(call: Call): Money =
        if(call.from.getHour >= NightltDiscountPolicy.LateNightHour){
            nightlyAmount + (call.duration.getSeconds / seconds.getSeconds)
        }else {
            regularAmount + (call.duration.getSeconds / seconds.getSeconds)
        }
}

object NightltDiscountPolicy {
    val LateNightHour = 22
}

트레이트로 부가 정책 구현하기

  • 스칼라에서는 다른 코드와 조합해서 확장할 수 있는 기능을 트레이트로 구현할 수 있다.
  • 기본 정책에 조합하려는 코드는 부가 정책을 구현하는 코드들이다.
  • 트레이트로 구현된 기능을 섞어넣게 될 대상은 기본 정책에 해당하는 RegularPolicy와 NightlyDiscountPolicy다.
  • 부가 정책 중 세금 정책에 해당하는 TaxablePolicy 트레이트를 먼저 구현
trait TaxablePolicy extends BasicRatePolicy {
    def taxRate: Double

    override def calculateFee(phone: Phone): Money = {
        val fee = super.calculateFee(phone)
        return fee + fee * taxRate
    }
}
  • TaxablePolicy 트레이트는 BasicRatePolicy를 확장하는데 이것은 상속의 개념이 아니다.
  • TaxablePolicy가 BasicRatePolicy나 BasicRatePolicy의 자손에 해당하는 경우에 대해서만 믹스인이 될 수 있다는 것을 의미한다.
  • TaxablePolicy 트레이트가 BasicRatePolicy를 상속하도록 구현했지만 실제로 TaxablePolicy가 BasicRatePolicy의 실제 자식 트레이트가 되는 것은 아니다.
  • 여기서 extends 문은 단지 TaxablePolicy가 사용될 수 있는 문맥을 제한할 뿐이다.
  • TaxablePolicy는 BasicRatePolicy를 상속받은 경우에만 믹스인될 수 있다.
  • 따라서 TaxablePolicy는 RegularPolicy와 NightlyDiscountPolicy에 믹스인될 수 있으며
  • 미래에 추가될 새로운 BasicRatePolicy 의 자손에게도 믹스인될 수 있다.
  • 하지만 다른 클래스나 트레이트에는 믹스인 불가하다.
  • 상속은 코드를 작성하는 시점에 부모 클래스와 자식 클래스의 관계를 코드를 작성하는 시점에 고정시켜버리지만
  • 믹스인은 제약을 둘 뿐 실제로 어떤 코드에 믹스인 될 것인지를 결정하지 않는다.
  • 실제로 트레이트를 믹스인하는 시점에 가서야 믹스인할 대상을 결정할 수 있다.
  • 기본 정책과 부가 정책을 부모 클래스와 자식 클래스라는 관계로 결합시켜놔야 했던 상속과 달리
  • 부가 정책과 기본 정책을 구현한 코드 사이에는 어떤 관계도 존재하지 않는다.
  • 이들은 독립적으로 작성된 후 원하는 기능을 구현하기 위해 조합된다.
  • TaxablePolicy에서 super로 참조되는 코드 역시 고정되지 않는다.
  • super 호출로 실행되는 calculate 메서드를 보관한 코드는 실제로 트레이트가 믹스인되는 시점에 결정된다.
  • super 참조가 가리키는 대상은 컴파일 시점이 아닌 실행 시점에 결정된다.
  • 상속의 경우에 일반적으로 this 참조는 동적으로 결정되지만, super 참조는 컴파일 시점에 결정된다.
  • 스칼라의 트레이트에서 super 참조는 동적으로 결정된다. => 트레이트의 경우 this 호출뿐만 아니라 super호출 역시 실행 시점에 바인딩된다.
  • 이것이 트레이트를 사용한 믹스인이 클래스를 사용한 상속보다 더 유연한 재사용 기법인 이유다.
  • 합성은 독립적으로 작성된 객체들을 실행 시점에 조립해서 더 큰 기능을 만들어내는 데 비해 믹스인은 독립적으로 작성된 트레이트와 클래스를 코드 작성 시점에 조합해서 더 큰 기능을 만들어 낼 수 있다.
  • 두 번째 부가 정책인 비율 할인 정책 트레이트 RateDiscountablePolicy 구현
trait RateDiscountablePolicy extends BasicRatePolicy {
    val discountAmount: Money

    override def calculateFee(phone: Phone): Money = {
        val fee = super.calculateFee(phone)
        fee - discountAmount
    }
}

부가 정책 트레이트 믹스인하기

  • 스칼라는 트레이트를 클래스나 다른 트레이트에 믹스인할 수 있도록 extends와 with 키워드를 제공한다.
  • 믹스인하려는 대상 클래스의 부모 클래스가 존재하는 존재하는 경우 부모 클래스는 extends를 이용해 상속받고 트레이트는 with를 이용해 믹스인해야 한다. => 트레이트 조합
  • RegularPolicy 클래스와 TaxablePolicy 트레이트로 트레이트 조합을 만들어 새로운 클래스에 믹스인
class TaxableRegularPolicy(
    amount: Money,
    seconds: Duration,
    val taxRate: Double
) extends RegularPolicy(amount, seconds) with TaxablePolicy
  • 스칼라는 특정 클래스에 믹스인한 클래스와 트레이트를 선형화해서 어떤 메서드를 호출할 지 결정한다.
  • 예제의 선형화 순서: TaxableRegularPolicy -> TaxablePolicy -> RegularPolicy -> BasicRatePolicy
  • 선형화 순서는 의존성의 방향과 같고 결국엔 선형화 순서의 제일 마지막에 오는 클래스가 클래스 계층 구조에서 제일 상위가 된다.
  • 믹스인 되기 전까지는 상속 계층 안에서 TaxablePolicy 트레이트의 위치가 결정되지 않는다.
  • 어떤 클래스에 믹스인 할지에따라 TaxablePolicy 트레이트의 위치는 동적으록 결정된다. 즉, 트레이트의 super는 컴파일타임이 아닌 런타임에 결정된다는 것이다.

  • 심야 할인 요금제 NightlyDiscountPolicy 클래스와 RateDiscountablePolicy 트레이트의 조합을 만들어 새로운 클래스에 믹스인
class RateDiscoutableNightlyDiscountPolicy(
    nightlyAmount: Money,
    regularAmount: Money,
    seconds: Duration, 
    val discountAmount: Money
) extends NightlyDiscountPolicy(nightlyAmount, regularAmount, seconds) 
  with RateDiscountablePolicy
  • 부가 정책 트레이트를 임의의 순서대로 조합할 수 있어야 한다.
  • 제일 나중에 적용하고 싶은 부가 정책 트레이트를 새로운 클래스 선언 제일 오른쪽에 with 키워드와 함께 기입하여 믹스인해줘야 한다.
  • 세금 정책을 먼저 적용하고 마지막에 비율 할인 정책을 적용
class RateDiscountableAndTaxableRegularPolicy(
    amount: Money,
    seconds: Duration,
    val discountAmount: Money,
    val taxRate: Double
) extends RegularPolicy(amount, seconds)
with TaxablePolicy
with RateDiscountablePolicy

  • 반대로 먼저 비율 할인 정책을 적용하고 마지막에 세금 정잭을 적용
class TaxableAndRateDiscountableRegularPolicy(
    amount: Money,
    seconds: Duration,
    val discountAmount: Money,
    val taxRate: Double
) extends RegularPolicy(amount, seconds)
with RateDiscountablePolicy
with TaxablePolicy
  • 믹스인을 사용하더라도 상속에서 클래스의 숫자가 기하급수적으로 늘어나는 클래스 폭발 문제는 여전히 남은 것은 아닌가?
  • 클래스 폭발의 진짜 문제는 클래스가 늘어나는 것이 아니라 중복 코드가 기하급수적으로 늘어난다는 것이다. 믹스인에는 이런 문제가 발생하지 않는다.
  • 클래스를 만들어야 하는 것을 지양하고 싶다면 클래스를 만들지 않고 인스턴스를 생성할 때 트레이트를 믹스인 할 수도 있다.
new RegularPolicy(Money(1000), Duration.ofSeconds(10))
        with RateDiscountablePolicy
        with TaxablePolicy{
    val discountAmount = Money(100)
    val taxRate = 0.02    
}

쌓을 수 있는 변경

  • 믹스인은 상속 계층 안에서 확장한 클래스보다 더 하위에 위치하게 된다.
  • 따라서 믹스인을 추상 서브클래스라고 부르기도 한다.
  • 믹스인을 사용하면 특정한 클래스에 대한 변경 또는 확장을 독립적으로 구현한 후 필요한 시점에 추가할 수 있다. 믹스인의 이러한 특징을 쌓을 수 있는 변경이라고 부른다.

'Architecture > 객체지향설계' 카테고리의 다른 글

[오브젝트] 다형성  (2) 2025.08.01
[오브젝트] 상속과 코드 재사용  (0) 2025.07.20
[오브젝트] 유연한 설계  (0) 2025.07.16
[오브젝트] 의존성 관리하기  (1) 2025.07.10
[오브젝트] 객체 분해  (0) 2025.07.06