Home [Effective Java] Item18. 상속보다는 컴포지션을 사용하라!
Post
Cancel

[Effective Java] Item18. 상속보다는 컴포지션을 사용하라!

Item18. 상속보다는 컴포지션을 사용하라!

  • 상속은 코드를 재사용하는 강력한 수단이나, 항상 최선은 아니다.
  • 상속을 상위, 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서 사용하거나, 확장할 목적으로 설계되었고 문서화도 잘 된 클래스에서 사용한다면 안전한 방법이다.

메서드 호출과 달리 상속은 캡슐화를 깨뜨린다.

  • 상위 클래스의 구현에 따라 하위 클래스 동작에 이상이 생길 가능성이 존재한다.
    • 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있음. -> 하위 클래스의 오작동 유발 가능성 내재
    • 상위 클래스 설계자가 확장을 충분히 고려하고 문서화하지 않으면 하위 클래스는 상위 클래스 변화에 발맞춰 수정해야만 함.

상속을 잘못 사용한 예 1

  • 처음 생성 이후 몇 개의 원소가 더해졌는지 알 수 있는 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class InstrumentedHashSet<E> extends HashSet<E> {
	private int addCount = 0;

	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;
	}
}
  • 아래 코드 실행 후 getAddCount()를 호출하면 3이 출력될 것을 기대한다.
1
2
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("용", "용용", "용용용"));
  • 하지만 6이 출력된다.
    • addAll 내부에서 add 메서드를 호출하고 있기에 중복 카운팅이 된다.

해결 방법

  • 이 예제에서의 문제 해결을 위해서는, addAll 메서드의 재정의를 하지 않거나 addAll 메서드를 다른 식으로 재정의하는 방법이 있다.
  1. addAll 메서드를 재정의 하지 않는 경우
    • HashSetaddAlladd 메서드를 이용해 구현했음을 가정한 해법이라는 한계
    • 이러한 가정에 의존하는 경우, 내부 구현의 구조가 변경되면 문제 발생 여지가 있다.
  2. addAll 메서드를 다른 식으로 재정의 하는 경우
    • 예로, 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 한 번만 호출하는 방식으로 해결한다.
    • 이러한 방법도 여전히 문제가 발생한다. 상위 클래스의 메서드 동작을 다시 구현하는 일이기에, 어렵고, 시간이 많이 소요되고, 오류를 유발할 가능성이 있고, 성능 하락의 가능성이 있다. 또 하위 클래스에서 접근 불가능한 private 필드 사용 시 이 방식 사용은 불가하다.

상속을 잘못 사용한 예 2

  • 보안 때문에 컬렉션에 추가된 모든 원소가 특정 조건을 만족해야만 하는 프로그램을 구성 시
    • 컬렉션을 상속해 원소 추가하는 모든 메서드를 재정의해 필요 조건 검사를 수행하게 한다?
      • 이러한 방식은 상위 클래스에 추가 메서드가 만들어 질 경우, 허용되지 않은 원소 추가가 가능해져 보안 구멍이 생길 수 있다.

문제의 원인

  • 메서드 재정의
  • 그럼 재정의 대신 새로운 메서드 생성해 사용하면 되는가?
    • 훨씬 안전하지만, 위험이 없다고 볼 수 없다.
    • 새로 생성해서 사용하던 중, 상위 클래스에 시그니처(메서드 이름, 매개변수 리스트)는 같은데 반환 타입만 다른 메서드 추가 시 컴파일 에러가 나게 된다.
    • 상위 클래스의 메서드가 요구하는 규약을 만족하지 못할 가능성이 크다.

문제의 해결 방법 : 컴포지션

컴포지션

기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 한다. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션이라 부른다.

전달(forwarding)

  • 새 클래스의 인스턴스 메서드들은 private 필드로 참조 중인 기존 클래스에 대응하는 메서드를 호출해 결과를 반환하는데, 이 방식을 전달이라 한다.
  • 새로운 클래스는 기존 클래스의 내부 구현 방식의 영향에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않는다.

예) 래퍼 클래스

  • 상속 대신 컴포지션을 사용했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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; }
}
  • 재사용할 수 있는 전달 클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ForwardingSet<E> implements Set<E> {
    // 기존 클래스를 Private 인스턴스로 선언
    private final Set<E> s;
    public ForwardingSet(Set<E> s) { this.s = s; }

    // Set methods -> 기존 클래스에 대응하는 메서드를 호출
    @Override public int size() { return s.size(); }
    @Override public boolean isEmpty() { return s.isEmpty(); }
    @Override public boolean contains(Object o) { return s.contains(o); }
    @Override public Iterator<E> iterator() { return s.iterator(); }
    @Override public Object[] toArray() { return s.toArray(); }
    @Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
    @Override public boolean add(E e) { return s.add(e); }
    @Override public boolean remove(Object o) { return s.remove(o); }
    @Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
    @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    @Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
    @Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
    @Override public void clear() { s.clear(); }
}
  • InstrumentedSetHashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.
  • 구제적으로 Set 인터페이스를 구현, Set의 인스턴스를 파라미터로 받는 생성자를 제공한다.
    • 임의의 Set에 카운팅 기능 넣어 새로운 Set으로 만드는 것이 InstrumentedSet 클래스의 핵심
  • 상속 방식과는 달리 한 번만 구현해두면 어떠한 Set 구현체라도 카운팅 가능하며 기존 생성자들과도 함께 사용이 가능하다.

래퍼 클래스(Wrapper class)

  • 다른 Set 인스턴스를 감싸고 있다는 뜻에서 InstrumentedSet 같은 클래스를 래퍼 클래스라 부른다.
  • 다른 Set에 카운팅 기능을 덧씌운다는 뜻에서 데코레이터 패턴이라고 한다.

컴포지션의 단점

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

핵심 정리

  • 상속은 강력하지만 캡슐화를 해친다는 문제가 있다.
  • 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일 때만 써야 한다. is-a 관계라도 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장 고려 없이 설계된 경우 문제가 될 수 있다.
  • 상속의 취약점을 피하기 위해 컴포지션, 전달을 사용하자.
  • 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 컴포지션을 사용하자. 래퍼 클래스는 하위 클래스보다 견고하고 강력하다.
This post is licensed under younghwani by the author.

[Effective Java] Item17. 변경 가능성을 최소화하라!

[Effective Java] Item19. 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라!

Comments powered by Disqus.