코틀린에서의 위임(Delegation in Kotlin)

이번 포스트에서는 위임에 대해 구체적인 개념부터 사용법을 알아보려 합니다. 이를 위해 안드로이드의 젯팩 컴포즈에서 사용되는 실제 코드(mutableStateOf)를 분석함으로써 위임에 대한 이해도를 높이고 동시에 이를 어떻게 커스텀하고 이용할 수 있을지도 함께 살펴 보도록 하겠습니다.

위임(Delegation)의 개념

위임은 다른 사람에게 나의 일을 맡기는 행위라고 할 수 있습니다.

제가 까페를 운영한다고 합시다. 까페를 운영하는 일은 매장관리, 재고관리, 홍보, 음료 준비 등 다양한 일을 포함합니다. 이를 혼자 다하는건 아주 힘든 일입니다. 그래서 저는 숙련된 바리스타인 “동동이”를 고용했습니다. 동동이는 손님이 오면 음료 준비와 관련된 모든 업무를 수행합니다. 즉 저는 동동이에게 음료 준비를 위임 함으로써 까페 운영을 위한 다른 업무에 집중하고, 좀 더 효율적으로 까페를 운영할 수 있게 되었습니다.

구성요소

위의 예제에서 까페 사장인 저는 델리게이터(Delegator), 바리스타로 고용된 동동이는 델리게이트(Delegate)라고 합니다. 델리게이터는 일을 떠넘기는 사람이고, 델리게이트는 일을 대신 받아서 해주는 사람입니다.

프로그래밍에서의 위임

직관적으로 위임을 잘 사용하면 내가 하려고 하는 일을 좀 더 효율적으로 할 수 있다는 것은 명확합니다. 그럼 프로그래밍의 세계에서는 위임이 구체적으로 어떤 방식으로 우리 일을 효율적으로 할 수 있도록 도와 줄까요?

위임의 장점

위임의 장점을 코드의 관점에서 바라보면 크게 아래 세 가지로 나눌 수 있습니다.

  • 코드의 재사용성
    동동이는 바리스타로서 음료 준비의 전문가입니다. 손님으로 부터 주문을 받으면 계속 동동이를 이용해서 음료를 제공할 수 있습니다. 제가 새로운 까페 체인점을 만든다면 다른 체인점에서 동동이를 쓸 수도 있겠죠. 잠시 일을 도우러 온 알바 생이 궁금한게 있으면 동동이에게 도움을 받을 수도 있습니다. 이렇게 바리스타로서 음료의 전문가인 동동이로부터 음료와 완련된 모든 일들에 걸쳐 도움 받을 수 있으니 재사용성이 높습니다.
  • 관심사의 분리(SoC - Separation of Concerns)
    까페의 사장으로서 저는 까페의 운영이라는 작업에 집중하고 음료 준비와 관련된 일들은 전문가인 동동이에게 맡길 수 있습니다. 즉 까페 운영이라는 관심사와 음료 준비라는 관심사가 분리되었습니다.
  • 확장성 & 동적 행위 변경
    음료의 퀄리티나 스타일을 바꾸고 싶으면 동동이를 변경하거나, 동동이를 해고하고 다른 바리스타를 고용하기만 하면 됩니다. 까페의 다른 구성요소의 변경 없이 최소한의 수정으로 런타임에 행위를 변경할 수 있습니다.

기본 사용법 - 클래스 위임

클래스 위임을 이용하면 인터페이스의 구현을 다른 델리게이트에게 넘길 수 있습니다. 즉 인터페이스의 기능(함수)와 데이터(속성)를 직접 구현할 필요 없이 다른 객체를 이용해서 처리할 수 있습니다.

인터페이스의 구현을 특정 델리게이트로 위임하기

아래의 예제를 보면 Barista를 구현하는 클래스를 생성하면서 by 키워드를 이용하여 Barista의 구현을 특정 델리게이트로 넘길 수 있습니다.

