시스템아 미안해

Chapter 6: Class design - Item 36: Prefer composition over inheritance 본문

책/Effective Kotlin

Chapter 6: Class design - Item 36: Prefer composition over inheritance

if else 2026. 2. 24. 13:53

클래스는 객체지향 프로그래밍(OOP) 패러다임에서 가장 중요한 추상화 수단입니다. 그리고 Kotlin에서 OOP는 가장 널리 사용되는 패러다임이기 때문에, 클래스 역시 매우 중요한 요소입니다.

 

이 장은 클래스 설계에 대한 내용을 다룹니다. 시스템 설계에 대한 이야기는 아닙니다. 시스템 설계는 훨씬 더 많은 분량이 필요하며, 이미 이 주제를 깊이 있게 다룬 훌륭한 책들이 많이 있기 때문입니다. 예를 들어 Robert C. Martin의 Clean Architecture, 그리고 Erich Gamma, John Vlissides, Ralph Johnson, Richard Helm이 집필한 Design Patterns와 같은 책들이 있습니다.

 

이 장에서는 Kotlin 클래스가 지켜야 할 계약(contract)에 초점을 맞춥니다. Kotlin의 다양한 구조를 어떻게 사용해야 하는지,

그리고 그것을 사용할 때 우리에게 무엇이 기대되는지를 설명합니다.

 

예를 들어 다음과 같은 질문들을 다룹니다.

  • 상속은 언제, 어떻게 사용해야 합니까?
  • 데이터 클래스는 어떤 방식으로 사용되는 것이 바람직합니까?
  • 단일 메서드를 가진 인터페이스 대신 함수 타입을 언제 사용하는 것이 적절합니까?
  • equals, hashCode, compareTo의 계약은 무엇입니까?
  • 멤버 함수 대신 확장 함수를 언제 사용하는 것이 좋습니까?

이 장에서는 이러한 질문들에 대한 답을 제시합니다. 이러한 내용이 중요한 이유는, 해당 계약을 어길 경우 심각한 문제가 발생할 수 있기 때문입니다. 반대로 이러한 원칙을 잘 지키면 코드의 안정성과 가독성을 크게 향상시킬 수 있습니다.


상속은 강력한 기능이지만, 본래 “is-a(이다)” 관계를 가지는 객체 계층을 만들기 위해 설계된 개념입니다.

이러한 관계가 명확하지 않은 상황에서 상속을 사용하면 문제가 발생할 수 있으며, 심지어 위험할 수도 있습니다.

 

단순히 코드 추출이나 재사용이 목적이라면 상속은 신중하게 사용해야 합니다.

이 경우에는 더 가벼운 대안인 클래스 합성(composition)을 우선적으로 고려하는 것이 바람직합니다.


단순한 동작 재사용

다음과 같이 부분적으로 유사한 동작을 가진 두 클래스가 있다고 가정해봅시다. 두 클래스 모두 어떤 작업 전에 프로그레스 바를 표시하고, 작업 후에 숨겨야 합니다.

class ProfileLoader {
    fun load() {
        // show progress bar
        // load profile
        // hide progress bar
    }
}

class ImageLoader {
    fun load() {
        // show progress bar
        // load image
        // hide progress bar
    }
}

많은 개발자들이 이 공통 동작을 공통 슈퍼클래스로 추출하려 할 것입니다:

abstract class LoaderWithProgressBar {
    fun load() {
        // show progress bar
        action()
        // hide progress bar
    }

    abstract fun action()
}

class ProfileLoader : LoaderWithProgressBar() {
    override fun action() {
        // load profile
    }
}

class ImageLoader : LoaderWithProgressBar() {
    override fun action() {
        // load image
    }
}

이 방식은 단순한 경우에는 잘 동작하지만, 중요한 단점들이 있습니다.

  • 하나의 클래스만 확장할 수 있습니다. 상속을 사용해 기능을 추출하면 지나치게 복잡한 타입 계층이 생기거나, 수많은 기능이 쌓인 거대한 BaseXXX 클래스가 만들어지기 쉽습니다.
  • 확장하면 클래스의 모든 것을 가져옵니다. 필요하지 않은 기능과 메서드를 가진 클래스가 만들어질 수 있습니다. 이는 인터페이스 분리 원칙(Interface Segregation Principle)을 위반하는 것입니다.
  • 슈퍼클래스의 기능을 사용하는 것이 명시적이지 않습니다. 개발자가 메서드를 읽을 때 슈퍼클래스를 여러 번 들여다봐야 이 메서드가 어떻게 동작하는지 이해할 수 있다면, 그것은 좋지 않은 신호입니다.

