담백로그

[Kotlin] Delegation 사용설명서

코틀린에서는 Delegation 디자인 패턴을 언어 수준에서 지원하는 기능을 가지고 있습니다.
이에 관하여 자세히 알아보고 사용법을 살펴보도록 하겠습니다.

Delegation

앞서 언급한 Delegation Pattern(위임 패턴) 에 대해서 간단하게 짚고 넘어갈 필요가 있습니다. 위임 패턴은 클래스를 상속받지 않고, 다른 클래스의 재사용할 수 있도록 해주는 디자인 패턴 중에 하나 입니다.

우선 소프트웨어 공학에서 다른 클래스 기능의 재사용을 위해 사용하는 방법은 다음과 같습니다.

  • 상속(Inheritance)
  • 포함(Composition)

상속은 상위 클래스의 기능을 하위 클래스에 가져와 사용할 수 있지만, 상위 클래스에 의존적이게 되고 캡슐화를 무너뜨리는 단점이 있습니다.
하지만 포함은 다른 클래스의 객체를 내 클래스에 포함하여 다른 클래스의 기능을 그대로 가져올 수 있으며, 상위 클래스에 의존하지 않아도 캡슐화를 유지할 수 있는 장점이 있습니다.

Composition 이란 단어는 ‘포함’을 비롯해 ‘구성’, ‘조합’ 등의 단어로 번역할 수 있지만, 본문에서는 ‘포함’이라는 단어를 사용하도록 하겠습니다.

그렇다고 포함이 무조건 옳은 것은 아니며 사용 사례에 따라 적절히 선택하여 사용해야 할 것 입니다.

상속과 포함은 어떤 상황에서 선택하면 좋을까?

  • 상속을 고려해야 하는 경우
    • 상위 클래스와 하위 클래스의 관계가 is-a 관계일 때
    • 예) 개발자(하위클래스)는 사람(상위클래스)이다.
  • 포함을 고려해야하는 경우
    • 상위 클래스가 하위 클래스를 포함 has-a 하는 관계일 때
    • 예) 개발자(상위클래스)는 키보드(하위클래스)를 가지고 있다.

포함을 사용해야 하는 상황이라면 코틀린의 위임을 이용하여, 상용구 코드 없이 단순하게 구현할 수 있습니다.
다음은 Delegation 을 구현하는 코틀린 코드의 일반적인 예제입니다.

interface Base {
    fun print()
}

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

class MyDelegate(b: Base) : Base by b // by 예약어를 사용하여 print() 메소드를 직접 구현하지 않고 b 객체의 이미 구현된 메소드를 사용할 수 있습니다.

fun main() {
    val e = Example(12)
    MyDelegate(e).print()
}

Delegated properites

메소드 뿐만 아니라 프로퍼티 차원에서도 Delegation 을 지원하는 기능이 있습니다.
일단 예제코드를 보겠습니다.

class Delegate {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        return "Hello, getValue()"
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        println(value)
    }
}

class Example {
    var delegatedProperty: String by Delegate() // by 키워드를 통해 String 의 Read, Write 기능을 Delegate 클래스에 위임
}

fun main() {
    val e = Example()
    e.delegatedProperty = "Hello, setValue()"
    println(e.delegatedProperty)
}

핵심은 by 키워드 입니다.
Example 클래스 내에 String 타입의 프로퍼티를 선언하고 by 키워드를 통해 프로퍼티에 대한 기능을 Delegate 클래스에 위임하였습니다. Delegate 클래스를 살펴보면 getValue(), setValue() 메소드가 operator 키워드를 통해 연산자 오버로딩 되어 있는 것을 확인할 수 있습니다.
여기서 눈치챌 수 있는 건, Delegate 클래스에서 String 클래스의 읽기와 쓰기 기능을 새로 구현하겠다는 것을 알 수 있습니다.

코드를 실행해보면 delegatedProperty 에 값을 할당하는 경우 Delegate 클래스의 setValue() 메소드가 호출되고, 값을 읽어들이면 getValue() 메소드가 호출되는 것을 확인할 수 있습니다.

