🪄 상속은 캡슐화를 깨뜨린다
상속은 좋은 의도로 사용된다.
1. 공통 코드를 재사용하여 중복 줄임
2. 유지보수, 확장, 유연함
등..
그런데 상속이 캡슐화를 깨뜨린다라.. 무엇을 이야기하는 걸까?
하위 클래스들이 "상위 클래스의 변화에 종속되어 끌려다니는" 좋지 못한 상황을 이야기하는 것이다.
*만약 상위 클래스가 확장을 잘 고려했고, 문서화도 잘 해두었다면 안전하여 괜찮다.
*참고: 상속용으로 설계한 클래스는 하위 클래스를 만들어서 검증해야 한다.
.
.
상속은 상위클래스와 하위클래스가 순수한 is-a 관계일 때만 써야 한다.
무엇보다도 상속 관계는 is-a 및 is kind of 관계, 컴포지션은 has-a 및 is part of 관계라고도 할 수 있다.
예를 들어 Car is-a Automobile이기 때문에 상속 관계,
Automobile has-a Engine이기 때문에 컴포지션(구성)관계 라고 할 수 있다. (예시참고)
class Car extends Automobile {
private Engine engine;
}
만약 A를 상속하는 B를 작성하려 할 때 둘이 is-a 관계라고 확답할 수 없다면 B가 A를 상속해서는 안되고, A를 private 인스턴스로 두어야 한다. 결국 A와는 다른 API를 제공해야 하는 상황이라는 것이다. 다시 말해 A는 B의 필수 요소가 아니라 구현하는 방법 중 하나일 뿐이다.
*참고: 자바 라이브러리에서도 이 원칙을 명백히 위반한 클래스가 있다. 예를 들어 스택은 벡터가 아니므로 Stack이 Vector를 확장해서는 안 됐다. 마찬가지로 속성목록이 해시테이블이 아니므로 Properties도 Hashtable을 확장해서는 안 됐다. 두 사례 모두 상속이 아니라 컴포지션이 더욱 적절했다.
.
.
상속의 취약점을 피하려면, 상속 대신 '컴포지션과 전달'을 사용하자.
기존 클래스를 상속하여 확장하는 대신 새로운 클래스를 만들고
private 필드로 기존 클래스의 인스턴스를 참조하여 사용하는 방법이다.
모든 기능이 필요없는 상황에서 굳이 상속할 필요가 없고,
내 API가 내부 구현(상위클래스)에 묶여 끌려다니게 할 필요가 없다.
또한 상속은 상위클래스의 API를 승계하는데, "결함까지" 그대로 가져온다는 것을 유의해야 한다.
이런 점을 잘 고민하여 컴포지션의 사용을 고려해보자.
🪄 상속 문제1) 하위클래스가 상위클래스에 맞춰가야 하는 예시
어떤 프로그램에서 HashSet을 사용하고 있는데, 이 HashSet에 다음과 같은 새로운 기능을 구현하려 한다.
"HashSet이 처음 생성된 이후 원소가 총 몇 개 더해졌는지 알수있는 기능"
그래서 다음과 같이 HashSet에다가 추가된 원소의 수를 저장하는 변수 addCount를 두고,
원소를 추가하는 메서드인 add와 addAll을 아래 코드와 같이 재정의했다. 우리는 addAll 메서드를 통해 원소 세개를 추가한다면 필드 addCount값은 3이 나와야 하지만, 6이 나온다.
원래 HashSet의 addAll이 내부적으로 add를 사용하기 때문이다.
그런데 재정의할 때 addCount를 증가시키는 코드를 addAll과 add에 모두 포함했기 때문에 의도대로 동작하지 않는다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@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 int getAddCount() {
return addCount;
}
}
하위 클래스에서 addAll을 재정의하지 않으면 해결이 되지만, 결국 addAll이 add메서드를 이용해 구현했음을 알고 있어야 하는 것은 마찬가지다. 하위 클래스의 캡슐화가 깨지는 예시라고 볼 수 있다.
addAll의 파라미터로 들어온 컬렉션을 순회하며 원소당 add를 한번만 호출하면 해결이 되겠으나, 그렇게 상위 클래스 자체의 메서드 동작을 다시 구현하는 방식은 어렵고, 시간도 들고, 오류를 내거나, 성능 하락이 될 수 있다. 또한 하위 클래스에서는 접근할 수 없는 private 필드를 써야 하는 상황이면 그런 구현 자체가 불가능하다.
🪄 상속 문제2) 상위클래스에 새로운 메서드가 추가된다면?
내가 어떤 하위클래스를 구현할 건데, 그 클래스에서는 컬렉션에 추가된 모든 원소가 '특정 조건'을 만족해야 보안 이슈를 통과한다고 해보자. 그렇다면 원소를 추가하는 모든 메서드를 재정의할 때 '필요한 조건을 먼저 검사하게끔' 하면 문제가 없을 것이다.
하지만 다음 릴리스에서 '상위 클래스에 새로운 메서드가 추가되었다'고 해보자. 그렇다면 아직 재정의하지 못한 그 새로운 메서드를 사용하여 '허용되지 않은' 원소를 추가해버릴 수가 있다.
실제로 자바에서는 컬렉션 프레임워크 이전부터 존재하던 Hashtable과 Vector를 컬렉션 프레임워크에 포함시키자 이와 관련된 보안 구멍들을 수정해야 하는 사태가 벌어졌다.
🪄 상속에서 문제점 정리
위에 언급한 두 사례 모두 재정의가 원인이다. 그렇다면 메서드를 재정의하여 확장하는 대신 새로운 메서드를 추가해서 쓰면 괜찮지 않을까도 싶다. 이 방법이 훨씬 안전한 것은 맞지만, 여전히 위험 요소는 존재한다.
만약 다음 릴리스에서 상위 클래스에 새 메서드가 추가됐는데, "하필" 우리가 하위클래스에 추가한 메서드와 시그니처(메서드명, 매개변수)가 같고 반환타입이 다르다면 컴파일조차 되지 않는다.
만약 메서드의 반환타입마저 같다면, 결국 새로 추가된 메서드를 재정의한 꼴이니 위에 언급한 두 개의 재정의 문제를 만날 뿐만 아니라, 상위 클래스의 그 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다.
🪄 상속을 안하면 도대체 어떻게 확장하나요? 컴포지션!
다행히 "컴포지션"을 사용하면 위 문제들을 피할 수 있다.
기존 클래스를 확장하는 대신 새로운 클래스를 만들고
private 필드로 기존 클래스의 인스턴스를 참조하여 사용하는 방법이다.
즉 기존 클래스가 새로운 클래스의 구성요소로 쓰이는 상태이며,
그런 뜻에서 이러한 설계를 컴포지션(composition)이라 한다.
그렇게 만든 새로운 클래스의 메서드들은, private 필드로 참조하고 있는 기존 클래스의 대응하는 메서드를 호출하여 그 결과를 반환한다. 이 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwading method)라 부른다.
∨ 컴포지션 장점
- 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어난다.
- 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않는다.
🪄 상속 대신 컴포지션
위에서 문제가 있었던 상속을 사용하여 확장한 InstrumentedHashSet을
'컴포지션과 전달' 방식으로 구현한 코드를 다시 보자.
래퍼 클래스로 사용할 InstrumentedHashSet 자신과,
전달 메서드만으로 이뤄진 재사용 가능한 전달 클래스로 구성되어있다.
─ 래퍼 클래스
아래 InstrumentedSet같은 클래스는 다른 Set 인스턴스를 감싸고 있다는 뜻에서 래퍼 클래스라고 한다. 또한 다른 Set에 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.
그러니까 앞선 잘못된 상속 예시와는 달리 InstrumentedSet을 구현하고자 할 때 HashSet을 직접 상속하지 않는다. 왜냐하면 HashSet의 전체 기능은 필요 없다. 즉 HashSet에 강하게 결합할 필요가 전혀 없다는 것이다. 대신 바로 이어서 설명할 ForwardingSet이라는 전달 클래스를 상속하는 형태다.
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(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
─ 재사용할 수 있는 전달 클래스
HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다. 임의의 Set에 원하는 기능을 덧씌워 새로운 Set으로 만드는 것이 이 클래스의 핵심이다. 만약 InstrumentedSet이 HashSet을 상속하는 방식이었더라면 구체 클래스 각각을 따로 확장해야 하며, 지원하고 싶은 상위 클래스의 생성자 각각에 대응하는 생성자를 별도로 정의해줘야 한다. 하지만 아래와 같은 컴포지션 방식은 한 번만 구현해두면 어떠한 Set 구현체라도 유연하게 활용할 수 있다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collections<?> c)
{ return s.contatinsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Overried public String toString() { return s.toString(); }
}
방금 말한 생성의 유연함이 무슨 이야기냐면,
아래와 같이 기존 생성자를 사용하여 내가 원하는 Set 구현체를 사용 가능하다는 것이다.
예1)
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
예2)
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
한편, 대상 Set 인스턴스를 특정 조건 하에서만 임시로 계측(instrumented)할 수도 있다.
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
... // 기존 dogs 대신 iDogs를 사용하기
}
🪄 래퍼 클래스!
위와 같은 래퍼 클래스는 단점이 거의 없다.
전달 메서드의 성능이나 래퍼객체가 메모리 사용량에 영향을 주지 않을까 걱정하기도 하지만, 실전에서는 둘 다 별다른 영향이 없다고 밝혀졌다! 전달 메서드들을 작성하는게 지루하겠지만 재사용가능한 전달클래스를 인터페이스당 하나씩만 만들어두면 원하는 기능을 덧씌우는 전달 클래스들을 아주 손쉽게 구현할 수 있다.
*좋은 예시: 자바 라이브러리인 구글 구아바는 모든 컬렉션 인터페이스용 전달 메서드를 전부 구현해두었다.
그러나 주의점은 래퍼 클래스가 "콜백 프레임워크"와 어울리지 않는다는 점이다. 콜백 프레임워크에서는 자기 자신의 참조를 다른 객체에 넘겨서 다음 콜백호출 때 사용하도록 한다. 그러나 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르기에 자기자신(this)의 참조를 넘기므로 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다. 이를 SELF 문제라고 한다.
🪄 자바의 불건전한 예시
컴포지션을 써야 하는데 상속을 사용한 잘못된 예시는 자바의 Properties에 있다.
Properties의 인스턴스 p가 있을 때, p.getProperty(key)와 p.get(key)는 결과가 다를 수 있다. 전자가 Properties의 기본 동작이고 후자는 그 상위클래스인 Hashtable에서 물려받은 메서드기 때문이다.
상위클래스인 Hashtable의 메서드를 직접 수정한다면 하위클래스인 Properties의 불변식을 깨버릴 수 있다. 예를 들어 Properties는 키와 값으로 문자열만 허용하도록 설계하려 했으나, 상위클래시인 Hashtable에서 메서드를 직접 호출하면 불변식이 깨질 수 있고, 불변식이 한번 깨지면 load와 store같은 다른 Properties API는 더 이상 사용할 수 없다.
이 문제가 밝혀졌을 때는 이미 많은 사용자가 Properties의 키, 값으로 '문자열 이외의 타입'을 사용하고 있는 상황이었다.
'JAVA > Effective Java' 카테고리의 다른 글
[이펙티브 자바] 추상 클래스보다는 인터페이스를 우선하라 ─ 4장:클래스와 인터페이스:Item20 (0) | 2023.12.10 |
---|---|
[이펙티브 자바] 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라 ─ 4장:클래스와 인터페이스:Item19 (1) | 2023.10.31 |
[이펙티브 자바] 변경 가능성을 최소화하라 ─ 4장:클래스와 인터페이스:Item17 (0) | 2023.10.25 |
[이펙티브 자바] public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라 ─ 4장:클래스와 인터페이스:Item16 (0) | 2023.10.25 |
[이펙티브 자바] 클래스와 멤버의 접근 권한을 최소화하라 ─ 4장:클래스와 인터페이스:Item15 (1) | 2023.10.24 |