이러한 이유들은 상속에 대한 대안을 진지하게 고민하게 만들기에 충분합니다. 그중에서도 매우 강력한 대안이 바로 컴포지션(composition)입니다.

 

컴포지션이란 객체를 프로퍼티로 보유하고(즉, 객체를 구성 요소로 포함하고), 그 객체가 제공하는 기능을 활용하는 방식을 의미합니다. 다시 말해, 상속을 통해 타입 계층을 형성하는 대신, 필요한 기능을 가진 객체를 내부에 포함하고 그 기능을 위임(delegate)하여 사용하는 구조입니다.

 

다음은 우리가 겪고 있는 문제를 상속이 아니라 컴포지션을 통해 해결하는 예시입니다.

class ProgressBar {
    fun show() {
        /* show progress bar */
    }
    fun hide() {
        /* hide progress bar */
    }
}

class ProfileLoader {
    val progressBar = ProgressBar()

    fun load() {
        progressBar.show()
        // load profile
        progressBar.hide()
    }
}

class ImageLoader {
    val progressBar = ProgressBar()

    fun load() {
        progressBar.show()
        // load image
        progressBar.hide()
    }
}

컴포지션은 구현 측면에서 더 번거롭습니다.
합성 대상이 되는 객체를 클래스 내부에 명시적으로 포함해야 하며, 이를 사용하는 모든 클래스에서 직접 참조하고 위임(delegate)해야 합니다.

 

이 점이 많은 개발자들이 상속을 선호하는 주요 이유입니다. 상속은 구조적으로 간결해 보이고, 추가 코드 없이 기능을 재사용할 수 있기 때문입니다.

 

그러나 컴포지션으로 인해 추가되는 코드는 결코 불필요한 중복이 아닙니다.
오히려 해당 클래스가 진행 바(progress bar)에 의존하고 있다는 사실과, 그 사용 방식이 무엇인지 명확하게 드러내는 역할을 합니다. 다시 말해, 의존성이 코드 구조에 명시적으로 표현됩니다. 또한 개발자는 진행 바의 동작 방식을 더 세밀하게 제어할 수 있으며, 필요하다면 구현을 교체하거나 확장하기도 용이합니다.

 

또한 여러 개의 기능을 분리하여 재사용하려는 경우에는 컴포지션이 더 적합합니다.
예를 들어, “로딩이 완료되었는지 여부를 알리는 기능”과 같은 별도의 책임을 추출해야 하는 상황에서는 상속보다 컴포지션이 더 유연한 구조를 제공합니다.

 
class ImageLoader {
    private val progressBar = ProgressBar()
    private val finishedAlert = FinishedAlert()

    fun load() {
        progressBar.show()
        // load image
        progressBar.hide()
        finishedAlert.show()
    }
}

클래스는 하나의 클래스만 상속할 수 있습니다.
따라서 상속을 사용하려 한다면, 두 가지 기능을 모두 하나의 상위 클래스에 함께 배치할 수밖에 없습니다.

 

이러한 구조는 기능을 추가하기 위해 타입 계층을 점점 복잡하게 만드는 결과로 이어지곤 합니다. 그리고 이렇게 형성된 계층 구조는 읽기도 어렵고, 수정하기는 더 어렵습니다.

 

예를 들어, 세 개의 하위 클래스 중 두 개에는 알림(alert) 기능이 필요하지만, 나머지 하나에는 필요하지 않은 상황을 생각해봅시다. 이런 요구사항이 생기면 상속 기반 구조에서는 설계가 급격히 복잡해집니다.

 

이 문제를 해결하는 한 가지 방법은 생성자에 파라미터를 두어 동작을 제어하는 방식입니다.

 
abstract class InternetLoader(val showAlert: Boolean) {
    fun load() {
        // show progress bar
        innerLoad()
        // hide progress bar
        if (showAlert) {
            // show alert
        }
    }

