Kotlin의 클래스 위임은 어떻게 동작하는가

If you wanna read English version, please see here.
Image source: https://pixabay.com/photo-2542109/

안녕하세요! 이전 글에서는 프로퍼티 위임(Delegated Property)과 이를 이용한 초기화 지연(Lazy-initialization)에 대해 살펴 보았습니다. 이 글에서는 Kotlin의 2가지 위임 모델 중 다른 한가지인 클래스 위임(Class Delegation)을 살펴보고자 합니다.

워밍업

가벼운 몸풀기로 코드를 하나 살펴보도록 하겠습니다. 아시다시피 우리는 interface를 통해 클래스에 필요한 프로토타입을 정의하는 코드를 작성할 수 있습니다. 예를 들자면 다음과 같습니다.

interface Base {
fun printX()
}


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

이 코드를 디컴파일해보면 다음과 같습니다.

public interface Base {
void printX();
}


public final class BaseImpl implements Base {
private final int x;

public void printX() {
int var1 = this.x;
System.out.print(var1);
}


public final int getX() { return this.x; }
public BaseImpl(int x) { this.x = x; }
}

네, 특별한 부분도 없고 이해하기 어려운 부분도 없습니다. 이처럼 상속 대신 인터페이스를 사용하면 어떤 이점이 있을까요? :)

클래스 위임(Class Delegation)이란 무엇인가

레퍼런스는 클래스 위임을 다음과 같이 설명하고 있습니다.

상속을 표현하는 슈퍼타입 리스트 내의 by 절은 b(에 대한 참조)가 상속 오브젝트의 내부에 저장되고 컴파일러가 b가 가지는 Base 인터페이스의 모든 메소드를 생성함을 나타냅니다.

쉽게 설명하자면 하나의 클래스를 다른 클래스에 위임하도록 선언하여 위임된 클래스가 가지는 인터페이스 메소드를 참조 없이 호출할 수 있도록 생성해주는 기능입니다.

interface A { ... }
class B : A { }
val b = B()
// C를 생성하고, A에서 정의하는 B의 모든 메서드를 C에 위임합니다.
class C : A by b

만약 interface A를 구현하고 있는 class B가 있다면, A에서 정의하고 있는 B의 모든 메소드를 클래스 C로 위임할 수 있습니다. 즉, C는 B가 가지는 모든 A의 메소드를 가지며, 이를 클래스 위임(Class delegation)이라고 합니다.

사실 b는 A의 타입의 private 필드로 C 내에 저장되며, B에서 구현된 모든 A의 메소드는 이를 참조하는 형태의 정적 메소드로 생성됩니다.

클래스 위임의 내부

이는 어떻게 동작할까요? 다음 간단한 코드를 보도록 하겠습니다.

interface Base {
fun printX()
}

class BaseImpl(val x: Int) : Base {
override fun printX() { print(x) }
}
val baseImpl = BaseImpl(10)
class Derived(baseImpl: Base) : Base by baseImpl

위의 코드는 다음과 같은 Java 코드를 생성합니다.

public interface Base {
void printX();
}
public final class BaseImpl implements Base {
private final int x;
public void printX() {
int var1 = this.x;
System.out.print(var1);
}
// ...
}
public final class Derived implements Base {
// $FF: synthetic field
private final Base $$delegate_0;
public Derived(@NotNull Base baseImpl) {
Intrinsics.checkParameterIsNotNull(baseImpl, "baseImpl");
super();
this.$$delegate_0 = baseImpl;
}
public void printX() {
this.$$delegate_0.printX();
}
}

보시다시피 $$delegate_0가 Base 타입의 본래 인스턴스를 참조할 수 있도록 생성되며, printX()도 정적 메소드로 생성되어 $$delegate_0printX()를 호출할 수 있도록 생성됩니다. 따라서, 우리가 Derived를 사용할 때 Base에 대한 명시적 참조를 생략하고, printX() 메소드를 호출하는 것이 가능합니다.

인터페이스에서 파생되지 않은 메소드

물론 Kotlin은 인터페이스에서 파생되지 않은 다른 메소드와 속성을 선언할 수 있습니다.

interface Base {
fun printX()
}

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

private var y : Int = 10
fun printY() { print(y) }
}

디컴파일된 Java 코드를 살펴봅시다.

public interface Base {
void printX();
}

public final class BaseImpl implements Base {
public final void printY() {
int var1 = this.y;
System.out.print(var1);
}

// ...
}

