상속! 악마의 속삭임, 그 속에 숨겨진 문제점, Kotlin에서는 Delegation을 활용해 보자.



개인 광고 영역

Java에서 설계할 때 매우 익숙하게 하는 게 있다. 바로 Base를 상속 구조로 만드는 것.

상속은 매우 쉽게 생각할 수 있고, 공통 작업을 분리할 때 매우 유용하게 사용할 수 있다.

Kotlin에서는 상속보다는 Delegation을 사용하라고 하는데, 상속 대신 Delegation을 사용할 때의 장점을 알아본다.

이 내용도 결국 Effective Java 3판에 잘 나오는데 아래 내용을 참고하면 좋다.

  • 아이템 18 : 상속보다는 컴포지션(Composition)을 사용하라
  • 아이템 19 : 상속을 고려해 설계하고 문서화하라. 그러지 않았다면 상속을 금지하라


이 글에서 알아볼 내용

  • 상속을 알아보고, 상속의 단점을 알아보다.
  • Composition을 알아본다.
  • Kotlin의 Delegation을 알아본다.


코틀린 클래스

코틀린에서 제공하는 클래스는 문서에 잘 나온다. 이 글에서는 다루지 않으니 링크를 남긴다.


상속 시 주의해야 할 점

상속은 매우 쉽게 사용할 수 있지만 매우 많은 고민이 필요한 존재이다.

  • Classes의 확장을 고려해야 한다.
  • 함수의 override를 허용할지를 정해야 한다.

이 두 가지 가치는 매우 중요하다. 원래 생각한 것과 다르게 동작할 수 있음을 사전에 알고 있어야 한다.


새로운 물길을 만들 것인가, 기존 물길을 지킬 것인가

상속을 물길로 표현해보았다. 예를 들면 아래와 같은 코드인데,

이 내용은 두 가지 참고를 하고 읽어보시길

  • kotlin의 class/function은 모두 open 키워드를 사용해 확장을 허용하는 코드로 작성했다.
  • 실제 Java의 Math는 확장을 금지하고 있다.
  • 여기서는 행동을 물길로 표현했다.
open class Math {

    open fun sum(a: Int, b: Int): Int =
        a + b
}

이 코드의 원래 물길은 a의 값과 b의 값을 더하는 매우 간단한 코드이다.

이 Math의 확장성을 염두 해 open 키워드를 정의했다. 코틀린에서는 open 키워드를 적용하지 않으면 기본 상속을 금지한다.

자바는 모든 클래스와 함수 모두 확장이 가능하고, final 키워드를 사용해 상속 및 재정의를 막을 수 있다.

Math class는 확장이 가능하기 때문에 아래와 같이 임의로 확장을 해보았다.

그리고 sum을 override 하고, sum의 값 a, b를 더하기 대신 곱하기로 변경했다.

class ExMath : Math() {

    override fun sum(a: Int, b: Int): Int {
        return a * b
    }
}

open 키워드를 사용했기 때문에 충분히 가능한 일이다. 결국 누가 보아도 더하기를 해야 할 것처럼 생긴 이 함수는 갑자기 곱하기로 변경되었다.


모든 class와 function에서 open 해두는 건 위험하다.

결국 이 코드는 물길이 달라졌는데, 누군가는 ExMath를 사용하면 곱하기라는 결과를 받을 수 있게 되었다.

극단적일까? 보통의 자바 개발에서는 final을 사용하지 않는 경우가 많다. 그 말은 충분히 확장을 허용하게 되고, 극단적인 코드도 만들 수 있다는 소리이다.

결국 하고 싶은 말은

  • classes의 확장을 허용하지 않으려면 막아야 한다.
    • Java에서는 final을 사용해 막아야 한다.
    • Kotlin은 아무것도 하지 않으면 final로 동작한다.
  • classes의 확장이 가능한 경우는 function의 확장 가능성을 사전에 염두 해 개발해야 한다.
    • Java에서는 final을 사용해 막아야 한다.
    • Kotlin은 아무것도 하지 않으면 final로 동작한다.

결국 classes 전부를 막아서 첨부터 재정의를 금지하는 방법이 있고, classes를 확장을 허용하는 대신, 각각의 function의 재정의를 허용하는 방법이 있다.

다행히 우리는 코틀린을 사용할 것이기에 open 키워드를 활용해 반대로 재정의를 허용해 줄 수 있다.


원래 함수의 동작을 방해하지 않으려면

처음 의도한 함수의 동작을 방해받지 않으려면 아래와 같은 코드로 변경함이 필요하다.

class Math {

    fun sum(a: Int, b: Int): Int =
        a + b
}

// 또는 class는 재정의를 허용하고, 함수는 막고 싶다면
open class Math {

    fun sum(a: Int, b: Int): Int =
        a + b
}

결국 최초 작성한 코드를 방해받지 않으려면 상속을 고려해 개발하고, 문서를 매우 잘 작성해 원래의 동작을 보증해주는 게 좋다.


그럼 상속은 언제 활용하는 게 좋을까?