    abstract fun innerLoad()
}

class ProfileLoader : InternetLoader(showAlert = true) {
    override fun innerLoad() {
        // load profile
    }
}

class ImageLoader : InternetLoader(showAlert = false) {
    override fun innerLoad() {
        // load image
    }
}

이 방식은 좋은 해결책이 아닙니다.
플래그 값(이 경우 showAlert)으로 특정 기능을 차단하는 구조는 설계 상 좋지 않은 신호입니다.

 

문제는 여기서 끝나지 않습니다. 하위 클래스가 필요하지 않은 다른 기능까지도 함께 물려받게 되면서, 이를 선택적으로 배제할 수 없다는 점이 더 큰 문제입니다.

 

이것이 상속의 특징입니다.
상속은 상위 클래스의 기능 중 필요한 것만 가져오는 것이 아니라, 정의된 모든 것을 그대로 물려받습니다.

 


전체 패키지를 가져오는 문제

상속을 사용하면 우리는 상위 클래스의 모든 것을 물려받습니다.
메서드뿐만 아니라, 그 클래스가 전제하는 계약(contract)과 동작 방식까지 함께 상속됩니다.

 

따라서 상속은 객체 계층을 표현하는 데에는 매우 적합한 도구입니다. 즉, “is-a 관계”가 명확한 타입 계층을 설계할 때는 강력합니다. 그러나 단지 공통 기능 일부를 재사용하고자 할 때는 적합하지 않습니다.

 

이런 경우에는 컴포지션이 더 나은 선택입니다. 컴포지션을 사용하면 필요한 동작만 선택적으로 조합할 수 있기 때문입니다.

 

예를 들어, 우리 시스템에서 짖고(bark) 냄새를 맡을 수(sniff) 있는 Dog를 표현하기로 했다고 가정해봅시다.

abstract class Dog {
    open fun bark() {
        /*...*/
    }
    open fun sniff() {
        /*...*/
    }
}

그런데 짖을 수는 있지만 냄새를 맡을 수 없는 로봇 개가 필요하다면 어떻게 할까요?

class Labrador : Dog()

class RobotDog : Dog() {
    override fun sniff() {
        error("Operation not supported")
        // Do you really want that?
    }
}

이와 같은 설계는 인터페이스 분리 원칙(Interface Segregation Principle)을 위반합니다.
RobotDog가 실제로는 필요하지 않은 메서드까지 가지게 되기 때문입니다.

 

또한 이는 리스코프 치환 원칙(Liskov Substitution Principle)도 위반합니다.
하위 클래스가 상위 클래스의 기대 동작을 깨뜨리기 때문입니다. 즉, 상위 타입을 대체할 수 있어야 한다는 계약을 만족하지 못합니다.

 

한편, 만약 RobotDog가 calculate 메서드를 가진 Robot 클래스의 역할도 수행해야 한다면 어떻게 될까요? 다시 말해, RobotDog가 Robot이기도 해야 하는 상황이라면 설계는 더욱 복잡해집니다.

 

그러나 Kotlin은 다중 상속(multiple inheritance)을 지원하지 않습니다.

abstract class Robot {
    open fun calculate() {
        /*...*/
    }
}

class RobotDog : Dog(), Robot() // Error

 

이런 문제와 제약은 설계 관점에서 꽤 치명적이며, 컴포지션을 사용하면 대부분 발생하지 않습니다. 컴포지션을 쓰면 재사용할 기능을 우리가 선택해서 조합할 수 있기 때문입니다.

 

타입 계층을 표현해야 한다면, 상속보다는 인터페이스를 사용하는 편이 더 안전합니다. Kotlin에서는 여러 인터페이스를 동시에 구현할 수 있으므로, 필요한 역할(role)을 유연하게 구성할 수 있습니다.

 

그리고 아직 다루지 않았지만, 상속은 예상치 못한 동작(unexpected behavior)을 유발할 수 있다는 추가적인 문제가 있습니다.


상속은 캡슐화를 깨뜨린다

어느 정도까지는, 우리가 어떤 클래스를 상속할 때 그 클래스의 외부에서 보이는 동작 방식뿐만 아니라, 내부 구현 방식에도 의존하게 됩니다.

