Computer Science/객체지향

합성과 유연한 설계 - 코드로 이해하는 객체지향

sh1mj1 2023. 9. 19. 14:33

상속부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하고

합성전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용합니다.

상속 관계 is-a 관계입니다. 자식 클래스가 Cat, 부모 클래스가 Animal 이면 Cat is a Animal 인 것이죠.

합성은 has-a 관계입니다. Cat has a Claw(발톱). 형식이지요.

 

이 둘은 코드 재사용이라는 동일한 목적을 가지지만, 이것을 제외하면 구현 방법, 변경을 다루는 방식이 다릅니다.

 

상속은 부모 클래스의 코드를 자식 클래스가 자신의 것처럼 사용하고, override 할 수 있게 합니다. 하지만 부모 클래스의 내부 구현, 의도를 자세히 알고 이해해야 상속을 제대로 사용할 수 있기 때문에 자식 클래스와 부모 클래스 사이의 결합도가 높아질 수 밖에 없습니다. 그래서 상속은 내부 구현을 알게되어서 white-box reuse 라고 하고, 합성은 black-box reuse 라고 합니다.

 

합성은 구현에 의존하지 않는다는 점에서 상속과 다릅니다. 합성은 내부 구현이 아닌 퍼블릭 인터페이스에 의존합니다. 내부 구현이 변경되어도 영향을 최소화 할 수 있지요. 재사용하는 것도 내부 구현이 아닌, 퍼블릭 인터페이스입니다. 즉, 결합도가 훨씬 낮습니다.

 

상속은 클래스 사이의 의존성이 컴파일 타임에 해결되는 정적인 관계입니다.

합성은 반면에 런타임에 의존성이 결정되는 동적인 관계이지요. 런타임에 상속 관계는 변경이 불가능하지만 합성 관계는 동적으로 변경 가능합니다. 물론 합성이 구현 관점에서 더 번거롭고 복잡할 수는 있습니다.

 

상속을 합성으로 변경하기

이전 글에 상속 남용 시 문제점을 알아보았습니다.

 

  • 불필요한 인터페이스 상속 문제
  • 메서드 오버라이딩의 오작용 문제
  • 부모 클래스와 자식 클래스의 동시 수정 문제

 

합성으로 이 문제들을 해결할 수 있습니다.

자식 클래스에 선언된 상속 관계를 제거하고, 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언하면 됩니다!

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

이전 글에서 PropertiesHashTable 클래스, Stack 과 Vector 클래스를 예로 살펴보았습니다.

 

먼저 Hashtable 클래스와 Propertis  클래스 사이 상속 관계를 합성 관계로 바꿔봅시다.

 

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 에서 정의한 오퍼레이션만 사용할 수 있습니다.

 

Properties 를 사용하는 클라이언트에서는 HashTable 의 모든 타입의 키, 값을 지정할 수 오퍼레이션을 쓸 수 없고 오직 String 타입의 키-값만 사용할 수 있게 되었습니다. 오직 Properties 의 퍼블릭 인터페이스(setProperty, setProperty 오퍼레이션)를 통해서만 Hashtable 과 협력할 수 있을 뿐입니다.

 

이제 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 의 오퍼레이션들이 없습니다. Stack 의 원래의 의도인 FIFO(push 와 pop) 만 가능하게 되었지요. Stack 을 원래 의도가 아닌 방식으로 잘못 사용할 수 있는 가능성을 없앴습니다.

 

 

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

HashSet 을 상속받았던 InstrumentedHashSet 도 같은 방법으로 합성 관계로 변경할 수 있습니다.

HashSet 인스턴스를 내부에 포함하는 합성으로 바꿔봅시다.

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

그런데 위 코드는 사실상 HashSet 이 제공하는 퍼블릭 인터페이스를 그대로 제공합니다. 

 

HashSet 에 구현 결합도는 제거하면서 퍼블릭 인터페이스는 그대로 상속받을 수 없을까요?

자바의 interface 를 사용하면 됩니다.

 

HashSetSet 인터페이스의 구현체 중 하나이기 때문에 InstrumentedHashSet 이 제공해야 하는 모든 오퍼레이션은 당연히 Set 인터페이스에 정의되어 있습니다.

