kyucumber
전체 글 보기

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

상속은 관계가 명확하지 않을 때 사용하면 여러 문제가 발생할 수 있다. 단순하게 코드 추출 또는 재사용을 위해 상속을 하는건 지양해야 한다.

일반적으로 아래와 같은 경우 상속보다는 컴포지션을 사용하는게 좋다.

  • 간단한 행위 재사용
  • 모든것을 가져올 수 밖에 없는 상속
  • 캡슐화를 깨는 상속

간단한 행위 재사용

아래와 같이 프로그레스 바를 어떤 로직 처리 전에 출력하고 처리후에 숨기는 유사한 동작을 하는 두개의 클래스가 있다.

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 클래스를 만들게 되고 굉장히 깊고 복잡한 계층 구조가 만들어진다.
  • 인터페이스 분리 원칙(Interface Segregation Principle) 위반

    • 상속은 클래스의 모든것을 가져오게 된다. 따라서 불필요한 함수를 갖는 클래스가 만들어질 수 있다.
  • 상속은 이해하기 어렵다.

    • 일반적으로 개발자가 메소드를 읽고 메소드의 장독 방식을 이해하기 위해 슈퍼클래스를 여러번 확인해야 한다면, 문제가 있는 것이다.

이러한 이유 때문에 다른 대안을 사용하는것이 좋다. 대표적인 대안은 바로 컴포지션(composition)이다.

컴포지션을 사용한다는 것은 객체를 프로퍼티로 갖고, 함수를 호출하는 형태로 재사용하는 것을 의미한다. 컴포지션을 사용해 문제를 해결하면 아래와 같은 코드를 사용한다.

class Progress { fun showProgress() {} fun hideProgress() {} } class ProfileLoader { 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() } }

    위와 같은 기능을 상속을 통해 구현하려면? 하나 이상의 클래스를 상속할 수 없으므로 상속으로 이를 구현하려면 두 기능을 하나의 슈퍼클래스에 배치해야 한다.

    이 때문에 클래스들에 복잡한 계층 구조가 만들어질 수 있다. 예를 들어 3개의 슈퍼클래스가 프로그레스바와 경고창을 만드는 슈퍼클래스를 상속받는데 1개의 서브클래스에서는 경고창이 필요없을때 아래와 같이 파라미터가 있는 생성자를 사용해야 한다.

    abstract class InternetLoader(val showAlert: Boolean) { fun load() { // 프로그레스 바를 보여줌 innerLoad() // 프로그레스 바를 숨김 if (showAlert) { // 경고창 출력 } } abstract class innerLoad() {} } class ProfileLoader : InternetLoader(showAlert = true) { override fun innerLoad() { // 프로파일을 읽어 들임 } }

    이것은 굉장히 나쁜 해결 방법이다. 서브클래스가 필요하지도 않은 기능을 갖고 단순하게 이를 차단하고 있다. 기능을 제대로 차단하지 못하면 문제가 발생할 수 있다.

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

상속은 슈퍼클래스의 모든 것을 가져온다. 일부분을 재사용하고 싶다면 컴포지션을 사용하는게 좋다.

bark(짖기) sniff(냄새 맡기)라는 함수를 갖는 Dog 클래스가 있을 때

abstract class Dog { open fun bark() {} open fun sniff() {} }

로봇 강아지는 bark(짖기)만 가능하고 sniff(냄새 맡기)는 못하게 하려는 요구사항이 있을 수 있다.

class RobotDog : Dog() { override fun sniff() { throw Error("Operation not suppoerted") } }

RobotDog는 아래와 같이 필요도 없는 메소드를 갖게 되며 아래와 같은 문제를 가진다.

  • 인터페이스 분리 원칙 위반
  • 슈퍼클래스의 동작을 서브클래스에서 깨버리므로 리스코프 치환 원칙 위반

컴포지션을 사용하면 위와 같은 문제가 발생하지 않는다.

무조건 컴포지션을 사용해야 하는 것은 아니며, 타입 계층 구조를 표현해야 한다면 인터페이스를 활용해 다중 상속을 하는 것이 좋을 수도 있다.

캡슐화를 깨는 상속

상속을 활용할 때 내부적인 구현 방법에 의해 클래스의 캡슐화가 깨질 수 있다.

아래 HasSet을 상속한 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) } } val counterList = CounterSet<String>() counterList.addAll(listOf("A", "B", "C")) print(counterList.elementsAdded) // 3이 아닌 6이 출력된다.