이 때문에 상속은 캡슐화를 깨뜨린다고 말합니다. 상위 클래스의 구현 세부 사항이 하위 클래스에 영향을 주기 때문입니다.

 

Joshua Bloch의 《Effective Java》에서 영감을 받은 예제를 살펴보겠습니다.

예를 들어, 어떤 집합(Set)이 자신의 생애 동안 몇 개의 요소가 추가되었는지를 기록해야 한다고 가정해봅시다.

 

이러한 Set은 HashSet을 상속하여 구현할 수 있습니다.

class CounterSet<T> : HashSet<T>() {
    var elementsAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementsAdded++
        return super.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        elementsAdded += elements.size
        return super.addAll(elements)
    }
}

이 구현은 잘 동작하는 것처럼 보이지만, 실제로는 올바르게 작동하지 않습니다:

val counterList = CounterSet<String>()
counterList.addAll(listOf("A", "B", "C"))
print(counterList.elementsAdded) // 6

왜 6이 나올까요? HashSet이 내부적으로 addAll 안에서 add 메서드를 사용하기 때문입니다. 그래서 addAll로 추가된 각 원소에 대해 카운터가 두 번씩 증가합니다. 커스텀 addAll 함수를 제거하면 단순하게 해결할 수 있습니다:

class CounterSet<T> : HashSet<T>() {
    var elementsAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementsAdded++
        return super.add(element)
    }
}

 

그러나 이 구현 방식은 위험합니다.

 

만약 Java 개발자들이 HashSet.addAll을 최적화하면서, 내부적으로 add 메서드에 의존하지 않도록 구현을 변경한다면 어떻게 될까요? 그렇게 되면, 이 구현은 Java 버전 업데이트와 함께 깨질 수 있습니다.

 

그리고 이 구현에 의존하고 있는 다른 라이브러리들까지 함께 영향을 받게 됩니다. 즉, 상위 클래스의 내부 구현 변경이 연쇄적으로 하위 구현과 외부 의존성까지 망가뜨릴 수 있습니다.

 

Java 개발자들도 이런 위험을 알고 있기 때문에, 이러한 핵심 클래스의 내부 구현을 변경할 때 매우 신중합니다. 이 문제는 Java 표준 라이브러리뿐 아니라, 모든 라이브러리 제작자나 대규모 프로젝트 개발자에게도 동일하게 적용됩니다.

 

그렇다면 이 문제를 어떻게 해결할 수 있을까요?
해답은 상속 대신 컴포지션을 사용하는 것입니다.

 

class CounterSet<T> {
    private val innerSet = HashSet<T>()
    var elementsAdded: Int = 0
        private set

    fun add(element: T) {
        elementsAdded++
        innerSet.add(element)
    }

    fun addAll(elements: Collection<T>) {
        elementsAdded += elements.size
        innerSet.addAll(elements)
    }
}

val counterList = CounterSet<String>()
counterList.addAll(listOf("A", "B", "C"))
print(counterList.elementsAdded) // 3

 

이 방식의 한 가지 문제는 다형성(polymorphism)을 잃게 된다는 점입니다.
CounterSet이 더 이상 Set 타입이 아니기 때문입니다.

 

이 다형성을 유지하려면 위임 패턴(delegation pattern)을 사용할 수 있습니다.

 

위임 패턴이란, 어떤 클래스가 특정 인터페이스를 구현하면서, 동시에 동일한 인터페이스를 구현한 객체를 내부에 포함(composition)하고, 인터페이스에 정의된 메서드 호출을 해당 객체로 전달(forwarding)하는 구조를 말합니다.

 

이처럼 내부 객체로 호출을 전달하는 메서드를 포워딩 메서드(forwarding method)라고 합니다.

 

다음 예제를 살펴보겠습니다.