결과

Hello, setValue()
Hello, getValue()

Stardard delegates

코틀린 표준 라이브러리에서 제공하는 Delegation 기능 집합을 소개합니다.

Lazy

lazy() 메소드를 사용하여 프로퍼티의 초기화를 위임할 수 있으며, 프로퍼티의 최초 참조 시 람다식에 구현된 코드를 실행하여 결과 값을 기억한 뒤 추후 참조 시에는 기억된 값만 가져오는 식으로 동작합니다.
lazy() 메소드는 프로퍼티의 초기화를 최초 참조하는 시점으로 지연시켜 효율적인 메모리 사용을 도와줍니다.

val lazyValue: String by lazy {
    println("computed!")
    "Hello"
}

fun main() {
    println(lazyValue)
    println(lazyValue)
}

결과

computed!       // 최초 참조의 결과 값
Hello           // 최초 참조의 결과 값
Hello           // 두 번째 참조의 결과 값

lazy() 메소드는 Lazy<> 인터페이스를 구현한 클래스의 객체를 반환하고 있는데, Lazy<> 인터페이스에 대해서 알아보겠습니다.

안드로이드 개발에서 우리는 Fragment 내부 특정 프로퍼티에 ViewModel 초기화를 위한 아래와 같은 코드를 많이 접해보았을 것입니다.

private val viewModel: MyViewModel by viewModels()

Fragment ktx를 사용하면 viewModels() 확장 함수를 사용하여 지연 초기화의 목적을 달성할 수 있습니다.
viewModels() 의 내부 구현은 어떠한 지 살펴보도록 하겠습니다.

@MainThread
public inline fun <reified VM : ViewModel> Fragment.viewModels(
    noinline ownerProducer: () -> ViewModelStoreOwner = { this },
    noinline extrasProducer: (() -> CreationExtras)? = null,
    noinline factoryProducer: (() -> Factory)? = null
): Lazy<VM> {
    val owner by lazy(LazyThreadSafetyMode.NONE) { ownerProducer() }
    return createViewModelLazy(
        VM::class,
        { owner.viewModelStore },
        {
            extrasProducer?.invoke()
            ?: (owner as? HasDefaultViewModelProviderFactory)?.defaultViewModelCreationExtras
            ?: CreationExtras.Empty
        },
        factoryProducer ?: {
            (owner as? HasDefaultViewModelProviderFactory)?.defaultViewModelProviderFactory
                ?: defaultViewModelProviderFactory
        })
}

최종적으로 createViewModelLazy() 를 호출하고 있음을 확인할 수 있습니다.
createViewModelLazy() 메소드의 구현을 들어가 봅니다.

@MainThread
public fun <VM : ViewModel> Fragment.createViewModelLazy(
    viewModelClass: KClass<VM>,
    storeProducer: () -> ViewModelStore,
    extrasProducer: () -> CreationExtras = { defaultViewModelCreationExtras },
    factoryProducer: (() -> Factory)? = null

): Lazy<VM> {
    val factoryPromise = factoryProducer ?: {
        defaultViewModelProviderFactory
    }
    return ViewModelLazy(viewModelClass, storeProducer, factoryPromise, extrasProducer)
}

단순히 Lazy<> 인터페이스를 구현한 ViewModelLazy 클래스의 객체를 최종 반환해주는 것을 확인할 수 있습니다.
여기까지 보았을 때, 지연 초기화의 목적을 달성하기엔 힘들어 보입니다.
그렇다면 ViewModelLazy 구현도 살펴봅니다.

