들어가며

지난 시간에 우리는 객체지향 설계에 있어 코드를 재사용하는 방법 중 상속에 대하여 살펴보았다.

이번 장에서는 코드 재사용 방법 중 합성에 대하여 살펴보고 합성이 왜 상속보다 더 우아한 방법인지에 대해 장단점을 비교해본다.


합성과 유연한 설계

상속합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다. 상속이 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하는데 비해 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다. 상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일 타임에 해결되지만 합성에서 두 객체 사이의 의존성은 런타임에 해결된다.

상속 관계는 is-a 관계라고 부르고 합성 관계는 has-a 관계라고 부른다. 상속과 합성은 코드 재사용이라는 동일한 목적을 가진다는 점을 제외하면 구현 방법부터 변경을 다루는 방식에 이르기 까지 모든 면에서 도드라진 차이를 보인다.

상속을 이용하면 자식 클래스의 정의에 부모 클래스의 이름을 덧붙이는 것만으로 부모 클래스의 코드를 재사용할 수 있게된다. 상속을 통해 자식 클래스는 부모 클래스의 정의 대부분을 물려받게 되며 부모 클래스와 다른 부분만 추가하거나 재정의함으로써 기존 코드를 쉽게 확장할 수 있다. 그러나 상속을 제대로 사용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 자식 클래스와 부모 클래스 사이의 결합도가 높아질 수 밖에 없다. 결과적으로 상속은 코드를 재사용할 수 있는 쉽고 간단한 방법일지는 몰라도 우아한 방법이라고는 할 수 없다.

합성은 구현에 의존하지 않는다는 점에서 상속과 다르다. 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 따라서 합성을 이용하면 포함된 객체의 내부 구현이 변경되더라도 영향을 최소화 할 수 있기 때문에 변경에 더 안정적인 코드를 얻을 수 있게된다.

합성은 구현에 의존하지 않는다. 내부에 포함되는 객체의 퍼블릭 인터페이스에 의존하기 때문에 내부 객체의 구현이 변경되더라도 영향을 최소화 할 수 있다.

상속 관계는 클래스 사이의 정적인 관계인 데 비해 합성 관계는 객체 사이의 동적인 관계이다. 이 차이점은 생각보다 중요한데, 코드 작성 시점에 결정한 상속 관계는 변경이 불가능 하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있기 때문이다. 따라서 상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있다.

물론 상속보다 합성을 이용하는 것이 구현 관점에서는 좀 더 번거롭고 복잡하게 느껴질 수 있다. 하지만 설계는 변경과 관련된 것이라는 점을 기억하라. 변경에 유연하게 대처할 수 있는 설계가 대부분의 경우에 정답일 가능성이 높다.

코드를 재사용하기 위해서는 객체 합성이 클래스 상속보다 더 좋은 방법이다.

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


상속을 합성으로 변경하기

상속으로 인한 몇 가지 문제점을 10장에서 살펴보았다.

1. 불필요한 인터페이스 상속 문제 자식 클래스에게는 부적합한 부모 클래스의 오퍼레이션이 상속되기 때문에 자식 클래스의 인스턴스 상태가 불안정해지는 문제. JDK에 포함된 java.util.propertiesjava.util.Stack을 살펴보았다.

2. 메서드 오버라이딩의 오작용 문제 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는 문제 java.util.HashSet을 상속받은 InstrumentedHashSet을 살펴보았다.

3. 부모 클래스와 자식 클래스의 동시 수정 문제 부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 하는 문제. PlayList를 상속받은 PersonalPlayList를 살펴보았다.

이제 상속으로 불거진 위의 문제들을 합성 관계로 변경했을 때 어떻게 바뀌는지 살펴보자.

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

먼저 HashTable 클래스와 Properties 클래스 사이의 상속 관계를 합성 관계로 바꿔보자. Properties 클래스에서 상속 관계를 제거하고 HashTableProperties의 인스턴스 변수로 포함시키면 합성 관계로 변경할 수 있다.