즉, InstrumentedHashSetSet 인터페이스를 구현하면서 내부에 HashSet 인스턴스를 합성하면 HashSet 에 대한 결합도는 없애면서 퍼블릭 인터페이스는 유지할 수 있습니다.

 

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

    @Override public int hashCode() {
        return set.hashCode();
    }

    @Override public Spliterator<E> spliterator() {
        return set.spliterator();
    }

    @Override public int size() {
        return set.size();
    }

    @Override public boolean isEmpty() {
        return set.isEmpty();
    }

    @Override public boolean contains(Object o) {
        return set.contains(o);
    }

    @Override public Iterator<E> iterator() {
        return set.iterator();
    }

    @Override public Object[] toArray() {
        return set.toArray();
    }

    @Override public <T> T[] toArray(T[] a) {
        return set.toArray(a);
    }

    @Override public boolean containsAll(Collection<?> c) {
        return set.containsAll(c);
    }

    @Override public boolean retainAll(Collection<?> c) {
        return set.retainAll(c);
    }

    @Override public boolean removeAll(Collection<?> c) {
        return set.removeAll(c);
    }
}

 

위 코드를 보면 Set 의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달합니다.

super 키워드를 사용하지 않고 말이죠.

이를 포워딩(forwarding)이라 합니다. 그리고 동일한 메서드를 호출하기 위해서 추가된 메서드를 포워딩 메서드(forwarding method)라고 합니다.

 

포워딩은 기존 클래스의 인터페이스를 그대로 외부에 제공하면서, 구현에 대한 결합 없이 일부 작동 방식을 변경하고 싶은 경우에 사용하는 기법입니다.

 

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

이전 글에서 Playlist 클래스, Song 클래스 등을 통해 부모 클래스, 자식 클래스의 구현은 영원히 변경하지 않거나, 동시에 수정해야 한다는 사실을 주의점을 익혔습니다.

 

이번에도 합성을 적용하면 이 문제를 해결할 수 있을까요?

아쉽게도 Playist 는 합성으로 변경해도 가수별 노래 목록을 유지하기 위해서는 PlaylistPersonalPlaysit 를 함께 수정해야 하는 문제가 해결되지 않습니다.

public class PersonalPlaylist {
    private Playlist playlist = new Playlist();

    public void append(Song song) {
        playlist.append(song);
    }

    public void remove(Song song) {
        playlist.getTracks().remove(song);
        playlist.getSingers().remove(song.getSinger());
    }
}

 

그래도 여전히 상속보다 합성을 사용하는 게 더 좋습니다.

 

나중에 Playist 의 내부 구현을 변경해도 파급 효과를 최대한 PersonalPlaylist 내부로 캡슐화할수 있기 때문이죠! 

 

대부분의 경우 구현에 대한 결합보다 인터페이스에 대한 결합이 더 좋습니다!!

 

 

지금까지는 변경에 불안정한 코드를 안정적으로 유지하는 법을 살펴보았습니다.

 

이제 유연성에 대해 알아봅시다. 구현이 아닌 인터페이스에 의존하면 설계가 유연해집니다.

 

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

상속으로 결합도가 높아지면 코드 수정 시 작업이 굉장히 늘어납니다.

 

일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우입니다.

 

예를 들어 어떤 알림 기능을 만들었다고 합시다. 알림은 SMS, facebook, Slack 메시지 중 하나로 보내지도록 만들었습니다.

상위 클래스 Notifier 와 세 가지 자식 클래스를 만들어서 상속을 하도록 구현했습니다.

그런데 이 중 여러 채널로 알림을 받고 싶어하는 고객들이 많이 생긴다면 상속을 사용하면 아래처럼 구현할 수 밖에 없습니다.

 1. 하나의 기능을 추가하거나 수정할 때 불필요하게 많은 수의 클래스를 추가/수정해야 함.

 2. 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수도 있음.

 

합성을 사용함으로써 상속으로 발생하는 클래스 증가와 중복 코드 문제를 간단히 해결할 수 있습니다.

 

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

이전 글에서 핸드폰 요금 시스템 코드를 만들었었죠? 여기에 새로운 요구사항이 추가된다고 합시다.

원래는 일반 요금제, 심야 할인 요금제 이렇게 두 종류의 요금제였습니다.

 

새 요구사항은 이 요금제에 '부가 정책'을 추가하는 것입니다. 핸드폰 요금제가 '기본 정책', '부가 정책'의 조합으로 구성되어야 한다는 요구사항입니다.

 

이 시스템에서는 아래와 같이 작동해야 한다고 합시다.

  • 부가 정책은 기본 정책의 계산 결과에 적용됨.
  • 부가 정책은 선택적으로 적용할 수도, 적용하지 않을 수도 있음. 두 개가 조합되어 적용될 수도 있음.
  • 부가 정책을 둘 모두 적용할 때 세금 정책이 먼저 적용될 수도, 기본 요금 할인 정책이 먼저 적용될 수도 있음.

