Skip to content

Latest commit

 

History

History
110 lines (78 loc) · 6.55 KB

File metadata and controls

110 lines (78 loc) · 6.55 KB

18 상속보다는 컴포지션을 사용하라

메소드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다르게 말하면, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작할 수 있다는 말이다.

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;
    
    public InstrumentedHashSet() {
    }
    
    public InstrumentedHashSet(int initCap, float loadFactor) {
        super(initCap, loadFactor);
    }
    
    @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 int getAddCount() {
        return addCount;
    }
}

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("틱", "탁탁", "펑"));
int addCount = s.getAddCount(); // 3을 기대했지만 실제로는 6을 반환한다.

getAddCount() 메소드를 호출하면 3을 반환하리라 기대하겠지만, 실제로는 6을 반환한다. 그 원인은 HashSetaddAll() 메소드가 add() 메소드를 사용해 구현된 데 있다. 이러한 내부 구현 방식은 HashSet 문서에는 쓰여 있지 않다.

이 경우 하위 클래스에서 addAll() 메소드를 재정의하지 않으면 문제를 고칠 수 있다. 하지만 당장은 제대로 동작할지 모르나, HashSetaddAll()add() 메소드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다. 이처럼 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당하며, 다음 릴리스에서도 유지될지는 알 수 없다. 따라서 이런 가정에 기댄 InstrumentedHashSet도 깨지기 쉽다.


다음 릴리스에서 상위 클래스에 새로운 메소드를 추가하는 경우에도 하위 클래스가 깨지게 된다. 하위 클래스에서 재정의하지 못한 그 새로운 메소드를 사용해 '허용되지 않은' 원소를 추가할 수 있게 된다.


이상의 두 문제 모두 메소드 재정의가 원인이었다. 따라서 클래스를 확장하더라도 메소드를 재정의하는 대신 새로운 메소드를 추가하면 괜찮으리라 생각할 수도 있다. 이 방식이 훨씬 안전한 것은 맞지만, 위험이 전혀 없는 것은 아니다. 다음 릴리스에서 상위 클래스에 새 메소드가 추가됐는데, 운 없게도 하필 하위 클래스에 추가한 메소드와 시그니처가 같고 반환 타입은 다르다면 클래스는 컴파일조차 되지 안는다.


기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(compositionl; 구성)이라 한다. 새 클래스의 인스턴스 메소드들은 기존 클래스의 대웅하는 메소드를 호출해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메소드들을 전달 메소드(forwarding method)라 부른다. 그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메소드가 추가되더라도 전혀 영향받지 않는다.

public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;
    
    public InstrumentedSet(Set<E> s) {
        super(s);
    }
    
    @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 int getAddCount() {
        return addCount;
    }
}
// 재사용할 수 있는 전달 클래스
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s) {
        this.s = s;
    }
    
    public void clear() {
        s.clear();
    }
    
    public boolean contains(Object o) {
        return s.contains(o);
    }
    
    ...
        
    @Override public boolean equals(Object o) {
        return s.equals(Object o);
    } 
}

다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴(Decorator Pattern)이라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다. 단, 엄밀히 따지면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우에만 위임에 해당한다.

래퍼 클래스의 단점은 래퍼 클래스가 콜백(callback) 프레임워크와는 어울리지 않는다는 점이다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제라고 한다.


상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다. 다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다. 클래스 A를 상속하는 클래스 B를 작성하려 한다면 "B가 정말 A인가?"라고 자문해보자. "그렇다"고 확신할 수 없다면 B는 A를 상속해서는 안 된다. 대답이 "아니다"라면 A를 private 인스턴스로 두고, A와는 다른 API를 제공해야 하는 상황이 대다수다. 즉, A는 B의 필수 구성요소가 아니라 구현하는 방법 중 하나일 뿐이다.


컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다. 가장 심각한 건 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다는 사실이다.