Ch.11 합성과 유연한 설계

About

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

하지만 아래에서 설명하듯 단순히 코드 재사용만을 위한 목적으로 상속을 이용한다면 변경하기 어려운 설계가 될 확률이 높다.

상속과 합성의 비교

구분상속 (Inheritance)합성 (Composition)

관계

is-a 관계 (정확히는 is a kind of)

has-a 관계

의존성 해결 시점

부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결된다. (정적인 관계.) - 코드 작성 시점에 결정한 상속 관계는 변경이 불가능하다.

두 객체 사이의 의존성은 런타임에 해결된다. (동적인 관계.) - 실행 시점에 동적으로 관계를 변경할 수 있다. 따라서 변경하기 쉽고 유연하다.

방식

부모 클래스의 이름을 덧붙이는 것만으로 부모 클래스의 코드를 재사용할 수 있다. 다른 부분만 추가하거나 재정의하여 기존 코드를 쉽게 확장할 수 있다.

합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다.

의존성

내부 구현에 대해 상세하게 알아야 하므로 결합도가 높아진다.

합성은 구현에 의존하지 않는다. 따라서 변경의 영향을 최소화하여 변경에 더 안정적인 코드를 얻을 수 있다.

가시성

부모 클래스의 내부가 자식 클래스에 공개되므로 따라 화이트박스 재사용(white-box reuse)이라고 부른다.

객체의 내부는 공개되지 않고 인터페이스를 통해서만 재사용되므로 블랙박스 재사용(black-box reuse)이라고 부른다.

(코드 재사용을 위해서는) 변경에 유연하게 대처할 수 있는 설계인 객체 합성이 상속보다 더 좋은 방법이다.

상속의 문제점

Chapter 10의 내용이다. Ch.10 상속과 코드 재사용에 따로 정리하진 않았다.

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

  • 자식 클래스에 부적합한 / 불필요한 부모 클래스의 오퍼레이션이 상속되어 자식 클래스가 불안정해진다.

  • e.g., java.util.Properties, java.util.Stack

java.util.HashTable을 상속받는 java.util.Properties 클래스는 제네릭이 도입되기 전에 만들어졌기 때문에 컴파일러가 키와 값의 타입이 String인지 여부를 체크할 수 있는 방법이 없었다. 그래서 의도와 다르게 HashTable의 인터페이스에 있는 put 메서드를 이용하면 String 이외의 타입의 키와 값을 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의 퍼블릭 인터페이스를 오염시키지 않고, 클라이언트는 정의된 오퍼레이션만 사용할 수 있다.

java.util.Vector를 상속받는 java.util.Stack은 Vector을 상속받았기 때문에 임의의 요소에서 요소를 추가하거나 제거할 수 있어 LIFO 구조를 구현하는 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);
    }
}

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

  • 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는다.

  • e.g., java.util.HashSet <- InstrumentedHashSet

addAll 메서드를 실행하게 되면 내부적으로 super.addAll에서 add가 호출되기 때문에 addCount가 이상한 값이 된다.

상속을 이용한 구현
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);
    }
}

이제 합성으로 개선해본다.

합성을 이용한 구현
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;
    }
}

이런 결합도를 제거하면서도 퍼블릭 인터페이스를 그대로 상속받고 싶다면 인터페이스 (Set) 구현을 택하면 된다.

  • 포워딩(forwarding)이라는 기법으로 동일한 메서드를 그대로 호출을 위임할 수 있다. 이러한 메서드를 포워딩 메서드(forwarding method)라고 부른다.

3. 부모 클래스와 자식 클래스의 동시 수정 문제

  • 부모 클래스와 자식 클래스의 개념적인 "결합"으로 인해, 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 한다.

  • e.g., Playlist <- PersonalPlaylist

상속을 이용한 구현
// 여기서 요구사항이 변경되어,
// Playlist에서 노래의 목록 뿐 아니라 가수별 노래 제목을 관리해야 한다고 가정하자.
// 노래를 추가한 후에 가수의 이름을 키로 노래의 제목을 추가하도록
// Playlist의 append 메서드를 수정해야 할 것이다.
class Playlist {
    private List<Song> tracks = new ArrayList<>();
    // private Map<String, String> singers = new HashMap<>();

