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

2022. 10. 10. 16:13JAVA/Effective Java

개요

코드를 재사용하기 위한 일반적인 방법은 상속을 이용한 방법이 있습니다. 하지만 상속을 이용한 방법은 최선의 선택이 아니고 잘못 사용하면 오류가 발생하기 쉽습니다. 이 글에서는 어떻게 상속이 캡슐화를 망가뜨릴 수 있는지 알아보고, 상속을 잘못되게 설계한 예제를 소개합니다. 그리고 상속을 사용하여 코드를 재사용하는대신 컴포지션을 사용하여 코드를 재사용하는 방법에 대해서 소개합니다. 마지막으로 상속을 사용시 주의할 점에 대해서 알아봅니다.

 

1. 상속이 캡슐화를 망가뜨릴 가능성이 높은 이유는 무엇인가?

메서드 호출과는 달리 상속의 사용은 캡슐화를 망가뜨릴 가능성이 높아지게 됩니다. 상위 클래스가 어떻게 구현하느냐에 따라 하위 클래스의 동작에 이상이 발생시키거나 하위 클래스의 멤버를 노출시킬 수 있습니다.

 

상위 클래스의 내부 구현 변경이 하위 클래스의 동작에 영향을 미치는 경우

예를 들어 상위 클래스의 내부 구현을 변경했을 때 하위 클래스는 내부 구현을 변경하지 않았음에도 하위 클래스의 동작에 이상이 발생할 수 있습니다.

class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    public void move(){
        System.out.println("다리를 이용하여 이동한다");
    }
}
class Reptile extends Animal{
    public Reptile(String name) {
        super(name);
    }
}

파충류(Reptile) 클래스는 동물(Animal) 클래스를 상속받습니다. 그런데 Animal 클래스에서 move 메서드의 내부 구현을 "다리를 이용하여 이동한다"고 변경하였다고 가정합니다. 

public class AnimalTest {
    @Test
    public void move(){
        Animal snake = new Reptile("뱀");

        snake.move(); // Expected Output : 다리를 이용하여 이동한다
    }
}
다리를 이용하여 이동한다

하위 클래스인 Reptile 클래스는 변경하지 않았음에도 상위 클래스인 Animal 클래스의 move 메서드 내부 구현 변경으로 인하여 잘못된 이동(뱀은 기어가야함)을 하게 되었습니다.

 

위와 같은 문제를 해결하기 위한 방법 중 하나는 상위 클래스를 추상 클래스로 정의하여 상속받은 하위 클래스에서 구현하는 방법이 있습니다.

abstract class Animal {
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    public abstract void move();
}
class Reptile extends Animal {
    public Reptile(String name) {
        super(name);
    }

    @Override
    public void move() {
        System.out.println("기어갑니다.");
    }
}

 

상속을 사용하여 하위 클래스가 노출되어 캡슐화가 망가지는 경우

상속을 사용하여 캡슐화가 망가지는 대표적인 사례는 오버라이딩을 통하여 메서드를 재정의하는 경우입니다.

class Car {
    public void move(){
        System.out.println("move!!");
    }
}
class Avante extends Car{
    @Override
    public void move() {
        System.out.println("Avante move!!");
    }
}

Avante 클래스는 Car 클래스의 하위 클래스입니다. 이때 Avante 클래스는 상위 클래스의 move 메서드를 재정의하였습니다.

    @Test
    public void move(){
        Car car1 = new Car();
        Car car2 = new Avante();

        car1.move();
        car2.move();
    }

위 테스트 코드의 실행결과는 다음과 같습니다.

move!!
Avante move!!

위 실행결과를 통하여 car2.move() 호출시 Car.move() 메서드를 호출하지 않고 Avante.move() 메서드를 호출한 것을 볼 수 있습니다. 위와 같은 상위 클래스(Car)의 메서드 구현은 하위 클래스(Avante)에게 노출되어 재정의할 가능성을 주어 캡슐화의 은닉화를 망가뜨렸다고 볼 수 있습니다.

 

만약 하위 클래스가 메서드를 재정의하지 못하도록 하고 싶다면 다음과 같이 개선할 수 있습니다.

class Car {

