018. (Objects) 11. 합성과 유연한 설계

11. 합성과 유연한 설계

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

상속은 슈퍼클래스와 서브클래스를 연결해서 슈퍼클래스의 코드를 재사용하도록 하기에 is-a 관계이고,

합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용하기에 has-a 관계라고 부른다.

상속과 합성은 코드 재사용이라는 목적 자체를 동일하지만, 구현 방벙부터 변경을 다루는 방식까지 모든 면에서 차이점을 가지고 있다.

상속은 서브클래스가 부모클래스의 대부분의 정의를 물려받아 다른 부분만 추가하거나 재정의함으로써 기존 코드를 쉽게 확장할 수 있지만, 슈퍼클래스와 서브클래스의 결합도가 높아지는 문제가 존재한다.

반면에 합성은 구현에 의존하지않으며 퍼블릭 인터페이스에 의존하기 때문에 포함된 객체의 내부 구현이 변경되더라도 그 영향이 최소화되어 안정적인 코드를 얻을 수 있게 된다.

간단하게 정리하면 아래 표와 같다.

구분 상속(is-a) 합성(has-a)
목적 코드 재사용 코드 재사용
결합도 높음 낮음
의존성 슈퍼클래스의 구현 포함된 객체의 퍼블릭 인터페이스
관계 표현 클래스 사이의 정적 관계 객체 사이의 동적 관계
변경 가능 시점 코드 작성 시점(컴파일 타임) 실행 시점(런타임)

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

상속을 남용하는 경우엔 몇몇 문제점이 발생할 수 있다.

각 문제점을 리마인드해보고, 합성으로 변경해보도록 하자.

1. 불필요한 인터페이스의 상속 문제

1
2
3
4
5
6
7
Properties properties = new Properties();
properties.setProperty("Bjarne Sttousstrup", "C++");
properties.setProperty("James Gosling", "Java");

properties.put("Dennis Ritchie", 67);

assertEquals("C", properties.getProperty("Dennis Ritchie")); // ERROR

Java의 Properties 클래스는 Hashtable 클래스를 상속하고 있기에 put(K key, V value) 메서드도 접근이 가능하다.

문제는 Hashtable 클래스는 V에 모든 타입을 적용할 수 있지만, PropertiesgetProperty(String key) 메서드는 값이 String 타입이 아니면 null을 반환한다.

애초에 setProperty(String key, String value) 메서드는 Hashtable 클래스의 put(K key, V value)을 호출하기 때문이다.

즉 값의 탕비이 String이 아니면 오류가 발생할 수 밖에 없다.

이 상속 관계를 합성으로 변경해보자.

1
2
3
4
5
6
7
8
9
10
11
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 클래스가 제공하는 인터페이스를 통해서만 properties 변수에 접근할 수 있다.

2. 메서드 오버라이딩의 오동작 문제

메서드 오버라이딩의 오동작으로 InstrumentedHashSet 이 있다.

참고 이펙티브 자바에서도 컴포지션 챕터에서 동일한 클래스를 언급한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;

@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
}

위의 클래스를 아래와 같이 쓰면 어떻게 될까?

1
2
InstrumentedHashSet<String> languages = new InstrumentedHashSet<>();
languages.addAll(Arrays.asList("Java", "Ruby", "Scala"));

실제 addCount는 3이 아니라 6이 출력된다.

이는 super.addAll(c)에서 HashSetadd() 메서드를 호출하기 때문이다.

이를 합성으로 변경하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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(Collections<? 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 public boolean equals (Object o) { return set.equals(o); }
@Override public int hashCode() ( return set.hashCode(); )
@Override public Spliterator<E) spliterator() {return set.spliterator(); }
@Override public int size() { return set.size(); )
@Override public boolean isEmpty() { return set.isEmpty(); }
@Override public boolean contains (Object o) { return set.contains(o); }
@Override public Iterator<E> iterator () { return set. iterator(); )
@Override public Object [] toArray() { return set. toArray(); }
@Override public <T> T[] toArray(T[] a) { return set. toArray(a) ;}
@Override public boolean containsAll (Collection<? c) ( return set.containsAll (c);
@Override public boolean retainAll (Collection<?) c) { return set.retainAll(c); }
@Override public boolean removeAll(Collection<?> c) { return set. removeAl1 (c); )
}

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

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

가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야하는 경우이다.

이 경우 다음과 같은 문제점이 발생할 수 있다.

  1. 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
  2. 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.

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

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

이는 상속 관계의 결정이 컴파일 타임에 일어나고 이후 변경할 수 없기때문에 다양한 조합만큼 새로운 클래스를 계속해서 추가할 수 밖에 없다.

11.3. 합성 관계로의 전환

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

컴파일 타임에 발생하는 상속관계와는 달리 합성 관계는 런타임에 발생하기 때문이며, 컴파일 타임 의존성과 런타임 의존성이 거리만큼 좀 더 유연한 설계를 얻을 수 있는 점을 상기해보면

합성 방식이 상속보다 더 우아한 방법임은 자명하다.

그렇다면 무조건 합성을 사용하고 상속을 사용하지않아야하는 것일까?

이를 위해선 상속을 두 종류로 분리하여 생각할 수 있어야 한다.

주로 문제가 되는 것은 강결합을 유도하는 구현의 상속이며, 추상화를 위한 인터페이스의 상속이 결합도에 영향을 최소소하한다.

11.4. 믹스인(Mixin)

코드를 재사용하기 위한 한 가지 기법을 더 살펴보기로 하자.

이 기법은 믹스인이라는 이름으로 널리 알려져있으며, 상속과 합성의 특성을 모두 가지고 있는 디자인 패턴이다.

믹스인은 객체를 생성할때 코드 일부를 클래스 안에 주입하여 섞은 뒤 재사용하는 기법을 뜻하며, 컴파일 시점에 필요한 코드 조각을 조합하여 재사용한다.

설명 자체로는 상속과 유사해보일 수 있다. 하지만 상속은 슈퍼클래스와 서브클래스를 동일한 개념적인 범주로 묶어 is-a 관계를 달성하기 위한 것이라면,

믹스인은 is-a관계의 정립 없이 코드를 다른 코드안에 섞어 넣는 것이기 때문이다.

또한 상속과 달리 믹스인은 유연하게 관계를 재구성할 수 있는 장점이 있다.