🏠 상속보다는 컴포지션을 사용하라
상속은 코드를 재사용하는 강력한 수단이지만, 항상 최선인 것은 아니다.
이번 챕터에서 말하는 상속이란 클래스가 인터페이스를 구현하거나, 인터페이스가 다른 인터페이스를 확장하는 상속은 제외한다.
챕터에서 소개하는 상속은 클래스가 다른 클래스를 확장하는 구현상속을 가르킨다.
일반적으로 패키지 경계를 넘어 상속하는 일은 위험하다고 한다.
💧 상속은 캡슐화를 깨뜨린다.
즉 상위 클래스가 어떻게 구현되냐에 따라 하위 클래스의 동작에 이상이 생길 수 있다는 뜻이다.
캡슐화란 객체의 상태와 행동을 하나의 단위로 묶고, 외부에는 상태를 감추고 행동만을 노출시키는 것이다.
하지만 상속은 하위 클래스가 상위 클래스의 구현에 의존하게 되어, 상위 클래스의 구현이 변경되면 하위 클래스도 영향을 받게 됩니다. 이로 인해 캡슐화가 깨지게 되고, 코드의 유연성이 떨어진다.
인터페이스가 아닌 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있으며, 그 여파로 한 줄도 건들이지 않은 하위 클래스가 오동작 할 가능성이 있다. 구체적인 예시를 살펴보자.
public class InstrumentedHastSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHastSet() {}
...
@Override
public boolean add(E e) {
addCount ++;
return super.add(e)
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size()
return super.addAll(c);
}
}
기존의 HashSet 에 추가된 원소의 개수를 카운트하기 위해 필드값을 추가해 상속해서 구현한 새로운 클래스이다.
정상적으로 동작한다고 생각할 수 있지만 addAll() 메소드를 호출할 때 기대와는 다른 값을 반환한다.
size가 3인 컬렉션을 매개변수로 넘겨 addAll() 을 호출한다고 가정해보자. getAddCount() 값을 3으로 기대할 것이다.
하지만 HashSet 의 addAll() 메서드는 각 원소를 add() 메서드를 호출해 추가한다. 때문에 재정의된 add() 메서드가 3번 추가로 호출된다는 뜻이고 이는 addCount++; 코드가 뜻하지 않게 3번 더 호출된다는 뜻이다.
즉 기대와는 달리 6이라는 값을 반환한다.
이런 경우 하위 클래스에서 addAll() 메서드를 재정의하지 않거나, add() 메서드를 다른 식으로 호출하게끔 재정의 할 수 있다.
하지만 상위 클래스의 메서드 동작을 다시 구현한다는 방식은 기존 성능을 떨어트리거나, 오류를 낼 가능성이 있어 추천하지 않는다.
또한 릴리스에서 상위 클래스에 새로운 메서드를 추가했을 때 문제가 생길 수도 있다.
그럼 메서드를 재정의하는 대신 하위 클래스에서 새로운 메서드를 추가하면 괜찮지 않냐고 할 수 있지만, 불가피하게 이름이 같은 메서드가 상위 클래스 릴리스에서 새롭게 추가될지는 아무도 모르는 일이다.
💊 컴포지션
컴포지션이란 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻이다. 새 클래스의 인스턴스 메서드들을 기존 클래스의 대응하는 메서드들을 호출해 결과를 반환한다.
이러한 방식을 전달이라 부르며 새 클래스의 메서드들을 전달 메서드라고 부른다.
컴포지션의 결과로 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
public class InstrumentedHastSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedHastSet() {
super(s)
}
...
@Override
public boolean add(E e) {
addCount ++;
return super.add(e)
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size()
return super.addAll(c);
}
}
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) {this.s = s;}
...
public boolean add(E e) {
return s.add(e)
}
public boolean addAll(Collection<? extends E> c) {
return s.addAll(c);
}
}
ForwardingSet 클래스는 Set<E> 인터페이스를 구현하는 클래스로, s라는 private 필드를 가지고 있다. 이 필드는 생성자에서 초기화되며, Set<E> 타입의 객체를 참조한다. ForwardingSet 클래스의 메서드들은 이 필드를 통해 작동하도록 정의되어 있다.
InstrumentedHashSet 클래스는 ForwardingSet 클래스를 상속받아 구현되었다. 구체적으로는 Set 인터페이스를 구현하였고, 임의의 Set에 계측 기능을 덧씌워 새로운 Set 으로 만드는 것이 해당 클래스의 핵심이다.
여기서 계측기능이란, 클래스의 기능을 확장하기 위해 추가적인 기능을 구현하는 것을 말한다. InstrumentedHashSet 클래스는 addCount 라는 private 필드를 추가하여 add와 addAll 메서드에서 증가시키며, 추가된 요소의 개수를 추적하는 계측기능을 구현했다.
이러한 계측기능은 클래스의 기본 기능을 확장하고, 추가적인 정보를 제공하는데 유용하다. 이 예제에서는 컴포지션을 사용하여 ForwardingSet 클래스의 메서드들을 재사용하고, 추가적인 계측기능을 구현하였다.
다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 와 같은 클래스를 래퍼클래스라고 하며, 다른 Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.
래퍼 클래스의 단점은 거의 없지만, 래퍼 클래스가 콜백 프레임워크와는 어울리지 않는다는 점만 주의하면 된다.
콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 호출 때 사용하도록 한다.
내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 this 의 참조를 넘기고 콜백 때에는 래퍼가 아닌 내부 객체를 호출하게 된다.
👊🏻 정리
상속은 반드시 하위 클래스가 상위 클래스의 "진짜" 하위 타입인 상황에서만 쓰여야 한다.
즉 A 를 상속하는 클래스 B를 만드려고 한다면, B는 정말 A인가 ? 를 자문해보자. 그렇다라고 확신할 수 없다면 B는 A를 상속해서는 안된다.
아니라 라고 한다면, A를 private 인스턴스로 두고, A와 다른 API를 제공해야 하는 상황이다.
컴포지션을 써야 할 상황에서 상속을 사용하는 건 내부 구현을 불필요하게 노출하는 꼴이다. 그 결과 API가 내부 구현에 묶이고 클래스의 성능도 영원히 제한된다. 더 심각한 문제는 클라이언트가 노출된 내부에 직접 접근할 수도 있다는 점이다.
확장하려는 클래스의 API에는 아무 결함이 없는 확인하고, 결함이 있다면 본인의 클래스 API에 전파되도 괜찮은가를 고려해보자.
결국 오픈소스도 사람이 작성한 코드이기 때문에 결함이 존재할 수 있다.
'개인 공부 > 스터디' 카테고리의 다른 글
[이펙티브 자바] 인터페이스의 용도 (0) | 2023.06.20 |
---|---|
[이펙티브 자바] equals 재정의 (0) | 2023.06.13 |
[데이터 중심 어플리케이션 설계] 설계시 고려사항 (0) | 2023.06.05 |
[이펙티브 자바] Comparable (0) | 2023.05.30 |
[이펙티브 자바] 불필요한 객체 생성 (2) | 2023.05.16 |