위 요구사항대로라면 기본 정책과 부가 정책의 조합 가능한 수가 매우 많습니다.

설계는 다양한 조합을 수용할 수 있도록 유연해야 겠죠...

 

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

추상 클래스인 Phone 클래스에서 기본요금을 계산하는 코드를 RegularPhoneNightlyDiscountPhone 에서 구현하게 합니다.

그리고 기본 요금을 계산하여 얻은 fee 를 부가 정책으로 다시 계산하여 최종 요금을 계산하도록 하고 있습니다.

 

Phone 클래스의 calculateFee 메서드 안에서 기본 정책에 의한 요금을 추상 메서드 calculateCallFee 메서드를 호출하여 계산하고 나서 부가 정책에 의한 최종 요금을  afterCalculated 메서드를 호출하는 방식으로 구현되어 있습니다.

 

딱 봐도 클래스 구조에 반복이 많으며 변경에도 취약해보이고, 클래스 이름도 너무 길어집니다.

굳이 코드를 보지 않아도 알 수 있겠죠..

 

참고 - 훅 메서드 (hook method)

참고로 OCP(개방-폐쇄 원칙)을 만족하는 설계를 하기 위해  부모 클래스에 새로운 추상 메서드를 추가하고 부모 클래스의 다른 메서드 안에서 이를 호출할 수 있습니다. 자식 클래스는 추상 메서드를 오버라이드하여 자신만의 로직을 구현해서 부모 클래스에서 정의한 플로우에 개입할수 있게 됩니다. 이 때 추상 메서드는 상속 계층에 속하는 모든 자식 클래스가 이 추상 메서드를 오버라이드 해야 한다는 단점이 있습니다.

위 클래스 다이어그램에서는 PhoneafterCalculate(fee) 메서드를 추상 메서드가 아닌 기본 구현을 가지고 있는 메서드로 만들고 이를 최하단 계층에 있는 클래스에서 오버라이드하도록 만들었습니다. 실제로는 최하단 계층 클래스에서 무조건 오버라이드하여 사실상 추상메서드처럼 사용하지만 중간 계층 클래스에서는 이를 오버라이드하지 않아도 되는 구조를 가지게 되었습니다.

이렇게 추상 메서드와 동일하게 자식 클래스에서 오버라이드할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공하는 메서드를 훅 메서드(hook method)라고 합니다.

 

위 클래스 다이어그램에서 볼 수 있듯이 이처럼 상속의 남용으로 하나의 기능을 추가하기 위해서 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발(class explosion) 문제 또는 조합의 폭발(combinational explosion) 문제라고 합니다.

 

클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제입니다.

이 문제는 새로운 기능을 추가할 때 뿐 아니라 기능을 수정할 때도 문제가 됩니다.

 

우리는 이 문제 해결을 위해 상속을 포기하는 것입니다.

 

합성 관계로 변경하기

합성은 컴파일 타임 관계를 런타임 관계로 변경해서 클래스 폭발 문제를 해결합니다. 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있습니다.

 

합성을 사용하면 컴파일 타임 의존성과 런타임 의존성을 다르게 만들 수 있습니다.

상속이 조합의 결과를 개별 클래스 안으로 밀어넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법입니다.

 

기본 정책 합성하기

Phone 내부에 RatePolicy 에 대한 참조자가 포함되어 있죠! 그리고 Phone 이 다양한 요금 정책과 협력할 수 있어야 하므로 요금 정책의 타입이 RatePolicy 라는 인터페이스로 정의되어 있습니다.

이것이 합성입니다. 

 

Phone 은 컴파일 의존성을 구체적인 런타임 의존성으로 대체하기 위해 생성자를 통해 RatePolicy 의 인스턴스에 대한 의존성을 주입받습니다. 보통 이렇게 다양한 종류의 객체와 협력하기 위해 합성 관계를 사용하는 경우, 합성하는 객체의 타입을 인터페이스나 추상 클래스로 선언하고 DI 를 사용해서 런타임에 필요한 객체를 설정할 수 있도록 구현하는 것이 일반적입니다.

 

부가 정책 적용하기

일반 요금제를 적용한 경우에 생성된 인스턴스 관계는 아래와 같습니다.