위에서는 의도한 3이 출력되지 않는다. 이는 HashSet의 addAll 내부에서 add를 사용하기 때문이다.

여기서는 아래와 같이 addAll을 제거하면 문제가 사라진다.

class CounterSet<T> : HashSet<T>() { var elementsAdded: Int = 0 private set override fun add(element: T): Boolean { elementsAdded++ return super.add(element) } }

하지만 이후 자바가 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) } }

이렇게 수정하는 경우 다형성이 사라진다는 문제가 있으며 이는 위임 패턴을 사용해 해결할 수 있다.

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

이렇게 구현된 메소드를 포워딩 메소드(forwarding method)라고 부르며 코드로 살펴보면 다음과 같다.

class CounterSet<T> : MutableSet<T>{ private val 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 // 길어서 생략, 대략 6개의 포워딩 메소드를 더 만들어야 한다 }

이렇게 만들면 포워딩 메소드가 너무 많아진다. (위 예제에서는 9개를 구현해야 한다.)

하지만 코틀린에서는 아래와 같이 위임 패턴을 쉽게 사용할 수 있는 문법을 제공한다.

class CounterSet<T>( private val innerSet: MutableSet<T> = mutableSetOf() ) : MutableSet<T> by innerSet { var elementsAdded: Int = 0 private set fun add(element: T): Boolean { elementsAdded++ return innerSet.add(element) } fun addAll(elements: Collection<T>): Boolean { elementsAdded += elements.size return innerSet.addAll(elements) } }

위처럼 다형성이 필요한데 상속된 메소드를 직접 활용하는게 위험할 때는 위와 같이 위임 패턴을 사용하는게 좋다.

오버라이딩 제한

개발자가 상속용으로 설계되지 않은 클래스를 상속하지 못하게 하려면 final을 사용하면 된다.

만약 어떤 이유로 상속은 허용하지만 메소드는 오버라이드하지 못하게 하고 싶은 경우가 있을 수 있다.

이 경우 클래스에 open 키워드를 사용하면 된다. open 클래스는 open 메소드만 오버라이드 할 수 있다.

open class Parent { fun a() {} open fun b() {} } class Child: Parent() { override fun a() {} // 오류 override fun b() {} }

정리

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

  • 컴포지션은 안전하다. 다른 클래스의 내부적인 구현에 의존하지 않고, 외부에서 관찰되는 동작에만 의존하므로 안전하다.
  • 컴포지션은 더 유연하다.

    • 상속은 한 클래스만을 대상으로 할 수 있지만 컴포지션은 여러 클래스를 대상으로 할 수 있다.
    • 상속은 모든 것을 받지만 컴포지션은 필요한 것만 받을 수 있다.
    • 슈퍼클래스의 동작을 변경하면 서브클래스의 동작도 큰 영향을 받지만 컴포지션은 이러한 영향이 제한적이다.
  • 컴포지션은 더 명시적이다.

    • 슈퍼클래스의 메소드를 사용할때는 리시버를 지정하지 않아도 된다.
    • 컴포지션을 사용하면 리시버를 명시적으로 활용해야 한다.
  • 컴포지션은 생각보다 번거롭다.

    • 객체를 명시적으로 사용해야 하므로 대상 클래스에 일부 기능을 추가할 때 이를 포함하는 객체의 코드를 변경해야 한다. 그래서 상속을 사용할 때 보다 코드를 수정해야 하는 경우가 많다.
  • 상속은 다형성을 활용할 수 있다.

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

Reference

  • 이펙티브 코틀린 - 프로그래밍 인사이트, 마르친 모스칼라 지음, 윤인성 옮김

개인적인 기록을 위해 작성된 글이라 잘못된 내용이 있을 수 있습니다.

오류가 있다면 댓글을 남겨주세요.