11장 : 합성과 유연한 설계

상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다.

  • 상속

    • 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용

    • 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결됨

    • is-a 관계

  • 합성

    • 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용

    • 부모 클래스와 자식 클래스 두 객체 사이의 의존성은 런타임에 해결됨

    • has-a 관계

상속과 합성은 재사용의 대상이 다르다. 상속은 부모 클래스 안에 구현됨 코드 자체를 재사용하지만 합서능 포함되는 객체의 퍼블릭 인터페이스를 재사용한다. 따라서 상속 대신 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경하며, 클래스 사이의 높은 결합도를 낮은 결합도로 대체할 수 있다.

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

  • 🚩 코드 재사용을 위해 상속을 남용했을 때 직면하는 세 가지 문제

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

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

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

합성을 사용하면 위 세 가지 문제점 해결 가능

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

  • Hashtable 클래스와 Properties 클래스 사이의 상속 관계 → 합성 관계로 변경

    • Properties 클래스에서 상속 관계를 제거하고 Hashtable을 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 클래스의 인스턴스 변수로 선언함으로써 합성 관계로 변경할 수 있다.

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

  • InstrumentedHashSet도 HashSet 인스턴스를 내부에 포함한 후 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;
	}
}
  • 포워딩 (Forwarding)

    • 부모의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 자식 인스턴스에게 동일한 메서드 호출을 그대로 전달하는 것

  • 포워딩 메서드 (Forwarding method)

    • 동일한 메서드를 호출하기 위해 추가된 메서드

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

  • 합성으로 변경하더라도, 동시 수정 문제가 해결되지 않는데 그렇다고 하더라도 여전히 상속보다는 합성을 사용하는 것이 더 좋다.

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

  • 🚩 상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다. 가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같은 두 가지 문제점이 발생한다.

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

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

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

  • 부가 정책은 다음과 같은 특성을 갖는다.

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

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

    • 조합 가능하다

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

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

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

중복 코드의 덫에 걸리다

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);
    }
}
public class RateDiscountableAndTaxableRegularPhone
        extends RateDiscountableRegularPhone {
    private double taxRate;

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

    @Override
    protected Money afterCalculated(Money fee) {
        return super.afterCalculated(fee).plus(fee.times(taxRate));
    }
}
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
    protected Money afterCalculated(Money fee) {
        return super.afterCalculated(fee).plus(fee.times(taxRate));
    }
}
public class TaxableAndDiscountableNightlyDiscountPhone extends TaxableNightlyDiscountPhone {
    private Money discountAmount;

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

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

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

  • 클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제다. 컴파일타임에 결정된 자식 클래스 사이의 관계는 변경될 수 없기 때문에 발생하는 문제다. 컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 발생하는 문제다.

  • 컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 발생하는 문제다. 컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.

3. 합성 관계로 변경하기

상속 관계는 컴파일타임에 결정되고 고정되기 떄문에 코드를 실행하는 도중에는 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합 가능한 경우별로 클래스를 추가해야한다.

합성은 컴파일타임 관계를 런타임 관계로 변경함으로써 이 문제를 해결한다. 합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있따.

컴파일타임 의존성과 런타임 의존성의 거리가 멀수록 설계가 유연해진다. 상속을 사용하는 것은 컴파일타임의 의존성과 런타임의 의존성을 동일하게 만들겠다고 선언하는 것이다. 따라서 상속을 사용하면 부모 클래스와 자식 클래스 사이의 관계가 정적으로 고정되기 때문에 실행 시점에 동적으로 관계를 변경할 수 있는 방법이 없는 반면, 상속과 달리 합성 관계는 런타임에 동적으로 변경할 수 있다.

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

상속이 조합의 결과를 개별 클래스 안으로 밀어 넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이라고 할 수 있다. 컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점이다.

기본 정책 합성하기

가장 먼저 해야할 일은 각 정책을 별도의 클래스로 구현하는 것이다. 분리된 정책들을 연결할 수 있도록 합성 관계를 이용해서 구조를 개선하면 실행 시점에 정책들을 조합할 수 있게 된다. 핸드폰이라는 개념으로부터 요금 계산 방법이라는 개념을 분리해야 한다.

 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.plus(calculateCallFee(call));
		}

		return result;
	}

	protected abstract Money calculateCallFee(Call call); 
}