만약 부가 정책인 세금 정책을 추가한다면, 부가 정책은 기본 정책에 대한 계산이 끝난 후에 적용되므로 RegularPolicy 의 계산이 끝나고 Phone 으로 리턴되기 전에 수행되어야 합니다.

 

만약 일반 요금제에서 기본 요금 할인 정책 적용 후, 세금 정책을 적용해야 한다면 아래와 같겠죠.

 

  • 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 함.
    즉, 부가 정책의 인스턴스는 모든 정책과 합성될 수 있어야 함.
  • Phone 입장에서는 자신이 기본 정책의 인스턴스에게 메시지를 전송하는지, 부가 정책의 인스턴스에게 전송하는지 몰라야 함.
    즉, 기본 정책과 부가 정책은 협력 안에서 동일한 '역할'을 수행해야 함.
    부가 정책이 기본 정책과 동일한 RatePolicy 인터페이스를 구현해야 함!!

 

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

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

 

정책 인터페이스 RatePolicy

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

 

기본 정책 추상 클래스 BasicRatePolicy

public abstract class BasicRatePolicy implements RatePolicy {
    @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);
}

 

기본 정책 구체 클래스 RegularPolicy, NightDiscountPolicy

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

 

부가 정책 추상 클래스 AdditionalRatePolicy

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

    abstract protected Money afterCalculated(Money fee);
}

AdditionalRatePolicyPhone 의 입장에서 RatePolicy의 역할을 수행하기 때문에 RatePolicy 인터페이스를 구현합니다.

또 다른 요금 정책과 조합될 수 있기 위해 RatePolicy 타입의 next 라는 이름의 인스턴스 변수를 내부에 가집니다.

컴파일타임 의존성을 런타임 의존성으로 대체할 수 있도록 RatePolicy 타입의 인스턴스를 인자로 받는 생성자도 제공하지요. 생성자 내부에서 next 에 전달되는 인스턴스에 대해 DI 합니다.

 

calculateFee 메서드는 next 인스턴스에게 calculateFee 메시지를 전송하고 반환된 요금에 부가 정책을 적용하기 위해서 afterCalculated 메서드를 호출합니다.

AdditionalRatePolicy 를 상속받은 자식 클래스는 afterCalculated 메서드를 오버라이드 해서 적절한 부가 정책을 구현할 수 있습니다.

 

 

부가 정책 구현 클래스 TaxablePolicy, RateDiscountablePolicy

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 TaxablePolicy(0.05, new RegularPolicy(...));

 

TaxablePolicy 의 생성자 인자로 다른 RatePolicy 의 구현인 RegularPolicy 를 받습니다.

 

일반 요금제에 기본 요금 할인 정책을 조합한 결과에 세금 정책을 조합하고 싶다면 아래처럼 생성하면 되죠.

