Java & Spring

이펙티브 자바 - 아이템 18 : 상속보다는 컴포지션을 사용하라

밍 끄적 2023. 4. 24. 16:52
728x90

상속은 캡슐화를 위반할 수 있다

캡슐화 : 객체의 상태와 행동을 하나의 단위로 묶고, 외부에는 상태를 감추고 행동만을 노출시키는 것

상속을 통해 메소드 재정의를 수행할 수 있는데, 이는 캡슐화를 해칠수 있다. 릴리스마다 내부 구현이 달라질 수 있는 상위 클래스로 인해 하위 클래스가 오동작 할 수 있기 때문이다.

따라서 이미 구현된 class의 상속을 지양하고, implement 상속을 사용하는 것이 좋다.

You should avoid implementation inheritance whenever possible

하위 클래스의 오작동 : self-use 패턴을 사용했을 때

자기 사용 self-use : 자신의 다른 부분을 사용하는 것 / 한 메소드가 같은 클래스 내의 다른 메서드를 사용하는 패턴

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @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("틱", "탁탁", "펑"));
System.out.println(s.getAddCount()); // 6

위 코드를 실행하면, 마지막에 6을 반환한다.

InstrumentedHashSet의 addAll은 내부에서 상위클래스인 HashSet의 addAll메서드를 사용하고, HashSet의 addAll메서드는 내부에서 add 메서드를 사용하게되고, InstrumentedHashSet의 재정의한 add 메서드가 사용되므로 addCount는 3이 아닌 6이 된다.

HashSet의 addAll 메소드는, 내부에서 위와 같은 AbstractCollection의 addAll메소드를 호출한다. 위 메소드는 add를 호출하고 있는 것을 알 수 있다.

HashSet의 addAll 메소드는, 내부에서 위와 같은 AbstractCollection의 addAll메소드를 호출한다. 위 메소드는 add를 호출하고 있는 것을 알 수 있다.

이 예제에서 InstrumentedHashSet는 addAll 메소드에서 자신의 다른 부분인 add 메소드를 사용하면서 self-use 패턴을 가지게 되었다.

이 문제를 해결하기 위해, InstrumentedHashSet의 addAll이 addCount += c.size();를 수행하지 않도록 만들었다. 하지만, 이는 HashSet이 앞으로도 내부에서 add 해주는 것은 변하지 않을 것이라는 가정아래에 적용할 수 있는 해결방안이다.

하위 클래스의 오작동 : 하위 클래스에서 재정의하지 않은 메소드의 등장

상위 클래스가 새롭게 릴리즈 되면서, 새로운 메소드가 추가되었는데, 하위 클래스는 이를 재정의하지 못한 상황이 있다고 가정하자.

만약, 그 메소드가 허용하지 않는 데이터를 추가하는 메소드라면, 의도하지 않은 결과를 만들 수 있다.

ex ) 컬렉션 프레임워크 이전부터 존재하던 Hashtable과 Vector를 컬렉션 프레임워크에 포함시키자 이와 관련한 보안 구멍들을 수정해야 하는 사태가 벌어졌다.

컴포지션을 사용하라

컴포지션

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

static class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> set) {
        this.s = set;
    }

    @Override
    public Spliterator<E> spliterator() {
        return s.spliterator();
    }

    @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();
    }
}

static class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedSet(Set<E> set) {
        super(set);
    }

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

위에서 다뤘던 InstrumentedSet에 컴포지션을 적용하였다.

새로운 클래스 ForwardingSet이 private 필드(s)로 Set 타입의 인스턴스를 참조한다. ForwardingSet은 Set을 구현한 객체를 받아 사용할 뿐이기 때문에 HashSet을 상속했을 때처럼 기존 기능을 재정의한다기보다 앞뒤에 독립적인 새로운 부가기능을 넣는 방식이다.

이런 ForwardingSet을 InstrumentedSet이 상속하고 있고, HashSet 클래스에 새로운 메소드가 생겨도 영향이 없다.

또한, HashSet을 상속했을 때는 HashSet에만 카운트 기능을 넣을 수 있었지만, Set 인터페이스를 구현함으로써, TreeSet 등 Set 인터페이스만 구현하면 적용 가능해졌다.

이렇게, 다른 Set 인스턴스를 감싸고 있는 클래스를 Wrapper Class라고 칭한다.

즉, 컴포지션을 활용하면 새로운 클래스는 기존 클래스의 내부 구현 방식에서 벗어나며, 기존 클래스에 새로운 메서드가 추가되더라도 전혀 영향을 받지 않게 된다.

상속을 주의하자 : is-a 관계일 때만 상속해야한다.

상속은 반드시 하위 클래스(B)가 상위 클래스(A)의 '진짜' 하위 타입인 상황인 IS-A 관계일 때만 쓰여야 한다.

만약 확신할 수 없다면, 상속이 아닌 A를 private 인스턴스로 두고 A와는 다른 API를 제공해야 하는 상황이 대다수이다.

상속을 주의하자 : 상속하는 상위 클래스의 결함은 함께 하위 클래스에 상속된다.

컴포지션은 상위 클래스의 결함을 숨기는 새로운 API를 설계할 수 있지만,  상속은 상위 클래스의 API를 그 결함까지도 그대로 승계한다.

레퍼런스

Why extends is evil

728x90