본문 바로가기
JAVA/Effective Java

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

by 민휘 2023. 5. 29.

요약

 

구현 상속은 부모 클래스의 구현을 자식 클래스가 그대로 물려받으므로, 부모와 자식이 구현에 의해 강하게 결합되어 캡슐화가 깨진다. 만약 상위 클래스의 메서드 구현이 하위 클래스에 영향을 받지 않도록 만들고 싶다면, 상속보다 캡슐화를 잘 지키는 컴포지션을 사용하는 것이 좋다.

 

컴포지션은 기존 클래스를 새로운 클래스의 구성요소로 사용하는 것이며, 새 클래스의 인스턴스 메소드는 기존 클래스의 대응하는 메소드를 호출해 그 결과를 반환한다. 이 방식을 전달이라 한다. 컴포지션과 전달의 조합은 넓은 의미로 위임에 해당한다. 위임은 상속과 달리 캡슐화를 깨뜨리지 않으면서 코드를 재사용할 수 있다.

 

상속은 서브 타입을 사용할 경우에 유효하다. 코드를 재사용하는 목적이라면 상속은 부모와 자식의 강한 결합을 유발하므로 사용하지 않는 것이 좋다. 서브 타입 구현을 통해 동일한 메시지를 이해하고 각자의 방식으로 동작하는 타입을 구현할 때 상속을 사용할 수 있다.

 

상속의 위험성

 

상속을 사용하여 코드를 재사용했을 때 캡슐화가 깨져 원래 의도와 다른 결과를 내는 예시를 살펴보자.

HashSet을 사용해 값을 저장하되, 새로 추가되는 값의 개수를 세는 InstrumentedHashSet을 구현한다. HashSet을 상속하고, add와 addAll을 오버라이딩하여 값의 개수를 추가하는 로직을 작성했다.

 

public class InstrumentedHashSet<E> extends HashSet<E> {
    // The number of attempted element insertions
    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는 우리가 기대한대로 동작하지 않는다. 컬렉션으로 3개의 아이템을 추가했는데 개수를 찍어보면 6개로 나온다.

public static void main(String[] args) {
	InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
	s.addAll(List.of("Snap", "Crackle", "Pop"));
	// expected 3, but result is 6
	System.out.println(s.getAddCount());
}

 

그 이유는 HashSet의 addAll이 내부적으로 add를 호출하기 때문이다. addAll에 의해 addCount에 3이 더해졌는데, super.addAll()을 호출하면서 재정의한 add()가 세번 더 호출되어 addCount의 값은 6이 되었다. 상속을 사용하면 상위 타입이 재정의된 메소드를 호출하기 때문에 사이드 이펙트가 발생한다.

public abstract class AbstractCollection<E> implements Collection<E> {

		public boolean addAll(Collection<? extends E> c) {
        boolean modified = false;
        for (E e : c)
            if (add(e)) // 내부적에서 add 호출
                modified = true;
        return modified;
    }

}

 

제대로 동작하게 하려면 add를 통해서만 addCount를 증가시키면 된다.

public class InstrumentedHashSet<E> extends HashSet<E> {
    // The number of attempted element insertions
    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);
    }

}

 

그러나 이 방법은 임시방편일 뿐이다. 우리가 의도한 대로 동작하지 않은 코드의 근본적인 문제는 InstrumentedHashSet이 HashSet이 어떻게 동작하는지 알고, 이 사실을 가정하여 자신의 코드를 작성하고 있다는 점이다. InstrumentedHashSet의 개수를 세는 코드는 HashSet addAll이 내부적으로 add를 호출하고 있다고 가정하고 작성한다. 만약 HashSet의 구현이 달라진다면, 이 가정은 쉽게 바뀔 것이고, InstrumentedHashSet의 코드 역시 영향을 받을 것이다. 캡슐화가 깨졌기 때문에 두 클래스 간의 결합도가 높아진 것이다.

 

상위 클래스의 구현이 바뀌는 것은 쉽게 발생할 수 있는 일이다. 상위 클래스는 릴리스마다 내부 구현이 달라질 수 있다. 또 상위 클래스에 새로운 메소드를 추가하는 경우에도 자식 클래스의 다른 메소드에 영향을 미칠 수 있다.

 

컴포지션을 이용한 개선

 

