JAVA/Effective Java

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

꾸준함. 2024. 2. 5. 02:51

클래스가 다른 클래스를 확장하는 구현 상속 은 코드를 재사용하는 장점을 제공하지만, 이를 활용하는 경우에는 아래와 같은 상황에서만 고려해야 합니다. 

  • 상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 내부일 경우
  • 확장할 목적으로 클래스가 설계되었고 문서화도 잘 된 클래스

 

위 두 케이스에 해당하지 않는 경우, 즉 패키지 경계를 넘어 다른 패키지의 구체 클래스를 상속하면 아래의 이유로 인해 위험합니다.

  • 상위 클래스에서 제공하는 메서드 구현이 변경되는 경우
  • 상위 클래스에서 새로운 메서드가 생기는 경우

 

1. 상위 클래스에서 제공하는 메서드 구현이 변경되는 경우

 

하위 클래스의 동작은 하위 클래스의 구현 내용에 따라 변경될 수 있습니다.

즉 상위 클래스가 새로운 릴리스 때 내부 구현이 달라질 경우 코드 한 줄 건드리지 않은 하위 클래스가 원치 않은 동작을 수행할 수 있습니다.

따라서 하위 클래스는 상위 클래스의 변화에 따라 수정되어야 하며, 이로 인해 상위 클래스의 내용이 하위 클래스에 노출되어야 하므로, 객체지향의 핵심인 캡슐화가 깨지게 됩니다.

 

HashSet에 원소를 추가하는 메서드인 add와 addAll 메서드를 재정한 InstrumentedHashSet 클래스를 통해 위 내용을 쉽게 파악할 수 있습니다.

 

 

 

 

클라이언트는 InstrumentedHashSet에 3개의 원소를 추가했으므로 getAddCount() 메서드가 3을 반환할 것이라 기대하겠지만 6을 반환합니다.

 

예상과 달리 6을 반환

 

InstrumentedHashSet의 addAll() 메서드는 addCount를 업데이트한 후 HashSet의 addAll을 호출하는데 해당 메서드는 내부적으로 add 메서드를 호출하는데 하위 클래스인 InstrumentedHashSet에서 add 메서드를 재정의했기 때문에 하위 클래스의 add가 호출되었습니다.

이에 따라 addCount가 추가로 3이 증가하였고, 결과적으로 6이 반환되게 된 것입니다.

 

 

원인을 알았으므로 하위 클래스의 addAll 메서드 내용을 컬렉션을 순회하며 원소 하나당 add 메서드를 한 번만 호출하도록 변경할 수 있습니다.

하지만 이 역시 HashSetadd 메서드가 변경될 경우 결과 내용이 변할 수 있으므로, 이는 불안전한 해결책입니다.

 

2. 상위 클래스에서 새로운 메서드가 생기는 경우

 

만약 HashSet에 새로운 원소를 추가하는 메서드가 생길 경우, 이를 하위 클래스에서 감지하고 addCount를 업데이트하는 로직을 추가하는 것이 좋겠지만, 이를 추가하지 않더라도 컴파일은 성공하므로 누락될 가능성이 있습니다.

  • 인터페이스의 경우 새로운 메서드가 추가되었고 구현체에서 재정의하지 않을 경우 컴파일 자체가 안되기 때문에 누락될 걱정은 하지 않아도 됨

 

위 두 문제는 모두 메서드 재정의가 원인이었기 때문에 "상위 클래스의 메서드를 재정의하지 않고 새로운 메서드를 정의하면 되지 않을까?"라고 생각할 수 있습니다.

하지만 운이 나쁘게도 다음 릴리스에서 상위 클래스에서 동일한 이름의 메서드가 정의된다면, 컴파일 자체가 되지 않거나 앞서 언급한 두 가지 문제가 다시 발생할 수 있습니다.

  • 시그니처가 다르면 컴파일 안됨
  • 시그니처가 동일하면 새로 정의한 메서드가 상위 클래스의 메서드를 오버라이딩하는 꼴
    • 앞서 언급한 두 문제에 다시 부닥칠 확률이 높고 새로 생성한 메서드가 상위 메서드가 요구하는 규약을 만족하지 못할 가능성이 큼

 

