HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
📖
공부한 책
/
📒
Effective Java
/
아이템18 : 상속보다는 컴포지션을 사용하라

아이템18 : 상속보다는 컴포지션을 사용하라

💡
상속을 염두에 두지 않고 설계했고 상속할 때의 주의점도 문서화해놓지 않은 ‘외부' 클래스를 상속할 때의 위험성에 대한 경고임! 상속을 잘못 사용할 시, - 상위 클래스의 결함을 그대로 승계할 수 있고 - 상위 클래스에서 변화가 생겼을 시, 하위 클래스에도 그대로 오동작이 전파됨 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자 특히, 래퍼 클래스로 구현할 적당한 인터페이스(예:Set)가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다
상속 = 클래스가 다른 클래스를 확장하는 구현 상속
  • 상위 클래스와 하위클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법임
  • 확장할 목적으로 설계 되었고 문서화도 잘 된 클래스도 마찬가지로 안전함
하지만 일반적인 구체 클래스를 패키지 경계를 넘어 즉, 다른 패키지의 구체클래스를 상속하는 일은 위험함

상속을 잘못 사용 시 생기는 문제

💡
하위 클래스에서 상위 클래스의 구현에 의존해서 기능 구현을 하기 때문에 상위 클래스의 구현이 바뀌게 되면 하위 클래스가 오동작할 수 있음!
  • 메서드 호출과 달리 상속은 캡슐화를 깨뜨림
    • 즉, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있음
      • 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있고 → 코드 한줄 건드리지 않은 하위 클래스가 오동작 할 수 있게 됨
package effectivejava.chapter4.item18; import java.util.*; // 코드 18-1 잘못된 예 - 상속을 잘못 사용했다! (114쪽) 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; } public static void main(String[] args) { InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); s.addAll(List.of("틱", "탁탁", "펑")); System.out.println(s.getAddCount()); // 6이 출력됨 } }
HashSet의 addAll 내부 구현에서 add를 사용하는데, InstrumentedHashSet에서 이를 override해서 사용하므로 이 함수가 불리게 됨(가상메서드)
  • 위 경우, 하위 클래스에서 addAll 메서드를 재정의하지 않으면 문제를 고칠 수 있지만, 이는 HashSet의 내부 구현을 알고 해결한 방법이라는 한계를 가짐 ⇒ 다음 릴리스에서 유지될 수 있을지 모름
  • 메서드 재정의가 원인이였기에, 클래스 확장 시 메서드 재정의말고 새로운 메서드를 추가하면 괜찮지 않을까..? ⇒ 훨씬 안전하긴 하지만 위험이 전혀 없는 것은 아님
    • 만약 내가 새로 정의한 메서드가 상위 클래스에서 생긴다면? 컴파일 에러
    • 가상 메서드에 대한 이해가 필요함!! →
      🧧
      객체 지향 핵심

상속 대신, private 필드로 기존 클래스 인스턴스 참조(=컴포지션)

  • 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 컴포지션이라 함
  • 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환 ⇒ 기존 클래스의 내부 구현 방식의 영향에서 벗어남
    • 이를 전달(forwarding)이라 함
    • 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부름
package effectivejava.chapter4.item18; import java.util.*; // 코드 18-2 래퍼 클래스 - 상속 대신 컴포지션을 사용했다. (117-118쪽) 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 static void main(String[] args) { InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>()); s.addAll(List.of("틱", "탁탁", "펑")); System.out.println(s.getAddCount()); } }
다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고도 함
package effectivejava.chapter4.item18; import java.util.*; // 코드 18-3 재사용할 수 있는 전달 클래스 (118쪽) 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(); } }
  • 전달 클래스를 사용함으로써, 위에서와 같이 상위 클래스와 하위 클래스의 메서드가 꼬이게 되는 일이 없어짐
    • 전달 클래스의 메서드들은 Set 인터페이스를 활용해 설계되어 견고하고, 컴포지션으로 갖고 있는 기존 클래스의 메서드의 결과를 전달해줌
  • 만약, InstrumentedSet에서 Set을 필드로 가지면서, Set의 메서드를 바로 호출하게 만든다면, 객체 지향의 특징을 많이 잃어버리게 될 듯함
    • Set인터페이스의 메서드와 이름이 같게 오버라이드도 안되고 견고하지가 않을 듯

상속은 언제 쓰냐

  • 하위 클래스가 상위 클래스의 ‘진짜’ 하위 타입인 상황에서만 쓰여야 함
    • 클래스 B와 클래스 A의 관계가 is-a 관계일 때

상속 잘못 사용시?

  • 그렇지 않으면 A를 private 인스턴스로 두고 A와는 다른 API를 제공해야 하는 상황이 대다수임
  • 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴임
    • API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한됨
    • 예) Properties의 인스턴스인 p가 있을 때, p.getProperty(key)와 p.get(key)는 결과가 다를 수 있음
      • p.getProperty가 Properties의 기본 동작인데 반해
      • p.get은 상위 클래스인 Hashtable로부터 물려받은 메서드임 (이 내부 구현을 불필요하게 노출) ⇒ 해당 메서드가 Properties의 키와 값에 문자열만 허용하도록 하는 설계를 깨버리게 됨

컴포지션 대신 상속 사용 결정전 마지막 질문

  • 확장하려는 클래스의 API에 아무런 결함이 없는가?
  • 결함이 있다면, 이 결함이 우리 클래스의 API까지 전파돼도 괜찮은가?
  • 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 ‘그 결함까지도’ 그대로 승계함