이제 더 이상 불필요한 HashTable의 오퍼레이션들이 Properties의 클래스의 퍼블릭 인터페이스를 오염시키지 않는다. 클라이언트는 오직 Properties에서 정의한 오퍼레이션만 사용할 수 있다. Properties의 클라이언트는 모든 타입의 키와 값을 저장할 수 있는 HashTable의 오퍼레이션을 사용할 수 없기 때문에 String 타입의 키와 값만 허용하는 Properties의 규칙을 어길 위험성은 사라진다.

내부 구현에 밀접하게 결합된 상속과 달리 합성으로 변경한 PropertiesHashTable의 내부 구현에 관해 알지 못한다. 단지 Propertiesgetset 오퍼레이션이 포함된 퍼블릭 인터페이스를 통해서만 HashTable과 협력할 수 있을 뿐이다.

Vector를 상속받는 Stack 역시 Vector의 인스턴스 변수를 Stack 클래스의 인스턴스 변수로 선언함으로써 합성 관계로 변경할 수 있다.

이제 Stack의 퍼블릭 인터페이스에는 불필요한 Vector의 오퍼레이션들이 포함되지 않는다. 클라이언트는 더 이상 임의의 위치에 요소를 추가하거나 삭제할 수 없다. 따라서 마지막 위치에서만 요소를 추가하거나 삭제할 수 있따는 Stack의 규칙을 어길 수 없게 된다. 합성 관계로 변경함으로써 클라이언트가 Stack을 잘못 사용할 수 도 있다는 가능성을 깔끌하게 제거한 것이다.


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

InstrumentedHashSet도 같은 방법을 사용해서 합성 관계로 변경할 수 있다. HashSet 인스턴스를 내부에 포함한 후 HashSet의 퍼블릭 인터페이스에서 제공하는 오퍼레이션들을 이용해 필요한 기능을 구현하면 된다.

여기까지만 보면 앞에서 살펴본 PropertiesStack을 변경하던 과정과 동일하게 보일 것이다. 하지만 InstrumentedHashSet의 경우에는 다른 점이 한 가지 있다. PropertiesStack을 합성으로 변경한 이유는 불필요한 오퍼레이션들이 퍼블릭 인터페이스에 스며드는 것을 방지하기 위해서다. 하지만 InstrumentedHashSet의 경우에는 HashSet이 제공하는 퍼블릭 인터페이스를 그대로 제공해야 한다.

HashSet에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스를 그대로 상속받기 위해서 자바는 인터페이스 문법을 제공한다. HashSetSet 인터페이스를 구현한 구현체 중 하나이며, InstrumentedHashSet이 제공해야 하는 모든 오퍼레이션들은 Set 인터페이스에 정의돼 있다. 따라서 InstrumentedHashSetSet 인터페이스를 실체화하면서 내부에 HashSet의 인스턴스를 합성하면 HashSet에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 유지할 수 있다.

InstrumentedHashSet의 코드를 보면 Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달한다는 것을 알 수 있다. 이를 포워딩이라고 부르고 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드라고 부른다.

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


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

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

그렇다고 하더라도 여전히 상속보다는 합성을 사용하는게 더 좋은데, 향후에 Playlist의 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlayList 내부로 캡슐화 할 수 있기 때문이다.

대부분의 경우 구현에 대한 결합보다는 인터페이스에 대한 결합이 더 좋다는 사실을 기억하라.

이번 장을 시작할 때 상속과 비교해서 합성안정성과 유연성이라는 장점을 제공한다고 말했다. 지금까지는 합성을 사용해서 변경에 불안정한 코드를 안정적으로 유지하는 방법을 살펴보았다. 이제 두 번째 장점인 유연성을 살펴보자.

이 경우에도 핵심은 동일하다. 구현이 아니라 인터페이스에 의존하면 설계가 더 유연해진다는 것이다.


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

상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다.

가장 일반적인 상황은 작은 기능을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같이 두 가지 문제점이 발생한다.

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

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

합성을 사용하면 상속으로 인해 발생하는 클래스의 증가와 중복 코드를 간단하게 해결할 수 있다.

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

