[Effective Kotlin] 아이템 36. 상속보다는 컴포지션을 사용하라

Jimin Lim·2023년 9월 18일
0

Effective Kotlin

목록 보기
36/39
post-thumbnail

아이템 36

상속보다는 컴포지션을 사용하라

상속은 is-a 관계의 객체 계층 구조를 만들기 위해 설계되었다. 관계를 명확하지 않을 때 사용하면 여러 가지 문제가 발생할 수 있다.

일반적으로 다음의 경우에는 상속보다 컴포지션을 사용하는 것이 좋다.

1️⃣ 간단한 행위 재사용

일반적으로 유사한 역할을 하는 클래스가 있다면 다음과 같이 슈퍼클래스를 만들어 공통되는 행위를 추출해서 사용한다.

abstract class LoaderWithProgress {
    fun load() {
        // 프로그레스 바 표시
        innerLoad()
        // 프로그레스 바 숨김
    }
    
    abstract fun innerLoad()
}

class ProfileLoader: LoaderWithProgress() {
    override fun innerLoad() {
        // 프로필 읽어들임
    }
}

class ImageLoader: LoaderWithProgress() {
    override fun innerLoad() {
        // 이미지 읽어들임
    }
}

위 코드는 몇 가지 단점이 존재한다.

  1. 상속은 하나의 클래스만을 대상으로 할 수 있다. 따라서 행위를 추출하다보면 많은 함수를 갖는 거대한 슈퍼클래스를 갖게 된다.
  2. 상속은 클래스의 모든 것을 가져온다. 불필요한 함수를 갖는 클래스가 만들어질 수 있다.
  3. 상속은 이해하기 어렵다. 메서드의 작동 방식을 이해하기 위해 슈퍼클래스를 여러 번 확인해야 한다.

따라서 상속으로 구현했던 코드를 컴포지션으로 사용하는 것이 좋다.

  • 컴포지션: 객체를 프로퍼티로 갖고 함수를 호출하는 형태
class Progress {
    fun showProgress() { /* 프로그레스 바 표시 */ }
    fun hideProgress() { /* 프로그레스 바 숨김 */ }
}

class ProfileLoader {
    val progress = Progress()
    
    fun load() {
        progress.showProgress()
        // 프로필 읽어 들임
        progress.hideProgress()
    }
}

class ImageLoader {
    val progress = Progress()
    
    fun load() {
        progress.showProgress()
        // 이미지 읽어 들임
        progress.hideProgress()
    }
}

또한 여기서 기능을 추가하고자 한다면 private val finishedAlert = FinishedAlert() 프로퍼티를 추가해서 각각 상황에 맞게 함수를 구현하도록 하면 된다.

2️⃣ 모든 것을 가져올 수 밖에 없는 상속

상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져온다. 즉, 일부분을 재사용하기 위한 목적으로는 적합하지 않다.

상속

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

class RobotDog: Dog() {
    override fun sniff() {
        throw Error("지원되지 않는 기능입니다")
        // 인터페이스 분리 원칙에 위반됨
    }
}

만약 상속으로 작성했다가 지원하지 않는 함수가 존재한다면 위와 같이 작성할 것이고, 이는 인터페이스 분리 원칙에 위반된다.

  • 인터페이스 분리 원칙: 클라이언트가 필요하지 않은 메서드에 의존하는 상황을 피해야 함 (gpt 쌤)

인터페이스

위 코드를 인터페이스로 잘 쪼개서 나타내면 아래와 같다.

//인터페이스 구현
interface Barkable {
    fun bark()
}

interface Sniffable {
    fun sniff()
}

abstract class Dog : Barkable, Sniffable {
    override fun bark() { /*...*/ }
    override fun sniff() { /*...*/ }
}

// RobotDog 클래스는 필요한 인터페이스만 구현
class RobotDog : Barkable {
    override fun bark() {
        //..
    }
}

3️⃣ 캡슐화를 깨는 상속

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, 예상치 못한 동작 

HashSet의 addAll은 내부에서 add를 호출하므로 3이 아닌 6이 출력된다.

HashSet의 addAll이 add를 호출할 것이라고 생각하고 코드를 짰다가, HashSet이 추후 add를 호출하지 않는 방향으로 수정된다면 영향을 받게 된다. (캡슐화 깨짐)

이때 컴포지션을 사용하는 것이 좋다.

//컴포지션 사용 
class CounterSet<T> {
    private var 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)
    }
}

하지만 이는 다형성을 잃게 되는데, 만약 Set을 유지하고 싶다면 위임 패턴을 사용할 수 있다.

  • 위임패턴: 인터페이스를 상속받게 하고, 인터페이스 구현

코틀린은 위임 패턴을 쉽게 구현할 수 있는 문법을 제공한다.

//다형성 보장, 위임 패턴
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)
    }
}

상속을 막고 싶다면?

open 클래스의 open 메서드만 오버라이드할 수 있으므로, open을 달지말자!

profile
💻 ☕️ 🏝 🍑 🍹 🏊‍♀️

0개의 댓글