여기서는 Barista를 구현하는 StarbucksBarista 또는 EdiyaBarista 둘 중 하나가 되겠네요.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Interface
interface Barista {
fun makeCoffee()
}

// Delegate
class StarbucksBarista : Barista {
override fun makeCoffee() {
println("Make Starbucks-style coffee")
}
}
// Delegate
class EdiyaBarista : Barista {
override fun makeCoffee() {
println("Make Ediya-style coffee")
}
}

// Delegator: Cafe, Delegate: StarbucksBarista
class StarbucksCafe(): Barista by StarbucksBarista
// Delegator: Cafe, Delegate: EdiyaBarista
class EdiyaCafe(): Barista by EdiyaBarista

// Usage
fun main() {
val starbucks = StarbucksCafe()
starbucks.makeCoffee()

val ediya = EdiyaCafe()
ediya.makeCoffee()
}

인터페이스의 구현을 생성자를 이용하여 특정 델리게이트로 위임 하기

좀 더 유연하게 쓰고 싶다면 아래 처럼 쓸 수 있습니다. 생성자를 통해 Barista 인터페이스를 구현하는 구현체를 넘기는 방식입니다. Cafe 클래스에 Barista만 바꿔서 생성하면 되니 좀 더 유연하다고 할 수 있겠네요. 이 방법은 처음 방식보다 조금 더 유연하지만 여전히 객체의 생성 시 바리스타가 고정되며, 이후 바리스타를 바꾸는 것은 불가능합니다. 까페를 개업할 때 바리스타를 고용해서 평생 함께 가는거네요. 바리스타 입장에선 고용 안정이 되어서 좋지만 사장 입장에선 자유롭게 바리스타를 바꿀 수 있었으면 좋겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Interface
interface Barista {
fun makeCoffee()
}

// Delegate
class StarbucksBarista : Barista {
override fun makeCoffee() {
println("Make Starbucks-style coffee")
}
}
// Delegate
class EdiyaBarista : Barista {
override fun makeCoffee() {
println("Make Ediya-style coffee")
}
}

// Delegator: Cafe, Delegate: barista
class Cafe(barista: Barista): Barista by barista

// Usage of Cafe
fun main {
val starbucks = Cafe(StarbucksBarista())
starbucks.makeCoffee()

val ediya = Cafe(EdiyaBarista())
ediya.makeCoffee()
}

동적으로 델리게이트 변경하기

동적으로 런타임에 새로운 객체 생성 없이 바리스타를 바꾸고 싶다면 이렇게 하면 됩니다. 델리게이터인 Cafe 객체를 생성할 때 델리게이트를 넣어줄 수도 있지만 changeBarista를 이용하여 객체의 재 생성 없이 델리게이트를 변경 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// Interface
interface Barista {
fun makeCoffee()
}

// Delegate
class StarbucksBarista : Barista {
override fun makeCoffee() {
println("Make Starbucks-style coffee")
}
}
// Delegate
class EdiyaBarista : Barista {
override fun makeCoffee() {
println("Make Ediya-style coffee")
}
}

// Delegator: Cafe, Delegate: changable in runtime
class Cafe(b: Barista): Barista by b {
private var barista: Barista = b

override fun makeCoffee() {
barista.makeCoffee()
}

fun changeBarista(newBarista: Barista) {
this.barista = newBarista
}
}

// Usage of Cafe
fun main {
val cafe = Cafe(StarbucksBarista())
cafe.makeCoffee()

cafe.changeBarista(EdiyaBarista())
cafe.makeCoffee()
}

기본 사용법 - 2. 프로퍼티 위임

말 그대로 속성의 접근을 다른 객체에 위임하는 방법입니다. 즉 속성의 값을 읽거나 쓰는 두 가지 동작을 다른 객체에 위임 할 수 있다는 말이 됩니다.

코틀린에서는 몇 가지 프로퍼티 위임을 처리하는 델리게이트를 제공합니다.

코틀린에서 제공하는 내장 Delegate의 종류

Lazy Properties