    public final void move(){
        System.out.println("move!!");
    }

}

위와 같이 메서드에 final를 적용하여 재정의하지 못하도록 개선합니다.

 

2. 잘못된 하위 클래스 설계

 

다음 InstrumentedHashSet 클래스는 HashSet 클래스의 하위클래스로써 처음 인스턴스가 생성된 이후에 원소가 몇개 더해졌는지 알 수 있는 클래스입니다. HashSet 클래스의 add, addAll 메서드 코드를 재사용하기 위해서 HashSet.add, HashSet.addAll 내부 구현이 어떻게 구성되어 있는지 모른 상태에서 정의한 코드입니다.

class InstrumentedHashSet<E> extends HashSet<E> {
    // 추가된 원소의 수
    private int addCount;

    public InstrumentedHashSet(){
        this.addCount = 0;
    }

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

    public int getAddCount(){
        return addCount;
    }
}

InstrumentedHashSet 클래스는 HashSet 클래스에서 add, addAll 메서드를 재정의하였습니다. 테스트 코드는 다음과 같습니다.

    @Test
    public void addAll(){
        //given
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        //when
        s.addAll(List.of("틱", "탁탁", "펑"));
        int actual = s.getAddCount();
        //then
        System.out.println(actual);
    }
6

위 실행결과를 보면 3개를 더했음에도 불구하고 실제 실행결과는 6임을 볼 수 있습니다. 위와 같은 결과가 나온 원인은 HashSet의 addAll 메서드가 add 메서드를 사용해 구현되었기 때문입니다. 즉, addAll 메서드를 호출하면 add 메서드를 3번 호출하게 되는 것입니다. 이때 add 메서드도 재정의하였기 때문에 InstrumentedHashSet.add 메서드를 호출하게 되어 카운팅이 한번더 된 것입니다.

 

위와 같은 상황에서 문제를 해결하기 위한 해결안

  • addAll 메서드를 재정의하지 않는 방식
    • HashSet의 addAll이 add 메서드를 이용해 구현했음을 가정한 해법이라는 한계를 가짐
    • addAll이 add 메서드를 호출하는 자기사용(self-use) 방식이 다음 릴리스에서도 유지될 지 알수가 없음
  • addAll 메서드를 재정의는 하되 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 한번만 호출하는 방식
    • 상위 클래스의 메서드 동작을 다시 구현하는 이 방식은 어렵고 시간도 더 들고, 자칫 오류를 내거나 성능을 떨어뜨릴 수 있음
    • 하위 클래스에서는 접근할 수 없는 private 필드에 접근해야 하는 상황이라면 이 방식으로는 구현할 수 없음
  • addAll 메서드를 재정의하지 않고 새로운 메서드를 추가하는 방식
    • 상위 클래스에서 새 메서드가 추가되었는데, 운이 없게도 하위 클래스에서 추가한 메서드와 시그니처(이름, 매개변수, 반환타입)이 같아서 재정의하게 되는 경우 동일한 문제가 발생함

addAll 메서드를 재정의하지 않는 방식

class InstrumentedHashSet<E> extends HashSet<E> {
	...
    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

	// addAll 메서드를 재정의하지 않음

    public int getAddCount(){
        return addCount;
    }
}

 

 

addAll 메서드를 재정의는 하되 주어진 컬렉션을 순회하며 원소 하나당 add 메서드를 한번만 호출하는 방식, 재정의한 addAll 메서드는 HashSet의 addAll를 더이상 호출하지 않습니다.

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
//        addCount += c.size();
        boolean modified = false;
        for (E e : c)
            if (add(e))
                modified = true;
        return modified;
    }

 

 

addAll 메서드를 재정의하지 않고 새로운 메서드를 추가하는 방식

    public boolean addElement(E e){
        addCount++;
        return super.add(e);
    }

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

 

 

정리하면 상위 클래스의 어떻게 내부 구현을 하느냐에 따라서 하위 클래스의 변경이 없어도 동작에 이상이 발생할 수 있습니다. 이러한 이유로 상위 클래스 설계자가 확장을 충분히 고려하고 문서화도 제대로 해두지 않으면 하위 클래스는 상위 클래스의 변환에 발맞추어 수정되야 합니다.

 

