///
Search
💡

item 18. 상속보다는 컴포지션을 사용하라 Favor composition over inheritance

상속의 단점

상속은 코드를 재사용하기 가장 쉬운 방법이다. (implement가 아닌 extends를 의미한다.) 하지만 다른 패키지에 있는 클래스를 상속 받는 것은 위험하다. 상속은 캡슐화를 깨뜨리기 때문이다.
상위 클래스가 어떻게 구현되어 있느냐에 따라 하위 클래스의 동작에 이상이 발생할 수 있다. 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 안 건드려도 하위 클래스가 오작동 할 수 있다.

오작동 예시

위 포스트는 상위 클래스에서 필드의 타입을 변경한 경우의 상속의 취약점에 대해 다룬다.
다른 예로, 상위 클래스 중 특정 필드에 대한 보안 대책이 필요해 모든 변경자를 override해 보안 대책 로직을 거친 후 변경하게 하는 자식 클래스를 만들었다고 생각해보자. 다음 릴리즈에서 상위 클래스에 새로운 변경자가 생긴다면 자식 클래스는 보안 대책을 거치지 않고 필드가 변경되는 경우가 생기게 된다.

오버라이드를 안 한다면?

위 두 사례는 메서드 재정의 때문에 생긴 일이므로, 메서드 재정의를 안 하면 해결되지 않을까? 새로운 메서드를 생성하는 건 어떨까. 그래도 문제는 생긴다. 추후 부모 객체에 내가 자식 객체에 새롭게 만들었던 메서드와 반환 타입만 다른 메서드를 추가하면 내 자식 객체는 컴파일 에러가 나게 된다. 반환타입까지 똑같다면, 의도치 않게 오버라이드를 한 것이 된다.

상속 대신 조합해보자

기존 클래스를 상속 하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스를 포함하자. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 점에서, 이 기법은 조합composition이라고 부른다.
새로운 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해준다. 이 방식은 전달forwarding이라고 한다. 이렇게 하면 새로운 클래스는 기존 클래스의 내부 구현 영향으로 부터 벗어나며, 새로운 메서드가 추가 되어도 영향을 받지 않는다. 또한 기존 클래스의 api를 호출하여 소통하므로 캡슐화를 깨지도 않는다.
다음은 조합 방식으로 다시 구현해 본 추가된 요소의 개수를 계측할 수 있는 InstrumentedSet의 예시이다.
// Wrapper class - uses composition in place of inheritance 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; } }
Java
복사
이와 같이 동일한 인터페이스를 감싸고 있는 클래스를 랩퍼 클래스 라고 부른다
// Reusable forwarding class 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); } public boolean isEmpty() { return s.isEmpty(); } public int size() { return s.size(); } public Iterator<E> iterator() { return s.iterator(); } public boolean add(E e) { return s.add(e); } public boolean remove(Object o) { return s.remove(o); } public boolean containsAll(Collection<?> c) { return s.containsAll(c); } public boolean addAll(Collection<? extends E> c) { return s.addAll(c); } public boolean removeAll(Collection<?> c) { return s.removeAll(c); } public boolean retainAll(Collection<?> c) { return s.retainAll(c); } public Object[] toArray() { return s.toArray(); } public <T> T[] toArray(T[] a) { return s.toArray(a); } @Override public boolean equals(Object o) { return s.equals(o); } @Override public int hashCode() { return s.hashCode(); } @Override public String toString() { return s.toString(); } }
Java
복사
이처럼 상위 객체를 인터페이스를 조합 방식으로 전달해주는 객체를 포워딩 클래스라고 부른다.

데코레이터 패턴

위의 예시 중 InstrumentedSet 처럼 기존 객체를 포함하고 동일한 인터페이스를 상속하며, 특정 기능을 추가하여 구현하는 패턴을 데코레이터 패턴이라고 부른다.

랩퍼클래스 활용에 주의할 점

콜백 프레임워크와 함께 쓰는 경우엔 주의해야 한다. 콜백 프레임워크는 자기 자신의 참조를 넘겨 다음 호출 때 사용되도록 하는데, 내부 객체는 자신을 감싸고 있는 객체의 존재를 모르기 때문에 대신 자신의 참조(SELF)를 넘기고 이 때문에 오동작 할 수 있다.

핵심 정리

상속은 강력하지만 캡슐화를 해친다는 문제가 있다. 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. is-a 관계여도, 패키지가 다르고 상위 객체가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다. 상속 대신 컴포지션과 전달을 사용하자.

용어정리

한글명
영어명
함수 호출
method invocation
랩퍼 클래스
wrapper class