3. 합성 관계로 변경하기
상속 관계는 컴파일타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다.
따라서 여러 기능을 조합해야 하는 설계의 경우 클래스 폭발 문제가 발생하게 된다.
합성은 컴파일타임 관계를 런타임 관계로 변경함으로써 이 문제를 해결한다.
컴파일타임 의존성과 런타임 의존성의 거리가 멀수록 설계가 유연하다.
상속은 컴파일타임 의존성과 런타임 의존성을 동일하게 만든다.
합성은 퍼블릭 인터페이스를 사용하여 두 의존성을 다르게 만들 수 있다.
두 의존성의 거리가 멀수록 설계의 복잡도는 상승하지만, 변경에 따르는 고통이 커지고 있다면 유연성을 택하는 것이 좋다.
합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 유연하게 변경할 수 있다.
- 상속: 조합의 결과를 개별 클래스 안으로 밀어 넣는 방법
- 합성: 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법
기본 정책 합성하기
각 정책을 별도의 클래스로 구현하자.
먼저 기본 정책과 부가 정책을 포괄하는 인터페이스를 추가하자.
interface RatePolicy {
calculateFee(phone: Phone): number;
}
기본 정책을 구성하는 일반 요금제와 심야 할인 요금제의 중복 코드를 담을 추상 클래스를 추가하자.
abstract class BasicRatePolicy implements RatePolicy {
public calculateFee(phone: Phone): number {
const result = 0;
for (const call of phone.getCalls()) {
result + this.calculateCallFee(call);
}
return result;
}
protected abstract calculateCallFee(call: Call): number;
}
class RegularPolicy extends BasicRatePolicy {
private amount: number;
private seconds: number;
constructor(amount: number, seconds: number) {
super();
this.amount = amount;
this.seconds = seconds;
}
protected calculateCallFee(call: Call): number {
return this.amount * (call.getDuration() / this.seconds);
}
}
class NightlyDiscountPolicy extends BasicRatePolicy {
private LATE_NIGHT_HOUR = 22;
private nightlyAmount: number;
private regularAmount: number;
private seconds: number;
constructor(nightlyAmount: number, regularAmount: number, seconds: number) {
super();
this.nightlyAmount = nightlyAmount;
this.regularAmount = regularAmount;
this.seconds = seconds;
}
protected calculateCallFee(call: Call): number {
if (call.getFrom() >= this.LATE_NIGHT_HOUR) {
return this.nightlyAmount * (call.getDuration() / this.seconds);
}
return this.regularAmount * (call.getDuration() / this.seconds);
}
}
이제 Phone을 수정하자.
class Phone {
private ratePolicy: RatePolicy;
private calls: Call[] = new Array();
constructor(ratePolicy: RatePolicy) {
this.ratePolicy = ratePolicy;
}
public getCalls(): Call[] {
return this.calls;
}
public calculateFee(): number {
return this.ratePolicy.calculateFee(this);
}
}
Phone 내부에 RatePolicy에 대한 참조자가 포함돼 있다. 이것이 바로 합성이다.
Phone은 생성자를 통해 RatePolicy의 인스턴스에 대한 의존성을 주입받는다.
다양한 종류의 객체와 협력하기 위해 합성을 사용하는 경우, 합성하는 객체의 타입을 인터페이스나 추상 클래스로 선언하고
의존성 주입으로 런타임에 필요한 객체를 설정할 수 있도록 구현하는 것이 일반적이다.
const regularPhone = new Phone(new RegularPolicy(10, 10));
const nightlyPhone = new Phone(new NightlyDiscountPolicy(5, 10, 10));
컴파일 시점의 Phone 클래스와 RatePolicy 인터페이스 사이의 관계가
런타임에 Phone 인스턴스와 RegularPolicy 인스턴스 사이의 관계로 대체됐다.
부가 정책 적용하기
부가 정책은 기본 정책에 대한 계산이 끝난 후에 적용되므로 두 가지 제약이 존재한다.
- 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다.
부가 정책의 인스턴스는 어떤 종류의 정책과도 합성될 수 있어야 한다. - Phone의 입장에서는 메시지를 전송하는 대상이 기본 정책의 인스턴스인지 부가 정책의 인스턴스인지 몰라야 한다.
기본 정책과 부가 정책은 협력 안에서 동일한 '역할'을 수행해야 한다. 즉, 동일한 RatePolicy 인터페이스를 구현해야 한다.
부가 정책은 RatePolicy 인터페이스를 구현해야 하며, 내부에 또 다른 RatePolicy 인스턴스를 합성할 수 있어야 한다.
abstract class AdditionalRatePolicy implements RatePolicy {
private next: RatePolicy;
constructor(next: RatePolicy) {
this.next = next;
}
public calculateFee(phone: Phone): number {
const fee = this.next.calculateFee(phone);
return this.afterCalculated(fee);
}
protected abstract afterCalculated(fee: number): number;
}
AdditionalRatePolicy의 calculateFee는 먼저 next가 참조하는 인스턴스에게 메시지를 전송한 후,
반환된 요금에 부가 정책을 적용하기 위해 afterCalculated 메서드를 호출한다.
class TaxablePolicy extends AdditionalRatePolicy {
private taxRatio: number;
constructor(taxRatio: number, next: RatePolicy) {
super(next);
this.taxRatio = taxRatio;
}
protected afterCalculated(fee: number): number {
return fee + fee * this.taxRatio;
}
}
class RateDiscountablePolicy extends AdditionalRatePolicy {
private discountAmount: number;
constructor(discountAmount: number, next: RatePolicy) {
super(next);
this.discountAmount = discountAmount;
}
protected afterCalculated(fee: number): number {
return fee - this.discountAmount;
}
}
const phone1 = new Phone(new TaxablePolicy(0.05, new RegularPolicy(...)));
const phone2 = new Phone(
new TaxablePolicy(0.05, new RateDiscountablePolicy(1000, new RegularPolicy(...)))
);
const phone3 = new Phone(
new RateDiscountablePolicy(1000, new TaxablePolicy(0.05, new RegularPolicy(...)))
);
const phone4 = new Phone(
new RateDiscountablePolicy(1000, new TaxablePolicy(0.05, new NightlyDiscountPolicy(...)))
);
객체를 조합하고 사용하는 방식이 상속을 사용한 방식보다 더 예측 가능하고 일관성 있다.
상속의 경우 새로운 부가 정책을 추가하기 위해서는 상속 계층에 불필요할 정도로 많은 클래스를 추가해야 한다.
합성의 경우 새로운 부가 정책 클래스 하나만 추가한 후 원하는 방식으로 조합하면 된다.
수정 시에도 하나의 클래스만 수정하면 된다. 합성을 이용한 설계는 단일 책임 원칙을 준수한다.
객체 합성이 클래스 상속보다 더 좋은 방법이다
상속은 코드 재사용을 위한 우아한 해결책은 아니다. 부모의 세부적인 구현에 자식을 강하게 결합시켜 코드의 진화를 방해한다.
상속이 구현을 재사용하는 데 비해 합성은 객체의 인터페이스를 재사용한다.
상속은 구현 상속과 인터페이스 상속의 두 가지로 나뉘어지며,
이번 장에서 살펴본 상속의 단점들은 구현 상속에 국한된다.
4. 믹스인
믹스인은 코드를 재사용하는 유용한 기법 중 한 가지다. 상속과 합성의 특성을 모두 보유한다.
상속의 경우 클래스의 확장과 수정을 일관성 있게 표현할 수 있는 추상화의 부족으로 변경하기 어려운 코드를 야기한다.
구체적인 코드를 재사용하면서도 낮은 결합도를 유지하는 방법은 재사용에 적합한 추상화를 도입하는 것이다.
믹스인은 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법이다.
- 합성: 실행 시점에 객체를 조합하는 재사용 방법
- 믹스인: 컴파일 시점에 필요한 코드 조각을 조합하는 재사용 방법
믹스인은 상속과는 다르다. 상속의 목적은 자식을 부모와 동일한 개념적인 범주로 묶어 is-a 관계를 만들기 위함이다.
믹스인은 말 그대로 코드를 다른 코드 안에 섞어 넣기 위한 방법이다.
코드를 섞어 넣는다는 기본 개념을 구현하는 방법은 언어마다 다르다.
그 방법이 무엇이건 코드를 다른 코드 안에 유연하게 섞어 넣을 수 있다면 믹스인이라고 부를 수 있다.
믹스인은 Flavors라는 언어에서 처음으로 도입됐고 이후 CLOS(Common Lisp OBject System)에 의해 대중화됐다.
스칼라 언어에서 제공하는 트레이트를 이용해 믹스인을 구현해보자.
스칼라의 트레이트는 CLOS에서 제공했던 믹스인의 기본 철학을 가장 유사한 형태로 재현하고 있다.
기본 정책 구현하기
이전의 예제와 거의 유사하다.
기본 정책을 구현하는 클래스는 기본 정책에 속하는 전체 요금제 클래스들이 확장할 수 있도록 추상 클래스로 구현하자.
표준 요금제와 심야 할인 요금제는 기본 정책 요금제를 상속받아 calculateCallFee를 오버라이딩하도록 하자.
abstract class BasicRatePolicy {
def calculateFee(phone: Phone): Integer =
phone.getCalls.map(calculateCallFee(_)).reduce(_ + _)
protected def calculateCallFee(call: Call): Integer;
}
class RegularPolicy(val amount: Integer, val seconds: Integer) extends BasicRatePolicy {
override protected def calculateCallFee(call: Call): Integer =
amount * (call.getDuration / seconds)
}
class NightlyDiscountPolicy(
val nightlyAmount: Integer,
val regularAmount: Integer,
val seconds: Integer) extends BasicRatePolicy {
override protected def calculateCallFee(call: Call): Integer =
if (call.getFrom() >= NightlyDiscountPolicy.LateNightHour) {
nightlyAmount * (call.getDuration / seconds);
} else {
regularAmount * (call.getDuration() / seconds);
}
}
object NightlyDiscountPolicy {
val LateNightHour: Integer = 22
}
트레이트로 부가 정책 구현하기
차이점은 부가 정책의 코드를 기본 정책 클래스에 섞어 넣을 때 두드러진다.
스칼라에서는 다른 코드와 조합해서 확장할 수 있는 기능을 트레이트로 구현할 수 있다.
부가 정책들을 트레이트로 구현해 보자.
trait TaxablePolicy extends BasicRatePolicy {
def taxRate: Double
override def calculateCallFee(phone: Phone): Integer = {
val fee = super.calculateFee(phone)
return fee + fee * taxRate
}
}
trait RateDiscountablePolicy extends BasicRatePolicy {
val discountAmount: Integer
override def calculateCallFee(phone: Phone): Integer = {
val fee = super.calculateFee(phone)
fee - discountAmount
}
}
트레이트는 BasicRatePolicy를 확장한다. 이는 상속의 개념이 아니다.
트레이트가 BasicRatePolicy나 BasicRatePolicy의 자손에 해당하는 경우에만 믹스인될 수 있다는 것을 의미한다.
클래스로 구현했던 상속 계층과 차이점이 거의 없는 것 아닌가?
왜 여기서는 또 super 호출을 사용하는 것인가?
트레이트가 BasicRatePolicy를 상속하도록 구현했지만 실제로 BasicRatePolicy의 자식 트레이트가 되는 것은 아니다.
extends 문은 단지 트레이트가 사용될 수 있는 문맥을 제한할 뿐이다.
트레이트는 BasicRatePolicy를 상속받은 경우에만 믹스인될 수 있다.
따라서 BasicRatePolicy와 NightlyDiscountPolicy에 믹스인 될 수 있으며,
미래에 추가될 새로운 BasicRatePolicy의 자손에게도 믹스인될 수 있지만 다른 클래스나 트레이트에는 믹스인될 수 없다.
상속은 정적이지만 믹스인은 동적이다.
상속은 부모와 자식의 관계를 코드를 작성하는 시점에 고정시켜 버리지만
믹스인은 제약을 둘 뿐 실제로 어떤 코드에 믹스인될 것인지를 결정하지 않는다.
따라서 super로 참조되는 코드 역시 고정되지 않는다.
super 호출로 실행되는 calculateFee 메서드를 보관한 코드는 실제로 트레이트가 믹스인되는 시점에 결정된다.
RegularPolicy의 메서드가 호출될 수도, NightlyDiscountPolicy의 메서드가 호출될 수도 있다.
상속의 경우 일반적으로 this 참조는 동적으로 결정되지만 super 참조는 컴파일 시점에 결정된다.
하지만 스칼라의 트레이트에서 super 참조는 동적으로 결정된다.
합성은 독립적으로 작성된 객체들을 실행 시점에 조합해서 더 큰 기능을 만들어내는 데 비해,
믹스인은 독립적으로 작성된 트레이트와 클래스를 코드 작성 시점에 조합해서 더 큰 기능을 만들어낸다.
부가 정책 트레이트 믹스인하기
부모 클래스는 extends를 이용해 상속받고 트레이트는 with를 이용해 믹스인해야 한다. 이를 트레이트 조합이라고 한다.
표준 요금제에 세금 정책을 조합해보자. 믹스인할 트레이트를 TaxablePolicy, 조합될 클래스는 RegularPolicy다.
class TaxablePolicy(
amount: Integer,
seconds: Integer,
val taxRate: Double)
extends RegularPolicy(amount, seconds)
with TaxablePolicy
스칼라는 특정 클래스에 믹스인한 클래스와 트레이트를 선형화해서 어떤 메서드를 호출할지 결정한다.
선형화를 할 때 항상 맨 앞에는 구현한 클래스 자기 자신이 위치한다. (TaxableRegularPolicy)
그 후 오른쪽에 선언된 트레이트를 그다음 자리에 위치시키고 왼쪽 방향으로 가면서 순서대로 그 자리에 위치시킨다.
(TaxableRegularPolicy -> TaxablePolicy -> RegularPolicy -> BasicRatePlicy)
TaxableRegularPolicy의 인스턴스가 calculateFee를 수신했다고 하자.
- 먼저 TaxableRegularPolicy 클래스에서 메서드를 찾는다.
- 메서드를 발견할 수 없기 때문에 TaxablePolicy의 calculateFee를 실행한다.
- 메서드 구현 안에 super 호출이 있기 때문에 RegularPolicy에 메서드가 존재하는지 검색한다.
- 메서드를 발견할 수 없기 때문에 BasicRatePolicy의 calculateFee를 실행한다.
- 제어는 TaxablePolicy 트레이트로 돌아오고 super호출 이후의 코드가 실행된다.
여기서 중요한 것은 믹스인되기 전까지는 상속 계층 안에서 TaxablePolicy 트레이트의 위치가 결정되지 않는다는 것이다.
어떤 클래스에 믹스인할지에 따라 TaxablePolicy 트레이트의 위치는 동적으로 변경된다.
세금 정책과 비율 할인 정책은 임의의 순서에 따라 조합될 수 있어야 한다.
표준 요금제에 세금 정책을 적용한 후 비율 할인 정책을 적용하는 경우
- RegularPolicy의 calculateFee 메서드 실행
- TaxablePolicy 트레이트를 적용
- RateDiscountablePolicy 트레이트 적용
(RateDiscountableAndTaxableRegularPolicy -> RateDiscountablePolicy -> TaxablePolicy -> RegularPolicy -> BasicRatePolicy)
RateDiscountablePolicy 상위에 TaxablePolicy를 위치시켜야 super 호출에 의해 세금 정책이 먼저 적용될 수 있다.
class RateDiscountableAndTaxableRegularPolicy(
amount: Integer,
seconds: Integer,
val discountAmount: Integer
val taxRate: Double)
extends RegularPolicy(amount, seconds)
with TaxablePolicy
with RateDiscountablePolicy
반대로 표준 요금제에 비율 할인 정책을 적용한 후 세금 정책을 적용하는 경우
class RateDiscountableAndTaxableRegularPolicy(
amount: Integer,
seconds: Integer,
val discountAmount: Integer
val taxRate: Double)
extends RegularPolicy(amount, seconds)
with RateDiscountablePolicy
with TaxablePolicy
믹스인은 재사용 가능한 코드를 독립적으로 작성한 후 필요한 곳에서 쉽게 조합할 수 있게 해준다.
믹스인도 클래스 폭발 문제를 야기하는 것으로 보일 수도 있다.
사실 클래스 폭발 문제의 단점을 클래스가 늘어난다는 것이 아니라 클래스가 늘어날수록 중복 코드도 함께 늘어난다는 점이다.
믹스인에는 이런 문제가 발생하지 않는다.
믹스인한 인스턴스가 오직 한 군데에서만 필요한 경우라면 클래스를 만들지 않고도 가능하다.
new RegularPolicy(100, 10)
with RateDiscountablePolicy
with TaxablePolicy {
val discountAmount = 100
val taxRate = 0.02
}
쌓을 수 있는 변경
전통적으로 믹스인은 특정한 클래스의 메서드를 재사용하고 기능을 확장하기 위해 사용돼 왔다.
믹스인은 상속 계층 안에서 확장한 클래스보다 더 하위에 위치하게 된다.
다시 말해서 믹스인은 대상 클래스의 자식 클래스처럼 사용된 용도로 만들어지는 것이다.
TaxablePolicy와 RateDiscountablePolicy는 BasicRatePolicy에 조합되기 위해 항상 상속 계층의 하위에 믹스인됐다.
따라서 믹스인을 추상 서브클래스라고 부르기도 한다.
객체지향 언어에서 슈퍼클래스는 서브클래스를 명시하지 않고도 정의될 수 있다.
그러나 서브클래스가 정의될 때는 슈퍼클래스를 명시해야 한다.
믹스인은 결론적으로는 슈퍼클래스로부터 상속될 클래스를 명시하는 메커니즘을 표현한다.
따라서 하나의 믹스인은 매우 다양한 클래스를 도출하면서 서로 다른 서브클래스를 이용해 인스턴스화될 수 있다.
믹스인의 이런 특성은 다중 클래스를 위한 단일의 점진적인 확장을 정의하는 데 적절하게 만든다.
이 클래스들 중 하나를 슈퍼클래스로 삼아 믹스인이 인스턴스화될 때 추가적인 행위가 확장된 클래스를 생성한다.
믹스인을 사용하면 특정한 클래스에 대한 변경 또는 확장을 독립적으로 구현한 후 필요한 시점에 차례대로 추가할 수 있다.
마틴 오더스키는 믹스인의 이러한 특징을 쌓을 수 있는 변경이라고 부른다.
super가 호출할 메서드의 구현은 트레이트를 클래스 구현에 믹스인할 때마다 클래스에 따라 새로 정해진다.
super가 이렇게 동작하기 때문에 트레이트를 이용해 변경 위에 변경을 쌓아올릴 수 있다.