BasicRatePolicy의 자식 클래스는 추상 메서드인 calculateCallFee를 오버라이딩헤사 Call의 요금을 계산하는 자신만의 방식을 구현할 수 있다.

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

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

	public RegularPolicy(Money amount, Durarion 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 = regulrAmount;
			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은 이 컴파일타임 의존성을 구체적인 런타임 의존성으로 대체라기 위해 생성자를 통해 RatePolicy의 인스턴스에 대한 의존성을 주입받는다. Phone의 경우처럼 다양한 종류의 객체와 협력하기 위해 합성 관계를 사용하는 경우에는 합성하는 객체의 타입을 인터페이스나 추상 클래스로 선언하고 의존성 주입을 사용해 런타임에 필요한 객체를 설정할 수 있도록 구현하는 것이 일반적이다.

일반 요금제의 규칙에 따라 통화 요금을 계산하고 싶다면 다음과 같이 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과 연결되는 RatePolciy 인터페이스의 구현 클래스가 어떤 타입인지에 따라 요금을 계산하는 방식이 달라진다.

부가 정책 적용하기

  • 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다. 다시 말해서 부가 정책의 인스턴스는 어떤 종류의 정책과도 합성될 수 있어야 한다.

  • Phone의 입장에서는 자신이 기본 정책의 인스턴스에게 메시지를 전송하고 있는지, 부가 정책의 인스턴스에게 메시지를 전송하고 있는지를 몰라야 하낟. 다시 말해서 기본 정책과 부가 정책은 협력 안에서 동일한 ‘역할’을 수행해야 한다. 이것은 부가 정책이 기본 정책과 동일한 RatePolicy 인터페이스를 구현해야 한다는 것을 의미한다.

부가 정책을 AddtionalRatePolicy 추상클래스로 구현하자.

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

Phone의 입장에서 AdditionalRatePolicy는 RatePolicy의 역할을 수행하기 때문에 RatePolicy 인터페이스를 구현한다.

AdditionalRatePolicy의 calculateFee 메서드는 먼저 next가 참조하고 있는 인스턴스에게 calculateFee 메시지를 전송한다. 그 후 반환딘 요금에 부가 정책을 적용하기 위해 afterCalculated 메서드를 호출한다. AdditionalRatePolicy를 상속받은 자식 클래스는 afterCalculated 메서드를 오버라이딩해서 적절한 부가 정책을 구현할 수 있따.

  • 세금 정책 구현

public class TaxablePolicy extends AdditionalRatePolicy {
	private double taxRatio;

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

	@Override
	proteced 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.5, 
		new RegularPolicy(...)));
  • 일반 요금제에 기본 요금 할인 정책을 조합한 결과에 세금 정책 조합할 경우의 Phone 인스턴스

Phone phone = new Phone(
		new TaxablePolicy(0.5, 
			 new RegularPolicy(Money.wons(1000),
				 new RegularPolicy(...)));
  • 동일한 정책을 심야 할ㅇ니 요금제에도 적용

Phone phone = new Phone(
		new TaxablePolicy(0.05, 
			 new RatePolicy(Money.wons(1000),
			        new NightlyPolicy(...)));

새로운 정책 추가하기

오직 하나의 클래스만 추가하고 런타임에 필요한 정책들을 조합해서 원하는 기능을 얻을 수 있다.

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

상속을 사용해야 하는 경우.

  • 먼저 상속을 구현 상속과 인터페이스 상속의 두 가지로 나눠야 한다.

    (이번 장에서 살펴본 상속에 대한 모든 단점들은 구현 상속에 둗한된다.)

04. 믹스인

  • 믹스인은 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법을 가리키는 용어다.

  • 상속이 클래스와 클래스 사이의 관계를 고정시키는 데 비해 믹스인은 유연하게 관계를 재구성할 수 있다.

Last updated