일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 서비스 vs 쓰레드
- 플러터 설치 2022
- 안드로이드 유닛 테스트
- Rxjava Observable
- 클래스
- 큐 자바 코드
- 멤버변수
- 스택 자바 코드
- rxjava disposable
- ar vr 차이
- rxjava cold observable
- rxjava hot observable
- 객체
- 안드로이드 유닛테스트란
- 안드로이드 레트로핏 crud
- 안드로이드 유닛 테스트 예시
- 자바 다형성
- ANR이란
- 2022 플러터 안드로이드 스튜디오
- 안드로이드 라이선스 종류
- 안드로이드 레트로핏 사용법
- 스택 큐 차이
- android ar 개발
- 2022 플러터 설치
- 서비스 쓰레드 차이
- 안드로이드 라이선스
- jvm이란
- android retrofit login
- 안드로이드 os 구조
- jvm 작동 원리
- Today
- Total
나만을 위한 블로그
[이펙티브 코틀린] 아이템 36. 상속보다는 컴포지션을 사용하라 본문
상속은 굉장히 강력한 기능으로 "is-a" 관계의 객체 계층 구조를 만들기 위해 설계됐다. 상속은 관계가 불명확할 때 쓰면 여러 문제가 발생할 수 있다. 따라서 단순하게 코드 추출 또는 재사용을 위해 상속을 하려고 한다면 좀 더 신중하게 생각해야 한다. 일반적으로 이런 경우에는 상속보다 컴포지션을 쓰는 게 좋다.
간단한 행위 재사용
간단한 코드부터 확인한다. 프로그레스 바를 어떤 로직 처리 전에 출력하고, 처리 후 숨기는 유사한 동작을 하는 2개의 클래스가 있다고 가정한다.
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 클래스를 만들게 되고, 굉장히 깊고 복잡한 계층 구조가 만들어진다
- 상속은 클래스의 모든 걸 가져오게 된다. 따라서 불필요한 함수를 갖는 클래스가 만들어질 수 있다. 인터페이스 분리 원칙을 위반하게 된다
- 상속은 이해하기 어렵다. 일반적으로 개발자가 메서드를 읽고 메서드 작동 방식을 이해하기 위해 슈퍼클래스를 여러 번 확인해야 한다면 문제가 있는 것이다
이런 이유 때문에 다른 대안을 쓰는 게 좋다. 대표적인 대안은 컴포지션이다. 컴포지션을 쓴다는 건 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하는 걸 의미한다. 상속 대신 컴포지션을 활용해서 문제를 해결한다면 다음과 같은 코드를 사용한다.
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()
}
}
하나 이상의 클래스를 상속할 수는 없다. 따라서 상속으로 이를 구현하려면 두 기능을 하나의 슈퍼클래스에 배치해야 한다. 이 때문에 클래스들에 복잡한 계층 구조가 만들어질 수 있다. 이런 계층 구조는 이해하기도, 수정하기도 어렵다.
예를 들어 3개의 클래스가 프로그레스 바와 경고창을 만드는 슈퍼클래스를 상속받는데 2개의 서브클래스에선 경고창을 사용하지만, 다른 1개의 서브클래스에선 경고창이 필요 없을 땐 어떻게 해야 하는가? 이 문제를 처리하는 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 = true) {
override fun innerLoad() {
// 이미지 읽어들임
}
}
이것은 굉장히 나쁜 해결법이다. 서브클래스가 필요하지도 않은 기능을 갖고, 단순하게 이를 차단할 뿐이다. 기능을 제대로 차단하지 못하면 문제가 발생할 수 있다.
상속은 슈퍼클래스의 모든 걸 가져온다. 필요한 것만 가져올 순 없다. 따라서 이런 형태로 활용하는 것은 좋지 않다.
모든 걸 가져올 수밖에 없는 상속
상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 걸 가져온다. 따라서 상속은 객체의 계층 구조를 나타낼 때 굉장히 좋은 도구다. 하지만 일부분을 재사용하기 위한 목적으론 적합하지 않다. 일부분만 재사용하고 싶다면 컴포지션을 쓰는 게 좋다. 컴포지션은 내가 원하는 행위만 가져올 수 있기 때문이다. 간단한 예로 bark(짖기)와 sniff(냄새 맡기)라는 함수를 갖는 Dog 클래스가 있다고 가정한다.
abstract class Dog {
open fun bark() { /*...*/ }
open fun sniff() { /*...*/ }
}
그런데 만약 로봇 강아지를 만들려는 데 로봇 강아지는 bark만 가능하고 sniff는 못하게 하려면 어떻게 해야 하는가?
class Labrador: Dog()
class RobotDog: Dog() {
override fun sniff() {
throw Error("지원되지 않는 기능입니다")
// 인터페이스 분리 원칙에 위반됨
}
}
이런 코드는 RobotDog가 필요도 없는 메서드를 갖기 때문에 인터페이스 분리 원칙에 위반된다. 또한 슈퍼클래스의 동작을 서브클래스에서 깨버리므로 리스코프 치환 원칙에도 위반된다. 반면 만약 RobotDog가 calculate라는 메서드를 갖는 Robot이란 클래스도 필요하다면 어떻게 해야 하는가? 코틀린은 다중 상속을 지원하지 않는다.
abstract class Robot {
open fun calculate() { /*...*/ }
}
class RobotDog: Dog(), Robot() { // 오류
override fun sniff() {
throw Error("지원되지 않는 기능입니다")
// 인터페이스 분리 원칙에 위반됨
}
}
컴포지션을 쓰면 이런 설계 문제가 전혀 발생하지 않는다. 무조건 좋다는 건 아니다. 타입 계층 구조를 표현해야 한다면 인터페이스를 활용해서 다중 상속하는 게 좋을 수 있다.
캡슐화를 깨는 상속
상속을 활용할 때는 외부에서 이를 어떻게 활용하는지도 중요하지만, 내부적으로 어떻게 활용하는지도 중요하다. 내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수 있기 때문이다.
아래와 같은 CounterSet 클래스가 있다고 친다. 이 클래스는 자신에게 추가된 요소의 개수를 알기 위한 elementAdded 프로퍼티를 가지며 HashSet을 기반으로 구현됐다.
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)
}
}
이 클래스는 큰 문제가 없어 보이지만 실제론 제대로 동작하지 않는다.
fun main() {
val counterList = CounterSet<String>()
counterList.addAll(listOf("A", "B", "C"))
print(counterList.elementsAdded) // 6
}
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)
}
}
왜 문제가 발생한 건가? 문제는 HashSet의 addAll 안에서 add를 썼기 때문이다. addAll과 add에서 추가한 요소 개수를 중복해서 세므로 요소 3개를 추가했는데 6이 출력되는 것이다. 간단하게 addAll()을 제거하면 이런 문제가 사라진다.
하지만 이런 해결법은 위험할 수 있다. 어느 날 자바가 HashSet.addAll을 최적화하고 내부적으로 add를 호출하지 않는 방식으로 구현하기로 했다면 어떻게 되는가? 그렇게 되면 현재 구현은 자바 업데이트가 이뤄지는 순간 예상 못한 형태로 동작한다. 또한 만약 다른 라이브러리에서 현재 만든 CounterSet을 활용해 뭔가를 구현했다면 그런 구현들도 연쇄적으로 중단될 것이다. 자바 개발자들도 이런 문제를 알기 때문에 어떤 구현을 변경할 때는 굉장히 신중을 기한다.
라이브러리 구현이 변경되는 일은 꽤 자주 접할 수 있는 문제다. 그럼 어떻게 해야 이런 문제가 발생할 가능성을 막을 수 있는가? 상속 대신 컴포지션을 쓰면 된다.
fun main() {
val counterList = CounterSet<String>()
counterList.addAll(listOf("A", "B", "C"))
print(counterList.elementsAdded) // 3
}
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)
}
}
이렇게 수정했을 때 문제가 하나 있는데 바로 다형성이 사라진다는 것이다. CounterSet은 더 이상 Set이 아니다. 만약 이를 유지하고 싶다면 위임 패턴을 사용할 수 있다.
위임 패턴은 클래스가 인터페이스를 상속받게 하고, 포함한 객체의 메서드들을 활용해서 인터페이스에서 정의한 메서드를 구현하는 패턴이다. 이렇게 구현된 메서드를 포워딩 메서드라고 부른다.
class CounterSet<T>: MutableSet<T> {
private var innerSet = HashSet<T>()
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)
}
override val size: Int
get() = innerSet.size
override fun clear() = innerSet.clear()
override fun isEmpty(): Boolean = innerSet.isEmpty()
override fun containsAll(elements: Collection<T>): Boolean =
innerSet.containsAll(elements)
override fun contains(element: T): Boolean =
innerSet.contains(element)
override fun iterator(): MutableIterator<T> = innerSet.iterator()
override fun retainAll(elements: Collection<T>): Boolean =
innerSet.retainAll(elements)
override fun removeAll(elements: Collection<T>): Boolean =
innerSet.removeAll(elements)
override fun remove(element: T): Boolean =
innerSet.remove(element)
}
이렇게 만들면 구현해야 하는 포워딩 메서드가 너무 많아진다고 생각할 수 있다. 하지만 코틀린은 위임 패턴을 쉽게 구현할 수 있는 문법을 제공하므로 위의 코드를 아래처럼 짧게 작성할 수 있다. 이렇게 코드를 작성하면 컴파일 시점에 포워딩 메서드들이 자동으로 만들어진다. 아래는 인터페이스 위임이 활용되는 예시다.
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)
}
}
fun main() {
val counterList = CounterSet<String>()
counterList.addAll(listOf("A", "B", "C"))
print(counterList.elementsAdded) // 3
}
지금까지 본 예시들처럼 다형성이 필요한데 상속된 메서드를 직접 활용하는 게 위험할 때는 이런 위임 패턴을 쓰는 게 좋다. 하지만 사실 일반적으로 다형성이 그렇게까지 필요한 경우는 없다. 그래서 단순하게 컴포지션을 활용하면 해결되는 경우가 굉장히 많다. 컴포지션을 쓴 코드는 이해하기 쉬우며 유연하다. 이런 경우 위임을 쓰지 않는 컴포지션이 훨씬 이해하기 쉽고 유연하므로 더 적합하다.
상속으로 캡슐화를 깰 수 있다는 사실은 보안 문제다. 하지만 대부분의 경우에 이런 행위는 규약으로 지정돼 있거나, 서브클래스에 의존할 필요가 없는 경우(일반적으로 메서드가 상속을 위해 설계된 경우)다.
컴포지션을 쓰는 데는 여러 이유가 있다. 컴포지션은 재사용하기 쉽고 더 많은 유연성을 제공하기 때문이다.
오버라이딩 제한하기
개발자가 상속용으로 설계되지 않은 클래스를 상속하지 못하게 하려면 final을 쓰면 된다. 그런데 만약 어떤 이유로 상속은 허용하지만 메서드는 오버라이드하지 못하게 만들고 싶을 수 있다. 이런 경우 메서드에 open 키워드를 사용한다. open 클래스는 open 메서드만 오버라이드할 수 있다.
open class Parent {
fun a() {}
open fun b() {}
}
class Child: Parent() {
override fun a() {} // 오류
override fun b() {}
}
상속용으로 설계된 메서드에만 open을 붙이면 된다. 참고로 메서드를 오버라이드할 때 서브클래스에서 해당 메서드에 final을 붙일 수도 있다.
abstract class InternetLoader {
open fun loadFromInternet() {
//
}
}
open class ProfileLoader: InternetLoader() {
final override fun loadFromInternet() {
// 프로필 읽어들임
}
}
이를 활용하면 서브클래스에서 오버라이드할 수 있는 메서드를 제한할 수 있다.
'책 > Effective Kotlin' 카테고리의 다른 글
[이펙티브 코틀린] 아이템 38. 연산 또는 액션을 수행할 때 인터페이스 대신 함수 타입을 사용하라 (0) | 2023.03.03 |
---|---|
[이펙티브 코틀린] 아이템 37. 데이터 집합 표현에 class 한정자를 사용하라 (0) | 2023.02.26 |
[이펙티브 코틀린] 아이템 35. 복잡한 객체를 생성하기 위한 DSL을 정의하라 (0) | 2023.01.31 |
[이펙티브 코틀린] 아이템 34. 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라 (0) | 2023.01.30 |
[이펙티브 코틀린] 아이템 33. 생성자 대신 팩토리 함수를 사용하라 (0) | 2023.01.29 |