[Effective Kotlin] 36 - 상속보다는 컴포지션을 사용하라
상속은 굉장히 강력한 기능으로 'is-a'관계의 객체 계층 구조를 만들기 위해 설계되었지만, 관계가 명확하지 않을 때 사용하면 여러 가지 문제가 발생할 수 있습니다. 따라서 단순하게 코드 추출 또는 재사용을 위해 상속을 사용하려고 한다면, 조금 더 신중하게 생각해야 합니다. 일반적으로 이러한 경우에는 상속보다 컴포지션을 사용하는 것이 좋습니다.
간단한 행위 재사용
어떤 로직 처리 전에 출력하고, 처리 후에 숨기는 유사한 동작을 하는 두 개의 클래스가 있다고 하면, 대부분의 많은 개발자가 슈퍼클래스를 만들어 공통되는 행위를 추출합니다.
abstract class LoaderWithProgress {
fun load() {
// 프로그레스 바를 보여 줌
innerLoad()
// 프로그레스 바를 숨김
}
}
class ProfileLoader: LoaderWithProgress() {
override fun innerLoad() {
// 프로필을 읽어 들임
}
}
class ImageLoader: LoaderWithProgress() {
override fun innerLoad() {
// 이미지를 읽어 들임
}
}
이 코드는 문제 없이 동작하지만 몇 가지 단점이 있습니다.
- 상속은 하나의 클래스만을 대상으로 할 수 있습니다.
- 상속을 사용해서 행위를 추출하면 많은 함수를 갖는 거대한 BassXXX 클래스가 만들어지게 되고, 굉장히 깊고 복잡한 계층 구조가 만들어집니다.
- 상속은 클래스의 모든 것을 가져오게 됩니다.
- 따라서 불필요한 함수를 갖는 클래스가 만들어질 수 있습니다.
(인터페이스 분리 원칙을 위반하게 됩니다.)
- 따라서 불필요한 함수를 갖는 클래스가 만들어질 수 있습니다.
- 상속은 이해하기 어렵습니다.
- 일반적으로 개발자가 메서드를 읽고, 메서드의 작동 방식을 이해하기 위해 슈퍼클래스를 여러 번 확인해야 합니다.
컴포지션은 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하는 것을 의미하기 때문에 위의 문제들에 대한 대안이 될 수 있습니다.
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()
}
}
위의 코드를 보면 프로그레스 바를 관리하는 객체를 다른 모든 객체에서 갖고 활용하는 추가 코드가 필요합니다. 이러한 추가 코드를 적절하게 처리하는 것이 조금 어려울 수도 있어 컴포지션보다 상속을 선호나는 경우도 많습니다. 하지만 이런 추가 코드로 인해서 코드를 읽는 사람들이 코드의 실행을 더 명확하게 예측할 수 있다는 장점도 있고, 프로그레스 바를 훨씬 자유롭게 사용할 수 있다는 장점도 있습니다.
또한 컴포지션을 활용하면, 하나의 클래스 내부에서 여러 기능을 재사용할 수 있게 됩니다.
예를 들어 이미지를 읽어들이고 나서 경고창을 출력한다면 다음과 같은 형태로 컴포지션을 활용할 수 있습니다.
class ImageLoader {
private val progress = Progress()
private val finishedAlert = FinishedAlert()
fun load() {
progress.showProgress()
// 이미지를 읽어 들임
progress.hideProgress()
finishedAlert.show()
}
}
Kotlin에서는 하나 이상의 클래스를 상속할 수 없습니다. 따라서 상속으로 이를 구현하려면 두 기능을 하나의 슈퍼클래스에 배치해야 합니다. 이 때문에 클래스들에 복잡한 계층 구조가 만들어질 수 있습니다.
모든 것을 가져올 수 밖에 없는 상속
상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져옵니다. 따라서 상속은 계체의 계층 구조를 나타낼 때 굉장히 좋은 도구입니다. 하지만 일부분을 재사용하기 위한 목적으로는 적합하지 않습니다. 일부분만 재사용하고 싶다면 컴포지션을 사용하는 것이 좋습니다. 컴포지션은 우리가 원하는 행위만 가져올 수 있기 때문입니다.
예를 들어 아래 처럼 bark와 sniff라는 함수를 갖는 Dog 클래스가 있습니다.
abstract class Dog {
open fun bark() {}
open fun sniff() {}
}
그런데 만약 로봇 강아지를 만들고 싶은데 로봇 강아지는 bark만 가능하고, sniff는 못하게 하려면 아래와 같이 구현하게 될것입니다.
class RobotDog: Dog() {
override fun sniff() {
throw Error("Operation not supported")
// 인터페이스 분리 원칙에 위반됨
}
}
하지만 이러한 코드는 인터페이스 분리 원칙에 위반되고, 슈퍼클래스의 동작을 서브클래스에서 깨버리므로 리스코프 치환 원칙에도 위반됩니다.
캡슐화를 깨는 상속
상속을 활용할 때는 외부에서 이를 어떻게 활용하는지도 중요하지만, 내부적으로 이를 어떻게 활용하는지도 중요합니다.
내부적인 구현 방법 변경에 의해서 클래스의 캡슐화가 깨질 수 있기 때문입니다.
예를 들어, 아래에 추가된 요소의 개수를 알기 위한 elementsAdded 프로퍼티를 가지며, HashSet을 기반으로 구현된 CounterSet이라는 클래스가 있습니다.
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)
}
}
위 클래스는 큰 문제가 없어 보이지만, 실제로는 제대로 동작하지 않습니다. 왜냐하면 addAll내부에서 add를 사용했기 때문에 addAll과 add에서 추가한 요소 개수를 중복해서 세게됩니다. addAll 함수를 제거해 버리면 이런 문제가 사라집니다.
하지만 이러한 해결 방법은 위험할 수 있습니다. 어느 날 자바가 HashSet.addAll을 최적화하고 내부적으로 add를 호출하지 않는 방식으로 구현하기로 했다면 예상하지 못한 형태로 동작하게 됩니다.
따라서 아래 처럼 컴포지션을 이용해 구현해 볼 수 있습니다.
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)
}
}
하지만 이렇게 되면 다형성이 사라진다는 문제점이 있습니다. 즉 CounterSet은 더 이상 Set이 아닙니다. 따라서 이를 유지하고 있다면 위임 패턴을 사용할 수 있습니다.
위임 패턴은 클래스가 인터페이스를 상속받게 하고, 포함한 객체의 메서드들을 활용해서 인터페이스에서 정의한 메서드를 구현하는 패턴입니다. 이렇게 구현된 메서드를 포워딩 메서드(forwarding method)라고 부릅니다.
아래와 같이 구현해 볼 수 있습니다.
class CounterSet<T>(
private val innerSet: MutableSet<T> = mutableSetOf()
): 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)
}
}
사실 일반적으로 다형성이 그렇게까지 필요한 경우는 없습니다. 그래서 단순하게 컴포지션을 활용하면 해결되는 경우가 굉장히 많습니다. 컴포지션을 사용한 코드는 이해하기 쉬우며, 유여하기까지 합니다.
상속으로 캡슐화를 깰 수 있다는 사실은 보안 문제입니다. 하지만 대부분의 경우에 이러한 행위는 규약으로 지정되어 있거나 서브클래스에 의존할 필요가 없는 경우입니다. (일반적으로 메서드가 상속을 위해서 설계된 경우입니다.)
오버라이딩 제한하기
개발자가 상속용으로 설계되지 않은 클래스를 상속하지 못하게 하려면, final을 사용하면 됩니다. 그런데 만약 어떤 이유로 상속은 허용하지만 메서드는 오버라이드하지 못하게 만들고 싶은 경우가 있을 수 있습니다. 이러한 경우에 메서드에 open 키워드를 사용합니다. open 클래스는 open 메서드만 오버라이드 할 수 있습니다.
open class Parent {
fun a() {}
open fun b() {}
}
class Child: Parent() {
override fun a() {} // 오류
ovveride fun b() {}
}
이를 활용하면 서브클래스에서 오버라이드할 수 있는 메서드를 제한할 수 있습니다.
정리
컴포지션과 상속은 다음과 같은 차이가 있습니다.
- 컴포지션은 더 안전합니다.
- 다른 클래스의 내부적인 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존하므로 안전합니다. - 컴포지션은 더 유연합니다.
- 상속은 한 클래스만을 대상으로 할 수 있지만, 컴포지션은 여러 클래스를 대상으로 할 수 잇습니다.
- 상속은 모든 것을 받지만, 컴포지션은 필요한 것만 받을 수 있습니다.
즉, 상속은 슈퍼클래스의 동작을 변경하면 서브클래스의 동작도 큰 영향을 받습니다. - 컴포지션은 더 명시적입니다.
- 상속의 경우 슈퍼 클래스의 메서드를 사용할 때는 리시버를 따로 지정하지 않아도 됩니다.
(코드는 짧아질 수 있지만 메서드가 어디에서 왔는지 혼동될 수 있습니다.) - 컴포진션은 생각보다 번거롭습니다.
- 컴포지션은 객체를 명시적으로 사용해야 하므로, 대상 클래스에 일부 기능을 추가할 때 이를 포함한 객체의 코드를 변경해야 합니다. - 상속은 다형성을 활용할 수 있습니다.
일반적으로 OOP에서는 상속보다 컴포지션을 사용하는 것이 좋습니다.
하지만 아래의 'is-a 관계'의 경우에서는 상속을 사용하는 것이 좋습니다.
슈퍼클래스를 상속하는 모든 서브클래스는 슈퍼클래스로 동작할 수 있는 경우
(즉, 슈퍼클래스의 모든 단위테스트는 서브클래스로도 통과할수 있어야 한다는 것을 의미합니다.)
예를 들어 JavaFx의 Application, 안드로이드의 Activity, iOS의 UIViewController등이 있습니다.