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

Q_Hyun·2023년 11월 7일
0

Effective Java 스터디

목록 보기
3/3
post-thumbnail

들어가며

상속은 Java를 배우면 시작부터 듣게 되는, 객체지향의 4대 원칙 중 하나이다.

상속이란 자식 객체가 부모 객체의 속성과 행동을 재사용 할 수 있고, 부모 객체의 행동을 재정의 하거나, 개별적인 행동 추가할 수 있으며, 다형성으로도 이용할 수 있다.

이렇게 유용해 보이는 특징으로 인해 비슷한 속성을 가진 클래스를 보면 상속을 하고 싶다는 욕망에 빠지기 쉬운데, 은탄환은 없다는 말 처럼 무작정 상속을 하는 것은 당신이 상상도 못할 위험에 빠질 수 있다는 사실을 이펙티브 자바 Item 18에서 보여준다.

탕!

상속과 컴포지션

그럼 대체 왜 상속을 사용하지 못하게 하는 걸까? 그리고 컴포지션은 대체 뭔가?

상속의 위험성

이펙티브 자바 Item 18에서는 상속에 대해서 다음과 같이 말한다.

상위 클래스와 하위 클래스를 모두 같은 프로그래머가 통제하는 패키지 안에서라면 상속도 안전한 방법이다. 확장할 목적으로 설계되었고 문서화도 잘 된 클래스(아이템19)도 마찬가지로 안전하다. 하지만 일반적인 구체 클래스를 패키지 경계를 넘어, 즉 다른 패키지의 구체 클래스를 상속하는 일은 위험하다. - p114

그렇기에, 이 상황에서는 다른 패키지에 있는 클래스를 상속하는 경우에 대해서 다룬다. 책에서 다룬 예제처럼 HashSet을 상속한 InstrumentedHashSet과 같이 말이다.

작가는 위의 상황에서 발생할 수 있는 상속의 위험성에 대해서 '캡슐화가 깨진다'고 말한다.

캡슐화가 깨진다?

캡슐화가 깨진다는 말이 무슨말일까?

우선 캡슐화는 객체지향의 4대 특징 중 하나로서 객체의 속성(data fields)과 행위(메서드, methods)를 하나로 묶고 실제 구현 내용 일부를 내부에 감추어 은닉한다.1_1

즉, 객체의 API에 대해서는 내부 내용을 살피지 않아도 된다는 뜻이다.

책의 예제를 다시 살펴보자.

import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;

class CustomSet<E> extends HashSet<E> {
    private int addCount = 0; // 자료형에 몇번 추가되었는지 세는 카운트 변수

