본문 바로가기
그룹 스터디 공부(IT 서적)/모던 자바 인 액션

chapter 11 합성과 유연한 설계

by hanyugyeong 2023. 9. 18.
반응형
SMALL

상속 관계는 is-a 관계라고 부르고 합성 관계는 has-a 관계라고 부른다. 

상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 자식 클래스와 부모 클래스 사이의 결합도가 높아질 수밖에 없다. 결과적으로 상속은 코드를 재사용할 수 있는 쉽고 간단한 방법일지는 몰라도 우아한 방법은 아니다. 

 

합성은 구현에 의존하지 않는다는 점에서 상속과 다르다. 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 

 

상속 관계는 클래스 사이의 정적인 관계인 데 비해 합성 관계는 객체 사이의 동적인 관계다. 

이 차이점은 생각보다 중요한데, 코드 작성 시점에 결정한 상속 관계는 변경이 불가능하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있기 때문이다. 

 

01. 상속을 합성으로 변경하기 

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

-> 자식 클래스에게는 부적합한 부모 클래스의 오퍼레이션이 상속되기 때문에 자식 클래스 인스턴스의 상태가 불안정해지는 문제 

 

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

-> 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는 문제

 

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

-> 부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 하는 문제 

불필요한 인터페이스 상속 문제: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);
    }
}

Vector를 상속받는 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);
    }
}

 

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

InstrumentedHashSet도 같은 방법을 사용해서 합성 관계로 변경할 수 있다. 

public class InstrumentedHashSet<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;
    }
}

Properties와 Stack을 합성으로 변경한 이유는 불필요한 오퍼레이션들이 퍼블릭 인터페이스에 스며드는 것을 방지하기 위해서다. 하지만 InstrumentedHashSet의 경우에는 다른 점이 한 가지 있다. InstrumentedHashSet이 제공해야 하는 모든 오퍼레이션들은 Set 인터페이스에 정의돼 있다. InstrumentedHashSet이 Set인터페이스를 실체화하면서 내부에 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);
    }
}

포워딩이라 부르고 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드라고 부른다. 

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

Playlist의 경우에는 합성으로 변경하더라도 가수별 노래 목록을 유지하기 위해 Playlist와 PersonalPlayllist를 함께 수정해야 하는 문제가 해결되지는 않는다. 

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

 

구현이 아니라 인터페이스에 의존하면 설계가 유연해진다는 것이다. 

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

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

일반적으로 다음과 같은 두 가지 문제점이 발생한다. 

 

* 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다. 

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

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

핸드폰 요금제가 '기본 정책'과 '부가 정책'을 조합해서 구성된다고 가정할 것이다. 

기본 정책은 '일반 요금제'와 '심야 할인 요금제' 는 통화량을 기반으로 요금을 계산

부가 정책은 통화량과 무관하게 기본 정책에 선택적으로 추가할 수 있는 요금 방식을 의미한다. 

 

부가 정책이 다음과 같은 특성을 가진다는 것을 기억하라 

 

* 기본 정책의 계산 결과에 적용된다. 

-> 세금 정책은 기본 정책인 RegularPhone이나 NightlyDiscountPhone의 계산이 끝난 결과에 세금을 부과한다. 

 

* 선택적으로 적용할 수 있다. 

-> 기본 정책의 계산 결과에 세금 정책을 적용할 수도 있고 적용하지 않을 수도 있다. 

 

* 조합 가능하다

-> 기본 정책에서 세금 정책만 적용하는 것도 가능하고, 기본 요금 할인 정책만 적용하는 것도 가능하다. 또한 세금 정책과 기본 요금 할인 정책을 함께 적용하는 것도 가능해야 한다. 

 

* 부가 정책은 임의의 순서로 적용 가능하다. 

-> 기본 정책에 세금 정책과 기본 요금 할인 정책을 함께 적용할 경우 세금 정책을 적용한 후에 기본 요금 할인 정책을 적용할 수 있고, 기본 할인 정책을 적용한 후에 세금 정책을 적용할 수도 있다. 

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

상속을 이용해서 기본 정책과 부가 정책을 구현해보자. 기본 정책은 Phone 추상 클래스를 루트로 삼는 기본의 상속 계층을 그대로 이용할 것이다. 일반 요금제를 구현하는 RegularPhone과 심야 할인 요금제를 구현하는  NightlyDiscountPhone은 Phone의 자식 클래스로 구현한다. 

 

Phone 상속 계층을 그대로 옮겨온 것으로서 기본 정책은 아래 세 클래스로 구성된다. 

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

    abstract protected 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());
        } else {
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }
}

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

