안드로이드 개발자 노트
[이펙티브 코틀린] Item36. 상속보다는 컴포지션을 사용하라 본문
상속은 객체 계층 구조를 만들기 위해 설계되었으며, 관계가 명확하지 않을 때 사용하면 여러 가지 문제가 발생할 수 있습니다.
단순하게 코드 추출 또는 재사용을 위해 상속을 하려고 한다면, 일반적으로 상속보다 컴포지션을 사용하는 것이 좋습니다.
간단한 행위 재사용
예시로 프로그레스 바를 어떤 로직 처리 전에 출력하고, 처리 후에 숨기는 동작을 하는 두 개의 클래스가 있다고 하겠습니다.
class ProfileLoader {
fun load() {
// 프로그레스 바를 보여 줌
// 프로파일을 읽어 들임
// 프로그레스 바를 숨김
}
}
class ImageLoader {
fun load() {
// 프로그레스 바를 보여 줌
// 이미지를 읽어 들임
// 프로그레스 바를 숨김
}
}
이러한 경우 많은 개발자들은 슈퍼클래스를 만들어서 공통되는 행위를 추출합니다.
abstract class LoaderWithProgress {
fun load() {
// 프로그레스 바를 보여 줌
innerLoad()
// 프로그레스 바를 숨김
}
abstract fun innerLoad()
}
class ProfileLoader: LoaderWithProgress() {
override fun innerLoad() {
// 프로파일을 읽어 들임
}
}
class ImageLoader: LoaderWithProgress() {
override fun innerLoad() {
// 이미지를 읽어 들임
}
}
이러한 코드는 간단한 경우에는 문제 없이 동작하지만, 몇 가지 단점이 있습니다.
- 상속은 하나의 클래스만 대상으로 할 수 있다. 상속을 사용해서 행위를 추출하다 보면, 많은 함수를 갖는 BaseXXX 클래스를 만들게 되고, 깊고 복잡한 계층 구조가 만들어진다.
- 상속은 클래스의 모든 것을 가져오게 된다. 불필요한 함수를 갖는 클래스가 만들어질 수 있다(인터페이스 분리 원칙 위반).
- 상속은 이해하기 어렵다. 메서드의 작동 방식을 이해하기 위해 슈퍼클래스를 여러 번 확인해야 한다면, 문제가 있는 것이다.
이러한 단점의 대표적인 대안은 컴포지션(composition)이며, 컴포지션을 사용한다는 것은 객체를 프로퍼티로 갖고 함수를 호출하는 형태로 재사용하는 것을 의미합니다.
class Progress {
fun showProgress() { /* show progress */ }
fun hideProgress() { /* hide progress */ }
}
class ProfileLoader {
private val progress = Progress()
fun load() {
progress.showProgress()
// 프로파일을 읽어 들임
progress.hideProgress()
}
}
class ImageLoader {
private 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()
}
}
상속으로 이를 구현하려면, 두 기능(progress, finishedAlert)을 하나의 슈퍼클래스에 배치해야 합니다.
모든 것을 가져올 수밖에 없는 상속
예를 들어, 3개의 클래스가 이러한 두 가지 기능(progress, finishedAlert)을 갖는 슈퍼클래스를 상속받는데 2개의 서브클래스에서는 경고창을 사용하지만 다른 1개의 서브클래스에서는 경고창이 필요 없는 경우가 있다고 합시다.
이 문제를 처리하는 한 가지 방법은 파라미터가 있는 생성자를 이용하는 것입니다.
abstract class InternetLoader(val showAlert: Boolean) {
fun load() {
// 프로그레스 바를 보여 줌
innerLoad()
// 프로그레스 바를 숨김
if (showAlert) {
// 경고창 출력
}
}
abstract fun innerLoad()
}
class ProfileLoader : InternetLoader(showAlert = true) {
override fun innerLoad() {
// 프로파일을 읽어 들임
}
}
class ImageLoader : InternetLoader(showAlert = false) {
override fun innerLoad() {
// 이미지를 읽어 들임
}
}
하지만 이것은 굉장히 나쁜 해결 방법입니다.
서브클래스가 필요하지도 않은 기능을 갖고, 단순하게 이를 차단할 뿐입니다.
캡슐화를 깨는 상속
상속을 활용할 때는 외부에서 이를 어떻게 활용하는지도 중요하지만, 내부적으로 이를 어떻게 활용하는지도 중요합니다.
내부적인 구현 방법 변경에 의해서 클래스의 캡슐화가 깨질 수 있기 때문입니다.
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를 사용했기 때문입니다.
addAll과 add에서 추가한 요소 개수를 중복해서 세므로, 요소 3개를 추가했는데 6이 출력되는 것입니다.
간단하게 addAll 함수를 제거하면 이러한 문제가 해결되지만, 어느 날 자바가 HashSet, addAll을 최적화하고 내부적으로 add를 호출하지 않는 방식으로 구현하기로 했다면 예상하지 못한 형태로 동작하게 될 수 있습니다.
또한, CounterSet을 활용한 구현들까지도 모두 연쇄적으로 문제가 발생할 수 있습니다.
이러한 문제 또한 컴포지션으로 해결할 수 있습니다.
class CounterSet<T> {
private val innserSet = HashSet<T>()
var elementsAdd: Int = 0
private set
fun add(element: T) {
elementsAdd++
innserSet.add(element)
}
fun addAll(elements: Collection<T>) {
elementsAdd += elements.size
innserSet.addAll(elements)
}
}
이렇게 수정했을 때 다형성이 사라진다는 문제가 발생합니다.
CounterSet은 더 이상 Set이 아니게 되며, 이를 유지하고 싶다면 위임 패턴을 사용할 수 있습니다.
위임 패턴은 클래스가 인터페이스를 상속받게 하고, 포함한 객체의 메서드들을 활용해서, 인터페이스에서 정의한 메서드를 구현하는 패턴입니다.
이렇게 구현된 메서드를 포워딩 메서드(forwarding method)라고 부릅니다.
class CounterSet<T>(
private val innerSet: MutableSet<T> = mutableSetOf()
) : MutableSet<T> by innerSet {
var elementsAdd: Int = 0
private set
override fun add(element: T): Boolean {
elementsAdd++
innerSet.add(element)
}
override fun addAll(elements: Collection<T>): Boolean {
elementsAdd += elements.size
innerSet.addAll(elements)
}
}
코틀린은 위임 패턴을 쉽게 구현할 수 있는 문법을 제공하므로, 이렇게 작성하면 포워딩 메서드들(remove, removeAll, isEmpty, clear 등)이 자동으로 만들어집니다.
다형성이 필요한데, 상속된 메서드를 직접 활용하는 것이 위험할 때는 이와 같은 위임 패턴을 사용하는 것이 좋습니다.
오버라이딩 제한하기
클래스를 상속하지 못하게 하려면, final을 사용하면 됩니다.
만약 어떤 이유로 상속은 허용하지만, 메서드는 오버라이드하지 못하게 만들고 싶다면 메서드에 open 키워드를 사용합니다.
open 클래스는 open 메서드만 오버라이드할 수 있습니다.
open class Parent {
fun a() {}
open fun b() {}
}
class Child: Parent() {
ovveride fun a() {} // 오류
ovveride fun b() {}
}
상속용으로 설계된 메서드에만 open을 붙이면 됩니다.
메서드를 오버라이드할 때, 서브클래스에서 해당 메서드에 final을 붙일 수도 있습니다.
open class ProfileLoader : InternetLoader() {
final override fun loadFromInternet() {
// 프로파일을 읽어 들임
}
}
정리
- 컴포지션은 다른 클래스의 내부적인 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존하므로 안전하다.
- 상속은 한 클래스만 대상으로 할 수 있지만, 컴포지션은 여러 클래스를 대상으로 할 수 있다.
- 상속은 모든 것을 받지만, 컴포지션은 필요한 것만 받을 수 있다.
- 슈퍼클래스에서 동작을 변경하면, 서브클래스의 동작도 큰 영향을 받지만, 컴포지션을 활용하면 이러한 영향이 제한적이다.
- 컴포지션을 활용하면, 리시버를 명시적으로 활용할 수밖에 없으므로 메서드가 어디에서 온 것인지 확실히 알 수 있다.
- 컴포지션은 객체를 명시적으로 사용해야 하므로, 대상 클래스에 일부 기능을 추가할 때 이를 포함하는 객체의 코드를 변경해야 한다. 따라서 상속을 사용할 때보다 번거로울 수 있다.
- 상속은 다형성을 활용할 수 있다. 슈퍼클래스와 서브클래스의 규약을 항상 잘 지켜서 코드를 작성해야 한다.
- 일반적으로 OOP에서는 상속보다 컴포지션을 사용하는 것이 좋다.
- 명확한 'is-a 관계'일 경우에만 상속을 사용하는 것이 좋다. 슈퍼클래스의 모든 단위 테스트는 서브클래스로도 통과할 수 있어야 한다.
'Kotlin > 이펙티브 코틀린' 카테고리의 다른 글
[이펙티브 코틀린] Item37. 데이터 집합 표현에 data 한정자를 사용하라 (0) | 2023.12.31 |
---|---|
[이펙티브 코틀린] Item35. 복잡한 객체를 생성하기 위한 DSL을 정의하라 (0) | 2023.12.25 |
[이펙티브 코틀린] Item34. 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라 (0) | 2023.12.24 |