속성의 지연 초기화를 지원합니다. 아래의 예에서 lazyValue는 최초 접근 시 lazy 함수에 전달되는 객체의 초기화 로직을 수행하는 람다 함수를 호출함으로써 객체를 초기화 합니다. 이 로직은 최초 접근 시 한번만 호출되며 이후에는 초기화 된 값으로 데이터 읽기가 수행됩니다.

1
2
3
4
5
val lazyValue: String by lazy {
println("Computed!")
"Hello"
}

Observable

Delegates.observable는 첫번째 파라미터로 초기값을, 두번째 파라미터로는 람다 함수를 인자로 받습니다. 람다 함수는 old, new로 이전 값과, 새로 바뀐 값을 전달해줍니다.

1
2
3
4
5
6
7
8
9
10
var name: String by Delegates.observable("<no name>") { prop, old, new ->
println("$old -> $new")
}

name = "first"
name = "second"

// result
<no name> -> first
first -> second

Vetoable

Delegates.vetoableDelegates.observable과 다르게 값이 변경되기 전에 콜백이 호출됩니다. 두번째 파라미터에서 Delegates.observable과 다른 점은 Boolean을 반환하는 람다를 파라미터로 받는 다는 점인데 이 값이 true인 경우에만 값이 변경됩니다.

아래의 예제에서는 새 값이 기존 값보다 클때만 max의 값이 변경됩니다.

1
2
3
4
5
6
7
8
9
10
11
var max: Int by Delegates.vetoable(0) { prop, old, new ->
new > old
}

println(max) // 0

max = 10
println(max) // 10

max = 5
println(max) // 10

아래와 같이 사용하면 아예 예외를 던지도록 하는 것도 가능합니다.

1
2
3
4
5
6
7
8
9
10
var max: Int by Delegates.vetoable(0) { property, oldValue, newValue ->
if (newValue > oldValue) true else throw IllegalArgumentException("New value must be larger than old value.")
}

println(max) // 0

max = 10
println(max) // 10

// max = 5 // will fail with IllegalArgumentException

Map Delegation

위임을 이용하면 map에 있는 값을 속성으로 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
val properties = mapOf(
"name" to "jude"
"age" to 10
)

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

val user = User(properties)
println(user) // User(map={name=jude, age=10})
println(user.name) // jude
println(user.age) // 10

아래와 같이 mutableMap을 이용하면 속성의 값을 수정 가능하도록 만들 수도 있습니다. 이를 이용하면 JSON을 파싱하는 작업을 아주 편하게 할 수 있습니다.

1
2
3
4
class MutableUser(val map: MutableMap<String, Any?>) {
var name: String by map
var age: Int by map
}

NotNull

var로 선언된 속성에 값이 설정되기 전에 접근하면 IllegalStateException을 발생 시킵니다. 이를 이용하면 런타임에 예외를 적절히 다뤄줘야하긴 하지만 선언과 값의 설정을 분리 할 수 있습니다. 참고로 코틀린에서는 notNull을 쓰지 않고 var로 변수를 선언하면서 값을 초기화 해주지 않으면 컴파일이 되지 않습니다.

