-
상속보다는 컴포지션을 사용하라JAVA/Effective java 2021. 2. 28. 13:58
- 상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선은 아니다. 잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들게 된다.
- 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안이라면 상속도 안전한 방법이다.
- 확장할 목적으로 설계되고 문서화 잘 된 클래스 역시 안전하다.
- 하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 다른 패키지의 구체 클래스를 상속한다면 이는 위험할 수 있다.
- 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
- 즉 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
- 상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 한 줄 건드리지 않은 하위 클래스가 오동작할 수 있다.
- getAddCount 메서드를 호출하면 3을 반환하리라 기대하지만, 실제로는 6을 반환한다.
- 그 원인은 HashSet의 addAll 메서드가 add 메서드를 사용해 구현된 데 있다. 이때 불리는 add는 InstructedHashSet에서 재정의한 메서드이고, addCount에 값이 중복으로 더해져, 최종값이 6으로 늘어난 것이다.
- 이처럼 자신의 다른 부분을 사용하는 자기사용 여부는 해당 클래스의 내부 구현 방식에 해당하며 깨지기 쉽다.
- 또 다음 릴리즈에서 상위 클래스에 새로운 메서드를 추가한다면 어떨까
- 그 컬렉션을 상속하여 원소를 추가하는 모든 메서드를 재정의해 필요한 조건을 먼저 검사하게끔 될 것 같지만
- 다음 릴리즈에서 우려한 일이 생기면, 하위 클래스에서 재정의하지 못한 그 새로운 메서드를 사용해 허용되지 않은 원소를 추가할 수 있게 된다.
- 실제 HashTabel과 Vector를 컬렉션 프레임워크에 포함할 때 이와 관련되어 수정할 사태들이 벌어졌다.
- 두 문제 모두 매서드 재정의가 원인이었다. 재정의 대신 새로운 메서드에 추가가 훨씬 안전한 것은 맞지만, 위험이 전혀 없는 것은 아니다.
- 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입이 다르다면 해당 클래스는 컴파일조차 되지 않을 것이다.
- 이러한 문제들을 피해가는 묘안이 있는데, 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하는 것이다.
- 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에 이러한 설계를 컴포지션이라 한다.
- 또한 새 클래스의 인스턴스 메서드들은 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환하는 방식을 전달이라 하고 새 클래스의 메서드들을 전달 메서드라 부른다.
- 컴포지션을 사용하면 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 새로운 메서드가 추가돼도 전혀 영향받지 않는다.
- InstrumentedHashSet을 컴포지션과 전달 방식으로 다시 구현한 코드를 통해 직접 확인해보자.
- InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 유연하다.
- 구체적으로 Set인터페이스를 구현했고, ,Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다.
- 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 핵심이다.
- 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며
- 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.
-
래퍼 클래스는 단점이 거의 없다. 한 가지, 래퍼 클래스가 콜백 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.
- 따라서 상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다.
- 다르게 말하면, 클래스 B가 클래스 A와 is-a 관계일 때만 클래스 A를 상속해야 한다.
- 클래스 A를 상속하는 클래스 B를 작성하려 한다면 "B가 정말 A인가?"라고 자문해보자.
- 확신할 수 없다면 B는 A를 상속해서는 안된다.
- 자바 플랫폼 라이브러리에서도 명백히 위반한 클래스들을 볼 수 있다.
- 예를 들어, 스택은 벡터가 아니므로 Stack은 Vector를 확장해서는 안 됐다.
- 속성 목록도 해시테이블이 아니므로 Properties도 HashTable을 확장해서는 안 됐다.
- 두 사례 모두 컴포지션을 사용했다면 더 좋았을 것이다.
- 컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다.
- 그 결과 API가 내부 구현에 묶이고 클래스의 성능도 영원히 제한된다.
- 또한 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.
- 가장 심각한 문제는 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있다는 사실이다.
- 컴포지션 대신 상속을 사용하길 결정하기 전에 마지막으로 자문하자.
- 확장하려는 클래스의 API에 아무런 결함이 없는가?
- 결함이 있다면 이 결함이 API까지 전파돼도 괜찮은가?
- 컴포지션은 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 '그 결함까지도' 그대로 승계한다.
정리
- 상속은 강력하지만 캡슐화를 해친다는 문제가 있다.
- 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다.
- is-a 관계일 때도 안심할 수만은 없는 게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다.
- 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자.
- 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.
참고 자료
'JAVA > Effective java' 카테고리의 다른 글
20. 추상 클래스보다는 인터페이스를 우선하라 (0) 2021.03.03 19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 (0) 2021.03.02 변경 가능성을 최소화하라 (0) 2021.02.11 public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 (0) 2021.02.01 클래스와 멤버의 접근 권한을 최소화하라 (0) 2021.01.23