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

김동훈·2023년 6월 10일
1

Effective-java

목록 보기
7/14
post-thumbnail

이번 챕터를 공부하며 든 생각은 내가 상속을 올바르게 구현 할 수 있을까? 였다. 읽어보면 알겠지만, 고려할 점이 너무나 많다.


상속의 문제점

캡슐화를 깨뜨린다.

사실 깨뜨린다 보다는 깨뜨릴수 있다 가 좀 더 어울린다고 생각한다.
캡슐화를 꺠뜨릴 수 있다는 관점에서 보자면, 하위 클래스가 상위클래스에 의존적이게 된다.
이 책에서는 HashSet(InstrumentedHashSet)을 가지고 설명하고 있다.

InstrumentedHashSet에서 addAll()add()를 재정의하여 사용하고 있다. 두 메소드 모두 addCount라는 변수를 증가시켜주고있다. 문제가 되는 부분은 addAll()의 사용에 있다.

        InstrumentedHashSet<String> set = new InstrumentedHashSet<>();
        set.addAll(List.of("틱", "택", "톡"));
        System.out.println("set.getAddCount() = " + set.getAddCount());

우리는 위 코드를 실행하면 3이 출력될 것이라 예상하지만, 실제로는 6이 나온다.
이 원인은 HashSet의 addAll 메소드가 add 메소드를 사용하여 구현된데에 있다.
현재 set변수는 InstrumendtedHashSet의 인스턴스이다. 따라서 set.addAll메소드를 호출하면 HashSet의 addAll 메소드가 호출된 뒤, 이 메소드에서 HashSet의 add메소드가 아닌 InstrumendtedHashSet의 add 메소드가 호출될 것이다. 이 메소드는 우리가 재정의한 메소드이므로, addCount가 다시 증가되어 결과적으로 6이 나오게 된것이다.
실제 동작과정은 직접 구현해보고 디버깅해보면 쉽게 알 수 있다.

상속과 캡슐화에 대해 찾아보면 메소드 오버라이딩이 문제다라고 소개되어 있는 내용을 찾아 볼 수 있다. 위 문제도 메소드 재정의가 원인이었다.
그래서 이 책에서 말해주는 해결법은 컴포지션이다.

컴포지션을 사용하라.

컴포지션이란 기존 클래스가 새로운 클래스의 구성요소로 쓰이는 것이다.

이제는, InstrumentedSet이라는 Wrapper class를 사용하게 된다. 이 클래스는 ForwardingSet 클래스를 상속받고 있다. 이 ForwardingSet클래스가 바로 컴포지션 방식이 적용된 클래스라고 본다.

현재 우리 스터디에서, 이 클래스에 대한 issue가 올라왔었는데, 왜 굳이 Set을 구현(implements)하고 있나?라는 내용이다.

나는 이에 대해 다음처럼 생각한다.

  • 지금은 InstrumentedSet이지만, 만약 예제처럼 계측 기능도 추가한 TreeSet을 사용하고 싶다면 InstrumentedTreeSet으로 클래스 이름을 짓고, ForwardingSet은 extends TreeSet을 하는 것이 올바르다고 생각한다.
  • TreeSet을 상속받아야 모든 TreeSet의 메소드를 사용할 수 있다.
  • 클래스 상속은 하위클래스에서 메소드 재정의를 하지 않아도 컴파일에러가 발생하지 않지만, 인터페이스의 구현은 다르다. 인터페이스에서 정의한 메소드를, 구현체에서 정의하지 않는다면 컴파일 에러가 발생한다.
  • 이제 issue에 대해 생각해보면, 우리의 InstrumentedSet은 Set인터페이스에서 정의해놓은 메소드들은 모두 사용할 수 있는것이 당연하다. 만약 개발자의 실수로 인해 ForwardingSet에서 구현해놓지 않은 메소드가 있다면, 이 메소드는 클라이언트가 사용할 수 없다.
  • 이는 InstrumentedSet의 설계의도에 어긋나게 된다. 이러한 상황을 방지하기 위해 implements키워드를 사용한 것이라고 본다. 만약 개발자가 실수로, 어떤 메소드를 구현하지 않게되면, 컴파일 에러가 발생하기 때문에 실수를 방지 할 수 있기 때문이다.

그럼 다시 책으로 돌아와 InstrumentedSet 클래스를 Wrapper class라 하였는데, 이 래퍼클래스는 단점이 거의 없다. 단지 래퍼 클래스가 콜백 프레임워크와 어울리지 않다라는 점만 주의하면 된다. 나는 이 의미가 잘 이해되지 않아 구글링을 한 결과 좀 도움이 될만한 을 보았다.