코틀린에서만 Delegation이 있는 것은 아니다. Effective Java에도 소개되어 있지만 아이템 18 - 상속보다는 컴포지션(Composition)을 사용하라를 참고하면 좋다.

안드로이드 개발자라면 매우 익숙하게 사용하는 AppCompatActivity 내부에도 이런 컴포지션(Composition)을 사용하고 있고, 매우 많은 상속을 볼 수 있다.

먼저 상속 구조는 간단하게만 보더라도 아래와 같다.

  • 내가 만든 Activity
  • androidx.appcompat.app.AppCompatActivity
  • androidx.activity.FragmentActivity
  • androidx.activity.ComponentActivity
  • androidx.core.app.ComponentActivity
  • android.app.Activity

안드로이드 개발에 있어 상속 지옥을 맛볼 수 있는데, 여기에 또 직접 만든 BaseActivity가 포함될 수 있고, 거기에 또 구현 클래스가 있다면 더 많은 상속 구현을 볼 수 있다.

public class AppCompatActivity extends FragmentActivity implements AppCompatCallback,
        TaskStackBuilder.SupportParentable, ActionBarDrawerToggle.DelegateProvider {
    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        final AppCompatDelegate delegate = getDelegate();
        delegate.installViewFactory();
        delegate.onCreate(savedInstanceState);
        super.onCreate(savedInstanceState);
    }

    /**
     * @return The {@link AppCompatDelegate} being used by this Activity.
     */
    @NonNull
    public AppCompatDelegate getDelegate() {
        if (mDelegate == null) {
            mDelegate = AppCompatDelegate.create(this, this);
        }
        return mDelegate;
    }
}

이 내용에선 상속 지옥보단 delegate의 활용을 눈여겨볼 수 있다. 책에서도 잘 나오지만 컴포지션(Composition)을 활용하는 걸 권장하고 있다.

이유는 아래와 같다.

  • 메소드 호출과 달리 상속은 캡슐화를 해칠 수 있다.

상속은 사용하기 편하지만 캡슐화를 해칠 수 있는 문제가 있다고 한다. 책에서는 상위 클래스의 구현 수정에 따라 하위 클래스의 동작에 이상이 생길 수 있다고 한다.

필자가 적었던 건 반대의 경우도 해당한다고 생각해 적었다. 원래의 규칙에 맞지 않은 행동은 부모든 자식이든 모두 할 수 있는 것이다.

다만 부모 클래스를 믿고 의지하던 자식은 부모의 행동의 변화로 모든 하위 자식들이 매우 쉽게 영향받을 수 있다.

정리하면

  • 상속에 의지하면 부모가 언제든 변형이 일어나면 자식은 이를 받아들이고, 수정되어야 한다.
  • 반대로 자식은 부모를 거부하고, 다른 행동을 만들어버리면 이 역시 상속의 의미가 퇴색될 수 있다.

그러므로 상속은 다음과 같은 경우에 추천된다.

  • 상속은 상위 클래스와 하위 클래스가 is-a 관계일 때 활용하는 것을 추천한다.
    • 상위 클래스로 선언하고, 자식 클래스로 생성이 허용되는 경우

결국 이 모든 건 설계의 문제이다. 상위 클래스가 하위 클래스에 대한 고려를 할지 말지를 코틀린에서는 명시적으로 open 키워드를 활용할 수 있고, interface 또는 abstract을 이용해 허용할 수 있다.


그래서 이것을 추천한다.

Java에서는 Composition을 추천하고 있다. Composition은 위에서 Activity에서 보았던 것처럼 내부에 private 변수를 두고, 이를 상속 대신 활용하는 것이다.

/**
 * @return The {@link AppCompatDelegate} being used by this Activity.
 */
@NonNull
public AppCompatDelegate getDelegate() {
    if (mDelegate == null) {
        mDelegate = AppCompatDelegate.create(this, this);
    }
    return mDelegate;
}

결국 상속보다는 안전하게 사용할 수 있고, Composition 패턴에서는 조금은 귀찮지만 Decorator(데코레이터) 패턴을 활용하는 interface나 abstract 정의를 잘 활용해 해결할 수 있다.


코틀린에서는 Delegation을 활용하자.

자바에서는 final을 강제하지는 않아 누구나 실수할 수 있고 언제나 의도치 않은 확장을 만들 수 있지만, 코틀린은 모든 상속 가능성을 닫아두는 형태를 사용하고 있다.

자바와 코틀린의 가장 큰 차이는 바로 이 부분인데, 코틀린은 처음부터 final로 구현되어 있다. 확장이 필요하다면 open을 붙여 사용해야 함을 알았다.

상속의 근본적인 문제는 해결되었고, 코틀린에서는 어떻게 활용하는 게 좋을까?

이전 글에서 Kotlin Delegation을 알아보고, Delegated Properties와 class에서의 Delegation을 알아본다.를 작성했었는데, 바로 위임할 수 있는 by를 활용해 자바보다 쉽게 접근할 수 있는 장점을 가진다.

바로 1급 객체(First-class citizen) 란? with Kotlin - Lazysoul을 활용할 수 있다.

interface Base {
    fun print()
}