3. 상속 대신 컴포지션을 사용한 설계

코드를 재사용하기 위해서 기존 클래스를 확장하는 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하게 할 수 있습니다. 기존 클래스가 새로운 클래스의 구성요소로 쓰인다는 뜻에서 이러한 설계를 컴포지션(composition; 구성)이라고 합니다.

 

새 클래스의 인스턴스 메서드들은 (private 필드 멤버로 참조하는) 기존 클래스의 대응하는 메서드를 호출해 그 결과를 반환합니다. 이러한 방식을 전달(forwarding)이라 하며, 새 클래스의 메서드들을 전달 메서드(forwarding method)라 부릅니다.

 

다음은 InstrumentedHashSet 클래스를 컴포지션과 전달 방식으로 다시 구현한 예제입니다. 여기서 ForwardingSet 클래스는 전달 메서드만으로 이루어진 재사용 가능한 전달 클래스입니다.

class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;

    public ForwardingSet(Set<E> s) {
        this.s = s;
    }

    @Override
    public int size() {
        return s.size();
    }

    @Override
    public boolean add(E e) {
        return s.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        return s.addAll(c);
    }
	...
}

ForwardingSet 클래스는 구성요소로 가지고 있는 set 필드멤버에게 메서드 위임을 수행합니다.

 

그리고 ForwardingSet 클래스를 확장하여 InstrumentedSet 클래스를 정의합니다.

class InstrumentedSet<E> extends ForwardingSet<E>{

    private int addCount;

    public InstrumentedSet(Set<E> s) {
        super(s);
        this.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;
    }

}

위와 같이 정의함으로써 다음과 같은 효과를 얻습니다

  • 새로운 클래스(InstrumentedSet)는 기존 클래스(Set)의 내부 구현 방식의 영향에서 벗어남
  • 기존 클래스(Set)에 새로운 메서드가 추가되더라도 영향을 받지 않음 

 

4. 상속 사용시 주의점

  • 상속은 반드시 하위 클래스가 상위 클래스의 "진짜" 하위 타입인 상황에서만 쓰여야 합니다
    • 클래스 B가 클래스 A와 is-a 관계일때만 클래스 A를 상속해야 합니다
    • 대표적인 사례는 자바 플랫폼 라이브러리에서 Stack과 Vector간의 관계는 잘못된 상속관계입니다. Vector의 노출된 add 메서드가 Stack의 저장순서에 영향을 미칩니다.
    @Test
    public void push_add(){
        //given
        Stack<String> stack = new Stack<>();
        //when
        stack.push("A");
        stack.push("B");
        stack.push("C");
        stack.add(0, "D"); // Stack의 저장순서는 FILO이기 때문에 0번째에 저장되면 스택의 개념이 망가진다.
        //then
        Assertions.assertThat(stack.toString()).isEqualTo("[D, A, B, C]");
    }
  • 확장하려는 클래스의 API에 아무런 결함이 없는지 고려합니다. 결함이 있다면, 이 결함이 하위 클래스의 API 까지 전파되도 괜찮은지 고려해야합니다.
  • 상속은 상위 클래스의 API를 결함까지도 포함하여 그대로 승계합니다

 

핵심정리

상속은 강력하지만 캡슐화를 해친다는 문제가 있습니다. 상속은 상위 클래스와 하위 클래스가 순수한 is-a 관계일때만 사용하여야 합니다. is-a 관계일 때도 안심할 수만은 없는 게, 하위 클래스의 패키지가 상위 클래스와 다르고, 상위 클래스가 확장을 고려해 설계되지 않았다면 여전히 문제가 될 수 있습니다. 상속의 취약점을 피하려면 상속 대신 컴포지션과 전달을 사용하자. 특히 래퍼 클래스로 구현할 적당한 인터페이스가 있다면 더욱 그렇습니다. 래퍼 클래스는 하위 클래스보다 견고하고 강력합니다.

 

References

source code : https://github.com/yonghwankim-dev/effective_java/tree/master/src/role18
Effective Java 3/E
상속이 캡슐화를 깬다?
상속보다는 컴포지션을 사용하라