이 때문에 아이템 18에서는 상속보다는 컴포지션을 권장합니다.

 

컴포지션

컴포지션은 기존 클래스를 확장하는 대신 새로운 클래스가 private 필드로 기존 클래스의 인스턴스를 참조하는 방식입니다.

새 클래스의 인스턴스 메서드들은 기조 클래스에 대응하는 메서드를 호출해 그 결과를 반환하며 해당 방식을 forwarding 메서드라고 부릅니다.

그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나기 때문에 기존 클래스에 새로운 메서드가 생기더라도 아무런 영향을 받지 않습니다.

 

아래 코드를 보면 컴포지션의 장점을 단번에 파악할 수 있습니다.

  • ForwardingSet은 어떠한 Set 구현체에 대해서도 재사용될 수 있는 Forwarding Class
  • InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 보다 견고하고 유연하며 임의의 Set에 원소가 추가될 때마다 계측하는 기능을 덧씌워 새로운 Set으로 만드는 것이 해당 클래스의 핵심

 

 

 

상속 방식은 각 구체 클래스를 개별적으로 확장해야 하며, 지원하려는 상위 클래스의 각 생성자에 대응하는 생성자를 별도로 정의해야 합니다.

반면에 컴포지션 방식은 한 번만 구현해 두면 기존 생성자들을 통해 어떤 Set 구현체라도 함께 사용할 수 있습니다.

다른 Set 인스턴스를 감싸고(wrap) 있다는 뜻에서 InstrumentedSet 같은 클래스를 Wrapper 클래스라고 부르며 다른 Set에 원소가 추가될 때마다 계측하는 기능을 추가한 것이므로 Decorator 패턴이라고 할 수 있습니다.

Wrapper 클래스는 단점이 거의 없으나 Wrapper 클래스가 콜백 프레임워크와는 어울리지 않다는 점만 주의하면 됩니다.

  • 콜백 함수: 다른 함수의 인자로 전달된 함수

 

 

 

FunctionInterface를 구현한 FunctionFunctionWrapper 클래스가 정의되어 있을 때, FunctionWrapperrun 메서드를 호출하면 아래와 같은 결과를 예상할 것입니다.

 

service run
functionWrapper 호출

 

하지만 예상과 달리 아래와 같이 콘솔에 찍힌 것을 확인할 수 있습니다.

 

 

 

위와 같이 출력되는 이유는 Function 클래스의 관점에서는 자신을 감싸고 있는 Wrapper 클래스의 존재를 알 수 없기 때문에 run 메서드에서 this (즉, 자기 자신)을 호출하기 때문입니다.

따라서 Wrapper 클래스의 call 메서드가 호출되길 바랐다면 직접 호출하는 방법 밖에 없습니다.

위 문제를 Wrapper 클래스의 Self 문제로 지칭합니다.

 

비고

 

1. 상속을 사용해야하는 케이스

 

상속은 반드시 하위 클래스가 상위 클래스의 진정한 하위 타입인 상황에서만 쓰여야 합니다.

즉 하위 클래스가 상위 클래스와 is-a 관계일 때만 상위 클래스를 상속해야 합니다.

  • ex) 상위 클래스: 운송수단, 하위 클래스: 자동차

 

단순히 다형성 혹은 코드 재사용성만을 생각해서 상속을 사용하는 것은 지양해야 합니다.

 

2. 상속을 사용할 때 주의해야 하는 점

 

컴포지션을 써야 할 상황에서 상속을 사용하는 것은 내부 구현을 불필요하게 노출하는 것과 같으며 최악의 경우 클라이언트에서 상위 클래스를 직접 수정하여 하위 클래스의 불변식을 해칠 수 있습니다.

상속은 상위 클래스의 결함까지 승계하기 때문에 주의가 필요합니다.

 

참고

이펙티브 자바
이펙티브 자바 완벽 공략 2부 - 백기선 강사님

반응형