    public void append(Song song) {
        tracks.add(song);
        // singers.put(song.getSinger(), song.getTitle());
    }
    
    public List<Song> getTracks() {
        return tracks;
    }
    
    // public Map<String, String> getSingers() {
    //     return singers;
    // }
}

// 노래 삭제 기능이 추가된 플레이리스트
class PersonalPlaylist extends PlayList {
    public void remove(Song song) {
        getTracks().remove(song);
        // getSingers().remove(song.getSinger());
    }
}

상속을 이용해 구현했더니 요구사항이 변경됨에 따라 부모 클래스와 자식 클래스를 변경해야되는 강한 결합도에 고통받을 수 있다. 합성을 이용해 개선해보자.

// 노래 삭제 기능이 추가된 플레이리스트
public class PersonalPlaylist {
    private Playlist playlist = new Playlist();
    
    public void append(Song song) {
        playlist.append(song);
    }

    public void remove(Song song) {
        playlist.getTracks().remove(song);
        playlist.getSingers().remove(song.getSinger());
    }
}

여전히 함께 수정해야되는 문제는 해결되지 않았지만, 내부 구현을 변경하더라도 파급효과를 최대한 PersonalPlaylist 내부로 캡슐화할 수 있기 때문에 합성이 더 유리하다.

몽키 패치(Monkey Patch)란 현재 실행 중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장하는 것을 가리킨다. Playlist를 수정할 권한이 없더라도 몽키 패치가 지원되는 환경이라면 Playlist에 직접 remove 메서드를 추가할 수 있다. (자바의 경우 바이트 코드 직접 변환 / AOP 등)

상속에서 합성으로 변경하기

합성을 사용하면 위의 세 가지 문제를 해결할 수 있다.

적용법은 간단하다. 상속 관계를 제거하고 부모 클래스의 인스턴스를 자식 클래스의 인스턴스 변수로 선언하자.

클래스 폭발 (Class Explosion) / 조합의 폭발 (Combinational Explosion)

상속을 이용하게 되면 다음과 같은 이유로 조합의 폭발이 발생할 수 있다.

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

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

반면 합성의 경우 하나의 클래스만 추가하고 런타임에 필요한 기능을 조합해 원하는 기능을 얻을 수 있다. 게다가 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다.

Tip: 훅 메서드

  • 메서드를 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지면 편의를 위해 기본 구현을 제공하는 메서드를 훅 메서드(hook method)라고 부른다.

믹스인 (Mixin)

상속의 장점인 구체적인 코드를 재사용하는 것, 그리고 합성의 장점인 낮은 결합도를 유지할 수 있는 유일한 방법은 재사용에 적합한 추상화를 도입하는 것이다.

믹스인은 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법이다.

  • 클래스 폭발 문제의 단점은 클래스가 늘어나는 것이 아니라, 클래스가 늘어날 수록 중복 코드도 기하급수적으로 늘어난다는 점인데, 믹스인에서는 이런 문제가 발생하지 않는다.

  • 합성이 실행 시점에 객체를 조합하는 재사용 방법이라면, 믹스인은 컴파일 시점에 필요한 코드 조각을 조합하는 재사용 방법이다.

  • 믹스인은 상속 계층 안에서 확장한 클래스보다 더 하위에 위치하게 되어, 대상 클래스의 자식 클래스처럼 사용될 용도로 만들어진다. 따라서 믹스인을 추상 서브클래스(abstract subclass)라고 부르기도 한다.

  • 믹스인을 사용하면 특정한 클래스에 대한 변경 / 확장을 독립적으로 구현한 후 필요한 시점에 차례대로 추가할 수 있다. 이를 쌓을 수 있는 변경(stackable modification이라고 부른다.

책의 예제에서는 스칼라의 트레이트(Trait)를 이용해 믹스인을 구현한다.

Last updated