public class ViewModelLazy<VM : ViewModel> @JvmOverloads constructor(
    private val viewModelClass: KClass<VM>,
    private val storeProducer: () -> ViewModelStore,
    private val factoryProducer: () -> ViewModelProvider.Factory,
    private val extrasProducer: () -> CreationExtras = { CreationExtras.Empty }
) : Lazy<VM> {
    private var cached: VM? = null

    override val value: VM
        get() { 
            val viewModel = cached
            return if (viewModel == null) { 
                // 최초 참조 시 ViewModelProvider 를 통해 인스턴스 생성 후 캐시
                val factory = factoryProducer()
                val store = storeProducer()
                ViewModelProvider(
                    store,
                    factory,
                    extrasProducer()
                ).get(viewModelClass.java).also {
                    cached = it
                }
            } else {
                // 두 번째 참조부터 캐시된 객체 반환
                viewModel
            }
        }

    override fun isInitialized(): Boolean = cached != null
}

value 의 getter 를 호출하면 ViewModelProvider 를 통하여 ViewModel 의 인스턴스를 제공받고, cached 프로퍼티에 캐시하고 있음을 알 수 있습니다.
싱글톤 패턴의 그것과 비슷하게 구성되어 있으며, Lazy<> 인터페이스를 상속하여 초기화 동작을 직접 구현하는 방식입니다.

앞서 설명한 lazy() 메소드도 구현부를 보면 Lazy<> 인터페이스를 상속한 SynchronizedLazyImpl 객체를 반환하는 방식으로 비슷하게 초기화를 구현하고 있습니다.
내부에는 멀티스레딩 환경에 대응할 수 있도록, synchronized 키워드를 사용한 다소 긴 코드를 구현하고 있습니다.

결론은 이와 같은 패턴으로 객체 초기화에 대한 상용구 코드를 줄일 수 있으며, 복잡한 초기화 동작을 특정 클래스(ViewModelLazy, SynchronizedLazyImpl 등)에게 위임할 수 있는 멋진 설계라 할 수 있습니다.

Observable

Delegates.observable() 메소드를 사용하면, 프로퍼티에 할당이 발생할 때 핸들러를 호출하도록 구현할 수 있습니다.
Jetpack 의 LiveData 와 비슷한 동작을 합니다. 초기값을 할당할 수 있으며, 핸들러 내부에서 old, new 파라미터를 넘겨받아 원하는 동작을 수행할 수 있습니다.

class User {
    var name: String by Delegates.observable("first") {
        prop, old, new ->
        println("$old -> $new")
    }
}

fun main() {
    val user = User()
    user.name = "second"
    user.name = "third"
}

결과

first -> second
second -> third

Vetoable

observable() 과 비슷한 vetoable() 메소드를 사용하면 핸들러에서 반환되는 Boolean 값을 기준으로 값이 할당되었을 때, 저장할 것인지 말지를 결정할 수 있습니다.

var max: Int by Delegates.vetoable(0) { property, oldValue, newValue ->
    newValue > oldValue // 새로운 값이 더 클 경우에만 값을 할당
}

println(max) // 0

max = 10     // 새로운 값이 더 커서 조건을 만족함으로 10은 정상적으로 할당됨
println(max) // 10

max = 5      // 새로운 값이 더 작아서 조건을 불만족함으로 5는 할당되지 않고 이전 값 10을 유지함
println(max) // 10

Storing properties in a map

프로퍼티 값을 맵에 저장하는 기능입니다. 일반적인 사용 사례로 파싱된 JSON 데이터를 가공하기에 적절할 것 입니다.

class User(map: Map<String, Any?>) {
    val name: String by map
    val age: Int by map
}

fun main() {
    val user = User(
        mapOf(
            "name" to "Mickey",
            "age" to 95
        )
    )

    println(user.name) // Mickey
    println(user.age)  // 95
}

위임된 프로퍼티에서 값을 읽으면 map 에서 값을 가져오게 됩니다.

가변 프로퍼티에 위임하고 싶다면 프로퍼티를 가변으로 설정 후 MutableMap 으로 선언하여 사용하면 가능합니다.

class User(map: MutableMap<String, Any?>) {
    var name: String by map
    var age: Int by map
}

fun main() {
    val user = User(
        mutableMapOf(
            "name" to "Mickey",
            "age" to 95
        )
    )
    ...
}