본문 바로가기

Effective Java

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

상속(Inheritance)

객체 지향 프로그래밍(OOP)의 특징 중 하나로써 코드를 재사용하기 위한 가장 중요한 특징이지만 항상 최선은 아니다.

잘못 사용하면 오류를 내기 쉬운 소프트웨어를 만들어내기 때문이다!

 

동일한 프로그래머가 좋은 문서화 방식과 함께 확장을 전제로 설계한 소프트웨어라면 상속은 사용하기에 정말 좋은 방식이다.

 

하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다.

다시 말해, 외부 패키지의 클래스를 확장하는 구현하는 구현 상속은 위험하다는 얘기다. (인터페이스 상속과는 무관~)


메소드 호출과 달리 상속은 캡슐화를 깨뜨린다. 다르게 말해, 상위 클래스가 어떻게 구현되느냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다. 상위 클래스는 릴리즈마다 내부 구현이 달라질 수 있으며, 그 여파로 코드 한 줄 건드리지 않은 하위 클래스가 오동작할 가능성이 생긴다.

따라서, 상위 클래스 설계자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변화에 발맞춰 그때 그때 수정돼야만 한다.


캡슐화가 상위 클래스에서 깨지는 경우

이해를 위해서 아래 HashSet의 메소드 중 add()와 addAll()을 주의깊게 관찰해보자

public class InstrumentedHashSet<E> extends HashSet<E>{
	//추가된 원소의 수
	private int addCount = 0;
    
    public InstrumentedHashset(){
    }
    
    public InstrumentedHashSet(int initCap, float loadFactor){
    	super(initCap, loadFactor);
    }
    
    @Override
    public void add(E e){
    	addCount++;
        return super.add(e);
    }
    
    @Override
    public boolean addAll(Collections<? extends E> c){
    	addCount+=c.size();
        return super.addAll(c);
    }
    
    public int getAddCount(){
    	return addCount;
    }
}

 

위 코드가 오버라이딩한 사용자의 의도대로 흘러가지 않는 이유는 HashSet의 add()와 addAll()의 구성 원리(로직)에 의해서이다.

addAll()이 add()함수를 이용하여 구현하였기에 오버라이딩하여도 원소가 중복된다.

 

이처럼 자신의 다른 부분을 사용하는 '자기 사용(self-use)'여부는 해당 클래스의 내부 구현 방식에 해당되며, 이로 인해 재정의 문제가 발생할 가능성이 존재한다.

 

위 예제에서의 문제를 해결하기 위해서 단순 addAll()이 add()를 호출하는 것이 아닌 원소의 중복 여부를 기반으로 원소 하나당 add 메소드를 한 번만 호출하는 것으로 접근할 수 있다.

 

이러한 방식이 그나마 낫더라도, 상위 클래스의 메소드 동작을 다시 구현하는 이 방식은 어렵고, 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어뜨릴 수도 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야하는 상황이라면 이 방식으로는 구현 자체가 불가능하다.


캡슐화가 하위 클래스에서 깨지는 경우

이후 릴리즈에서 상위클래스에 새로운 메소드가 추가되는 상황에서, 보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야만 하는 프로그램을 가정해보자. 컬렉션을 상속하여 원소를 추가하는 모든 메소드를 재정의해 필요한 조건을 먼저 검사하게끔!

하지만 위와 같은 방식은 상위 클래스에 또 다른 원소 추가 메소드가 만들어지면 하위 클래스에 재정의하지 못한 메소드에 의해 허용되지 않은 원소를 추가할 수 있게 되어 문제가 발생한다.


위 두 문제 모두 메소드 재정의가 원인이다.

따라서 클래스를 확장하더라도 메소드를 재정의하는 대신 새로운 메소드를 추가하면 괜찮으리라 생각할 수도 있다.

하위 클래스에 추가한 메소드와 시그니처가 같고 반환 타입은 다르다면 컴파일조차 되지 않으며, 반환 타입마저도 같게 된다면 오버라이딩한 것과 같은 꼴이니 같은 문제가 발생한다.

또한, 새로 메소드를 작성할 때는 상위 클래스에 메소드가 존재하지도 않으니 상위 클래스의 메소드가 요구하는 규약을 만족하지 못한 가능성이 크다.


문제를 피해가는 묘안 :  컴포지션(composition)

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 하자.

기존 클래스가 새로운 클래스의 구성요소로 사용된다는 점에서 이러한 설계를 '컴포지션'이라 한다.

새 클래스의 인스턴스 메소드들은 private 필드로 참조하는 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다.(전달)

새 클래스의 메소드들을 전달 메소드라 부른다.

이러한 방식을 통해 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 심지어 기존 클래스에 새로운 메소드가 추가되더라도 전혀 영향받지 않는다.

 

위 코드를 컴포지션으로 재설계하면 아래 코드와 같다.

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(Collections<? enxtends E> c){
    	addCount += c.size();
        return super.addAll(c);
    }
    
    public int getAddCount(){
    	return addCount;
    }
}

컴포지션 설계

InstrumentedSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 구체적으로는 Set 인터페이스를 구현했고, Set의 인스턴스를 인수로 받는 생성자를 하나 제공한다. 임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다.

 

상속 방식은 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다.


래퍼 클래스(Wrapper Class)

다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임(delegation)이라고 부른다. 정확히 말하자면 래퍼 객체가 내부 객체에 자기 자신의 참조를 넘기는 경우만 위임에 해당된다.

 

래퍼 클래스는 단점이 거의 없다.

한 가지, 래퍼 클래스가 콜백(callback) 프레임 워크와는 어울리지 않는다는 점만 주의하자.

콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출(콜백) 때 사용하도록 한다. 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제라 한다.

 

(전달 메소드를 작성하는 것이 귀찮고 지루할 수 있어도, 인터페이스당 하나만 작성해두어도 원하는 기능을 덧씌우는 전달 클래스들을 아주 쉽게 구현할 수 있다는 점에서 작성할만 하다.)


컴포지션과 상속

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

 

컴포지션을 써야할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 그 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수 있다는 점이다.

👉🏻 ex ) Properties의 인스턴스 문제. 

 

컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 자문해야 할 질문

"확장하려는 클래스의 API에 아무런 결함이 없는가?"

결함이 있다면 우리의 클래스의 API까지 전파돼도 괜찮은가? 컴포지션으로는 이런 결함을 숨기는 새로운 API를 설계할 수 있지만, 상속은 상위 클래스의 API를 '그 결함까지도' 그대로 승계한다.


핵심 정리

상속은 강력하지만 캡슐활르 해친다는 문제가 있다 👉🏻 상위 클래스, 하위 클래스에서 캡슐화가 깨지는 두 가지 경우

상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. is-a 관계일 때도 안심할 수만은 없는게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있다.

상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자. 특히 래퍼 클래스로 구현할 적당할 인터페이스가 있다면 더욱 그렇다. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.

is-a 관계
클래스 B가 클래스 A와 is-a 관계일 때에만 상속이 가능하다. 클래스 A를 상속하는 클래스 B를 작성하려 한다면 " B가 정말 A인가" 를 자문해보자