상속을 사용했을 때는 캡슐화가 깨졌다. 반면 컴포지션과 포워딩을 사용하면 캡슐화를 지키고, 부수 효과를 발생시키지 않으면서 다른 클래스의 기능을 재사용할 수 있다.

  • 컴포지션(구성) : 새로운 클래스가 private 필드로 기존 클래스의 인스턴스를 참조한다
  • 포워딩(전달) : 새 클래스의 인스턴스 메소드는 기존 클래스의 대응하는 메소드를 호출한다
  • 위임 : 컴포지션과 전달의 조합으로 메시지를 통해 실제 기능 수행을 다른 객체에게 맡기는 것을 말한다.

 

Set 인터페이스를 구현하여 Set과 동일한 메시지를 수신할 수 있으며, 메시지를 실제 Set 구현체에게 위임할 수 있도록 Set 타입을 내부 필드로 포함한 ForwardingSet을 만들었다. 실제 Set 구현체는 생성자를 통해 외부에서 주입받도록 만들었다.

// 재사용할 수 있는 전달 클래스
public class ForwardingSet<E> implements Set<E> {

    private final Set<E> s; // 컴포지션
    public ForwardingSet(Set<E> s) { this.s = s; } // DI

		// 전달 메소드
    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(); }
}

 

ForwardingSet는 Set으로 들어오는 요청을 받아서 자신이 참조하는 Set 구현체에게 처리를 요청하는 래퍼 클래스이다. 이 래퍼 클래스를 재정의해서 부가 기능을 추가하면 데코레이터 패턴으로 활용할 수 있다. 아래 코드는 Set에 아이템을 추가할 때 그 개수를 세는 기능을 추가한 InstrumentedSet이다. ForwardingSet를 상속해서 만들었다. ForwardingSet는 메소드의 구현이 담긴 것이 아니라 위임만 하기 때문에 상속해도 캡슐화가 깨지지 않는다.

// 새로 추가되는 아이템 개수를 세는 부가 기능
// 을 추가한 데코레이터
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;
    }

    public static void main(String[] args) {
				// 생성자 DI
        InstrumentedSet<String> s = new InstrumentedSet<>(new HashSet<>());
        s.addAll(List.of("Snap", "Crackle", "Pop"));
        System.out.println(s.getAddCount());
    }
}

 

 

컴포지션이 어울리지 않는 상황

콜백 활용 패턴

함수 A의 인자로 전달된 함수 B를 콜백 함수라고 부른다. A 내부에서 필요한 시점에 B가 호출된다. 템플릿 콜백 패턴에서 변경되지 않는 부분을 템플릿으로 재사용하고, 변경되는 부분만 콜백으로 분리하여 템플릿에서 필요한 시점에 콜백을 호출하는 패턴을 사용할 수 있다.

아래 코드에서 SmsService의 sendSms는 템플릿, 세번째 인자는 콜백 함수이다. 자바8에 도입된 람다식으로 함수를 객체 형태로 넘길 수 있다. sendSms는 공통된 흐름을 처리하다가 변경되는 부분만 콜백으로 받아서 콜백을 호출한다.

@Test
void send_sms_using_a_service_lambda() {
    String cellphoneNumber = "010-1111-2222";
    String smsContents = "Hello! Strategy Pattern.";
    SmsService service = new SmsService();

    service.sendSms(cellphoneNumber, smsContents, (cellphoneNumber1, smsContents1) -> {
        log.info("A 외부 서비스 사용: [{}]에게 '{}' 내용 문자 보내기", cellphoneNumber1, smsContents1);
        return true;
    });
}

@Slf4j
public class SmsService {
		// 템플릿
    public void sendSms(String cellphoneNumber, String smsContents,
                        SmsSendingStrategy smsSendingStrategy) {
        log.info("문자 발송 시작");
        long startTime = System.currentTimeMillis();

        boolean result = smsSendingStrategy.execute(cellphoneNumber, smsContents);

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
    }
}

 

 

콜백 프레임워크

필드를 내부 구성으로 가지면서 위임을 하는 래퍼 클래스는 단점이 거의 없으나, 콜백 프레임워크와는 어울리지 않는다. 콜백 프레임워크는 비동기 프로그래밍 모델에서 사용되며, 이벤트가 발생했을 때 해당 이벤트를 처리하는 콜백을 호출하는 방식으로 동작한다.

 

