코드 재사용 기법
- 상속: 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용
두 객체 사이의 의존성은 컴파일타임에 해결된다.
is-a 관계 - 합성: 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용
두 객체 사이의 의존성은 런타임에 해결된다.
has-a 관계
합성은 상속과 달리 구현에 의존하지 않는다. 합성은 구현이 아닌 퍼블릭 인터페이스에 의존한다.
상속 관계는 코드 작성 시점에 결정한 후 변경이 불가능한 정적인 관계이지만,
합성 관계는 실행 시점에 동적으로 변경할 수 있는 동적인 관계다.
상속은 부모 클래스 안에 구현된 코드 자체를 재사용하지만, 합성은 포함되는 객체의 퍼블릭 인터페이스를 재사용한다.
1. 상속을 합성으로 변경하기
상속을 남용했을 때의 문제점
- 불필요한 인터페이스 상속 문제
- 메서드 오버라이딩의 오작용 문제
- 부모 클래스와 자식 클래스의 동시 수정 문제
합성을 사용하면 이 문제점들을 해결할 수 있다.
상속을 합성으로 바꾸려면 자식 클래스에 선언된 상속 관계를 제거하고 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언하면 된다.
불필요한 인터페이스 상속 문제: 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);
}
}
이제 더 이상 불필요한 Hashtable의 오퍼레이션들이 Properties의 퍼블릭 인터페이스를 오염시키지 않는다.
String 타입의 키/값만 허용하는 Properties의 규칙을 어길 위험성이 사라졌다.
상속과 달리 합성은 내부 구현에 관해 알지 못한다.
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
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;
}
}
Properties와 Stack을 합성으로 변경한 이유는 불필요한 오퍼레이션들이 퍼블릭 인터페이스에 스며드는 것을 방지하기 위해서다.
InstrumentedHashSet의 경우에는 HashSet이 제공하는 퍼블릭 인터페이스를 그대로 제공해야 한다.
인터페이스를 사용하면 구현 결합도는 제거하면서도 퍼블릭 인터페이스는 그대로 상속받을 수 있다.
HashSet은 Set 인터페이스를 실체화하는 구현체 중 하나다.
InstrumentedHashSet이 Set 인터페이스를 실체화하면서 내부에 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 ...
}
Set의 오퍼레이션을 오버라이딩한 인스턴스 메서드에서 내부의 HashSet 인스턴스에게 동일한 메서드 호출을 그대로 전달한다.
이를 포워딩이라 하고, 동일한 메서드를 호출하기 위해 추가된 메서드를 포워딩 메서드라고 한다.
기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합 없이 일부 작동 방식을 변경할 수 있다.
부모 클래스와 자식 클래스의 동시 수정 문제: PersonalPlaylist
Playlist의 경우에는 합성으로 변경하더라도 PersonalPlaylist를 함께 수정해야 하는 문제가 해결되지는 않는다.
class PersonalPlaylist {
private playlist: Playlist = new Playlist();
public append(song: Song): void {
this.playlist.append(song);
}
public remove(song: Song): void {
const idx = this.playlist.getTracks().findIndex((item) => item.getId() === song.getId());
this.playlist.getTracks().splice(idx, 1);
this.playlist.getSingers().delete(song.getSigner());
}
}
그래도 향후 Playlist의 내부 구현에 대한 변경의 파급효과를 최대한 PersonalPlaylist 내부로 캡슐화할 수 있기 때문에,
상속보다는 합성을 사용하는 게 좋다.
대부분의 경우 구현에 대한 결합보다는 인터페이스에 대한 결합이 더 좋다.
2. 상속으로 인한 조합의 폭발적인 증가
상속의 문제점
- 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
- 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.
기본 정책과 부가 정책 조합하기
일반 요금제와 심야 할인 요금제에 부가 정책을 추가해보자.
부가 정책은 통화량과 무관하게 기본 정책에 선택적으로 추가할 수 있는 요금 방식이다.
- 세금 정책: 세금을 부과하는 정책
- 기본 요금 할인 정책: 최종 계산된 요금에서 일정 금액을 할인해 주는 정책
부가 정책의 특성
- 기본 정책의 계산 결과에 적용된다
- 선택적으로 적용할 수 있다
- 조합 가능하다
- 부가 정책은 임의의 순서로 적용 가능하다
기본 정책과 부가 정책의 조합 가능한 수는 매우 많다. 따라서 설계는 유연해야 한다.
상속을 이용해서 기본 정책 구현하기
기본 정책은 Phone 추상 클래스를 루트로 삼는 기존의 상속 계층을 그대로 이용하자.
abstract class Phone {
private calls: Call[] = new Array();
public calculateFee(call: Call): number {
let result: number = 0;
this.calls.forEach((call) => {
result += this.calculateFee(call);
});
return result;
}
protected abstract calculateCallFee(call: Call): number;
}
class RegularPhone extends Phone {
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 NightlyDiscountPhone extends Phone {
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);
}
}
RegularPhone과 NightlyDiscountPhone의 인스턴스만 단독으로 생성한다는 것은
부가 정책은 적용하지 않고 오직 기본 정책으로 요금을 계산한다는 것을 의미한다.
기본 정책에 세금 정책 조합하기
일반 요금제에 세금 정책을 조합해야 할 때, 가장 간단한 방법은 RegularPhone을 상속받은 TaxableRegularPhone을 추가하는 것이다.
class TaxableRegularPhone extends RegularPhone {
private taxRate: number;
constructor(amount: number, seconds: number, taxRate: number) {
super(amount, seconds);
this.taxRate = taxRate;
}
public calculateFee(): number {
const fee: number = super.calculateFee();
return fee + fee * this.taxRate;
}
}
부모 클래스의 메서드를 재사용하기 위해 super 호출을 사용하면 결과를 얻을 순 있지만 결합도가 높아진다.
결합도를 낮추는 방법은 부모 클래스에 추상 메서드를 제공하는 것이다.
그러면 자식 클래스는 부모 클래스의 구체적인 구현이 아닌 추상화에 의존하게 된다.
Phone에 추상 메서드 afterCalculated를 추가하자. 이는 전체 요금을 계산한 후에 수행할 로직을 추가할 수 있게 해준다.
abstract class Phone {
private calls: Call[] = new Array();
public calculateFee(): number {
let result: number = 0;
this.calls.forEach((call) => {
result += this.calculateCallFee(call);
});
return result;
}
protected abstract calculateCallFee(call: Call): number;
protected abstract afterCalculated(fee: number): number;
}
class RegularPhone extends Phone {
...
protected afterCalculated(fee: number): number {
return fee;
}
}
class NightlyDiscountPhone extends Phone {
...
protected afterCalculated(fee: number): number {
return fee;
}
}
부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩해야 하는 문제가 발생한다.
모든 추상 메서드의 구현도 동일하다. 그럼 Phone에서 afterCalculated에 대한 기본 구현을 함께 제공하도록 해보자.
abstract class Phone {
...
protected afterCalculated(fee: number): number {
return fee;
}
protected abstract calculateCallFee(call: Call): number;
}
이처럼 기본 구현을 제공하는 메서드를 훅 메서드라고 한다.
추상 메서드와 동일하게 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공한다.
나머지 코드도 수정해보자.
class TaxableRegularPhone extends RegularPhone {
private taxRate: number;
constructor(amount: number, seconds: number, taxRate: number) {
super(amount, seconds);
this.taxRate = taxRate;
}
protected afterCalculated(fee: number): number {
return fee + fee * this.taxRate;
}
}
class TaxableNightlyDiscountPhone extends NightlyDiscountPhone {
private taxRate: number;
constructor(nightlyAmount: number, regularAmount: number, seconds: number, taxRate: number) {
super(nightlyAmount, regularAmount, seconds);
this.taxRate = taxRate;
}
protected afterCalculated(fee: number): number {
return fee + fee * this.taxRate;
}
}
이제 조합에 따라 원하는 인스턴스를 생성하면 된다.
문제는 TaxableNightlyDiscountPhone과 TaxableRegularPhone 사이에 코드를 중복했다는 것이다.
대부분의 객체지향 언어는 단일 상속만 지원하기 때문에 상속으로 인해 발생하는 중복 코드 문제를 해결하기 쉽지 않다.
기본 정책에 기본 요금 할인 정책 조합하기
기본 요금 할인 정책을 Phone의 상속 계층에 추가해보자.
class RateDiscountableRegularPhone extends RegularPhone {
private discountAmount: number;
constructor(amount: number, seconds: number, discountAmount: number) {
super(amount, seconds);
this.discountAmount = discountAmount;
}
protected afterCalculated(fee: number): number {
return fee - this.discountAmount;
}
}
class RateDiscountableNightlyDiscountPhone extends NightlyDiscountPhone {
private discountAmount: number;
constructor(
nightlyAmount: number,
regularAmount: number,
seconds: number,
discountAmount: number
) {
super(nightlyAmount, regularAmount, seconds);
this.discountAmount = discountAmount;
}
protected afterCalculated(fee: number): number {
return fee - this.discountAmount;
}
}
어떤 클래스를 선택하느냐에 따라 적용하는 요금제의 조합이 결정된다.
하지만 이번에도 두 클래스 사이에는 중복 코드가 존재한다.
중복 코드의 덫에 걸리다
상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스를 추가하는 것이다.
새로운 정책을 추가하기 위해서는 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 한다.
상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가하는 걸 클래스 폭발/조합의 폭발 문제라고 부른다.
클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계로 발생한다.
클래스 폭발 문제는 새로운 기능 추가 뿐만 아니라 수정 시에도 문제가 된다.