이펙티브 코틀린 Item 36 : 상속보다는 컴포지션을 사용하라.

woga·2023년 10월 7일
0

코틀린 공부

목록 보기
39/54
post-thumbnail

상속은 관계가 명확하지 않을 때 사용하면 여러 가지 문제가 발생할 수 있다. 그래서 단순하게 코드 추출 또는 재사용을 위해 사용할 때는 컴포지션을 사용하자.

그럼 상속으로 어떤 문제들이 발생할 수 있을까?

간단한 행위 재사용

만약 프로그레스바를 로직 전 후로 출력하고 숨기는 동작을 한다고 해보자

class ProfileLoader {
	fun load() {
    	// show progress bar
        // read file
        // hide progress bar
    }
}

class ImageLoader {
	fun load() {
    	// show progress bar
        // read image
        // hide progress bar
    }
}

이럴 때 많은 경우 슈퍼클래스를 만들어 공통된 행위를 추출한다.

abstract class LoaderWithProgress {
	fun load() {
    	// show progress bar
        innerLoad()
        // hide ..
   }

	abstract fun innerLoad()
}

간단한 동작이라면 문제가 없지만 몇 가지 단점이 있다.

  • 상속은 하나의 클래스만을 대상으로 할 수 있다. 행위를 추출하다보면 많은 함수를 갖는 거대한 BaseXXX가 탄생하고 깊고 복잡한 계층 구조가 된다.

  • 상속은 클래스의 모든 것을 가져온다. 불필요한 함수까지 갖게되어 인터페이스 분리 원칙에 위반된다.

  • 상속은 이해하기 어렵다. 슈퍼클래스까지 여러 번 확인해야 한다.

그래서 다른 대안인 컴포지션을 사용하는 것이 좋다.
위 코드를 컴포지션으로 사용한다면 아래와 같다.

class Progress {
	fun showProgress() { */ show progress */ }
    fun hideProgress() { */ hide progress */ }
}

class ProfileLoader {
	val progress = Progress()
    
    fun load() {
    	progress.showProgress()
        // read file
        progress.hideProgress()
    }
}

ImageLoader 이하 동문

프로그레스바를 관리하는 객체를 다른 모든 객체에서 갖고 활용하는 추가 코드가 필요하다. 그래서 이 추가 코드를 적절하게 활용하는 것이 어려워서 상속을 선호하는 경우도 많다. 그러나 이런 추가 코드로 인해 코드를 읽는 사람들이 코드의 실행을 더 명확하게 예측할 수 있다는 장점도 있고, 프로그레스 바를 훨씬 자유롭게 사용가능하다.

또한, 컴포지션은 하나의 클래스 내부에서 여러 기능을 재사용할 수 있게되는 장점도 가지고 있다.

class ProfileLoader {
	private val progress = Progress()
    private val finishedAlert = FinishedAlert()
    
    fun load() {
    	progress.showProgress()
        // read file
        progress.hideProgress()
        finsihedAlert.show()
    }
}

게다가 하나 이상의 클래스를 상속할 수는 없기 때문에 두 기능을 하나의 슈퍼클래스에 배치해야하는데 컴포지션을 사용하면 여러 기능을 유연하게 사용할 수 있다.

만약 꿋꿋하게 상속을 쓰고 싶다고 하면 슈퍼클래스에 파라미터를 받아서 컨트롤 해야한다

ex)

abstract class InternetLoader(val showAlert: Boolean) {
}

class ProfileLoader : InternetLoader(showAlert = true) {}
class ImageLoader : InternetLoader(showAlert = false) {}

서브클래스가 필요하지도 않은 기능을 갖고 단순하게 이를 차단할 뿐인 나쁜 해결 방법이다.

이렇듯,

상속은 슈퍼클래스의 모든 것을 가져온다. 그렇기에 필요한 것만 가져올 수는 없다.

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

위에서 말했듯이 상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져온다. 따라서 계층 구조를 나타낼 때 굉장히 좋은 도구가 될 수 있지만, 일부분을 재사용하기 위한 목적으로는 적합하지 않다. 이럴땐 컴포지션을 활용하는게 좋다.

만약 로봇 강아지를 만드는데 bark만 가능하고 sniff는 못하게 한다고 해보자

class RobotDog : Dog() {
	override fun sniff() {
    	throw Error("Operation not supported")
        //인터페이스 분리 원칙에 위반됨
    }
}

// 그럼 다중 상속을하면 되지 않아? -> X, 코틀린은 다중상속을 지원하지 않는다.
abstract class Robot {
	open fun calculate() {  */...*/ }
}

class RobotDog : Dog(), Robot() // Error

위와 같은 상황들이 벌어지기 때문에 상속을 사용했을 때 문제가 발생한다.

캡슐화를 깨는 상속