핸드폰 과금 시스템에 새로운 요구사항을 추가해보자. 현재 시스템에는 일반 요금제심야 할인 요금제라는 두 가지 종류의 요금제가 존재한다. 새로운 요구사항은 이 두 요금제에 부가 정책을 추가하는 것이다.

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

기본 정책은 가입자의 통화 정보를 기반으로 한다. 기본 정책은 가입자의 한달 통화량을 기준으로 부과할 요금을 계산한다. 앞 장에서 소개한 일반 요금제심야 할인 요금제는 통화량을 기반으로 요금을 계산하기 때문에 기본 요금 정책으로 분류된다.

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

부가 정책은 다음과 같은 특성을 가진다.

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

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

조합 가능하다. 기본 정책에 세금 정책만 적용하는 것도 가능하고, 기본 요금 할인 정책만 적용하는 것도 가능하다. 또한 세금 정책과 기본 요금 할인 정책을 적용한 후에 세금 정책을 적용할 수도 있다.

부가 정책은 임의의 순서로 적용 가능하다. 기본 정책에 세금 정책과 기본 요금 할인 정책을 함께 적용할 경우 세금 정책을 적용한 후에 기본 요금 할인 정책을 적용할 수도 있고, 기본 요금 할인 정책을 적용한 후에 세금 정책을 적용할 수도 있다.

위 그림은 기본 정책부가 정책을 조합해서 만들 수 있는 모든 요금 정책의 종류를 나타낸 것이다. 그림을 통해 알 수 있는 것처럼 이 요구사항을 구현하는 데 가장 큰 장벽은 기본 정책과 부가 정책의 조합 가능한 수가 매우 많다는 것이다. 따라서 설계는 다양한 조합을 수용할 수 있도록 유연해야 한다.


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

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

RegularPhoneNightlyDiscountPhone의 인스턴스만 단독으로 생성한다는 것은 부가 정책은 적용하지 않고 오직 기본 정책만으로 요금을 계산한다는 것을 의미한다.

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

만약 일반 요금제에 세금 정책을 조합해야 한다면 어떻게 해아할까? 가장 간단한 방법은 RegularPhone 클래스를 상속받은 TaxableRegularPhone 클래스를 추가하는 것이다. TaxableRegularPhone 클래스는 부모 클래스의 calculateFee 메서드를 오버라이딩 한 후 super 호출을 통해 부모 클래스에게 calculateFee 메시지를 전송한다. RegularPhonecalculateFee 메서드는 일반 요금제 규칙에 따라 계산된 요금을 반환하므로 이 반환값에 세금을 부과해서 반환하면 일반 요금제와 세금 정책을 조합한 요금을 계산할 수 있다.

부모 클래스의 메서드를 재사용하기 위해 super 호출을 사용하면 원하는 결과는 쉽게 얻을 수 있지만 자식 클래스와 부모 클래스 사이의 결합도가 높아지고 만다.

결합도를 낮추는 방법은 자식 클래스가 부모 클래스의 메서드를 호출하지 않도록 부모 클래스에 추상 메서드를 제공하는 것이다.

부모 클래스가 자신이 정의한 추상 메서드를 호출하고 자식 클래스가 이 메서드를 오버라이딩해서 부모 클래스가 원하는 로직을 제공하도록 수정하면 부모 클래스와 자식 클래스의 결합도를 느슨하게 만들 수 있다. 이 방법은 자식 클래스가 부모 클래스의 구체적인 구현이 아니라 필요한 동작의 명세를 기술하는 추상화에 의존하도록 만든다.

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

자식 클래스는 afterCalculated 메서드를 오버라이딩해서 계산된 요금에 적용할 작업을 추가한다. 일반 요금제를 구현하는 RegularPhone은 요금을 수정할 필요가 없기 때문에 afterCalculated 메서드에서 파라미터로 전달된 요금을 그대로 반환하도록 구현한다.

위 코드에서 알 수 있는 것처럼 부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩 해야 하는 문제가 발생한다. 자식 클래스의 수가 적다면 큰 문제가 아니겠지만 자식 클래스의 수가 많을 경우에는 꽤나 번거로운 일이 될 수 밖에 없다.