public final class Derived implements Base {
// $FF: synthetic field
private final BaseImpl $$delegate_0;

public Derived(@NotNull Base baseImpl) {
Intrinsics.checkParameterIsNotNull(baseImpl, "baseImpl");
super();
this.$$delegate_0 = new BaseImpl(10);
}

public void printX() {
this.$$delegate_0.printX();
}

}

예상한 것처럼 클래스 DerivedBase 인터페이스에 존재하지 않는 printY() 메소드를 가지지 않습니다. :)

왜 클래스 위임을 사용해야 할까

클래스 위임이 인스턴스에 대한 참조없이 구현된 메소드를 사용하는 더 쉬운 방법이라고 일단 이해할 수 있습니다. 그렇다면 언제, 왜 이를 사용해야 할까요?

기본적으로 Kotlin의 클래스는 JVM의 final 속성을 가지고 있어 상속을 방지하고 있으므로, 만약 상속이 가능한 클래스를 만들고 싶은 경우 open 키워드를 사용해야 합니다.

제 개인적인 생각입니다만, Kotlin 언어의 디자이너들은 클래스 상속에 의한 복잡한 종속성이 야기하는 문제들을 방지하고 싶은 의지를 가지고 있는 듯 합니다.

우리는 Kotlin의 클래스 위임(Class Delegation)을 통해 상속의 방식 대신 Delegate Pattern을 응용할 수 있습니다. 클래스 위임은 다음과 같은 기능을 제공합니다.

  • 별도의 추가 코드 없이 상속(Inheritance)의 대안 제공
  • 인터페이스에 의해 정의된 메소드만 호출할 수 있도록 보호
  • private 필드에 위임된 인스턴스를 저장하여 직접적인 접근 차단

가장 중요한 점은 클래스 위임을 통해 모듈을 유연하게 구성할 수 있다는 점입니다. 다음 코드를 보겠습니다.

interface Vehicle {
fun go(): String
}
class CarImpl(val where: String): Vehicle {
override fun go() = "is going to $where"
}
class AirplaneImpl(val where: String): Vehicle {
override fun go() = "is flying to $where"
}
class CarOrAirplane(
val model: String,
impl: Vehicle
): Vehicle by impl {
fun tellMeYourTrip() {
println("$model ${go()}")
}
}
fun main(args: Array<String>) {
val myAirbus330
= CarOrAirplane("Lamborghini", CarImpl("Seoul"))
val myBoeing337
= CarOrAirplane("Boeing 337", AirplaneImpl("Seoul"))

myAirbus330.tellMeYourTrip()
myBoeing337.tellMeYourTrip()
}

위의 코드는 클래스 위임(Class Delegation)에 의해 캡슐화와 다형성을 구현하는 방법을 보여줍니다.

예제 : CoffeeMaker

Dagger2의 CoffeeMaker는 DI가 작동하는 방식을 설명하는 좋은 예제입니다. 클래스 위임을 통해 유사한 방식으로 의존성을 주입할 수 있도록 비슷한 코드를 작성해 보도록 하겠습니다.

원래 코드를 Kotlin으로 완벽하게 변환하기 보다는 상속 대신 클래스 위임을 사용하여 코드를 작성하는 방법을 보여 주기 위한 예제임을 참조하시기 바랍니다.

클래스 위임(Class Delegation)을 통해 간단한 Dependency Injection 코드의 작성 방법을 이해하기에 충분한 예제이기를 바랍니다. :)

솔직히 말해서 가장 어려운 부분은 열 순환기의 작동 방식을 이해하는 것이었습니다. 🤣 저처럼 열 순환기의 동작 방식이 궁금하면 여기여기를 확인하시기 바랍니다.

클래스 위임은 코드를 보다 유연하게 만들 수 있습니다.

위임 패턴은 강력한 도구입니다. 요구 사항 변경과 같은 몇 가지 좋지 못한 사태가 발생했을 때 전면적인 코드 수정을 방지할 수 있는 유연성을 제공할 수 있습니다. Kotlin의 클래스 위임은 언어 수준에서 지원되는 기능이므로 위임 패턴을 손쉽게 구현할 수 있습니다. :)

인터페이스를 신중하게 정의한 다음 인터페이스를 사용하여 위임하도록 구성한다면 적은 노력으로 더 유연한 코드의 구현이 가능할 것입니다. :)

오타나 이상한 부분을 발견하시거나 기타 궁금한 점, 의견 등이 있으시다면, 코멘트나 이메일을 남겨주시기 바랍니다. 논의할 만한 주제도 언제든지 환영합니다! :)