1
2
3
4
#### NotNull
속성에 값이 설정되기 전 접근 시 IllegalStateException을 발생 시킴
```kotlin
var notNullVar: String by Delegates.notNull()

프로퍼티 위임의 커스텀

코틀린에서는 속성에 대한 위임을 지원합니다. 즉 클래스, 함수, 코드 블럭 안에서 사용하는 변수에 대한 접근을 델리게이트에 위임할 수 있습니다.

속성에 대한 접근이라고 하면 데이터를 가져오는 동작과, 데이터를 쓰는 동작이 있습니다. 따라서 속성에 대한 델리게이트는 결국 이 두 가지 동작을 커스텀 델리게이트에 구현하는 것 입니다.

먼저 사용법을 바로 보도록 하겠습니다.

1
2
3
class Example {
var p: String by Delegate()
}

위의 샘플 코드는 아래와 같이 문법적으로 나눠서 볼 수 있습니다. 일반적인 변수 선언 방법에서 뒤에 by <expression> 이 추가된 형태로, by 뒤에 있는 델리게이트에 속성에 대한 읽기, 쓰기를 위임한다는 의미입니다.

val/var <property name>: <Type> by <expression>

코틀린에서의 속성에 대한 델리게이트는 아래와 같은 형태로 구현할 수 있습니다. 아까 언급한 것처럼 읽기와 쓰기를 대신 처리해주기 위해 getValuesetValue를 구현하고 있는걸 볼 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
import kotlin.reflect.KProperty

class Delegate {
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return "$thisRef, thank you for delegating '${property.name}' to me!"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
println("$value has been assigned to '${property.name}' in $thisRef.")
}
}

만약 델리게이트가 읽기 전용으로 val로만 쓰인다면 getValue만 추가하면 됩니다. 수정이 가능한 var로 사용하신다면 델리게이트에 setValue도 추가로 구현이 필요합니다.

ReadOnlyPropertyReadWriteProperty 인터페이스

코틀린 표준 라이브러리에서는 델리게이트를 만들기 위해 구현해야하는 getValuesetValue를 정의하는 인터페이스를 제공합니다. 이름을 보면 알 수 있듯이 ReadOnlyProperty에서는 getValue만을 정의하고, ReadWriteProperty에서는 getValue와 setValue 둘 다 정의하고 있습니다.

이를 구현하지 않아도 앞서 설명한 getValuesetValue를 구현해주면 델리게이트로 동작하지만 ReadOnlyPropertyReadWriteProperty를 이용하면 클래스 선언 없이 익명 클래스로 바로 델리게이트를 만들 수도 있습니다. 또한 구현하고 있는 인터페이스를 보면 바로 델리게이트로서 동작이 가능하다는 것을 알 수 있기 때문에 가독성도 올라갑니다. 복잡한 getValuesetValue의 시그니처를 외울 필요없이 IDE의 도움을 받아 편하게 정의 할 수 있는 점도 장점이겠죠?

1
2
3
4
5
6
7
8
9
10
11
fun resourceDelegate(resource: Resource = Resource()): ReadWriteProperty<Any?, Resource> =
object : ReadWriteProperty<Any?, Resource> {
var curValue = resource
override fun getValue(thisRef: Any?, property: KProperty<*>): Resource = curValue
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Resource) {
curValue = value
}
}

val readOnlyResource: Resource by resourceDelegate() // ReadWriteProperty as val
var readWriteResource: Resource by resourceDelegate()

실제 코드로 보는 위임의 사용 예(mutableStateOf)

안드로이드에서 Jetpack Compose로 UI를 개발하고 있다면 State의 개념은 아주 친숙할 것입니다.

Compose는 데이터를 UI로 그대로 노출하는 선언형 UI 프레임워크 입니다. Compose에서는 UI에 노출할 데이터를 관리하는 특별한 인터페이스를 하나 제공하는데요. 바로 State라는 인터페이스입니다.

1
2
3
4
@Stable
interface State<out T> {
val value: T
}

State안에는 val value: T 형태로 value 속성을 가지고 있습니다. State의 특별함은 Composable function에서 State의 상태 변화를 구독해서 State가 변경되면 자동으로 재구성(Recomposition) 트리거 된다는 점입니다.

이런 State는 어떤 식으로 사용될까요? 아래는 안드로이드에서 컴포즈로 UI를 개발하고 있다면 아주 친숙한 코드일거라 생각합니다.
(Composable 함수가 아닌 ViewModel에서는 mutableStateOf가 아닌 mutableStateFlow의 사용이 권장 되지만 이해를 돕기위한 예제로 이렇게 구성했으니 참고해주세요)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class MyViewModel: ViewModel() {
...
var showDialog: MutableState<Boolean> = mutableStateOf(false)
...
}

@Composable
fun MyScreen(viewModel: MyViewModel) {
...
if (viewModel.showDialog.value) {
MyDialog(onDismissRequest = {
viewModel.showDialog.value = false
})
...
}

showDialogvalue에 따라 MyDialog()를 UI에 노출 합니다. showDialog의 값은 MutableState기 때문에 변경이 가능하구요. 하지만 위의 예를 보고 뭔가 이상함을 느끼신 분들도 계실거 같습니다. 사실 우리가 실제로 MutableState를 쓰는 방식은 대부분 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyViewModel: ViewModel() {
...
var showDialog: MutableState<Boolean> by mutableStateOf(false)
...
}

@Composable
fun MyScreen(viewModel: MyViewModel) {
if (viewModel.showDialog) {
MyDialog(onDismissRequest = {
viewModel.showDialog = false
})
}
}

귀찮거나, 지저분한 코드를 병적으로 싫어하는 개발자 답게 코틀린의 위임을 이용해서 조건문에서 showDialog.valueState에 접근하던걸 showDialog로 단순화 했네요. 위임을 사용했기 때문에 showDialog를 선언할 때 =by로 바뀐 것을 볼 수 있습니다.

그럼 MutableState 부터 차근차근 코드를 따라가 보도록 하겠습니다. 당연히 MutableState 역시 State를 구현하고 있는걸 볼 수 있습니다. 한 가지 눈에 띄는 부분은 Statevalue를 오버라이드 하면서 val 키워드로 선언되었던 value가 변경 가능한 변수임을 나타내는 var로 바뀐 점입니다. MutableState라는 이름에서 보이듯이 MutableState는 가지고 있는 value가 변경 가능해야하기 때문입니다.

1
2
3
4
5
6
@Stable
interface MutableState<T> : State<T> {
override var value: T
operator fun component1(): T
operator fun component2(): (T) -> Unit
}

MutableState의 델리게이트 구현

MutableStateState가 어떻게 생겼는지 보았는데 이것들이 델리게이트로는 어떻게 구현이 되어있는지를 살펴보도록 하겠습니다.

먼저 MutableState는 값의 변경이 가능해야합니다. 즉 쓰기 가능해야합니다. 따라서 setValue를 구현해주어야겠죠? 코드를 보면 바로 해당 함수를 찾을 수 있습니다. 아주 심플하게 this.value = value 이렇게 구현이 되어있네요.

1
2
3
inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
this.value = value
}

그럼 getValue 는 어떻게 구현되어 있을까요? 역시나 간단히 State 인터페이스의 value를 반환하는 단순한 코드로 구현이 되어있습니다.

1
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value

델리게이트를 알기 전에는 외계어 같이 보였던 코드가 지금은 너무 단순한 코드로 읽히네요. 😆

위임의 본질

상속 vs 구성(위임)

앞에 설명드린 많은 예제들을 보면 사실 위임이 아닌 다양한 방식으로 구현이 가능합니다. 전 프로그래밍이 결국 글쓰기와 동일하다고 생각합니다. 글쓰기에서는 전하는 핵심 메세지가 동일하더라도 이를 표현하는 문장은 수없이 다양할 수 있습니다.

다만 제목에 적은 세 가지의 개념을 간단히 한번 짚고 넘어간다면 도움이 될거 같습니다. 위의 세 가지 개념은 객체지향 프로그래밍의 토대가 되는 개념 들이며 대다수의 객체지향 프로그래밍 언어에서 이를 이용하여 코드를 작성할 수 있도록 지원합니다.

상속

먼저 상속은 객체의 관계를 부모-자식 관계로, 또는 is - a 관계로 많이 표현합니다. 당연히 구현하려고 하는 객체 사이의 관계를 이렇게 표현하는게 직관적이라면 상속을 사용할 수 있습니다. 상속을 사용하면 객체간의 관계를 수직적으로 표현하여 사람이 직관적으로 이해하기 좋고 부모의 속성과 기능이 자식에게 모두 전해지기 때문에 잘 모델링 하면 공유되는 기능과 속성을 부모에 두고 코드를 재활용 할 수 있는 장점이 있습니다.

상속보단 구성(Composition over Inheritance)

객체 지향 디자인 원칙에서 자주 언급되는 개념입니다. 앞서 상속의 장점들을 이야기 했지만 상속의 장점은 곧 단점이 되기도 합니다. 부모의 속성과 기능이 자식에게 전해지는 것은 얼핏 보면 코드의 재사용성을 높여주기도 하지만 크게 보면 캡슐화가 약해지는 단점과, 부모의 변경에 따라 자식이 영향을 받게 되는 강한 결합이 발생하며, 상속 관계가 깊어지면 자식 객체는 지나치게 비대해 져서 필요없는 기능이나 속성이 늘어나고, 어떤 기능과 속성이 있는지를 파악하는 것도 매우 힘들어집니다.

구성

이에 따라 최근에는 상속 보단 구성을 이용해서 코드를 작성하는걸 권장하고 있습니다. 구성은 has-a 관계로 많이 표현하며 해야하는 작업을 여러 객체로 나누고 합쳐서 원하는 기능을 구현하는 방식입니다. 각각의 객체가 할 일을 나눠서 가지고 있기 때문에 캡슐화가 잘 지켜지며 상속과 같은 복잡한 수직 구조를 갖지 않습니다. 물론 이에 따라 훨씬 더 많은 객체가 생겨나고 이들의 관계가 복잡해 지는 부분은 단점이 될 수도 있습니다.

위임

위임은 객체가 필요한 기능을 다른 객체로 전달하는 방식입니다. 결국 위임은 구성의 한 방법입니다. 구성이 좀 더 강력하고 편리하게 쓰일 수 있도록 도와주는 문법적인 장치라고도 볼 수 있습니다.

실제로 코틀린에서 by 키워드를 통해 위임을 사용하면 코틀린 컴파일러는 by가 지정하는 객체를 직접 생성해서 해당 객체의 레퍼런스를 갖고 이를 호출 하는 방식으로 위임을 처리합니다. 즉 다른 객체를 생성하고 이를 이용해서 특정 기능을 수행하는 구성과 완전 동일합니다. 다만 코틀린에서 문법적으로 매번 해당 객체의 레퍼런스를 호출하는걸 줄여주는 것일 뿐이죠.

아래 코드는 코틀린 공식 페이지에 있는 설명으로, 위임이 실제 어떤 식으로 구현이 되는지를 보여줍니다.

1
2
3
4
5
6
7
8
9
10
11
class C {
var prop: Type by MyDelegate()
}

// this code is generated by the compiler instead:
class C {
private val prop$delegate = MyDelegate()
var prop: Type
get() = prop$delegate.getValue(this, this::prop)
set(value: Type) = prop$delegate.setValue(this, this::prop, value)
}

위임의 대표적인 사용 예

위임이 구성의 한 방법이라고 보면 아래의 일들은 당연히 위임으로 처리하기 아주 좋은 사례라고 할 수 있습니다. 위임을 하면 특정 인터페이스나, 속성의 접근을 내가 통제할 수 있습니다. 즉 프록시나, 훅과 같은 역할을 델리게이트에 구현할 수 있는 것이죠. 결국 아래의 사용 사례들은 프록시나 훅으로 처리하기 좋은 것들이기도 합니다.

  • 로직의 재사용
  • 데이터 유효성 검사
  • 의존성 주입
  • 동적 행위 변경
  • 자원 관리
  • 모니터링 & 로깅
  • 원래의 기능에 새로운 기능 추가

이번 포스트에서는 위임에 대해 알아보았습니다. 내용이 좀 많긴 하지만 위임을 활용했을 때 가질 수 있는 많은 장점을 생각하면 여기서 다룬 내용들이 독자에게 많은 도움이 될거라 생각합니다.

또한 위임을 통해 구현된 코틀린, 안드로이드의 많은 기능들을 직접 코드를 따라가면서 분석해본다면 코드를 이해하는데에도 많은 도움이 될 것입니다.