모든 추상 메서드의 구현이 동일하다는 사실에도 주목하기 바란다. 유연성을 유지하면서도 중복 코드를 제거할 수 있는 방법은 Phone에서 afterCalclated 메서드에 대한 기본 구현을 함께 제공(훅메서드)하는 것이다. 이제 RegularPhoneNightlyDiscountPhone 클래스에서는 afterCalclated 메서드를 오버라이딩 할 필요가 없다.

이제 드디어 TaxableRegularPhone을 수정할 차례다. TaxableRegularPhoneRegularPhone이 계산한 요금에 세금을 부과한다. 다음과 같이 afterCalclated 메서드를 오버라이딩 한 후 fee에 세금을 더해서 반환하도록 구현하자.

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

지금까지 구현한 Phone의 상속 계층에 세금 정책을 추가한 상속 계층을 다이어그램을 표현하면 아래와 같다.

문제는 TaxableNightlyDiscountPhoneTaxableRegularPhone 사이에 코드를 중복했다는 것이다. 두 클래스의 코드를 자세히 살펴보면 부모 클래스의 이름을 제외하곤 대부분의 코드가 거의 동일하다는 사실을 알 수 있을 것이다. 사실 자바를 비롯한 대부분의 객체지향 언어는 단일 상속만 지원하기 때문에 상속으로 인해 발생하는 중복 코드 문제를 해결하기가 쉽지 않다.


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

이번에는 두 번째 부가 정책인 기본 요금 할인 정책Phone의 상속 계층에 추가해보자. 기본 요금 할인 정책이란 매달 청구되는 요금에서 고정된 요금을 차감하는 부가 정책을 가리킨다. 예를 들어 매달 1000원을 할인해주는 요금제가 있다면 이 요금제에는 부가 정책으로 기본 요금 할인 정책이 조합돼 있다고 볼 수 있다.

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

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

아래 그림은 기본 요금 할인 정책을 추가한 후의 상속 계층을 표현한 것이다. 세금 정책과 마찬가지로 어떤 클래스를 선택하느냐에 따라 적용하는 요금제의 조합이 결정된다는 사실을 알 수 있다.

하지만 이번에도 부가 정책을 구현한 RateDiscountableRegularPhoneRateDiscountableNightlyDiscountPhone 클래스 사이의 중복 코드를 추가했다. 이제 이 중복 코드가 어떤 문제를 초래하는지 살펴보자.


중복 코드의 덫에 걸리다.

부가 정책은 자유롭게 조합할 수 있어야 하고 적용되는 순서 역시 임의로 결정할 수 있어야 한다. 이 요구사항에 따르면 앞에서 구현한 세금 정책과 기본 요금 할인 정책을 함께 적용하는 것도 가능해야 하고, 세금 정책을 적용한 후에 기본 요금 할인 정책을 적용하거나 기본 요금 할인 정책을 적용한 후에 세금 정책을 적용하는 것도 가능해야 한다.

상속을 이용한 해결 방법은 모든 가능한 조합 별로 자식 클래스를 하나씩 추가하는 것이다. 만약 일반 요금제의 계산 결과에 세금 정책을 조합한 후 기본 요금 할인 정책을 추가하고 싶다면 TaxableRegularPhone을 상속받는 새로운 자식클래스인 TaxableAndRateDiscountableRegularPhone을 추가해야 한다.

TaxableAndRateDiscountableRegularPhoneafterCalclated 메서드는 부모 클래스인 TaxableRegularPhoneafterCalclated 메서드를 호출(super)해서 세금이 부과된 요금을 계산한 후 기본 요금 할인 정책을 적용한다. 따라서 세금을 부과하고 나서 기본 요금 할인을 적용하는 순서로 정책을 조합할 수 있다.

표준 요금제에 요금 할인 정책을 먼저 적용한 후 세금을 나중에 부과하고 싶다면 RateDiscountableRegularPhone을 상속받는 RateDiscountAndTaxableReuglarPhone 클래스를 추가하면 된다.