    @Override
    public boolean add(E e) {
        // 만일 add되면 카운트를 증가 시키고, 부모 클래스 HashSet의 add() 메소드를 실행한다.
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        // 만일 리스트 자체로 들어와 통쨰로 add 한다면, 컬렉션의 사이즈를 구해 카운트에 더하고, 부모 클래스 HashSet의 addAll() 메소드를 실행한다.
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

public class Main {
    public static void main(String[] args) {
        CustomSet<String> mySet = new CustomSet<>();

        mySet.addAll(Arrays.asList("가", "나", "다", "라", "마"));
        mySet.add("바");

        System.out.println(mySet.getAddCount()); // ! 6이 나와야 정상이지만 11이 나오게 된다.
    }
}

코드 출처 - https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5%EC%9D%98-%EC%83%81%EC%86%8D-%EB%AC%B8%EC%A0%9C%EC%A0%90%EA%B3%BC-%ED%95%A9%EC%84%B1Composition-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

하단 링크

위의 코드에 달린 주석처럼 6이 addCount 변수의 값은 6이 나와야 할 것 같지만, 실제로는 11이 나온다. 그 이유는 HashSetaddAll() 메서드가 add() 메서드를 반복적으로 수행하고, 그 과정에서 현재 오버라이드한 add() 메서드가 호출되었기 때문에, 하나의 원소당 addCount++ 연산이 2회씩 발생한 것이다.

addCount의 값이 11이 나온 원인을 알기 위해서 addAll() 메서드의 내부 동작 코드를 확인해야 했기 때문에, 캡슐화가 깨지게 된 것이다.

그 외..

캡슐화가 깨진다는 점 외에도 상속을 하면 발생할 수 있는 문제점으로는, 상속관계에서 오는 강결합으로 인해 부모의 변경이 자식 클래스에 여파를 끼쳐서 제대로 동작하지 않을 수 있다는 점, 부모 클래스에서 발생한 결함이 자식 클래스에게도 전파가 된다는 점, is-a 관계가 명확하지 않은 상속 관계로 인해 관계 없는 메서드를 상속받아야 한다는 점 등이 있다.

위의 케이스들은 Vector를 상속받은 Stack에게 발생하는 문제를 통해 쉽게 볼 수 있다.

첫번째로 부모 클래스에서 발생한 성능 저하가 자식 클래스에게도 미치는 점이다.
Vectorsynchronized 제한자가 달린 메서드들을 전부 사용하고 있기 때문에, Vector를 상속받은 Stack 역시 synchronized를 사용하거나, Vector의 메서드를 재사용하는 것으로 성능의 저하가 발생한다. ('Stack이 다시 메서드를 구현하면 되지 않느냐' 라는 말은 상속을 하는 이유도 없고, 다시 만들기도 어렵다 )

두번째로 부모 클래스의 public 메서드를 상속받기 때문에, 논리적 오류가 발생한다.

Stack이란 클래스는 Stack 자료구조를 나타낸다. 즉 LIFO의 원칙에 따라서 TOP에 있는 원소에 대한 정보 및 전체 크기를 다룰 것 같지만...

Vector를 상속받았기 때문에, 이런 메소드를 이용할 수 있다.

        Stack<Integer> stack = new Stack<>();
        stack.get(2);
        stack.indexOf(2);
        stack.firstElement();
        stack.removeElementAt(3);

불-편

컴포지션

위와 같이 여러 문제가 발생할 수 있기 때문에, 책에서는 명확한 상황이 아니라면, 상속을 피하고 컴포지션을 사용하라고 한다. 그럼 컴포지션은 무엇인가??

개념

컴포지션은 아래 그림처럼 상속 받을 클래스를 상속 받지 않고, 해당 클래스를 필드로 유지하는 것을 말한다.

composition example class diagram

좀 더 정확하게 설명하면, 컴포지션 관계는 클래스 내부의 필드들을 클래스에서 생성하며 has-a 관계를 이룬다. 이 과정에서 관계를 맺는 클래스들 간의 생명주기를 함께 같는 관계를 뜻한다. (생성을 해준 클래스가 소멸할 때, 함께 소멸한다.)

이펙티브 자바에서 이야기하는 컴포지션은 본래 상속 받을 클래스 내부에, 상속될 클래스를 필드로 가지고 있으면서, 원하는 메서드를 전달(forward)해주라고 한다. 그리고 이때의 본래 상속받을 클래스를 래퍼 클래스(Wrapper Class)라고 부른다.

예제

그럼 위의 예시처럼 Stack은 본래 Stack이 가져야 할 메서드가 아닌 것들이 외부에 공개가 많이 되어있다. 위 클래스 다이어그램처럼 Vector를 컴포지션 관계로 하는 CustomStack을 만들어보자.

public class CustomStack<T> {

    private Vector<T> vector;

    public CustomStack() {
        vector = new Vector<>();
    }

    public CustomStack(int initialCapacity) {
        vector = new Vector<>(initialCapacity);
    }

    public void push(T t) {
        vector.add(t);
    }

    public T pop(){
        return vector.remove(vector.size()-1);
    }

    public int size(){
        return vector.size();
    }
}

Stack 자료구조가 가져야 할 push, pop 연산과 현재 사이즈를 확인하는 size 메서드만을 만들었기 때문에, 기존에 문제가 되었던 get, indexOf와 같은 메서드는 외부에 공개되지 않아 논리적 오류가 발생하지 않았다.

정리

지금까지의 내용을 정리하면 이렇다.

상속은 유용한 개념이고, 기능이지만 정확한 설계와 고려 없이 이용하는 행위는 캡슐화를 깨드리고, 예상치 못한 버그들을 마주칠 수 있게 해준다.

그렇기에 명확한 상속 상황이 확실하지 않다면(is-a) 컴포지션(has-a) 관계를 이용하는 것이 안전하다.

ref

1_1 위키백과 - 캡슐화
상속을 자제하고 합성(Composition)을 이용하자 - Inpa Dev 👨‍💻:티스토리
UML - 클래스 다이어그램 고급 - 집합과 합성 - DogHujup

0개의 댓글