Phone phone = new Phone(new TaxablePolicy(0.05, 
                            new RateDicountablePolicy(Money.wons(1000),
                                new RegularPolicy(...)));

이런 식으로 사용하면 됩니다.

 

합성을 사용하는 설계에 익숙해지고 나면 객체를 조합하고 사용하는 방식이 상속을 사용한 방식보다 더 예측 가능하고 일관성 있다는 사실을 알게 됩니다.

 

합성을 사용하면 새로운 클래스를 추가하거나 수정하는 것도 굉장히 깔끔해집니다.

 

새로운 정책 추가하기

기본 정책에서 고정 요금제 정책 FixedRatePolicy를, 부가 정책에 약정 할인 정책이라는 AgreementDiscountablePolicy 을 추가한다고 합시다.

단지 클래스 하나만 추가하면 됩니다.

 

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

상속은 구현을 재사용하는 데 비해 합성은 객체의 인터페이스를 재사용합니다!
그로 인해 합성은 코드를 재사용하면서 상속보다 낮은 결합도를 유지할 수 있다.

 

그렇다면 상속은 사용하면 안 될까요? 상속을 사용해야 하는 경우도 당연히 있습니다.

 

상속은  구현 상속과 인터페이스 상속의 두 가지로 나뉩니다. 위에서 문제가 되었던 모든 단점들은 구현 상속에 국한됩니다. 이는 나중에 자세히 알아봅시다.

 

일단은 합성과 상속의 특성을 모두 보유하고 있는 코드 재사용 방법인 믹스인을 알아보면서 상속과 합성의 장단점에 대해 깊게 이해하고 넘어가도록 합시다.

 

믹스인

우리는 코드를 재사용하면서도 괜찮은 수준의 결합도를 유지하는 것입니다.

구체적인 코드를 재사용하면서도 낮은 결합도를 유지할 수 있는 유일한 방법은 재사용에 적합한 추상화를 도입하는 것입니다.

 

믹스인(mixin)객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법입니다.

합성이 런타임에 객체를 조합하는 재사용 방법이라면 믹스인컴파일 타임에 필요한 코드 조각을 조합하는 재사용 방법입니다.

 

🙋 그러면 그건 상속 아닌가요?

유사하게 보이겠지만 믹스인은 상속과는 다릅니다.

상속의 결과로 부모 클래스의 코드를 재사용할 수 있기는 하지만 상속의 목적은 부모, 자식 클래스를 같은 개념의 범주로 묶어서 is-a 관계를 만드는 것이고, 믹스인은 말 그대로 코드를 다른 코드 안에 섞어서 넣는(Mix in) 방법입니다.

 

믹스인은 클래스 사이 관계를 고정시키는 것이 아닌, 관계를 유연하게 재구성할 수 있습니다. 코드 재사용에 특화된 방법이면서도 상속과 같은 결합도 문제를 초래하지 않습니다.

합성처럼 유연하게, 상속처럼 쉽게 코드를 재사용할 수 있는 것이지요.

 

이 믹스인이라는 개념을 구현하는 방법이 언어마다 다르기 때문에 조금 어려울 수 있습니다.

이번에는 스칼라 언어에서 제공하는 트레이트(trait) 를 이용해서 믹스인을 구현해보겠습니다.

 

기본 정책 구현하기

기본 정책을 구현하는 BasicRatePolicy

기본 정책에 속하는 전체 요금제 클래스들이 확장할 수 있도록 추상 클래스로 구현.

abstract class BasicRatePolicy {
  def calculateFee(phone: Phone): Money = 
      phone.calls.map(calculateCallFee(_)).reduce(_ + _)
  
  protected def calculateCallFee(call: Call): Money;
}

 

기본 정책 구현체 클래스 RegularPolicy, NightlyDiscountPolicy

class RegularPolicy(val amount: Money, val seconds: Duration) extends BasicRatePolicy {

  override protected def calculateCallFee(call: Call): Money = 
          amount * (call.duration.getSeconds / seconds.getSeconds)
}

 

 

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

스칼라에서는 다른 코드와 조합해서 확장할 수 있는 기능을 트레이트(trait: 특성이란 뜻)로 구현할 수 있습니다.

 

기본 정책에 부가 정책 구현체 코드를 조합합시다. 트레이트로 구현된 기능들을 섞어 넣게 될 대상은 기본 정책에 해당하는 RegularPolicy, NightlyDiscountPolicy 입니다.

 

부가 정책 중 세금 정책 TaxablePolicy 트레이트

trait TaxablePolicy extends BasicRatePolicy {
  val taxRate: Double
  override def calculateFee(phone: Phone): Money = {
    val fee = super.calculateFee(phone)
    return fee + fee * taxRate
  }
}

TaxablePolicy 트레이트는 BasicRatePolicy 를 확장합니다.

 

이는 상속의 개념이 아니라 TaxablePolicyBasicRatePolicyBasicRatePolicy 의 자손일 때만, 믹스인될 수 있다는 뜻입니다.

이 제약을 스칼라에서는 trait 라는 키워드로 표현하여 의미를 정확히 전달할 수 있습니다.

 

부가 정책은 항상 기본 정책 요금 계산이 끝난 결과 요금에 실행되어야 합니다. 따라서 BasicRatePolicycalculateFee 메서드를 오버라이드한 후 super.calculateFee 로 먼저 BasicRatePolicy 의 메서드를 실행한 후 자신의 동작을 처리합니다.

 

🤔🤔🤔🤔 엥? 이거 완전 상속이잖아? 게다가 super 호출을 사용하네?????

 

이렇게 상속하게 되면 다시 TaxablePolicyBasicRatePolicy 사이 결합도가 높아지는 것 아닐까요?

 

코드 상으로 TaxablePolicy 트레이트가 BasicRatePolicy 를 상속하도록 구현했지만 실제로 TaxablePolicyBasicRatePolicy 의 자식 트레이드가 되는 것은 아닙니다.  extends 는 단지 TaxablePolicyBasicRatePolicy 를 상속받은 경우에만 믹스인될 수 있다는 문맥을 제한할 뿐입니다.

 

TaxablePolicy 트레이트는 어떤 코드에 믹스인될지는 실제로 믹스인하는 시점에 대상을 결정합니다.

즉, 부모 클래스를 고정시키지 않죠.

 

당연히 super.calculateFee 가 어떤 메서드를 호출할지도 고정되지 않습니다. 실제로 믹스인되는 시점에 결정됩니다.

만약 TaxablePolicyRegularPolicy 와 믹스인 된다면 그 클래스의 calculateFee 메서드가, NightlyDiscountPolicy 와 믹스인된다면 또 NightlyDIscountPolicy 클래스의 calculateFee 메서드가 호출되는 것이지요.

 

스칼라의 트레이트의 경우 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 를 이용해서 믹스인합니다. 

이를 트레이트 조합(trait composition)이라고 합니다.

 

표준 요금제에 세금 정책을 조합한다면 아래처럼 RegularPolicy 클래스를 extends 하고 with 으로 TaxablePolicy 트레이트를 믹스인합니다.

class TaxableRegularPolicy(
	amount: Money, 
    secondes: Duration, 
    val taxRate: Double) extends RegularPolicy(amount, seconds) with TaxablePolicy

선형화 후의 TaxableRegularPolicy 계층 구조

 

스칼라는 특정 클래스에 믹스인한 클래스와 트레이트를 선형화해서 어떤 메서드를 호출할지 결정합니다.

선형화를 할 때 맨 앞에 구현한 클래스 자기 자신(TaxableRegularPolicy)이 위치, 그 후에 선언된 트레이트(TaxablePolicy), 그리고 RegularPolicy 가 위치합니다.

 

만약 :TaxableRegularPolicycalculateFee 메시지를 받으면, TaxableRegularPolicy 클래스에서 메시지를 처리할 메서드를 찾고, 위 경우에서는 메서드를 찾을 수 없기 때문에 다음 단계인 TaxablePolicy 에서 찾습니다.

 

TaxablePolicy 안에 구현되어 있는 calculateFee 메서드를 실행하는 데 그 메서드 바디 안에 super 가 있기 때문에 이것이 호출되어 BasicRatePolicycalculateFee 메서드가 호출되서 표준 요금제에 따라 요금이 계산된 후  요금에 세금이 부과된 후에 리턴됩니다.

 

믹스인 되기 전까지는 상속 계층 안에서 TaxablePolicy 트레이트의 위치가 결정되지 않습니다. 어떤 클래스에 믹스인할지에 따라 TaxablePolilcy 트레이트의 위치는 동적으로 변합니다.

 

 

 

 

 

 

 

 

세금 정책과 비율 할인 정책은 순서가 서로 바뀔 수 있습니다. 이 때도 선형화를 통해 간단히 해결할 수 있습니다.

비율할인 정책을 먼저 적용한다면 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(100), Duration.ofSeconds(10))
    with RateDiscountablePolicy
    with TaxablePolicy {
        val discountAmount = Money(100)
        val taxRate = 0.02
}