public class TaxableRegularPhone extends RegularPhone {
    private double taxRate;

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

 

Phone 클래스에 새로운 추상 메서드인 afterCalculated를 추가하자. 이 메서드는 자식 클래스에게 전체 요금을 계산한 후에 수행할 로직을 추가할 수 있는 기회를 제공한다. 

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

    protected abstract Money calculateCallFee(Call call);
    protected abstract Money afterCalculated(Money fee);
}

일반 요금제를 구현하는 RegularPhone은 요금을 수정할 필요가 없기 때문에 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;
    }
}

심야 할인 요금제를 구현하는 NightlyDiscountPhone 클래스 역시 수정해야 한다. 

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());
        } else {
            return regularAmount.times(call.getDuration().getSeconds() / seconds.getSeconds());
        }
    }

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

taxableRegularPhone을 수정할 차례다. 

public class TaxableRegularPhone extends RegularPhone {
    private double taxRate;

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

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

심야 할인 요금제인 NightlyDiscountPhone에도 세금을 부과할 수 있도록 NightlyDiscountPhone의 자식 클래스인 TaxableNightlyDiscountPhone을 추가하자. 

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
    protected Money afterCalculated(Money fee) {
        return fee.plus(fee.times(taxRate));
    }
}

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

일반 요금제와 기본 요금 할인 정책을 조합하고 싶다면 RegularPhone을 상속받는 RateDiscountableRegularPhone 클래스를 추가하면 된다. 

public class RateDiscountableRegularPhone extends RegularPhone {
    private Money discountAmount;

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

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

심야 할인 요금제와 기본 요금 할인 정책을 조합하고 싶다면 NightDiscountPhone을 상속받는 RateDiscountableDiscountPhone 클래스를 추가하면 된다. 

public class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone {
    private Money discountAmount;

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

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

중복 코드의 덫에 걸리다.

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
    protected Money afterCalculated(Money fee) {
        return super.afterCalculated(fee).minus(discountAmount);
    }
}

TaxableAndRateDiscountableRegularPhone의 afterCalculated 메서드는 부모 클래스인 TaxableRegularPhone의 afterCalculated 메서드를 호출해서 세금이 부과된 요금을 계산한 후 기본 요금 할인 정책을 적용한다. 

 

상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발 문제 또는 조합의 폭발 문제라고 부른다. 

03. 합성 관계로 변경하기 

합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다. 

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

기본 정책 합성하기 

기본 정책과 부가 정책을 포괄하는 RatePolicy 인터페이스를 추가하자. RatePolicy는 Phone을 인자로 받아 계산된 요금을 반환하는 calculateFee 오퍼레이션을 포함하는 간단한 인터페이스다. 

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

일반 요금제를 구현하자. BasicRatePolicy의 자식 클래스로 RegularPolicy를 추가하자. 

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

심야 할인 요금제를 구현하는 NightlyDiscountPolicy 클래스 역시 유사한 방식으로 구현할 수 있다. 

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

이제 기본 정책을 이용해 요금을 계산할 수 있도록 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);
    }
}

Phone 내부에 RatePolicy에 대한 참조자가 포함돼 있다는 것에 주목하라. 이것이 바로 합성이다. 

Phone이 다양한 요금 정책과 협력할 수 있어야 하므로 요금 정책의 타입이 RatePolicy라는 인터페이스로 정의돼 있다는 것에도 주목하라. 

 

일반 요금제의 규칙에 따라 통화 요금을 계산하고 싶다면 다음과 같이 Phone과 BasicRatePolicy의 인스턴스를 합성하면 된다. 

 Phone phone = new Phone(new RegularPolicy(Money.wons(10), Duration.ofSeconds(10)));

심야 할인 요금제의 규칙에 따라 통화 요금을 계산하고 싶다면 다음과 같이 Phone과 NightlyDiscountPolicy의 인스턴스를 합성하면 된다. 

Phone phone = new Phone(new NightlyDiscountPolicy(Money.wons(5),
            Money.wons(10),Duration.ofSeconds(10)));

부가 정책 적용하기 

Phone 클래스와 RatePolicy 인터페이스 사이의 관계가 런타임에 Phone 인스턴스와 RegularPolicy 인스턴스 사이의 관계로 대체됐다는 것을 알 수 있다. 

세금 정책은 RegularPolicy의 계산이 끝나고 Phone에게 반환되기 전에 적용돼야 한다. RegularPolicy와 Phone 사이에 세금 정책을 구현하는 TaxablePolicy 인스턴스를 연결해야 한다. 

 

요약하면 부가 정책은 RatePolicy 인터페이스를 구현해야 하며, 내부에 또 다른 RatePolicy 인스턴스를 합성할 수 있어야 한다. 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

반응형
LIST