본문 바로가기
Language/JAVA

[Effective java] 상속보다는 컴포지션을 사용하라

by jepa 2023. 9. 9.
728x90
SMALL

목적

확장할 목적으로 설계되었고 문서화도 잘 된 클래스라면 상속도 안전합니다.

하지만 일반적인 다른 패키지의 구체 클래스를 상속하는 일은 위험할 수 있다고 합니다.

 

오늘은 상속을 쓰기 좋은 상황과 상속의 위험성과 상속대신 컴포지션을 사용하는 방법을 알아보려고 합니다.

 

상속의 위험성

상위 클래스는 릴리스마다 내부 구현이 달라질 수 있습니다.

해당 여파로 하위 클래스가 오동작할 수 있습니다.

 

하위클래스가 상위클래스로 인해 깨지기 쉬운 예시

1. HashSet - 문서화가 잘 안되어 있을 경우

HashSet으로 생성된 이후 원소가 몇 번 더해졌는지 알 수 있어야한다라고 했을 때

다음과 같이 상속으로 구현할 수 있을 것입니다.

import java.util.Collection;
import java.util.HashSet;

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;
    }
}

위의 코드를 구현했을 때

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("A","BB","C"));

를 사용하면 3이 나오길 기대했지만 실제로는 6이 나옵니다.

 

이러한 이유는 HashSet의 addAll메서드는 add()메서드를 사용해 구현되어 있기 때문입니다.

 

때문에 실행순서는 아래와 같습니다.

구현한 addAll 실행
→ addCount+=3;
→ HashSet(상위클래스)의 addAll 호출
→ 상위클래스의 addAll이 각 원소마다 add호출
→ 해당 add는 새로 구현한 add메소드
→ 따라서 카운팅을 2번씩 중복으로 해주게 됨.

HashSet 처럼 자신의 다른 부분을 사용하는 자기사용(self-use) 여부를

다음 릴리스에서도 유지한다고 보장 못하기 때문에

이처럼 상속하는 것은 위험하다고 볼 수 있습니다.

 

참고: AbstractCollection 클래스의 addAll 메소드
HashSet은 AbstractSet을 구현하고 AbstractSet은 AbstractCollection을 구현하여
HashSet은 AbstractCollection의 addAll메소드를 사용한다.
AbstractCollection의 addAll메소드의 문서는 아래와 같다.

 

2. 상위 클래스에 새로운 메서드를 추가 했을 때

예를들어 새로운 릴리스에서 보안성 문제때문에

컬렉션에 추가되는 원소는 특정 조건을 만족해야하는 규약이 생겼다고 가정했을 때

재정의를 해놓은 하위 클래스들도 규약에 맞춰 원소를 추가하는 모든 메서드는 재정의가 필요할 것 입니다.

 

이런 상황에서 재정의를 하지 못한 채 릴리스가 된다면

기존의 재정의한 메서드로 허용되지 않은 원소를 추가할 수 있게 될 수 있습니다.

 

위의 두 문제들 모두 메서드를 재정의했기 때문에 발생하는 문제라고 볼 수 있습니다.

참고) 이를 해결하기 위해 재정의 하는 대신 새로운 메서드를 추가한다면 괜찮아 질 수 있을까?
해당 방식이 훨씬 안전한 것은 맞지만, 운이 없게 상위 클래스의 새로운 릴리스에서 새로 추가한 메서드와 시그니처가 같다면 더 큰 문제가 발생할 수 있다고 합니다.
참고)
실제로 Hashtable과 Vector를 컬렉션 프레임워크에 포함시켰을 때 이와 관련된 보안 문제가 발생한 적이 있다고 합니다.
참고) 그럼 상속은 어떨 때 사용하는 것이 좋을까?

1. 상/하위 모두 한 프로그래머가 통제하는 패키지 안 일 경우
2. 확장될 목적으로 설계되었을 경우
3. 문서화가 잘 되어있을 경우
4. 인터페이스인 경우

등으로 볼 수 있다.

 

컴포지션 설계로 바꾸어 보자!

컴포지션 설계를 활용하면 위와 같은 문제들을 피할 수 있습니다.

 

컴포지션은 기존 클래스를 확장하는 대신 아래와 같은 방법으로 구성할 수 있습니다.

1. 새로운 클래스를 만든다

2. private 필드로 기존 클래스의 인스턴스를 참조한다.

 

이렇게 기존 클래스가 새로운 클래스의 구성요소로 쓰이기 때문에 이러한 설계를 컴포지션(composition; 구성)이라 한다.

 

집합클래스 - 전달클래스를 상속받음.

import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

public class InstrumentedHashSet<E> extends ForwardingSet<E> {
    private int addCount = 0;
    public InstrumentedHashSet(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;
    }
}

 

전달 클래스 - 정말 전달만하는 애

새 클래스의 메서드들을 전달 메서드(forwarding method)라고 한다.

import java.util.Collection;
import java.util.Iterator;
import java.util.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(Collection<?> c) {return s.containsAll(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();}
    @Override
    public String toString(){return s.toString();}
}

위의 코드에서는 Set 인터페이스를 구현한 다음 Set 인스턴스를 인수로 받는 생성자를 제공한다.

임의의 Set에 계측 기능을 덧씌워 새로운 Set으로 만드는 것이 클래스의 핵심이다.

 

결과 새로운 클래스는 기존 클래스의 내부 구현 방식이 영향에서 벗어나고, 

기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향받지 않게된다.

 

위의 코드처럼 구현하면 아래 코드와 같이 어떠한 Set 구현체(ex TreeSet, HashSet)라도 계측가능하며 기존 생성자들과도 함께 사용이 가능해진다.

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

이렇게 InstrumentteSet은 HashSet의 모든 기능을 정의한 Set 인터페이스를 활용해 설계되어 견고하고 아주 유연하다.

 

또한 InstrumentteSet은 다른 Set 인스턴스를 감싸고 있다는 뜻에서 래퍼 클래스라 하며,

Set에 계측 기능을 덧씌운다는 뜻에서 데코레이터 패턴 이라고 한다.

 

결론

상속은 클래스의 관계가 is 관계일 때만 상속해야한다.

=> 클래스B가 클래스 A와 is-a 관계일 때만 A를 상속해야한다.

그러므로 상속을 하려고 할 때 "B는 정말 A인가?"를 고려해보자.

 

그렇지 않으면 A를 private 인스턴스로 두자.

그렇게 하면 A는 B의 필수 구성요소가 되는 것이 아닌, 구현하는 방법 중 하나가 되어 코드가 유연해진다.

728x90
LIST