콜백 프레임워크에 부가 기능을 구현한 래퍼 클래스를 콜백으로 사용하는 경우, 부가 기능이 제대로 동작하지 않는다. 그 이유는 래퍼의 내부 클래스가 this를 전달하기 때문에 콜백을 호출하는 클래스가 래퍼가 아닌 내부 객체를 호출하기 때문이다. 내부 클래스는 자신을 감싸는 래퍼 클래스의 존재를 모르기 때문에 이러한 문제가 발생한다. 이를 self 문제라고 부른다.

 

아래 예제에서 콜백을 호출하는 클래스는 Service, 콜백 내부 객체는 BobFunction, 부가기능을 적용한 래퍼 클래스는 BobFunctionWrapper 클래스이다. 서비스에 래퍼를 넘기면 부가기능까지 적용되어 콜백이 실행될 것을 기대하지만, 실제로는 내부 객체인 BobFunction만 실행된다.

public class Service {

    public void run(FunctionToCall functionToCall) {
        System.out.println("뭐 좀 하다가.."); // (5)
        functionToCall.call(); // (6) functionToCall은 BobFunction
    }

    public static void main(String[] args) {
        Service service = new Service();
        BobFunction function = new BobFunction(service);
        BobFunctionWrapper wrapper = new BobFunctionWrapper(function);
        wrapper.run(); // (1)
    }

}

// 부가기능과 위임을 하는 래퍼 클래스
public class BobFunctionWrapper implements FunctionToCall {

    private final BobFunction bobFunction;

    public BobFunctionWrapper(BobFunction bobFunction) {
        this.bobFunction = bobFunction;
    }

    @Override
    public void call() {
        this.bobFunction.call();
        System.out.println("커피도 마실까.."); // 부가기능 기대
    }

    @Override
    public void run() {
        this.bobFunction.run(); // (2) 
    }
}

// 콜백으로 사용되는 내부 객체
public class BobFunction implements FunctionToCall {

    public final Service service; // (4)

    public BobFunction(Service service) {
        this.service = service;
    }

    @Override
    public void call() {
        System.out.println("밥을 먹을까..");
    }

    @Override
    public void run() {
        this.service.run(this); // (3)
    }
}

 

제대로 동작하도록 만들려면 래퍼 클래스의 부가기능이 구현된 메소드를 직접 호출해야한다.

public class Service {

    public static void main(String[] args) {
        Service service = new Service();
        BobFunction function = new BobFunction(service);
        BobFunctionWrapper wrapper = new BobFunctionWrapper(function);
        wrapper.call(); // 부가기능이 구현된 메소드
    }

}

 

 

상속 도입 시 고려사항

 

상속은 반드시 하위 클래스가 상위 클래스의 진짜 하위 타입인 상황에서만 쓰여야한다. 즉, 클래스의 서브 타입을 만드는 경우에 상속이 적합하다. 부모 클래스를 언제나 자식 클래스가 대신할 수 있어야 하며, 대신했을 때 어떠한 문제도 발생하지 않아야 한다. 부모가 의도한 메시지의 목적을 자식에서도 동일하게 수행할 수 있어야하며, 그 구체적인 행동 방법만 달라질 수 있다.

 

다음과 같은 경우에는 상속을 권장하지 않는다.

  • 부모의 메시지를 자식이 메소드로 호출할 수 없는 경우 : 부모 Animal의 speak 메시지를 Fish는 구현할 수 없다. 예외를 던지거나 null인 상태를 출력해야한다. 이러한 경우는 Animal과 Fish가 is-a 관계가 아니다. Speakable을 인터페이스로 분리한 뒤 다른 Animal 하위 클래스가 Speakable을 구현하도록 해야한다.
  • 부모의 메시지 외에 자식이 추가로 이해할 수 있는 메시지가 있는 경우 : Item을 상속하는 Book은 getAuthorName 인터페이스를 가지고 있다. 기존 객체들이 상위 타입에 속하는 객체들 간의 상호작용으로 협력을 추상화해두었는데, Book의 getAuthorName 호출이 필요해진다면 더이상 추상화된 협력을 사용할 수 없으며, Item의 하위 타입인 Book이 존재한다는 것을 코드에 노출해야한다.

 

컴포지션 대신 상속을 사용하기로 결정하기 전에 마지막으로 확인할 것

  • 확장하려는 클래스의 API에 아무런 결함이 없는가?
  • 결함이 있다면, 이 결합이 하위 클래스의 API까지 전파되어도 괜찮은가?

 

관련 키워드