item 18 : 상속보다는 컴포지션을 사용하라
1. 구현 상속
일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다. 상기하자면, 이 책에서의 ‘상속’은 클래스가 다른 클래스를 확장하는 구현 상속을 말한다. 이번 아이템 에서 논하는 문제는 클래스가 인터페이스를 구현하거나 인터페이스가 다른 인터페이스를 확장하는 인터 페이스 상속과는 무관하다.
1) 상속이란
한 클래스가 다른 클래스의 속성과 메서드를 확장 혹은 재정의할 수 있도록 해주는 매커니즘
2) 상속의 문제점
강한 결합 : 부모 클래스의 내부 변경이 자식 클래스에 영향을 줄 수 있어 유연성이 저하된다.
캡슐화 위반 : 자식 클래스가 부모 클래스의 구현 세부 사항에 의존하게되면, 캡슐화가 약화된다.
재사용성 저하 : 특정 구현에 강하게 결합된 상속 구조는 새로운 상황에 재사용하기 어렵다.
3) 메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.
다르게 말하면, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다.
잘못된 상속의 예
일반적으로 위 코드 실행 후 addCount
가 3이 될 것이라 예상할 것이다. 하지만 실제로는 6이다. 이유는 부모 클래스인 HashSet
의 addAll
메서드 안에서 add
메서드를 호출하기 때문이다.
addAll
을 호출하면 내부에서add
를 호출하는데add
가 상위 클래스의 add를 호출할줄 알았지만InstrumentedHashSet
의add
를 호출했다.
위에서의 문제는 메서드 재정의 시 당장은 해결할 수 있으나, HashSet의 addAll
이 add 메서드를 이용해 구현했음을 가정한 해법이라는 한계를 지닌다. 이처럼 자신의 다른 부분을 사용하는 ‘’ 여부는 해당 클래스의 내부 구현 방식 에 해당하며, 자바 플랫폼 전반적인 정책인지, 그래서 다음 릴리스에서도 유지될지는 알 수 없다. 따라서 이런 가정에 기댄 도 깨지기 쉽다.
addAll
메서드를 다른 식으로 재정의할 수도 있다. 주어진 컬렉션을 순회하며 원소 하나당 add
메서드를 하나만 호출하는 것이다. 조금 나은 방법이지만 상위 클래스의 메서드 동작을 다시 구현하는 것은 어렵고, 비용이 든다. 또한 하위 클래스에서 접근할 수 없는 private 필드를 써야 한다면 이 방식으로는 구현 자체가 불가능하다.
상위 클래스의 구현에 의존:
HashSet
의 내부 구현이addAll()
에서add()
를 호출한다는 사실에 의존하고 있다.메서드 오버라이딩의 위험성: 상위 클래스의 메서드를 재정의하면, 상위 클래스의 내부 구현이 변경될 때 하위 클래스의 동작이 예기치 않게 변할 수 있다.
이 방식이 훨씬 안전한 것은 맞지만, 위험이 전혀 없는 것은 아니다. 다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데, 하필 하위 클래스에 추가한 메서드와 시그니처가 같고 반환 타입은 다르 다면 여러분의 클래스는 컴파일조차 되지 않을 수 있다.
2. 컴포지션(composition)
하나의 클래스가 다른 클래스의 인스턴스를 포함하여, 그 인스턴스의 메서드를 활용하는 방식
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자. 상속의 문제의 대안법인 컴포지션은 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻이다.
새 클래스의 인스턴스 메 서드들은 (private 필드로 참조하는) 기존 클래스의 대응하는 메서드를 호출 해 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부른다.
그 결과 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
상속 대신 컴포지션(Composition)을 사용하여 기존 Set
인스턴스를 감싸는 래퍼 클래스(Wrapper Class)를 만들어기
설명:
InstrumentedSet
은Set
인터페이스를 구현하고, 내부에 실제 작업을 수행할Set
인스턴스를 가진다.모든 메서드는 내부
set
객체에 작업을 위임한다.add()
와addAll()
메서드에서addCount
를 정확하게 증가시킬 수 있다.상속이 아닌 컴포지션을 사용함으로써 상위 클래스의 내부 구현에 의존하지 않는다.
사용 예시:
안전성: 상위 클래스의 내부 구현 변화에 영향을 받지 않는다.
유연성: 기존 클래스의 기능을 확장하거나 변경할 때 더 안전하게 구현할 수 있다.
재사용성: 다양한 클래스와 함께 사용할 수 있으며, 기능을 추가하거나 변경하기 쉽다.
위임 메서드를 일일이 작성하는 대신, 재사용 가능한 포워딩 클래스를 만들어서 중복 코드를 줄일 수 있다.
이제 InstrumentedSet
은 ForwardingSet
을 상속받아 필요한 기능만 추가하면 됨
InstrumentedSet
은 HashSet
의 모든 기능을 정의한 Set
인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 구체적으로는 Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다. 임의의 Set에 계측 기능을 덧씌어 새로운 Set으로 만드는 것이 이 클래스의 핵심이다. 이 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 계측할 수 있으며, 기존 생성자들도 함께 사용할 수 있다.
다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet
같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다.
상속은 반드시 하위 클래스가 상위 클래스의 '진짜' 하위 타입인 상황에서만 쓰여야 한다. 다르게 말하면, 클래스 B가 클래스 A와 is-a
관계일 때만 클래스 A를 상속해야 한다.
컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.
콜백 프레임워크에서 는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제
라고 한다.
전달 메서드가 성능에 주는 영향이나 래퍼 객체가 메모리 사용량에 주는 영향을 걱정하는 사람도 있지만, 실전에서는 둘 다 별다른 영향이 없다고 밝혀졌다. 전달 메서드들을 작성하는 게 재사용할 수 있는 전달 클래스를 인터 페이스당 하나씩만 만들어두 면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있다.
3. 상속과 컴포지션의 비교
1) 상속(Inheritance)의 예시
예제: 전자기기(ElectronicDevice)와 스마트폰(Smartphone)
부모 클래스: ElectronicDevice
ElectronicDevice
설명:
ElectronicDevice
클래스는 전자기기의 일반적인 동작인powerOn()
과powerOff()
메서드를 가지고 있다.
자식 클래스: Smartphone
Smartphone
설명:
Smartphone
클래스는ElectronicDevice
를 상속받아 전자기기의 기능을 확장한다.관계:
Smartphone
은ElectronicDevice
이다라는 Is-a 관계를 형성한다.
사용 예시
결과:
Smartphone
객체는ElectronicDevice
의 메서드인powerOn()
과powerOff()
를 그대로 사용할 수 있다.
2) 컴포지션(Composition)의 예시
예제: Battery
클래스와 Laptop
클래스
독립된 기능을 가진 클래스: Battery
Battery
설명:
Battery
클래스는 배터리의 동작을 정의한다.
컴포지션을 사용하는 클래스: Laptop
Laptop
설명:
Laptop
클래스는Battery
객체를 구성요소로 포함하고 있다.관계:
Laptop
은Battery
를 가진다라는 Has-a 관계를 형성한다.
사용 예시
결과:
Laptop
객체는Battery
객체의 기능을 사용하여 동작을 수행한3다.
3) 상속과 컴포지션의 비교
1. 관계의 차이
상속(Inheritance): Is-a 관계를 나타낸다
컴포지션(Composition):
Has-a 관계를 나타냅니다.
한 클래스가 다른 클래스의 객체를 구성요소로 포함하고, 그 기능을 사용합니다.
예시:
Laptop
은Battery
를 가진다.
2. 코드 재사용 측면
상속:
부모 클래스의 모든 public 및 protected 멤버를 자동으로 상속받습니다.
코드 재사용이 쉽지만, 부모 클래스와 강한 결합이 발생합니다.
컴포지션:
필요한 기능만 선택적으로 사용 가능합니다.
구성요소 클래스의 인터페이스를 통해서만 접근하므로 결합도가 낮습니다.
3. 유연성과 유지보수성
상속:
부모 클래스의 변경이 자식 클래스에 영향을 미칩니다.
부모 클래스의 내부 구현에 자식 클래스가 의존하게 되면, 예기치 않은 버그가 발생할 수 있습니다.
컴포지션:
구성요소의 변경이 상대적으로 덜 영향을 미칩니다.
클래스 간의 결합도가 낮아 유지보수가 용이합니다.
4. 다형성 활용
상속:
자식 클래스는 부모 클래스 타입으로 취급될 수 있어 다형성을 활용할 수 있습니다.
예시:
컴포지션:
다형성보다는 기능 확장이나 조합에 중점을 둡니다.
4) 🤔 언제 상속과 컴포지션을 사용해야 할까?
상속을 사용해야 하는 경우
클래스 간에 Is-a 관계가 명확할 때.
부모 클래스의 동작을 그대로 물려받아 사용하거나, 동작을 확장해야 할 때.
다형성을 적극적으로 활용하여 코드의 유연성을 높이고자 할 때.
컴포지션을 사용해야 하는 경우
클래스 간에 Has-a 관계가 있을 때.
기능을 재사용하고 싶지만, 부모 클래스와 강한 결합을 피하고자 할 때.
기존 클래스의 일부 기능만 활용하거나, 내부 구현에 의존하지 않고 안정적인 코드를 작성하고자 할 때.
5) 상속과 컴포지션의 장단점
상속의 장점
코드 재사용이 쉽습니다.
다형성을 활용하여 유연한 코드를 작성할 수 있습니다.
부모 클래스의 기능을 그대로 사용하거나, 필요한 경우 재정의하여 확장할 수 있습니다.
상속의 단점
부모 클래스와 강한 결합이 발생하여, 부모 클래스의 변경이 자식 클래스에 영향을 미칩니다.
잘못된 상속 구조는 유지보수성을 떨어뜨리고, 코드의 안정성을 해칠 수 있습니다.
자식 클래스가 부모 클래스의 불필요한 기능까지 상속받을 수 있습니다.
컴포지션의 장점
클래스 간의 결합도가 낮아 유지보수가 용이합니다.
필요한 기능만 선택적으로 사용 가능하며, 코드의 재사용성이 높습니다.
내부 구현에 의존하지 않으므로, 구성요소 클래스의 변경에도 안정적입니다.
컴포지션의 단점
상속에 비해 구현해야 할 코드가 많아질 수 있습니다.
다형성을 활용하기 어려울 수 있습니다.
메서드 위임이 필요하여 코드가 장황해질 수 있습니다.
추가 예시: 도형(Drawing) 프로그램에서의 상속과 컴포지션
상속의 예시: Shape
클래스와 Circle
클래스
설명:
Circle
은Shape
의 일종이므로 상속을 사용합니다.
컴포지션의 예시: Canvas
클래스와 Pen
클래스
설명:
Canvas
클래스는Pen
객체를 사용하여 도형을 그립니다.관계:
Canvas
는Pen
을 가지고 있으며,Shape
객체를 사용하여 그림을 그립니다.
✨ 정리
상속은 is-a 관계일 때만 사용해야 한다. 클래스 간의 계층 구조를 형성할 때 말이다. 즉, 하위 클래스가 상위 클래스의 진짜 하위 타입인 경우에만 상속을 사용해야 한다. 내부 구현에 의존하거나 강한 결합이 발생할 수 있으므로 주의해야 한다.
컴포지션은 Has-a 관계를 나타내며, 클래스의 기능을 유연하게 확장하고 재사용할 수 있다. 컴포지션과 위임을 사용하면 상속의 단점을 피하면서 유연하고 안전하게 기능을 확장할 수 있다. 하지만결합도가 낮아 유지보수가 용이하지만, 다형성 활용이 제한적일 수 있다.
래퍼 클래스를 사용하면 기존 클래스의 내부 구현에 의존하지 않고도 기능을 추가하거나 변경할 수 있다.
특히, 상위 클래스에 새로운 메서드가 추가되더라도 하위 클래스의 동작에 영향을 주지 않는다.
참고 글
Last updated