개발 공부 기록하기/01. JAVA & Kotlin

짧은 코멘트와 함께하는 이펙티브 자바) #18 상속보다는 컴포지션을 사용하라

lannstark 2020. 10. 16. 23:29

짧은코멘트

  1. 회사 블로그에 기고한 글에서도 살짝 이야기 했지만 상속보다는 합성을 사용하는 것이 훨씬 수월하다.
  2. 합성에 익숙해지면 어느덧 extends 보다 implements를 선호하게 될 것이다.

 

 

상속보다는 합성을 사용하라

일반적인 구체 클래스를 패키지 경계를 넘어 상속하는 일은 위험하다.

메소드 호출과 달리 상속은 캡슐화를 깨뜨린다.

  • 상위 메소드를 부를때 자기 사용 여부에 따라 의도와 다르게 동작할 수 있다. (자기 사용 : 한 클래스의 퍼블릭 인터페이스에서 같은 클래스 내 다른 퍼블릭 인터페이스를 사용하여 기능을 완성한 것)
  • 또한, 상위 클래스의 한 메소드가 상위 클래스의 다른 메소드를 사용하는 자기 사용 여부는 내부 구현 방식에 해당되기 때문에 다음 릴리스에서 유지될지 알 수 없다.
  • 다음 릴리스에서 상위 클래스에 새로운 메소드가 추가된다면 하위 클래스에서 재정의하지 못한 새로운 메소드를 사용해 ‘허용되지 않은’ 원소를 추가할 수 있다.
  • 다음 릴리스에서 상위 클래스에 새로운 메소드가 추가되었는데 하필 하위 클래스에 추가한 메소드와 시그니처가 같다면 문제에 부딪힌다.

이러한 캡슐화를 깨뜨리고 유지 보수를 어렵게 하는 문제를 모두 피해가는 방법은 바로 합성이다. 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 것이다.

구체적인 예를 들어보기 위해 HashSet에 추가로 들어간 원소의 수를 count해야 한다고 해보자

상속을 이용하는 경우이다.

@NoArgsContructor
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 int getAddCount() {
        return addCount;
    }
}

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("A", "B", "C"));
s.getAddCount(); // 3이 아니라 6이 나온다.

합성을 이용하는 경우이다.

public class InstrumentedSet<E> {
    private Set<E> s = new HashSet();
    private int addCount = 0;

    // 전달 메소드
    public boolean add(E e) {
        addCount ++;
        return s.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return s.add(e);
    }
}

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("A", "B", "C"));
s.getAddCount(); // 3이 나온다.

이렇게 합성을 이용하는 방법을 재사용 가능한 전달 클래스래퍼 클래스로 쪼갤 수도 있다.

/* 재사용 가능한 전달 클래스 */
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 add(E e) {
        return s.add(e);
    }
    // ...
    // Set 메소드를 모두 래핑함
}

/* 래퍼 클래스 */
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();
    }

}

이런 패턴을 데코레이터 패턴이라고 한다.

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

자바 플랫폼 라이브러리에서도 상속을 명백히 잘못 사용한 클래스들을 찾아 볼 수 있다. 예를 들어, 스택은 벡터가 아니므로 Stack은 Vector를 확장해서는 안됐다. 마찬가지로 Properties도 Hashtable을 확장해서는 안 됐다. 두 사례 모두 합성을 사용했으면 더 좋았을 것 이다.