들어가며
지난 시간에 우리는 객체지향 설계에 있어 코드를 재사용하는 방법 중 상속
에 대하여 살펴보았다.
이번 장에서는 코드 재사용 방법 중 합성
에 대하여 살펴보고 합성
이 왜 상속
보다 더 우아한 방법인지에 대해 장단점을 비교해본다.
합성과 유연한 설계
상속
과 합성
은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용
기법이다. 상속이 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하는데 비해 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다. 상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일 타임
에 해결되지만 합성
에서 두 객체 사이의 의존성은 런타임
에 해결된다.
상속
관계는 is-a
관계라고 부르고 합성
관계는 has-a
관계라고 부른다. 상속과 합성은 코드 재사용이라는 동일한 목적을 가진다는 점을 제외하면 구현 방법부터 변경을 다루는 방식에 이르기 까지 모든 면에서 도드라진 차이를 보인다.
상속을 이용하면 자식 클래스의 정의에 부모 클래스의 이름을 덧붙이는 것만으로 부모 클래스의 코드를 재사용할 수 있게된다. 상속을 통해 자식 클래스는 부모 클래스의 정의 대부분을 물려받게 되며 부모 클래스와 다른 부분만 추가하거나 재정의함으로써 기존 코드를 쉽게 확장할 수 있다. 그러나 상속을 제대로 사용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 자식 클래스와 부모 클래스 사이의 결합도가 높아질 수 밖에 없다. 결과적으로 상속은 코드를 재사용할 수 있는 쉽고 간단한 방법일지는 몰라도 우아한 방법이라고는 할 수 없다.
합성
은 구현에 의존하지 않는다는 점에서 상속과 다르다. 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 따라서 합성
을 이용하면 포함된 객체의 내부 구현이 변경되더라도 영향을 최소화 할 수 있기 때문에 변경에 더 안정적인 코드를 얻을 수 있게된다.
합성은 구현에 의존하지 않는다. 내부에 포함되는 객체의 퍼블릭 인터페이스에 의존하기 때문에 내부 객체의 구현이 변경되더라도 영향을 최소화 할 수 있다.
상속
관계는 클래스 사이의 정적인 관계
인 데 비해 합성
관계는 객체 사이의 동적인 관계
이다. 이 차이점은 생각보다 중요한데, 코드 작성 시점에 결정한 상속 관계는 변경이 불가능
하지만 합성 관계는 실행 시점에 동적으로 변경
할 수 있기 때문이다. 따라서 상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있다.
물론 상속보다 합성을 이용하는 것이 구현 관점에서는 좀 더 번거롭고 복잡하게 느껴질 수 있다. 하지만 설계는 변경과 관련된 것
이라는 점을 기억하라. 변경에 유연하게 대처할 수 있는 설계가 대부분의 경우에 정답일 가능성이 높다.
코드를 재사용하기 위해서는 객체 합성이 클래스 상속보다 더 좋은 방법이다.
상속과 합성은 재사용의 대상이 다르다. 상속
은 부모 클래스 안에 구현된 코드 자체를 재사용
하지만 합성
은 포함된 객체의 퍼블릭 인터페이스를 재사용
한다. 따라서 상속 대신 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경
할 수 있다. 다시말해서 클래스 사이의 높은 결합도를 객체 사이의 낮은 결합도로 대체할 수 있는 것이다.
상속을 합성으로 변경하기
상속으로 인한 몇 가지 문제점을 10장에서 살펴보았다.
1. 불필요한 인터페이스 상속 문제
자식 클래스에게는 부적합한 부모 클래스의 오퍼레이션이 상속
되기 때문에 자식 클래스의 인스턴스 상태가 불안정해지는 문제. JDK
에 포함된 java.util.properties
와 java.util.Stack
을 살펴보았다.
2. 메서드 오버라이딩의 오작용 문제
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는 문제 java.util.HashSet
을 상속받은 InstrumentedHashSet
을 살펴보았다.
3. 부모 클래스와 자식 클래스의 동시 수정 문제
부모 클래스와 자식 클래스 사이의 개념적인 결합
으로 인해 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 하는 문제. PlayList
를 상속받은 PersonalPlayList
를 살펴보았다.
이제 상속으로 불거진 위의 문제들을 합성
관계로 변경했을 때 어떻게 바뀌는지 살펴보자.
불필요한 인터페이스 상속 문제 : java.util.Properties와 java.util.Stack
먼저 HashTable
클래스와 Properties
클래스 사이의 상속 관계를 합성 관계로 바꿔보자. Properties
클래스에서 상속 관계를 제거하고 HashTable
을 Properties
의 인스턴스 변수로 포함시키면 합성 관계로 변경할 수 있다.
이제 더 이상 불필요한 HashTable
의 오퍼레이션들이 Properties
의 클래스의 퍼블릭 인터페이스를 오염시키지 않는다. 클라이언트는 오직 Properties
에서 정의한 오퍼레이션만 사용할 수 있다. Properties
의 클라이언트는 모든 타입의 키와 값을 저장할 수 있는 HashTable
의 오퍼레이션을 사용할 수 없기 때문에 String
타입의 키와 값만 허용하는 Properties
의 규칙을 어길 위험성은 사라진다.
내부 구현에 밀접하게 결합된 상속과 달리 합성
으로 변경한 Properties
는 HashTable
의 내부 구현에 관해 알지 못한다. 단지 Properties
는 get
과 set
오퍼레이션이 포함된 퍼블릭 인터페이스를 통해서만 HashTable
과 협력할 수 있을 뿐이다.
Vector
를 상속받는 Stack
역시 Vector
의 인스턴스 변수를 Stack
클래스의 인스턴스 변수로 선언함으로써 합성 관계로 변경할 수 있다.
이제 Stack
의 퍼블릭 인터페이스에는 불필요한 Vector
의 오퍼레이션들이 포함되지 않는다. 클라이언트는 더 이상 임의의 위치에 요소를 추가하거나 삭제할 수 없다. 따라서 마지막 위치에서만 요소를 추가하거나 삭제할 수 있따는 Stack
의 규칙을 어길 수 없게 된다. 합성 관계로 변경함으로써 클라이언트가 Stack
을 잘못 사용할 수 도 있다는 가능성을 깔끌하게 제거한 것이다.
메서드 오버라이딩의 오작용 문제 : InstrumentedHashSet
InstrumentedHashSet
도 같은 방법을 사용해서 합성 관계로 변경할 수 있다. HashSet
인스턴스를 내부에 포함한 후 HashSet
의 퍼블릭 인터페이스에서 제공하는 오퍼레이션들을 이용해 필요한 기능을 구현하면 된다.
여기까지만 보면 앞에서 살펴본 Properties
와 Stack
을 변경하던 과정과 동일하게 보일 것이다. 하지만 InstrumentedHashSet
의 경우에는 다른 점이 한 가지 있다. Properties
와 Stack
을 합성으로 변경한 이유는 불필요한 오퍼레이션들이 퍼블릭 인터페이스에 스며드는 것을 방지하기 위해서다. 하지만 InstrumentedHashSet
의 경우에는 HashSet
이 제공하는 퍼블릭 인터페이스를 그대로 제공해야 한다.
HashSet
에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스를 그대로 상속받기 위해서 자바는 인터페이스 문법
을 제공한다. HashSet
은 Set
인터페이스를 구현한 구현체 중 하나이며, InstrumentedHashSet
이 제공해야 하는 모든 오퍼레이션들은 Set
인터페이스에 정의돼 있다. 따라서 InstrumentedHashSet
이 Set
인터페이스를 실체화하면서 내부에 HashSet
의 인스턴스를 합성하면 HashSet
에 대한 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 유지할 수 있다.
InstrumentedHashSet
의 코드를 보면 Set
의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet
인스턴스에게 동일한 메서드 호출을 그대로 전달
한다는 것을 알 수 있다. 이를 포워딩
이라고 부르고 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드라고 부른다.
포워딩은 기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합 없이 일부 작동 방식을 변경하고 싶은 경우에 사용할 수 있는 유용한 기법이다.
부모 클래스와 자식 클래스의 동시 수정 문제 : PersonalPlayList
안타깝게도 Playlist
의 경우에는 합성으로 변경하더라도 가수별 노래 목록을 유지하기 위해 PlayList
와 PersonalPlayList
를 함께 수정해야 하는 문제가 해결되지는 않는다.
그렇다고 하더라도 여전히 상속보다는 합성을 사용하는게 더 좋은데, 향후에 Playlist
의 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlayList 내부로 캡슐화 할 수 있기 때문이다.
대부분의 경우 구현에 대한 결합보다는 인터페이스에 대한 결합이 더 좋다는 사실을 기억하라.
이번 장을 시작할 때 상속
과 비교해서 합성
은 안정성과 유연성
이라는 장점을 제공한다고 말했다. 지금까지는 합성을 사용해서 변경에 불안정한 코드를 안정적으로 유지하는 방법
을 살펴보았다. 이제 두 번째 장점인 유연성
을 살펴보자.
이 경우에도 핵심은 동일하다. 구현이 아니라 인터페이스에 의존하면 설계가 더 유연해진다는 것이다.
상속으로 인한 조합의 폭발적인 증가
상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다.
가장 일반적인 상황은 작은 기능을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같이 두 가지 문제점이 발생한다.
하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
_ 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다._
합성
을 사용하면 상속으로 인해 발생하는 클래스의 증가와 중복 코드를 간단하게 해결할 수 있다.
기본 정책과 부가 정책 조합하기
핸드폰 과금 시스템에 새로운 요구사항을 추가해보자. 현재 시스템에는 일반 요금제
와 심야 할인 요금제
라는 두 가지 종류의 요금제가 존재한다. 새로운 요구사항은 이 두 요금제에 부가 정책을 추가
하는 것이다.
지금부터 핸드폰 요금제가 기본 정책
과 부가 정책
을 조합해서 구성된다고 가정 할 것이다.
기본 정책은 가입자의 통화 정보
를 기반으로 한다. 기본 정책은 가입자의 한달 통화량을 기준으로 부과할 요금을 계산한다. 앞 장에서 소개한 일반 요금제
와 심야 할인 요금제
는 통화량을 기반으로 요금을 계산하기 때문에 기본 요금 정책
으로 분류된다.
부가 정책
은 통화량과 무관하게 기본 정책
에 선택적으로 추가할 수 있는 요금 방식을 의미한다. 세금을 부과하는 정책이 바로 부가 정책
에 해당한다.
부가 정책
은 다음과 같은 특성을 가진다.
기본 정책의 계산 결과에 적용된다.
세금 정책은 기본 정책인 RegularPhone
이나 NightlyDiscountPhone
의 계산이 끝난 결과에 세금을 부과한다.
선택적으로 적용할 수 있다. 기본 정책의 계산 결과에 세금 정책을 적용할 수도 있고 적용하지 않을 수도 있다.
조합 가능하다. 기본 정책에 세금 정책만 적용하는 것도 가능하고, 기본 요금 할인 정책만 적용하는 것도 가능하다. 또한 세금 정책과 기본 요금 할인 정책을 적용한 후에 세금 정책을 적용할 수도 있다.
부가 정책은 임의의 순서로 적용 가능하다. 기본 정책에 세금 정책과 기본 요금 할인 정책을 함께 적용할 경우 세금 정책을 적용한 후에 기본 요금 할인 정책을 적용할 수도 있고, 기본 요금 할인 정책을 적용한 후에 세금 정책을 적용할 수도 있다.
위 그림은 기본 정책
과 부가 정책
을 조합해서 만들 수 있는 모든 요금 정책의 종류를 나타낸 것이다. 그림을 통해 알 수 있는 것처럼 이 요구사항을 구현하는 데 가장 큰 장벽은 기본 정책과 부가 정책의 조합 가능한 수가 매우 많다는 것
이다. 따라서 설계는 다양한 조합을 수용할 수 있도록 유연해야 한다.
상속을 이용해서 기본 정책 구현하기
상속
을 이용해서 기본 정책과 부가 정책을 구현해보자. 기본 정책은 Phone
추상 클래스를 루트로 삼는 기존의 상속 계층을 그대로 이용할 것이다. 일반 요금제를 구현하는 RegularPhone
과 심야 할인 요금제를 구현하는 NightlyDiscountPhone
은 Phone
의 자식 클래스로 구현한다.
RegularPhone
과 NightlyDiscountPhone
의 인스턴스만 단독으로 생성한다는 것은 부가 정책은 적용하지 않고 오직 기본 정책만으로 요금을 계산한다는 것을 의미한다.
기본 정책에 세금 정책 조합하기
만약 일반 요금제에 세금 정책을 조합해야 한다면 어떻게 해아할까? 가장 간단한 방법은 RegularPhone
클래스를 상속받은 TaxableRegularPhone
클래스를 추가하는 것이다. TaxableRegularPhone
클래스는 부모 클래스의 calculateFee
메서드를 오버라이딩 한 후 super
호출을 통해 부모 클래스에게 calculateFee
메시지를 전송한다. RegularPhone
의 calculateFee
메서드는 일반 요금제 규칙에 따라 계산된 요금을 반환하므로 이 반환값에 세금을 부과해서 반환하면 일반 요금제와 세금 정책을 조합한 요금을 계산할 수 있다.
부모 클래스의 메서드를 재사용하기 위해 super 호출을 사용하면 원하는 결과는 쉽게 얻을 수 있지만 자식 클래스와 부모 클래스 사이의 결합도가 높아지고 만다.
결합도를 낮추는 방법은 자식 클래스가 부모 클래스의 메서드를 호출하지 않도록 부모 클래스에 추상 메서드를 제공하는 것이다.
부모 클래스가 자신이 정의한 추상 메서드를 호출하고 자식 클래스가 이 메서드를 오버라이딩해서 부모 클래스가 원하는 로직을 제공하도록 수정하면 부모 클래스와 자식 클래스의 결합도를 느슨하게 만들 수 있다. 이 방법은 자식 클래스가 부모 클래스의 구체적인 구현이 아니라 필요한 동작의 명세를 기술하는 추상화
에 의존하도록 만든다.
먼저 Phone
클래스에 새로운 추상메서드인 afterCalclated
를 추가하자. 이 메서드는 자식 클래스에게 전체 요금을 계산한 후에 수행할 로직을 추가할 수 있는 기회를 제공한다.
자식 클래스는 afterCalculated
메서드를 오버라이딩해서 계산된 요금에 적용할 작업을 추가한다. 일반 요금제를 구현하는 RegularPhone
은 요금을 수정할 필요가 없기 때문에 afterCalculated
메서드에서 파라미터로 전달된 요금을 그대로 반환하도록 구현한다.
위 코드에서 알 수 있는 것처럼 부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩 해야 하는 문제가 발생한다. 자식 클래스의 수가 적다면 큰 문제가 아니겠지만 자식 클래스의 수가 많을 경우에는 꽤나 번거로운 일이 될 수 밖에 없다.
모든 추상 메서드의 구현이 동일하다는 사실
에도 주목하기 바란다. 유연성을 유지하면서도 중복 코드를 제거할 수 있는 방법은 Phone
에서 afterCalclated
메서드에 대한 기본 구현
을 함께 제공(훅메서드
)하는 것이다. 이제 RegularPhone
과 NightlyDiscountPhone
클래스에서는 afterCalclated
메서드를 오버라이딩 할 필요가 없다.
이제 드디어 TaxableRegularPhone
을 수정할 차례다. TaxableRegularPhone
은 RegularPhone
이 계산한 요금에 세금을 부과한다. 다음과 같이 afterCalclated
메서드를 오버라이딩 한 후 fee
에 세금을 더해서 반환하도록 구현하자.
이제 심야 할인 요금제인 NightlyDiscountPhone
에도 세금을 부과할 수 있도록 NightlyDiscountPhone
의 자식 클래스인 TaxableNightlyDiscountPhone
을 추가하자.
지금까지 구현한 Phone
의 상속 계층에 세금 정책을 추가한 상속 계층을 다이어그램을 표현하면 아래와 같다.
문제는 TaxableNightlyDiscountPhone
과 TaxableRegularPhone
사이에 코드를 중복
했다는 것이다. 두 클래스의 코드를 자세히 살펴보면 부모 클래스의 이름을 제외하곤 대부분의 코드가 거의 동일하다는 사실을 알 수 있을 것이다. 사실 자바를 비롯한 대부분의 객체지향 언어는 단일 상속만 지원하기 때문에 상속으로 인해 발생하는 중복 코드 문제를 해결하기가 쉽지 않다.
기본 정책에 기본 요금 할인 정책 조합하기
이번에는 두 번째 부가 정책인 기본 요금 할인 정책
을 Phone
의 상속 계층에 추가해보자. 기본 요금 할인 정책이란 매달 청구되는 요금에서 고정된 요금을 차감하는 부가 정책을 가리킨다. 예를 들어 매달 1000원을 할인해주는 요금제가 있다면 이 요금제에는 부가 정책으로 기본 요금 할인 정책이 조합돼 있다고 볼 수 있다.
일반 요금제와 기본 요금 할인 정책을 조합하고 싶다면 RegularPhone
을 상속받는 RateDiscountableRegularPhone
클래스를 추가하면 된다.
심야 할인 요금제와 기본 요금 할인 정책을 조합하고 싶다면 NightlyDiscountPhone
을 상속받는 RateDiscountableNightlyDiscountPhone
클래스를 추가하면 된다.
아래 그림은 기본 요금 할인 정책을 추가한 후의 상속 계층을 표현한 것이다. 세금 정책과 마찬가지로 어떤 클래스를 선택하느냐에 따라 적용하는 요금제의 조합이 결정된다는 사실을 알 수 있다.
하지만 이번에도 부가 정책을 구현한 RateDiscountableRegularPhone
와 RateDiscountableNightlyDiscountPhone
클래스 사이의 중복 코드
를 추가했다. 이제 이 중복 코드가 어떤 문제를 초래하는지 살펴보자.
중복 코드의 덫에 걸리다.
부가 정책은 자유롭게 조합할 수 있어야 하고 적용되는 순서 역시 임의로 결정할 수 있어야 한다. 이 요구사항에 따르면 앞에서 구현한 세금 정책과 기본 요금 할인 정책을 함께 적용하는 것도 가능해야 하고, 세금 정책을 적용한 후에 기본 요금 할인 정책을 적용하거나 기본 요금 할인 정책을 적용한 후에 세금 정책을 적용하는 것도 가능해야 한다.
상속을 이용한 해결 방법은 모든 가능한 조합 별로 자식 클래스를 하나씩 추가하는 것이다. 만약 일반 요금제의 계산 결과에 세금 정책을 조합한 후 기본 요금 할인 정책을 추가하고 싶다면 TaxableRegularPhone
을 상속받는 새로운 자식클래스인 TaxableAndRateDiscountableRegularPhone
을 추가해야 한다.
TaxableAndRateDiscountableRegularPhone
의 afterCalclated
메서드는 부모 클래스인 TaxableRegularPhone
의 afterCalclated
메서드를 호출(super)해서 세금이 부과된 요금을 계산한 후 기본 요금 할인 정책을 적용한다. 따라서 세금을 부과하고 나서 기본 요금 할인을 적용하는 순서로 정책을 조합할 수 있다.
표준 요금제에 요금 할인 정책을 먼저 적용한 후 세금을 나중에 부과하고 싶다면 RateDiscountableRegularPhone
을 상속받는 RateDiscountAndTaxableReuglarPhone
클래스를 추가하면 된다.
TaxableAndDiscountableNightlyDiscountPhone
클래스는 심야 할인 요금제의 계산 결과에 세금 정책을 적용한 후 기본 요금 할인 정책을 적용하는 케이스를 구현한다.
마지막으로 RateDiscountableAndTaxableNightlyDiscuontPhone
클래스는 심야 할인 요금제의 계산 결과에 기본 요금 할인 정책을 적용한 후 세금 정책을 적용한다.
아래 그림은 현재까지 구현된 상속 계층을 그림으로 표현한 것이다. 꽤 복잡해 보이지 않는가? 하지만 복잡성 보다 더 큰 문제가 있다. 바로 새로운 정책을 추가하기가 어렵다는 것이다. 현재의 설게에 새로운 정책을 추가하기 위해서는 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 한다.
만일 위와 같은 상속 계층에 새로운 기본 정책
을 추가해야 한다고 가정해보자. 추가할 기본 정책은 고정 요금제
로 FixedRatePhone
이라는 클래스로 구현할 것이다. 모든 부가정책은 기본 정책에 적용 가능해야 하며 조합 순서 역시 자유로워야 한다. 따라서 새로운 기본 정책을 추가한다면 그에 따라 조합 가능한 부가 정책의 수만큼 새로운 클래스를 추가해야 한다.
아래 그림은 새로운 기본 정책을 추가한 결과를 다이어그램으로 표현한 것이다. 그림에서 짙은 음영으로 표현한 클래스가 새로 추가된 클래스로서 고정 요금제 하나를 추가하기 위해 5개의 새로운 클래스를 추가했다는 것을 알 수 있다.
이번에는 새로운 부가정책을 추가하는 경우를 생각해보자. 만일 약정 할인 부가 정책
이 추가된다고 가정한다면 문제는 기본 정책을 구현하는 RegularPhone
, NightlyDiscountPhone
, FixedRatePhone
에 약정 할인 정책을 선택적으로 적용할 수 있어야 할 뿐 아니라 다른 부가정책인 세금정책, 기본 요금 할인 정책과도 임의의 순서로 조합 가능해야 한다는 것이다.
하나의 부가 정책 추가 시 모든 조합 가능한 수는 폭발적으로 늘어나게 된다.
상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발 문제 또는 조합의 폭발 문제라고 부른다.
클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제다.
컴파일 타임에 결정된 자식 클래스와 부모 클래스 사이의 관계
는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.
클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 떄도 문제
가 된다. 만약 세금 정책을 변경해야 한다면 어떻게 해야할까? 세금 정책과 관련된 코드가 여러 클래스 안에 중복돼 있기 때문에 세금 정책과 관련된 모든 클래스를 찾아 동일한 방식으로 수정해야 할 것이다. 이 클래스 중에서 하나라도 누락한다면 세금이 부과되지 않는 버그가 발생하고 말것이다.
이 문제를 해결할 수 있는 최선의 방법은 상속을 포기하는 것이다.
합성 관계로 변경하기
상속 관계는 컴파일 타임에 결정
되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 사용하면 모든 조합 가능한 경우별로 클래스를 추가해야 한다. 이것이 바로 핸드폰 과금 시스템의 설계 과정에서 직면했던 클래스 폭발 문제
다.
합성은 컴파일 타임 관계를 런타임 관계로 변경
함으로써 이 문제를 해결한다. 합성
을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있다.
상속을 사용하는 것은 컴파일타임의 의존성과 런타임의 의존성을 동일하게 만들겠다고 선언하는 것이다. 따라서 상속을 사용하면 부모 클래스와 자식 클래스의 사이가 정적으로 고정되기 때문에 실행 시점에 동적으로 관계를 변경할 수 있는 방법이 없다.
상속과 달리 합성 관계는 런타임에 동적으로 변경
할 수 있다. 합성을 사용하면 컴파일타임 의존성과 런타임 의존성을 서로 다르게 만들 수 있다.
클래스 폭발 문제를 해결하기 위해 합성을 사용하는 이유는 런타임에 객체 사이의 의존성을 자유롭게 변경할 수 있기 때문이다.
사실 우리는 이미 행동을 조합하기 위해 합성을 이용하는 개념적인 방법
에 관해 앞에서 살펴보았다. 아래 그림을 다시 살펴보자.
위 그림에서 기본 정책
과 부가 정책
을 독립적인 박스로 표현
하고 순서에 따라 조합
했다는 것에 주목하자.
이것이 바로 합성의 본질이다.
합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있다.
상속이 조합의 결과를 개별 클래스 안으로 밀어넣는 방법 (표준 요금제에 세금 정책 적용 후 기본 할인 정책을 적용하는 TaxableAndRateDiscountableRegularPhone라는 하나의 클래스)이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이라고 할 수 있다.
컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점이다.
물론 컴파일타임 의존성
과 런타임 의존성
의 거리가 멀면 멀수록 설계의 복잡도가 상승하기 때문에 코드를 이해하기 어려워진다는 것 역시 사실이다. 하지만 설계는 변경과 유지보수를 위해 존재한다는 사실을 기억하라. 설계는 트레이드오프의 산물이다. 대부분의 경우에는 단순한 설계가 정답이지만 변경에 따르는 고통이 복잡성으로 인한 혼란을 넘어서고 있다면 유연성의 손을 들어주는 것이 현명한 판단일 확률이 높다.
기본 정책 합성하기
가장 먼저 해야 할 일은 각 정책을 별도의 클래스로 구현
하는 것이다. 즉, 기본 정책
과 부가 정책
을 별도의 클래스로 구현한 후 분리된 정책들을 연결
할 수 있도록 합성 관계
를 이용하여 구조를 개선하면 실행 시점에 정책들을 조합할 수 있게 된다. 이를 위해서는 핸드폰
이라는 개념으로부터 요금 계산 방법
이라는 개념을 분리해야한다.
먼저 기본 정책과 부가 정책을 포괄
하는 RatePolicy
인터페이스를 추가하자. RatePolicy
는 Phone
을 인자로 받아 계산된 요금을 반환하는 calculateFee
오퍼레이션을 포함하는 간단한 인터페이스다.
기본 정책
부터 구현하자. 기본 정책을 구성하는 일반 요금제
와 심야 할인 요금제
는 개별 요금을 계산하는 방식을 제외한 전체 처리 로직이 동일하다. 이 중복 코드를 담을 추상 클래스 BasicRatePolicy를 추가하자.
BasicRatePolicy
의 기본 구현은 상속 버전의 Phone
의 클래스와 거의 동일하다. BasicRatePolicy
의 자식 클래스는 추상 메서드인 calculateCallFee
를 오버라이딩해서 Call
의 요금을 계산하는 자신만의 방식을 구현할 수 있다.
먼저 일반 요금제
를 구현하자. BasicRatePolicy
의 자식 클래스로 RegularPolicy
를 구현하자.
심야 할인 요금제
를 구현하는 NightlyDiscountPolicy
클래스 역시 유사한 방식으로 구현할 수 있다.
이제 기본 정책을 이용해 요금을 계산할 수 있도록 Phone
을 수정하자.
Phone
의 내부에 RatePolicy
에 대한 참조자가 포함돼 있다는 것에 주목하자. 이것이 바로 합성
이다. Phone
이 다양한 요금 정책과 협력할 수 있어야 하므로 요금 정책의 타입이 RatePolicy
라는 인터페이스로 정의되어 있다는 점도 주목하라ㅏ. Phone
은 컴파일 타임의존성을 구체적인 런타임 의존성으로 대체하기 위해 생성자
를 통해 RatePolicy
의 인스턴스에 대한 의존성을 주입받는다.
일반 요금제의 규칙에 따라 통화 요금을 계산하고 싶다면 다음과 같이 Phone
과 BasicRatePolicy
의 인스턴스를 합성하면 된다.
심야 할인 요금제의 규칙에 따라 통화 요금을 계산하고 싶다면 다음과 같이 Phone
과 NightlyDiscountPolicy
의 인스턴스를 합성하면 된다.
합성
을 사용하면 Phone
과 연결되는 RatePolicy
인터페이스의 구현 클래스가 어떤 타입인지에 따라 요금을 계산하는 방식이 달라진다. 여기까지만 보면 단순히 원하는 클래스를 선택하는 상속보다 더 복잡해졌다는 생각이 들 수도 있을 것이다. 상속을 사용한 경우에는 어떤 클래스의 인스턴스를 조합해야 하는지 고민할 필요 없이 기본 요금제를 적용하고 싶은 경우에는 RegularPhone
을, 심야 할인 요금제를 적용하고 싶은 경우에는 NightlyDiscountPhone
의 인스턴스를 생성하면 됐기 때문이다. 하지만 현재의 설계에 부가 정책을 추가
하면 합성의 강력함의 실감할 수 있을 것이다.
부가 정책 적용하기
일반 요금제
를 적용한 경우에 생성된 인스턴스의 관계를 살펴보자. 아래의 그림에서 알 수 있는 것처럼 컴파일 시점
의 Phone
클래스와 RatePolicy
인터페이스의 관계가 런타임
에 Phone
의 인스턴스와 RegularPolicy
인스턴스 사이의 관계로 대체되었다는 것을 알 수 있다.
지금부터 할 일은 여기에 부가 정책
을 추가하는 것이다. 부가 정책은 기본 정책에 대한 계산이 끝난 후에 적요이 된다는 것을 기억하라. 만약 위의 그림에 세금 정책을 추가한다면 세금 정책은 RegularPolicy의 계산이 끝나고 Phone에게 반환되기 전에 적용되어야 한다. 따라서 아래의 그림과 같이 RegularPolicy
와 Phone
사이에 세금 정책을 구현하는 TaxablePolicy
인스턴스를 연결해야 한다.
만약 일반 요금제
에 기본 요금 할인 정책(부가 정책)
을 적용한 후에 세금 정책(부가 정책)
을 적용해야 한다면 아래의 그림과 같은 순서로 인스턴스들을 연결해야 한다. (적용 순서와 호출 순서는 반대가 됨에 주목)
위 그림들은 다음 두 가지 제약에 따라 부가 정책
을 구현해야 한다는 사실을 잘 보여준다.
1. 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다. 다시 말해서 부가 정책의 인스턴스는 어떤 종류의 정책과도 합성될 수 있어야 한다.
2. Phone의 입장에서는 자신이 기본 정책의 인스턴스에게 메시지를 전송하고 있는지, 부가 정책의 인스턴스에게 메시지를 전송하고 있는지를 몰라야 한다. 다시 말해서 기본 정책과 부가 정책은 협력 안에서 동일한 역할을 수행해야 한다. 이것은 부가 정책이 기본 정책과 동일한 RatePolicy 인터페이스를 구현한다는 것을 의미한다.
요약하면 부가 정책
은 RatePolicy
인터페이스를 구현해야 하며, 내부에 또 다른 RatePolicy
인스턴스를 합성할 수 있어야 한다.
부가 정책
을 AdditionalRatePolicy
추상 클래스로 구현하자
Phone
의 입장에서 AdditionalRatePolicy
는 RatePolicy
의 역할을 수행하기 때문에 RatePolicy
인터페이스를 구현한다. 또한 다른 요금 정책과 조합될 수 있또록 RatePolicy
타입의 next
라는 이름을 가진 인스턴스 변수를 내부에 포함한다. AdditionalRatePolicy
는 컴파일 타임 의존성을 런타임 의존성으로 쉽게 대체할 수 있도록 RatePolicy 타입의 인스턴스를 인자로 받는 생성자를 제공한다.
AdditionalRatePolicy
의 calculateFee
메서드는 먼저 next
가 참조하고 있는 인스턴스에게 calculateFee
메시지를 전송한다. 그 후 반환된 요금에 부가 정책을 적용하기 위해 afterCalculated
메서드를 호출한다. AdditionalRatePolicy
를 상속받은 자식 클래스는 calculateFee
메서드를 오버라이딩해서 적절한 부가 정책을 구현할 수 있다.
먼저 세금 정책(부가 정책)
부터 구현하자.
기본 요금 할인 정책(부가 정책)
을 추가하는 것도 간단하다.
아래 그림은 모든 요금 계산과 관련된 모든 클래스 사이의 관계를 다이어그램으로 표현한 것이다.
모든 준비가 끝났다. 이제 구현된 정책들을 합성해보자.
기본 정책과 부가 정책 합성하기
이제 다양한 방식으로 정책들을 조합할 수 있는 설계가 준비됐다. 남은 일은 원하는 정책의 인스턴스를 생성한 후 의존성 주입
을 통해 다른 정책의 인스턴스에게 전달하는 것 뿐이다.
이와 같은 설계는 상속
을 사용한 설계보다 복잡하고 정해진 규칙에 따라 객체를 생성하고 조합해야 하기 때문에 처음에는 코드를 이해하기가 어려울 수 있다. 하지만 일단 설계에 익숙해지고 나면 객체를 조합하고 사용하는 방식이 상속을 사용하는 방식보다 더 예측 가능하고 일관성이 있다는 사실을 알게 될 것이다.
하지만 합성
의 장점은 여기서 끝나지 않는다. 합성의 진가는 새로운 클래스를 추가하거나 수정하는 시점이 되어서야 비로소 알 수 있다.
새로운 정책 추가하기
상속
을 기반으로 한 설계에 새로운 부가 정책
을 추가하기 위해서는 상속 계층에 불필요할 정도로 많은 클래스들을 추가해야만 했다. 합성
을 기반으로 한 설계에서는 이 문제를 간단하게 해결할 수 있다.
고정 요금제(기본 정책)
이 필요하다면 고정 요금제를 구현한 클래스를 하나
만 추가한 후 원하는 방식으로 조합하면 된다.
약정 할인 정책(부가 정책)
이라는 새로운 부가 정책이 필요하다면 역시 클래스 하나
만 추가하면 된다.
우리는 오직 하나의 클래스만 추가하고 런타임에 필요한 정책들을 조합
해서 원하는 기능을 얻을 수 있다. 이 설계를 필요한 조합의 수 만큼 매번 새로운 클래스를 추가했던 상속
과 비교해보라. 왜 많은 사람들이 그렇게 코드 재사용을 위해 상속 보다는 합성을 사용하려고 하는지 그 이유를 이해할 수 있을 것이다.
더 중요한 것은 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다는 것이다.
세금 정책
을 변경한다고 생각해보자. 세금 정책을 다루는 코드가 상속 계층 여기 저기에 중복돼있던 상속
에서는 세금 정책을 변경하기 위해 한번에 여러 클래스를 수정
해야만 한다. 그에 비해 합성
에서는 TaxablePolicy
클래스 하나만 변경하면 된다. 변경 후의 설계는 단일 책임 원칙을 준수하고 있는 것이다.
객체 합성이 클래스 상속보다 더 좋은 방법이다.
객체 지향에서 코드를 재사용하기 위해 가장 널리 사용되는 방법은 상속
이다. 하지만 상속
은 코드 재사용을 위한 우아한 해결책은 아니다. 상속은 부모 클래스의 세부적인 구현에 자식 클래스를 강하게 결합시키기 때문에 코드의 진화를 방해한다.
코드를 재사용하면서도 건전한 결합도를 유지할 수 있는 더 좋은 방법은 합성
을 사용하는 것이다. 상속이 구현을 재사용하는데 비해 합성은 객체의 인터페이스를 재사용한다.
여기서 한 가지 의문이 들 것이다. 그렇다면 상속은 사용해서는 안되는 것인가? 상속을 사용해야 하는 경우는 언제인가? 이 의문에 대답하기 위해서는 먼저 상속을 “구현 상속”과 “인터페이스 상속” 두가 지로 나눠야 한다는 사실을 이해해야 한다. 그리고 이번 장에서 살펴본 상속에 대한 모든 단점들은 “구현 상속”에 국한된다는 점 또한 이해해야 한다
참고 및 출처
- 오브젝트