상속을 활용할 때는 외부에서 어떻게 활용하는지도 중요하지만, 내부적으로 이를 어떻게 활용하는지도 중요하다. 왜냐하면 내부적인 구현 방법 변경에 의해 클래스의 캡슐화가 깨질 수 있기 때문이다.

만약 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을 호출 후 elementsAdded를 읽어보면 오동작되는 걸 확인할 수 있다
ex) addAll(listOf("1", "2", "3)) print(counterList.elementsAddede) = 6

바로 HastSet인 addAll 함수 내부에서 add를 호출하고 있기 때문이다. 그래서 이에 맞춰서 서브클래스도 수정을 해놓았는데 추후에 자바가 내부적으로 add를 호출하지 않는 방식으로 변경된다면 예상하지 못한 형태로 또 동작하게 된다.

또한, 다른 라이브러에서 현재 만든 CounterSet을 활용해서 구현했다면 그 구현들도 연쇄적으로 중단될 수 밖에 없다.

라이브러리 구현이 변경되는 것은 자주 접할 수 있는 문제다. 그럼 이를 어떻게 하면 좋을까? 바로 컴포지션을 활용하는 것이다.

class CounterSet<T> {
	private val innerSet = HashSet<T>()
    ...
}

그러나 이렇게 구현하면 다형성이 사라져서 CounterSet은 더이상 Set이 아니게 된다. 이를 유지하고 싶다면 위임패턴을 사용하자

위임 패턴은 클래스가 인터페이스를 상속받게 하고 포함한 객체의 메서들을 활용해서 인터페이스에서 정의한 메서드를 구현하는 패턴

이렇게 구현된 메서드를 포워딩 메서드라고 부른다.

class CounterSet<T>: MutableSet<T> {
	private val innerSet = HashSet<T>()
    // ...
    
    override val size: Int
    	get() = innerSet.size
    
    override fun contains(element: T): Boolean = innerSet.contains(element)

	//...
}

// 위와 같이 다 override해서 구현한다면 너무 많아진다.
// 코틀린은 위임 패턴을 쉽게 구현할 수 있는 문법을 제공하므로 다음과 같이 구현해보자

class CounterSet<T>(
	private val innerSet: MutableSet<T> = mutableSetOf()
): MutableSet<T> by innerSet {
	//...
}

그래서 이처럼 다형성이 필요한데, 상속된 메서드를 직접 활용하는 것이 위험할 때는 이와 같은 위임 패턴을 사용하는 것이 좋다.
(물론 일반적으로 다형성이 그렇게까지 필요한 경우는 없어서 단순한 컴포지션으로 해결되는 경우가 많다)

오버라이딩 제한하기

개발자가 상속용으로 설계되지 않은 클래스를 상속하지 못하게 하려면, final을 사용한다. 만약 어떤 이유로 상속은 허용하지만 메서드는 오버라이드하지 못하게 만들고 싶다면 다음과 같이 해보자.

open class Parent {
	fun a() {}
    open fun b() {}
}

class Child: Parent() {
	ovveride fun a() {} //Error
    ovveride fun b() {}
}

상속용인 메서드에만 open 키워드를 붙이자. 또한 서브클래스에서 오버라이드된 메서드에 final을 붙일 수도 있다.

정리

컴포지션과 상속은 다음과 같은 차이가 있다

  • 컴포지션이 더 안전하다. 외부에서 관찰되는 동작에만 의존하므로

  • 컴포지션이 더 유연하다. 컴포지션은 여러 클래스를 대상으로 필요한 것만 받을 수 있으므로

  • 컴포지션이 더 명시적이다. 리시버를 명시적으로 활용하므로 메서드가 어디에 있는지 확실하게 알 수 있다.

  • 컴포지션은 생각보다 번거롭다. 객체를 명시적으로 사용해야해서 일부 기능이추가되면 이를 포함하는 객체의 코드를 다 변경해야 하므로 코드를 수정해야 하는 경우가 더 많다.

  • 상속은 다형성을 활용할 수 있다. 그러나 이를 쓰면 슈퍼클래스의 모든 것을 동작해야하기 때문에 규약을 잘 지켜서 코드를 잘 작성해야 한다.

그렇다고 상속을 아예 사용하지 말라는 것은 아니다. 명확한 is-a 관계일 때 상속을 사용하는 것이 좋다.

슈퍼클래스를 상속하는 모든 서브클래스는 슈퍼클래스로도 동작할 수 있어야 한다.
슈퍼클래스의 모든 단위 테스트는 서브클래스로도 통과할 수 있어야한다.

뷰를 출력하기 위해 사용되는 Android의 Acitivity, iOS의 UIViewController, React의 React.Component 등이 대표적이다.
또한, 상속을 위해 설계되지 않은 메서드는 final로 만들어두는 것이 좋다!

profile
와니와니와니와니 당근당근

0개의 댓글