TaxableAndDiscountableNightlyDiscountPhone 클래스는 심야 할인 요금제의 계산 결과에 세금 정책을 적용한 후 기본 요금 할인 정책을 적용하는 케이스를 구현한다.

마지막으로 RateDiscountableAndTaxableNightlyDiscuontPhone 클래스는 심야 할인 요금제의 계산 결과에 기본 요금 할인 정책을 적용한 후 세금 정책을 적용한다.

아래 그림은 현재까지 구현된 상속 계층을 그림으로 표현한 것이다. 꽤 복잡해 보이지 않는가? 하지만 복잡성 보다 더 큰 문제가 있다. 바로 새로운 정책을 추가하기가 어렵다는 것이다. 현재의 설게에 새로운 정책을 추가하기 위해서는 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 한다.

만일 위와 같은 상속 계층에 새로운 기본 정책을 추가해야 한다고 가정해보자. 추가할 기본 정책은 고정 요금제FixedRatePhone이라는 클래스로 구현할 것이다. 모든 부가정책은 기본 정책에 적용 가능해야 하며 조합 순서 역시 자유로워야 한다. 따라서 새로운 기본 정책을 추가한다면 그에 따라 조합 가능한 부가 정책의 수만큼 새로운 클래스를 추가해야 한다.

아래 그림은 새로운 기본 정책을 추가한 결과를 다이어그램으로 표현한 것이다. 그림에서 짙은 음영으로 표현한 클래스가 새로 추가된 클래스로서 고정 요금제 하나를 추가하기 위해 5개의 새로운 클래스를 추가했다는 것을 알 수 있다.

이번에는 새로운 부가정책을 추가하는 경우를 생각해보자. 만일 약정 할인 부가 정책이 추가된다고 가정한다면 문제는 기본 정책을 구현하는 RegularPhone, NightlyDiscountPhone, FixedRatePhone에 약정 할인 정책을 선택적으로 적용할 수 있어야 할 뿐 아니라 다른 부가정책인 세금정책, 기본 요금 할인 정책과도 임의의 순서로 조합 가능해야 한다는 것이다.

하나의 부가 정책 추가 시 모든 조합 가능한 수는 폭발적으로 늘어나게 된다.

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

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

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

클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 떄도 문제가 된다. 만약 세금 정책을 변경해야 한다면 어떻게 해야할까? 세금 정책과 관련된 코드가 여러 클래스 안에 중복돼 있기 때문에 세금 정책과 관련된 모든 클래스를 찾아 동일한 방식으로 수정해야 할 것이다. 이 클래스 중에서 하나라도 누락한다면 세금이 부과되지 않는 버그가 발생하고 말것이다.

이 문제를 해결할 수 있는 최선의 방법은 상속을 포기하는 것이다.


합성 관계로 변경하기

상속 관계는 컴파일 타임에 결정되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 사용하면 모든 조합 가능한 경우별로 클래스를 추가해야 한다. 이것이 바로 핸드폰 과금 시스템의 설계 과정에서 직면했던 클래스 폭발 문제다.

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

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

상속과 달리 합성 관계는 런타임에 동적으로 변경할 수 있다. 합성을 사용하면 컴파일타임 의존성과 런타임 의존성을 서로 다르게 만들 수 있다.

클래스 폭발 문제를 해결하기 위해 합성을 사용하는 이유는 런타임에 객체 사이의 의존성을 자유롭게 변경할 수 있기 때문이다.

사실 우리는 이미 행동을 조합하기 위해 합성을 이용하는 개념적인 방법에 관해 앞에서 살펴보았다. 아래 그림을 다시 살펴보자.

위 그림에서 기본 정책부가 정책독립적인 박스로 표현하고 순서에 따라 조합했다는 것에 주목하자.

이것이 바로 합성의 본질이다.

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

상속이 조합의 결과를 개별 클래스 안으로 밀어넣는 방법 (표준 요금제에 세금 정책 적용 후 기본 할인 정책을 적용하는 TaxableAndRateDiscountableRegularPhone라는 하나의 클래스)이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이라고 할 수 있다.

컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점이다.

물론 컴파일타임 의존성런타임 의존성의 거리가 멀면 멀수록 설계의 복잡도가 상승하기 때문에 코드를 이해하기 어려워진다는 것 역시 사실이다. 하지만 설계는 변경과 유지보수를 위해 존재한다는 사실을 기억하라. 설계는 트레이드오프의 산물이다. 대부분의 경우에는 단순한 설계가 정답이지만 변경에 따르는 고통이 복잡성으로 인한 혼란을 넘어서고 있다면 유연성의 손을 들어주는 것이 현명한 판단일 확률이 높다.


기본 정책 합성하기

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

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

기본 정책부터 구현하자. 기본 정책을 구성하는 일반 요금제심야 할인 요금제는 개별 요금을 계산하는 방식을 제외한 전체 처리 로직이 동일하다. 이 중복 코드를 담을 추상 클래스 BasicRatePolicy를 추가하자.

BasicRatePolicy의 기본 구현은 상속 버전의 Phone의 클래스와 거의 동일하다. BasicRatePolicy의 자식 클래스는 추상 메서드인 calculateCallFee를 오버라이딩해서 Call의 요금을 계산하는 자신만의 방식을 구현할 수 있다.

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

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

이제 기본 정책을 이용해 요금을 계산할 수 있도록 Phone을 수정하자.

Phone의 내부에 RatePolicy에 대한 참조자가 포함돼 있다는 것에 주목하자. 이것이 바로 합성이다. Phone이 다양한 요금 정책과 협력할 수 있어야 하므로 요금 정책의 타입이 RatePolicy라는 인터페이스로 정의되어 있다는 점도 주목하라ㅏ. Phone은 컴파일 타임의존성을 구체적인 런타임 의존성으로 대체하기 위해 생성자를 통해 RatePolicy의 인스턴스에 대한 의존성을 주입받는다.

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

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

합성을 사용하면 Phone과 연결되는 RatePolicy 인터페이스의 구현 클래스가 어떤 타입인지에 따라 요금을 계산하는 방식이 달라진다. 여기까지만 보면 단순히 원하는 클래스를 선택하는 상속보다 더 복잡해졌다는 생각이 들 수도 있을 것이다. 상속을 사용한 경우에는 어떤 클래스의 인스턴스를 조합해야 하는지 고민할 필요 없이 기본 요금제를 적용하고 싶은 경우에는 RegularPhone을, 심야 할인 요금제를 적용하고 싶은 경우에는 NightlyDiscountPhone의 인스턴스를 생성하면 됐기 때문이다. 하지만 현재의 설계에 부가 정책을 추가하면 합성의 강력함의 실감할 수 있을 것이다.


부가 정책 적용하기

일반 요금제를 적용한 경우에 생성된 인스턴스의 관계를 살펴보자. 아래의 그림에서 알 수 있는 것처럼 컴파일 시점Phone 클래스와 RatePolicy 인터페이스의 관계가 런타임Phone의 인스턴스와 RegularPolicy 인스턴스 사이의 관계로 대체되었다는 것을 알 수 있다.

지금부터 할 일은 여기에 부가 정책을 추가하는 것이다. 부가 정책은 기본 정책에 대한 계산이 끝난 후에 적요이 된다는 것을 기억하라. 만약 위의 그림에 세금 정책을 추가한다면 세금 정책은 RegularPolicy의 계산이 끝나고 Phone에게 반환되기 전에 적용되어야 한다. 따라서 아래의 그림과 같이 RegularPolicyPhone 사이에 세금 정책을 구현하는 TaxablePolicy 인스턴스를 연결해야 한다.

만약 일반 요금제기본 요금 할인 정책(부가 정책)을 적용한 후에 세금 정책(부가 정책)을 적용해야 한다면 아래의 그림과 같은 순서로 인스턴스들을 연결해야 한다. (적용 순서와 호출 순서는 반대가 됨에 주목)