public class NotSuitableCallback {

    public static void main(String[] args) {
        SomeService   service       = new SomeService();
        WrappedObject wrappedObject = new WrappedObject(service);
        Wrapper       wrapper       = new Wrapper(wrappedObject);
        wrapper.doSomething();
    }
}
interface SomethingWithCallback {

    void doSomething();

    void call();

}


class WrappedObject implements SomethingWithCallback {

    private final SomeService service;

    WrappedObject(SomeService service) {
        this.service = service;
    }

    @Override
    public void doSomething() {
        service.performAsync(this);
    }

    @Override
    public void call() {
        System.out.println("WrappedObject callback!");
    }
}


class Wrapper implements SomethingWithCallback {

    private final WrappedObject wrappedObject;

    Wrapper(WrappedObject wrappedObject) {
        this.wrappedObject = wrappedObject;
    }

    @Override
    public void doSomething() {
        wrappedObject.doSomething();
    }

    void doSomethingElse() {
        System.out.println("We can do everything the wrapped object can, and more!");
    }

    @Override
    public void call() {
        System.out.println("Wrapper callback!");
    }
}

final class SomeService {

    void performAsync(SomethingWithCallback callback) {
        new Thread(() -> {
            perform();
            callback.call();
        }).start();
    }

    void perform() {
        System.out.println("Service is being performed.");
    }
}

실행결과로 wrapper의 call()이 호출되어 Wrapper callback이 출력되어야겠지만, 실제로는 그렇지 않다. 이 책에서도 말하는 부분은 내부 객체는 자신을 감싸고 있는 래퍼의 존재를 모르니 대신 자신(this)의 참조를 넘기고, 콜백 때는 래퍼가 아닌 내부 객체를 호출하게 된다.라고 하고 있다.
예시 코드에서 바로 WrappedObject 클래스를 보면 된다. 이 내부 클래스는 래퍼의 존재를 모른다. 그래서 doSomething 메소드는 원래는 래퍼의 참조를 넘겨야 했겠지만, 실제로는 자기자신(WrappedObject)의 참조를 넘기게되어 결과적으로 내부 객체가 호출되는 문제가 발생한다.

잘못 구현된 자바 플랫폼 라이브러리

대표적으로 stack과 Properties클래스를 소개하고 있다. 그러면서 두 사례 모두 컴포지션을 사용했다면 더 좋았을 것이라 말하고 있는데, 그래서 나는 Stack클래스를 컴포지션을 통해 구현해보았다.

우선 기존 Stack의 문제점에 대해 말해보자.

Vector를 확장하면 안되었다.
현재 Stack클래스는 FILO의 특성을 가지고 있고, 이는 한 방향으로만 삽입,삭제가 일어나야 한다. 하지만 Vector를 상속받고 있어서 Vector의 기능까지 사용가능하다.
그 중 set 메소드를 생각해보면 index를 지정하여 value를 덮어씌우게 된다. 그럼 FILO의 특성을 어긋나게 되는 문제가 발생한다.


public class CustomStack<E> {
    private final Vector<E> vector = new Vector<>();

    public CustomStack() {
    }

    public E push(E item) {
        vector.addElement(item);

        return item;
    }

    public synchronized E pop() {
        E obj;
        int len = vector.size();
        obj = peek();
        vector.removeElementAt(len - 1);
        return obj;
    }

    private E peek() {
        int len = vector.size();

        if (len == 0)
            throw new EmptyStackException();
        return vector.elementAt(len - 1);
    }

    public boolean empty() {
        return vector.size() == 0;
    }

    public synchronized int search(Object o) {
        int i = vector.lastIndexOf(o);

        if (i >= 0) {
            return vector.size() - i;
        }
        return -1;
    }

    @Override
    public String toString() {
        return vector.toString();
    }

    private static final long serialVersionUID = 1224463164541339165L;

}

이 처럼 Vector클래스를 내부 구성요소로 사용하는 컴포지션 방식을 사용한다.
그럼 자연스레 CustomStack에서 정의하지 않은 메소드들은 사용할 수 없다. (Vector클래스를 확장하지 않음)


결론

상속은 is-a관계일 때만 사용하자.
그리고 상위클래스가 확정을 고려해 설계 되지 않았다면 상속받지 말자.(item 19)
상속대신 컴포지션을 사용하자.

참고

effective-java스터디에서 공유하고 있는 전체 item에 대한 정리글

profile
董訓은 영어로 mentor

0개의 댓글