class BaseImpl(val x: Int) : Base {
    override fun print() { print(x) }
}

class Derived(b: Base) : Base by b

fun main() {
    val b = BaseImpl(10)
    Derived(b).print()
}

결국 상속보다는 안전하게 사용할 수 있고, 필요하다면 재정의도 가능한데 아래 Base의 print를 override 해서 사용할 수 있다. 그리고 넘겨받은 b를 아래와 같이 접근할 수 있다.

참고로 아래처럼 Delegation의 Base에 접근하기 위해서는 private val를 선언해 줘야 접근이 가능하다.

interface Base {
    fun print()
}

class BaseImpl(val x: Int) : Base {
    override fun print() { print(x) }
}

class Derived(private val b: Base) : Base by b {

    override fun print() {
        println("Derived print")
        b.print()
    }
}

fun main() {
    val b = BaseImpl(10)
    Derived(b).print()
}

만약 주입은 하지 않지만 초기화를 하고 싶다면 아래처럼 생성자에 default로 생성하는 걸 추천한다.

class Derived(x: Int, private val b: Base = BaseImpl(x)) : Base by b

참고로 이 코드의 디컴파일 결과는 Effective Java에서 설명하는 컴포지션(Composition) 형태를 볼 수 있는데, 외부 주입을 통해 처리한 부분이라 아래와 같이 Base를 주입받아 처리하는 걸 볼 수 있다.

public final class Derived implements Base {
   private final Base b;

   public void print() {
      String var1 = "Derived print";
      boolean var2 = false;
      System.out.println(var1);
      this.b.print();
   }

   public Derived(@NotNull Base b) {
      Intrinsics.checkNotNullParameter(b, "b");
      super();
      this.b = b;
   }
}


조금은 귀찮다.

매번 interface를 작성하고, 상속을 받는 구조를 만들거나, 상속 대신 선택한 클래스를 Decorator(데코레이터) 패턴을 사용해 확장해두는 형태이다.

장단점은 명확한데, 데코레이터 패턴을 구현한다는 건 매핑을 하는 단점이 있는 대신 안전하게 사용할 수 있다는 점이다. (참고로 책들에 보면 대부분 컬렉션을 사용할 때를 기준으로 설명한다.)

interface의 단점은 매번 정의해야 하는 점이고, 장점은 필요할 때 확장한 클래스에서 재정의도 가능하고, 상속처럼 불필요한 데이터까지 매번 안고갈 필요성이 줄어드는 장점이 있다.

대신 코틀린에서는 이런 패턴을 활용하는 장점으로 class Delegation을 활용할 수 있다는 점이다.


마무리

이 글은 정답은 없지만 상속을 잘 구현하고 사용한다면 매우 활용성이 높을 수 있다.

다만 상속이 마냥 좋은 것은 아님을 알아보았다. 처음 개발한 사람이 확장되었을 때 주어지는 제한을 사전에 알아차리지 못하고, 매뉴얼도 제공하지 않은 상태로 이를 상속받아 재정의 했다면 예기치 못한 상황은 언제든 생길 수 있다.

둘 중 하나를 선택해야 한다. 새로운 상속 구조를 만들지, 기존 상속을 믿고 따를 것인지. 결국 새로운 상속은 또 다른 레거시를 만드는 것이고, 기존 상속을 따르는 것 역시 좋지 않은 결과를 만들 수 있다. 그렇다고 함부로 리팩토링 하기도 쉽지 않을 것이다.

처음 설계가 잘 되었다면 이런 문제는 적겠지만 그렇지 않을 가능성이 높다. 개발 규모는 계속 증가하고, 점점 더 비대해진다. 결국 손대지 못할 코드가 만들어질 수 있다.

이런 연결 고리를 그나마 단순하게 만들 수 있는 제안이 바로 Effective Java에서 말하는 Composition이고, 코틀린에서는 위임을 통해 사용할 수 있는 Delegation 패턴이다.

어떤 게 유용한 지는 결국 본인의 선택이니 좋은 방법을 선택해 좋은 코드로 발전시키길 바란다.

이번 글 요약

  • 상속을 잘 설계할 수 없다면 Composition을 활용하라.
  • is-a 관계를 유지하기 어렵다면 이 역시 상속을 생각하지 않는 게 좋다.
  • Java는 final을 통해 확장을 거부할 수 있다.
  • Kotlin은 open 키워드를 통해 확장을 허용해 주는 형태를 사용할 수 있다.
  • 상속이 모든 걸 도움 주지 않는다.
  • Kotlin에서는 Decorator 패턴을 활용해 Delegation을 활용하는 걸 추천한다.
  • 사실 어떤 것도 해답은 없으나 조금 더 좋은 결과물을 만들 수 있으니 유용한 방법을 활용하라.


이 글만 보기 아쉽죠. Effective Kotlin 더 보기

지금까지 작성한 EffectiveKotlin 관련 글은 태그로 모아 링크를 추가한다.

더 보기 - Effective Kotlin 관련 글



About Taehwan

My name is Taehwan Kwon. I have developed Android for 6 years and blog has been active for eight years.

Comments