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

2024. 3. 17. 15:35Java/Effective Java

반응형

(구체 클래스를 확장하는) 상속은 캡슐화를 깨뜨리는 특성입니다.

  • 상위 클래스의 새로운 릴리즈로 내부 구현의 변경이 생겼을 때,
    • 그를 구현한 커스텀 구현 클래스에서 예기치 못하는 오동작을 만날 수 있습니다.
  • 상위 클래스에서 새로운 메서드를 추가했을 때,
    • 기존에 특정 validation 조건을 만족해야만 값을 추가할 수 있는 구현 클래스가 있었다고 할 때, 상위 클래스의 새로운 릴리즈 버전에서 추가된 메서드가 validation 체크 없이 데이터를 넣을 수 있는 우려도 발생될 수 있습니다.
    • 기존에 이용하고 있던 커스텀 구현 클래스에서 그 메서드와 똑같은 시그니쳐를 가진 메서드를 보유하고 있을 경우 기존에 잘 되던 동작도 되지 않을 수 있고, 디버깅도 까다롭습니다

1. 상속의 문제점

상속의 문제점에 대해 알아 볼 수 있는 예제를 확인해봅시다.

아래 코드는
HashSet의 add, addAll 함수를 통해 추가된 요소의 수를 구하고자 addCount 라는 필드값의 추가하여 기능을 수행하는 코드 예제 입니다.

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

    public InstrumentedHashSet() {
    }
    public InstrumentedHashSet(int initialCapacity, float loadFactor) {
        super(initialCapacity, 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);
    }
}

add나 addAll함수를 호출한 횟수를 출력하고자합니다.
아래의 실행코드에서 우리가 원하는 결과값은 4이지만 놀랍게도 7이 출력되는것을 확인할 수 있습니다.

static void ex1() {
    InstrumentedHashSet<Integer> s = new InstrumentedHashSet<>();
    s.add(3);
    s.addAll(List.of(5, 6, 8));
    System.out.println(s.getAddCount());
}
7

이는, HashSet에서 사용하고 있는 addAll함수 내에서 add함수를 호출하기 때문에 발생된 문제입니다.
그 때문에,

public boolean addAll(Collection<? extends E> c) {
    boolean modified = false;
    for (E e : c)
        if (add(e))
            modified = true;
    return modified;
}

우리가 새로 추가한 addCount라는 필드값은 기존에 Set, HashSet 컬렉션에서 요구하는 구조와 벗어났기 때문에 발생된 문제입니다.

기존에 제공되고 있는 라이브러리를 활용하면서, 기능을 확장하고 싶다면
상속을 통한 기능 확장보다는, 기존 클래스의 구성요소를 새로운 클래스의 구성요소로 사용되는 Composition(구성) 방식을 사용하는 것이 좋습니다.


2. 컴포지션을 사용한 예제

Set을 필드로 갖고, Set으 기능을 그대로 사용하는 전달용 클래스를 하나 만듭니다.
내부에 갖고 있는 모든 메서드는 Set에 정의된 함수를 그대로 사용합니다.

package me.jiniworld.effectivejava.item18;

import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

public class ForwardingSet<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();
    }
}

그리고 위에서 생성한 포워딩클래스를 필드로 갖는 컴포지션 클래스를 생성합니다.

여기에서, addAll함수는 ForwardingSet에 정의해둔 addAll 함수를 사용하기 때문에, InstrumentedSet에서 정의한 add를 호출하지 않습니다.

@EqualsAndHashCode(callSuper = false)
@Getter
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);
    }
}

따라서, 아래의 실행결과는 우리가 원하는 대로 4가 출력되는 것을 확인할 수 있습니다.

static void ex2() {
    InstrumentedSet<Integer> s = new InstrumentedSet<>(new HashSet<>());
    s.add(3);
    s.addAll(List.of(5, 6, 8));
    System.out.println(s.getAddCount());
}
4

기능을 확장하고 싶다면 상속보다 컴포지션 방식을 사용하는 것을 권장합니다.

상속을 사용하기 전에 아래 2가지를 먼저 체크해야합니다.

  1. 확장하고자 하는 기능을 has-a 방식(컴포지션)으로 구현할 수 있는지 체크해야 하고
  2. 확장하고자 하는 클래스에 들어있있는 기능들이 결함이 없는지 반드시 체크해야 합니다.
    • 상속을 하게 되면, 상위 클래스의 결함도 그대로 전파됩니다.
728x90
반응형