이렇게 인스턴스를 만드는 모습을 보면 자바에서의 합성과 매우 비슷한 모습을 하고 있다는 것을 볼 수 있습니다.

 

하지만 코드 여러 곳에서 동일한 트레이트를 믹스인해서 사용해야 한다면 명시적으로 클래스를 정의하는 것이 좋습니다.  계속해서 위처럼 인스턴스를 만드는 것이 아닌 단순히 new TaxablePolicyAndRateDiscountablePolicy(...) 라는 코드로 만들 수 있기 때문입니다.

 

쌓을 수 있는 변경

전통적으로 믹스인은 특정 클래스의 메서드를 재사용하고 기능을 확장하기 위해 사용되어 왔습니다. 핸드폰 요금 시스템의 예시에서는 BasicRatePolicycalculateFee 메서드 기능을 확장하기 위해 믹스인을 사용했습니다.

 

믹스인은 자식 클래스처럼 사용될 용도로 만들어집니다. 하지만 상속과는 다르게 동적으로 믹스인될 것을 고를 수 있죠. 믹스인을 추상 서브클래스(abstract subclass)라고 부르기도 합니다.

 

믹스인을 사용하면 특정 클래스에 대한 변경, 확장을 독립적으로 구현한 후 필요한 시점에 차례대로 추가할 수 있습니다. 그래서 쌓을 수 있는 변경(stackable modification) 이라고 합니다.

 

 

이렇게 재사용과 유연한 설계를 위해 상속 대신 합성과 믹스인을 살펴보았습니다.