프로그래밍 노트/Effective 시리즈

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

깡냉쓰 2023. 10. 17. 19:11
728x90
반응형
  • 상속은 굉장히 강력한 기능인 만큼 여러 가지 문제를 발생시킬 수 있다.
  • 따라서 단순하게 코드 추출 또는 재사용을 위해 상속을 한다면 신중하게 생각해봐야 한다.

간단한 행위 재사용

  • 슈퍼 클래스를 만들어서 공통 행위를 추출한 경우
abstract class LoaderWithProgess {
  fun load() {
    // 프로그레스 바 보여줌
    innerLoad()
    // 프로그레스 바 숨김
  }

  abstract fun innerLoad()
}

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

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

몇 가지 단점 존재

  • 상속은 하나의 클래스만 할 수 있음
    • 상속으로 행위를 추출하다 보면, 많음 함수를 갖는 거대한 BaseXXX 클래스를 만들게 됨. 깊고 복잡한 계층 구조
  • 상속은 클래스 모든 것을 가져오게 됨
    • 필요 없는 함수에 의존하게 됨
    • 일반적으로 필요 이상으로 많은걸 포함하는 모듈에 의존하는 것은 해롭다.
    • ISP(인터페이스 분리 원칙)와 연관되어 있음
  • 이해하기 어려움
    • 메소드를 읽고, 작동 방식을 이해하기 위해 슈퍼클래스를 여러번 확인해야 한다면 문제가 있음

상속 대신 컴포지션(composition)을 사용하자

  • 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하자
class Progress {
  fun showProgress() { /* show progress */ }
  fun hideProgress() { /* hide progress */ }
}

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

class ImageLoader {
  val progress = Progress()
  fun load() {
    progress.showProgress()
    // 이미지를 읽어 들임
    progress.hideProgress()
  }
}
  • 추가 코드가 필요하지만 코드를 읽은 사람들이 코드의 실행을 더 명확하게 예측할 수 있다는 장점 존재
  • 프로그레스 바를 훨씬 자유롭게 사용할 수 있다는 장점 또한 존재

문제점1 - 모든 것을 가져올 수 밖에 없는 상속

  • 상속은 객체의 계층 구조를 나타낼 때 굉장히 좋은 도구이지만, 일부분을 재사용하기 위한 목적으로는 적합하지 않음
  • 일부분만 재사용하고 싶다면 컴포지션을 이용하자.
    • 원하는 행위만 가져올 수 있기 때문

상속 이용 케이스

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

class Labardog: Dog()

// 로봇 강아지를 만들고 싶은데, bark만 가능하다면?
class RobotDog: Dog() {
  override fun sniff() {
    throw Error("Operation not supported")
    // ISP(Interface Segregation Principle) 위배
  }
}
  • 인터페이스 분리 원칙(ISP) 위반
  • 리스코프 치환 원칙(LSP) 위반
    • 슈퍼클래스의 동작을 서브클래스에서 깨버림
  • 참고) SOLID 원칙
  • 컴포지션보다 인터페이스를 활용한 다중 상속이 좋을 수도 있음~ (상황에 따라 다름)

문제점2 - 캡슐화를 깨는 상속

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

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

  override fun addAll(elements: Collection<T>): Boolean {
    elementAdded += 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이 출력됨
  • 간단하게 addAll 함수를 제거해 버리면 되지 않을까? 당장에 해결은 되겠으나..
    • 자바 업데이트로 인하여 addAll이 add를 호출하지 않는 방식으로 구현된다면? CounterSet를 활용한 구현들 연쇄 중단

컴포지션(composition)을 사용하면 될까?

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

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

  fun addAll(elements: Collection<T>) {
    elementAdded += elements.size
    innerSet.addAll(elements)
  }
}
  • 구현상 문제가 없겠으나, 다형성이 사라지게 됨
    • CounterSet은 더 이상 Set이 아니다..

위임 패턴을 사용해도 된다.

  • 위임 패턴은 클래스가 인터페이스를 상속받게 하고, 포함한 객체의 메서드들을 활용하여 인터페이스에서 정의한 메서드를 구현하는 패턴
  • 이렇게 구현된 메서드를 포워딩 메서드(forwarding method)라고 부름
class CounterSet<T> (
  private val innerSet: MutableSet<T> = mutableOf()
): MutableSet<T> by innerSet {
  var elementAdded: Int = 0
      private set

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

  override fun addAll(elements: Collection<T>): Boolean {
    elementAdded += elements.size
    return innerSet.addAll(elements)
  }
}
  • kotlin 에서는 위임 패턴을 쉽게 구현할 수 있는 문법을 제공하므로 위와 같이 짧게 구현이 가능하다. kotlin 클래스 위임
  • 컴파일 시점에 포워딩 메서드들이 자동으로 만들어짐
  • 다형성이 필요한데, 상속 메소드를 직접 활용하는 것이 위험할 때 위임 패턴을 사용
    • 하지만 컴포지션을 활용하면 해결되는 경우가 많다.

오버라이딩 제한하기

  • 상속은 허용하지만, 메서드 오버라이드하지 못하게 만들고 싶을 때 메서드에 open 키워드 사용

정리

  • 컴포지션은 더 안전하다.
    • 내부 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존하므로
  • 컴포지션은 더 유연하다.
    • 컴포지션은 필요한 것만 받을 수 있음
  • 컴포지션은 더 명시적이다.
  • 컴포지션은 생각보다 번거롭다.
    • 객체를 명시적으로 사용해야 하므로, 대상 클래스에 일부 기능을 추가할 때 이를 포함하는 객체 코드도 변경해야 한다.

일반적으로 OOP에서는 상속보다 컴포지션을 사용하는 것이 좋다. 상속은 명확한 is-a 관계일 때만 사용하자

728x90
반응형