class CounterSet<T> : MutableSet<T> {
    private val innerSet = HashSet<T>()
    var elementsAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementsAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        elementsAdded += elements.size
        return innerSet.addAll(elements)
    }

    override val size: Int
        get() = innerSet.size

    override fun contains(element: T): Boolean =
        innerSet.contains(element)

    override fun containsAll(elements: Collection<T>):
    Boolean = innerSet.containsAll(elements)

    override fun isEmpty(): Boolean = innerSet.isEmpty()

    override fun iterator() =
        innerSet.iterator()

    override fun clear() =
        innerSet.clear()

    override fun remove(element: T): Boolean =
        innerSet.remove(element)

    override fun removeAll(elements: Collection<T>):
    Boolean = innerSet.removeAll(elements)

    override fun retainAll(elements: Collection<T>):
    Boolean = innerSet.retainAll(elements)
}

이제 새로운 문제가 생깁니다.
우리는 다수의 포워딩 메서드를 직접 구현해야 합니다. 이 예제의 경우만 해도 아홉 개나 됩니다.

 

다행히 Kotlin은 이런 상황을 해결하기 위해 인터페이스 위임(interface delegation) 기능을 제공합니다.

 

인터페이스를 특정 객체에 위임하면, Kotlin 컴파일러가 필요한 모든 포워딩 메서드를 자동으로 생성해줍니다. 즉, 우리가 직접 위임 코드를 작성하지 않아도 됩니다.

 

다음은 Kotlin의 인터페이스 위임이 실제로 어떻게 동작하는지 보여주는 예시입니다.

class CounterSet<T>(
    private val innerSet: MutableSet<T> = mutableSetOf()
) : MutableSet<T> by innerSet {

    var elementsAdded: Int = 0
        private set

    override fun add(element: T): Boolean {
        elementsAdded++
        return innerSet.add(element)
    }

    override fun addAll(elements: Collection<T>): Boolean {
        elementsAdded += elements.size
        return innerSet.addAll(elements)
    }
}

이 경우에는 위임이 적절한 선택입니다. 다형성이 필요하고, 상속을 사용하면 위험해질 수 있기 때문입니다.

 

하지만 위임이 항상 일반적인 선택은 아닙니다. 대부분의 경우에는 다형성이 필요하지 않거나, 다른 방식으로 활용됩니다. 그래서 위임까지 사용하지 않는 단순 컴포지션이 더 적합한 경우가 많습니다.

 

상속이 캡슐화를 깨뜨린다는 점은 보안 관점에서도 문제가 될 수 있습니다. 다만 많은 경우 동작이 명확한 계약(contract)으로 정의되어 있거나, 하위 클래스가 상위 클래스의 내부 동작에 의존하지 않도록 설계되어 있기도 합니다. 특히 상속을 고려해 설계된 메서드라면 이러한 문제가 비교적 덜합니다.

 

그럼에도 불구하고 컴포지션을 선택해야 하는 다른 이유들이 있습니다. 그중 하나는 재사용성이 더 높고, 설계에 더 큰 유연성을 제공한다는 점입니다.

 

오버라이딩 제한하기

상속을 고려하여 설계되지 않은 클래스가 무분별하게 확장되는 것을 막기 위해, 해당 클래스를 final로 두면 됩니다.

 

다만 어떤 이유로 상속을 허용해야 한다면, Kotlin에서는 클래스뿐만 아니라 메서드도 기본적으로 final입니다. 즉, 단순히 클래스를 open으로 선언하는 것만으로는 충분하지 않습니다.

 

하위 클래스에서 오버라이딩을 허용하려면, 개별 메서드 역시 open으로 명시적으로 선언해야 합니다.

 
open class Parent {
    fun a() {}
    open fun b() {}
}

class Child : Parent() {
    override fun a() {} // Error
    override fun b() {}
}

이 메커니즘은 신중하게 사용해야 합니다.
상속을 전제로 설계된 메서드에 한해서만 open을 선언해야 합니다.

 

또한, 어떤 메서드를 오버라이드할 때는 그 구현을 더 이상 하위 클래스에서 재정의하지 못하도록 final로 다시 지정할 수 있다는 점도 기억해야 합니다.

 

즉, 상위 클래스에서 open이었던 메서드라도, 중간 단계의 하위 클래스에서 오버라이드한 뒤 final로 선언하면 그 아래 계층에서는 더 이상 변경할 수 없습니다.

 

 

open class ProfileLoader : InternetLoader() {
    final override fun load() {
        // load profile
    }
}

이렇게 하면 서브클래스에서 오버라이드할 수 있는 메서드의 수를 제한할 수 있습니다.