본문 바로가기
  • 개발하는 곰돌이
Programming Language/Kotlin & Java

[Effective Java] 상속보다는 컴포지션을 사용하라(Feat. Stack)

by 개발하는 곰돌이 2023. 2. 27.

목차

    상속보다는 컴포지션을 사용하라
    - 이펙티브 자바 3/E 아이템 18

     

    상속은 자바가 갖는 유연한 구조의 기반이 되는 기법이다. 잘 사용한 상속 구조는 애플리케이션의 확장과 유지보수를 수월하게 해주지만, 잘못 사용한 상속 구조는 예상하지 못한 문제를 일으킬 수 있다. 이번 포스트에서는 자바의 Stack 클래스를 예시로 잘못 사용한 상속 구조에서 발생할 수 있는 문제와 컴포지션을 통해 이러한 문제를 해결하는 것을 다룰 것이다.


    상속

    일단 여기서 다루는 상속은 오직 클래스에서 클래스로의 상속만 해당된다.

     

    상속을 사용하면 기본적으로 자식 클래스는 부모 클래스의 public과 protected 속성 + 자식 클래스만의 속성을 갖게 된다. 이 과정에서 상속은 캡슐화를 깨트리게 된다. 자식 클래스는 부모 클래스에 종속되어 부모 클래스에 변화가 생기면 자식 클래스도 영향을 받아 동작에 이상이 생길 수 있다. 예를 들어 부모 클래스의 새로운 버전이 릴리즈되어 기존에 없던 메소드가 추가된 경우에 자식 클래스에서 이 새로운 메소드를 별도로 원하는 동작을 하도록 오버라이드하지 않은 경우라면, 자식 클래스는 부모 클래스의 변화로 인해 의도하지 않은 동작을 수행할 수도 있게 되는 것이다.


    자바의 Stack 클래스

    자바의 Stack 클래스는 잘못 작성된 클래스로 악명이 높다고 한다. 기본적으로 스택은 가장 위에 요소를 삽입하는push, 가장 위의 요소를 제거하는 pop, 가장 위의 요소를 조회하는 peek 연산으로 구성되어 있다. 자바의 Stack 클래스도 기본적으로 이와 같은 구성으로 되어있으며, 여기에 추가적으로 스택이 비어있는지를 확인하는 empty() 메소드와 찾고자 하는 요소가 가장 위에서부터 몇 번째인지를 탐색하는 search() 메소드가 더 있을 뿐이다. Stack 클래스의 구성은 아래와 같다.

    public class Stack<E> extends Vector<E> {
        public Stack() {
        }
    
        public E push(E item) {
            ...
        }
    
        public synchronized E pop() {
            ...
        }
    
        public synchronized E peek() {
            ...
        }
    
        public boolean empty() {
            ...
        }
    
        public synchronized int search(Object o) {
            ...
        }
    }

    여기까지는 크게 문제가 없어 보인다. 하지만 Stack 클래스의 가장 큰 문제는 바로 Vector 클래스를 상속받아서 작성된 클래스라는 점이다. 스택의 특성상 Stack 클래스에서 작성된 기능 외에는 불필요하지만 Vector 클래스를 상속받아서 작성되었다는 점으로 인해 Vector의 메소드 또한 호출할 수 있게 된 것이다. 이로 인해 원래 스택에서는 허용되지 않을 중간에 요소 추가, 중간에 있는 요소 조회 및 삭제를 비롯하여 온갖 동작을 수행할 수 있게 되었고, 이로 인해 스택을 구현하기 위해 작성되었던 Stack 클래스는 그 의미를 크게 상실하고 말았다.

    Stack은 Vector를 상속받아 작성되었기 때문에 스택이라면 허용되지 않을 Vector의 메소드까지 호출할 수 있다.

    이렇게 적절하지 않은 상속으로 인해 발생한 문제는 컴포지션으로 해결할 수 있다.


    컴포지션

    컴포지션(Composition : 구성)은 기존에 존재하는 클래스를 새로운 클래스의 구성 요소로 사용하는 기법을 뜻한다. 컴포지션을 사용하면 새로운 클래스는 기존 클래스의 객체를 private 필드로 갖게 되고, 새로운 클래스의 메소드들은 이 private 필드에서 기존 클래스의 메소드들을 호출하고 결과를 반환하는 동작을 수행하도록 구성할 수 있다. 이렇게 컴포지션을 통해 기존 클래스를 참조하게 되면 상속에서 문제가 되었던, 부모 클래스에 자식 클래스가 종속되는 문제를 해결할 수 있다. 컴포지션을 이용하여 작성된 클래스는 기존 클래스의 객체를 private 필드로 갖기 때문에 외부에서는 두 클래스가 전혀 연관이 없는 것으로 인식하게 되고, 새로운 클래스는 기존 클래스에 작성되어 의도하지 않은 메소드를 호출할 우려도 사라지게 된다.


    컴포지션을 활용하여 작성한 ComposedStack 클래스

    이렇게 컴포지션을 활용하여 스택을 표현하기 위해 작성한 ComposedStack 클래스는 아래와 같다.

    public class ComposedStack<E> {
        private final Vector<E> vector = new Vector<>();
    
        public E push(E item) {
            vector.add(item);
            return item;
        }
    
        public E pop() {
            return vector.remove(vector.size() - 1);
        }
    
        public E peek() {
            return vector.get(vector.size() - 1);
        }
    
        public boolean empty() {
            return vector.isEmpty();
        }
    
        public int search(Object o) {
            int i = vector.lastIndexOf(o);
    
            if (i >= 0) {
                return vector.size() - i;
            }
            return -1;
        }
    }

    이렇게 컴포지션으로 작성된 ComposedStack 클래스는 Vector를 상속받아 작성된 기존 Stack이 가졌던 문제에서 완전히 해방되었다. Vector는 단순히 private 필드로서 객체를 참조만 하기 때문에 스택의 동작에 불필요한 Vector의 메소드를 호출할 수 있던 문제도 사라졌고, 그로 인해 발생할 수 있던 의도하지 않은 메소드 호출로 인한 오동작의 우려도 사라졌다.

    컴포지션을 활용하여 작성된 ComposedStack은 Vector를 참조하여 작성되었지만 자기 자신에 작성된 메소드만 호출할 수 있다.


    무작정 상속을 사용하지 말자

    상속은 다형성을 얻을 수 있는 기능이지만 확실하게 상속을 고려하고 작성된 클래스인 경우에만 상속하여 사용하는 것이 좋다고 생각한다. 무분별하게 상속을 사용하면 이 포스트에서 예시로 든 Stack의 경우처럼 의도하지 않은 동작을 수행하거나, 예상하지 못한 오류가 발생할 수 있다.

     

    자식 클래스가 확실하게 부모 클래스의 일부로 표현되는 경우가 아니라면 컴포지션을 사용하여 클래스를 작성하는 것이 바람직하지 않을까 생각하게 되었다.

    댓글