위 그림들은 다음 두 가지 제약에 따라 부가 정책을 구현해야 한다는 사실을 잘 보여준다.

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

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

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

부가 정책AdditionalRatePolicy 추상 클래스로 구현하자

Phone의 입장에서 AdditionalRatePolicyRatePolicy의 역할을 수행하기 때문에 RatePolicy 인터페이스를 구현한다. 또한 다른 요금 정책과 조합될 수 있또록 RatePolicy 타입의 next라는 이름을 가진 인스턴스 변수를 내부에 포함한다. AdditionalRatePolicy컴파일 타임 의존성을 런타임 의존성으로 쉽게 대체할 수 있도록 RatePolicy 타입의 인스턴스를 인자로 받는 생성자를 제공한다.

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

먼저 세금 정책(부가 정책)부터 구현하자.

기본 요금 할인 정책(부가 정책)을 추가하는 것도 간단하다.

아래 그림은 모든 요금 계산과 관련된 모든 클래스 사이의 관계를 다이어그램으로 표현한 것이다.

모든 준비가 끝났다. 이제 구현된 정책들을 합성해보자.


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

이제 다양한 방식으로 정책들을 조합할 수 있는 설계가 준비됐다. 남은 일은 원하는 정책의 인스턴스를 생성한 후 의존성 주입을 통해 다른 정책의 인스턴스에게 전달하는 것 뿐이다.

이와 같은 설계는 상속을 사용한 설계보다 복잡하고 정해진 규칙에 따라 객체를 생성하고 조합해야 하기 때문에 처음에는 코드를 이해하기가 어려울 수 있다. 하지만 일단 설계에 익숙해지고 나면 객체를 조합하고 사용하는 방식이 상속을 사용하는 방식보다 더 예측 가능하고 일관성이 있다는 사실을 알게 될 것이다.

하지만 합성의 장점은 여기서 끝나지 않는다. 합성의 진가는 새로운 클래스를 추가하거나 수정하는 시점이 되어서야 비로소 알 수 있다.


새로운 정책 추가하기

상속을 기반으로 한 설계에 새로운 부가 정책을 추가하기 위해서는 상속 계층에 불필요할 정도로 많은 클래스들을 추가해야만 했다. 합성을 기반으로 한 설계에서는 이 문제를 간단하게 해결할 수 있다.

고정 요금제(기본 정책)이 필요하다면 고정 요금제를 구현한 클래스를 하나만 추가한 후 원하는 방식으로 조합하면 된다.

약정 할인 정책(부가 정책)이라는 새로운 부가 정책이 필요하다면 역시 클래스 하나만 추가하면 된다.

우리는 오직 하나의 클래스만 추가하고 런타임에 필요한 정책들을 조합해서 원하는 기능을 얻을 수 있다. 이 설계를 필요한 조합의 수 만큼 매번 새로운 클래스를 추가했던 상속과 비교해보라. 왜 많은 사람들이 그렇게 코드 재사용을 위해 상속 보다는 합성을 사용하려고 하는지 그 이유를 이해할 수 있을 것이다.

더 중요한 것은 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다는 것이다.

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


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

객체 지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법은 상속이다. 하지만 상속은 코드 재사용을 위한 우아한 해결책은 아니다. 상속은 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시키기 때문에 코드의 진화를 방해한다.

코드를 재사용하면서도 건전한 결합도를 유지할 수 있는 더 좋은 방법은 합성을 사용하는 것이다. 상속이 구현을 재사용하는데 비해 합성은 객체의 인터페이스를 재사용한다.

여기서 한 가지 의문이 들 것이다. 그렇다면 상속은 사용해서는 안되는 것인가? 상속을 사용해야 하는 경우는 언제인가? 이 의문에 대답하기 위해서는 먼저 상속을 “구현 상속”과 “인터페이스 상속” 두가 지로 나눠야 한다는 사실을 이해해야 한다. 그리고 이번 장에서 살펴본 상속에 대한 모든 단점들은 “구현 상속”에 국한된다는 점 또한 이해해야 한다

참고 및 출처

  • 오브젝트