Kotlin ‐ 상속보다 합성을 선호하라[Effective Kotlin Item 36] - thought-corner/Backend-PlayGround GitHub Wiki

상속보다 합성을 선호하라

  • 상속은 강력한 기능이지만 is a의 관계로 객체의 계층 구조를 만들기 때문에 관계가 명확하지 않으면 위험할 수 있다.
  • 간단한 코드 추출 또는 재사용이 목적이라면 상속 대신에 더 가벼운 클래스 합성을 선호한다.

간단한 동작 재사용

// ❌ Bad - 공통 로직을 부모 클래스에 두고 상속받게 함
abstract class LoaderWithProgress {

    fun load() {
        // 로딩 전후 공통 처리
        println("로딩 시작")
        innerLoad()
        println("로딩 완료")
    }

    abstract fun innerLoad()
}

class ProfileLoader : LoaderWithProgress() {
    override fun innerLoad() {
        println("프로필 불러오는 중")
    }
}

class ImageLoader : LoaderWithProgress() {
    override fun innerLoad() {
        println("이미지 불러오는 중")
    }
}
  • 이와 같은 슈퍼 클래스는 매우 간단한 경우라면 효과적이지만 중요한 단점이 있다.
    • 하나의 클래스만 상속할 수 있다. 상속을 사용하여 기능을 추출하면 타입의 계층구조가 지나치게 복잡해지며, 수많은 기능을 가지고 있는 클래스가 탄생하는 경우가 많다.
    • 상속할 때 클래스에서 모든 것을 가져온다. 이로 인해 필요하지 않은 기능과 메서드가 있는 클래스가 생성되면서 ISP를 위반할 여지가 있다.
    • 슈퍼 클래스를 사용하면 코드를 이해하기 힘들어진다. 일반적으로 개발자가 메서드를 읽고 이 메서드의 작동 방식을 이해하기 위해 슈퍼 클래스로 이동해야 한다면 좋지 못한 설계가 될 수 있다.
  • 이를 대체할 좋은 방법으로 합성(Composition)이 제시되었다. 합성이란, 각각의 기능을 가진 객체들을 프로퍼티로 구성하여 사용하는 것을 뜻한다.
// ⭕ Good - 공통 기능을 독립된 클래스로 분리
class ProgressReporter {
    fun reportStart() = println("로딩 시작")
    fun reportEnd() = println("로딩 완료")
}

class ProfileLoader {
    private val progress = ProgressReporter()   // 합성

    fun load() {
        progress.reportStart()
        println("프로필 불러오는 중")
        progress.reportEnd()
    }
}

class ImageLoader {
    private val progress = ProgressReporter()   // 같은 기능을 품어서 재사용

    fun load() {
        progress.reportStart()
        println("이미지 불러오는 중")
        progress.reportEnd()
    }
}

상속은 클래스의 모든 것을 가져온다

  • 상속을 하게 되면 슈퍼 클래스에서 메서드, 기대(규약), 동작과 같은 모든 것을 가져온다.
  • 따라서 상속은 객체의 계층구조를 표현하기에는 좋지만 공통 부분을 재사용하려는 경우에는 그리 좋지 않다.
  • 공통 부분을 추출할 때는 필요한 기능을 선택할 수 있으므로 합성이 더 좋다.
// ❌ Bad - 추상 클래스(상속이므로 하나만 가능, 구현/상태가 딸려옴)
abstract class Loader {
    protected val cache = mutableMapOf<String, Any>()  // 상태까지 물려받음
    abstract fun load()
    fun clearCache() { cache.clear() }  // 원치 않아도 노출됨
}

class ProfileLoader : Loader() {  // 다른 걸 또 상속할 수 없음
    override fun load() { }
}
// ⭕ Good - 인터페이스(계약만 정의, 여러 개 동시 구현 가능)
interface Loader {
    fun load()
}
interface Cacheable {
    fun clearCache()
}

// 둘 다 구현 가능 — 추상 클래스로는 불가능했던 조합
class ProfileLoader : Loader, Cacheable {
    override fun load() { }
    override fun clearCache() { }
}
  • 합성을 사용하면 규칙을 위반하지 않고 제약도 크지 않다.
  • 합성을 사용할 때는 재사용할 항목을 선택한다.
  • 타입 계층을 표현할 때도 상속 대신 인터페이스가 더 나은 방법이다.
  • 인터페이스를 사용하는 편이 안전하며, 여러 개의 인터페이스를 동시에 구현할 수도 있기 때문이다.

상속은 캡슐화를 깨뜨린다

  • 클래스를 상속하게 되면 외부에서 사용하는 법뿐 아니라 내부 구현 방식도 함께 고려해야 하기에 캡슐화가 깨진다.
// ⭕ Good - MutableSet을 상속하지 않고, 내부 set에 위임(by)
class CounterSet<T>(
    private val innerSet: MutableSet<T> = mutableSetOf()
) : MutableSet<T> by innerSet {   // Set의 모든 메서드를 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)
    }
}

오버라이딩 제한

  • 상속 용도로 설계되지 않은 클래스가 상속되는 상황을 막고 싶다면 클래스에 접근 제어자를 추가하지 않으면 된다.
  • 하지만 상황을 허용해야 한다면 모든 메서드가 기본적으로 상속이 불가능하므로 클래스 메서드에 open 제어자를 추가하면 된다.
class Animal {
    fun sound() = "..."   // 기본 final, 오버라이딩 불가
}
open class Animal {
    open fun sound() = "..."   // open이라 오버라이딩 가능
}

class Dog : Animal() {
    override fun sound() = "멍멍"
}