ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 상속보다는 컴포지션을 사용하라
    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 관계일 때도 안심할 수만은 없는 게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다.
    • 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자.
    • 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.

     

    참고 자료 


    이펙티브 자바

Designed by Tistory.