[코틀린] 위임 프로퍼티

hee09·2021년 12월 17일
1
post-thumbnail

위임 프로퍼티 개념

코틀린이 제공하는 관례에 의존하는 특성 중 하나로 위임 프로퍼티(delegated property)를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있습니다. 예를 들어 프로퍼티는 위임을 사용해 자신의 값을 필드가 아니라 데이터베이스 테이블이나 브라우저 세션, 맵 등에 저장할 수 있습니다.

이런 특성의 기반에는 위임이 있는데, 위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 의미합니다. 이때 작업을 처리하는 도우미 객체를 위임 객체(delegate)라고 부릅니다.


위임 프로퍼티 소개

위임 프로퍼티의 일반적인 문법은 아래와 같습니다.

일반적인 위임 프로퍼티 구조

// 위임 관례를 따르는 클래스는 getValue, setValue 메소드를 제공해야함
class Delegate {
    // getValue는 게터를 구현하는 로직을 담음
    operator fun getValue(...) { ... }
    // setValue는 세터를 구현하는 로직을 담음
    operator fun setValue(...) { ... }
}

class Foo {
    // by 키워드는 프로퍼티와 위임 객체를 연결
    var p: Type by Delegate()
}

Foo 클래스의 p 프로퍼티는 접근자 로직(getter, setter)를 다른 객체에게 위임합니다. 여기서는 Delegate 클래스의 인스턴스를 위임 객체로 사용합니다. by 뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻습니다. 프로퍼티 위임 객체가 따라야하는 관례를 따르는 모든 객체를 위임에 사용할 수 있습니다. 즉, 위의 Delegate 클래스와 같이 getValue와 setValue 메소드를 제공하는 모든 객체는 위임 객체로 사용할 수 있는 것입니다(변경 가능한 프로퍼티의 경우에만 setValue를 제공).

이렇게 사용하면 컴파일러는 Foo 클래스에 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화합니다. p 프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임하는 것입니다.

class Foo {
    private val delegate = Delegate() // 컴파일러가 생성한 도우미 프로퍼티
    var p: Type
        // p 프로퍼티를 위해 컴파일러가 생성한 접근자는
        // delegate의 getValue와 setValue 메소드를 호출
        set(value: Type) = delegate.setValue(..., value)
        get() = delegate.getValue(...)
}

fun main() {
    val foo = Foo()
    val oldValue = foo.p
    foo.p = newValue
}

컴파일러가 자동으로 위와 같이 도움을 주기에 foo.p와 같이 일반 프로퍼티처럼 사용할 수 있습니다. 하지만 실제로는 p의 게터나 세터는 Delegate 타입의 위임 프로퍼티 객체에 있는 메소드를 호출합니다. 이러한 방식으로 코틀린 라이브러리는 프로퍼티 위임을 사용해 프로퍼티 초기화를 지연시켜 줄 수 있습니다.


by lazy()

지연 초기화(lazy initialization)는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요한 경우 초기화할 때 흔히 쓰이는 패턴입니다. 이러한 패턴을 사용하는 예로는 초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티가 있습니다.

by lazy()를 사용하지 않은 프로퍼티 초기화

예를 들어 아래의 예제는 by lazy()를 사용하지 않고 작성한 코드인데 Person 클래스가 자신이 작성한 이메일의 목록을 제공한다고 가정하겠습니다. 이메일은 데이터베이스에 들어있어서 불러오려면 시간이 오래 걸려서, 이메일 프로퍼티의 값을 최초로 사용할 때 단 한 번만 가져오고 싶습니다.

이메일을 불러오기 전에는 null을 저장하고, 불러온 다음에는 이메일 리스트를 저장하는 _emails 프로퍼티를 추가해서 지연 초기화를 구현한 클래스입니다.

// 이메일 클래스
class Email(val content: String)

// 이메일을 가져오는 메소드
fun loadEmails(person: Person): List<Email> {
    println("${person.name}의 이메일 목록")
    return listOf(/*...*/)
}

// 사람 클래스(이메일 프로퍼티를 가지고 있는 클래스)
class Person(val name: String) {
    // 데이터를 저장하고 emails의 위임 객체 역할을 하는 _emails 프로퍼티
    private var _emails: List<Email>? = null
    val emails: List<Email>
        get() {
            if(_emails == null) {
                // 최초 접근 시 이메일 가져옴
                _emails = loadEmails(this)
            }
            return _emails!!
        }
}

fun main() {
    val p = Person("Alice")
    // 최초로 emails를 읽을 때 단 한 번만 이메일을 가져온다
    p.emails
}

여기서는 뒷밤침하는 프로퍼티(backing property)라는 기법을 사용합니다. _emails라는 프로퍼티는 값을 저장하고, 다른 프로퍼티인 emails_emails라는 프로퍼티에 대한 읽기 연산을 제공합니다. _emails는 널이 될 수 있는 타입인 반면 emails는 널이 될 수 없는 타입이므로 프로퍼티를 두 개 사용해야 합니다. 이런 기법은 자주 사용됩니다.

하지만 이런 코드를 만드는 일은 약간 성가시며, 지연 초기화해야하는 프로퍼티가 많아지면 코드는 굉장히 길어집니다. 게다가 이 구현은 스레드 안전하지 않아서 언제나 제대로 작동한다고 말할 수도 없습니다.

by lazy()를 사용한 프로퍼티 초기화

여기서 위임 프로퍼티를 사용하면 코드가 훨씬 더 간단해집니다. 위임 프로퍼티는 데이터를 저장할 때 쓰이는 뒷받침하는 프로퍼티와 값이 오직 한 번만 초기화됨을 보장하는 게터 로직을 함께 캡슐화해줍니다. 위의 예제와 같은 경우를 위한 위임 객체를 반환하는 표준 라이브러리 함수가 lazy입니다.

class Person(val name: String) {
    val emails by lazy { loadEmails(this) }
}

lazy 함수는 코틀린의 관례에 맞는 시그니처의 getValue 메소드가 들어있는 객체를 반환합니다. 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있습니다. lazy 함수는 기본적으로 스레드 안전합니다. 하지만 필요에 따라 동기화에 사용할 락을 lazy에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 하지 못하게 막을 수도 있습니다. 즉, 위에서 적절한 lazy를 선택하여 사용하면 됩니다.


위임 프로퍼티 구현

이제 위임 프로퍼티를 구현하는 방법을 살펴보겠습니다. 예를 들어 어떤 객체의 프로퍼티가 바뀔 때마다 리스너에게 변경 통지를 보내고 싶을 때가 있는데, 어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 자동으로 UI도 바뀌어야하는 경우입니다. 자바에서는 PropertyChangedSupport와 PropertyChangeEvent 클래스를 사용해 이런 통지를 처리하는 경우가 자주 있습니다. 우선 위임 프로퍼티 없이 이러한 기능을 구현하고 나중에 위임 프로퍼티를 사용해 리팩토링 해보겠습니다.

PropertyChangeSupport 클래스는 리스너의 목록을 관리하고 PropertyChangeEvent 이벤트가 들어오면 목록의 모든 리스너에게 이벤트를 통지합니다. 자바 빈 클래스의 필드에 PropertyChangeSupport 인스턴스를 저장하고 프로퍼티 변경 시 그 인스턴스에게 처리를 위임하는 방식으로 이러한 통지 기능을 주로 구현합니다.

PropertyChangeSupport를 사용하기 위한 도우미 클래스

// 프로퍼티 변경 리스너를 추적해주는 작은 도우미 클래스
open class PropertyChangeAware {
    // PropertyChangeSupport 인스턴스를 이 필드에 저장
    protected val changeSupport = PropertyChangeSupport(this)

    fun addPropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.addPropertyChangeListener(listener)
    }

    fun removePropertyChangeListener(listener: PropertyChangeListener) {
        changeSupport.removePropertyChangeListener(listener)
    }
}

이제 Person 클래스를 작성하겠습니다. 읽기 전용 프로퍼티(이름)와 변경 가능한 프로퍼티 둘(나이와 급여)를 정의합니다. 이 클래스는 나이나 급여가 바뀌면 그 사실을 리스너에게 통지합니다.

class Person(
    val name: String, age: Int, salary: Int
): PropertyChangeAware() { // 도우미 클래스 상속
    var age: Int = age
        set(newValue) {
            val oldValue = field // 뒷밤침하는 필드에 접근할 때 "field" 식별자 사용
            field = newValue
            // 프로퍼티 값이 변경되면 등록된 Listener들에게 변경을 통지
            changeSupport.firePropertyChange(
                "age", oldValue, newValue
            )
        }

    var salary: Int = salary
        set(newValue) {
            val oldValue = field
            field = newValue
            // 프로퍼티 변경을 리스너에게 통지
            changeSupport.firePropertyChange(
                "salary", oldValue, newValue
            )
        }
}

fun main() {
    val p = Person("Dmitry", 34, 2000)
    // 프로퍼티 변경 리스너를 추가(PropertyChangeSupport에 리스너를 추가)
    p.addPropertyChangeListener(
        // 리스너
        // 프로퍼티 값이 변경되면 PropertyChangeSupport가 리스너들에게 그 사실(이벤트)을 통보하고
        // 리스너는 사실(이벤트) 받아서 자신의 코드 블록을 실행하는 구조
        PropertyChangeListener { event ->
            println("Property ${event.propertyName} change " +
                    "from ${event.oldValue} to ${event.newValue}")
        }
    )
}

이 코드는 field 키워드를 사용해 age와 salary 프로퍼티를 뒷받침하는 필드에 접근하는 방법을 보여줍니다. 다만 세터 코드에 중복이 많습니다. 이제 프로퍼티 값을 저장하고 필요에 따라 통지를 보내주는 클래스를 추출하겠습니다.

리팩토링 - 1

도우미 클래스를 통해 프로퍼티 변경 통지 구현

// 도우미 클래스
class ObservableProperty(
    val propName: String, var propValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    // get()
    fun getValue(): Int = propValue
    // set()
    fun setValue(newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        // 등록된 리스너에게 변경된 사실(이벤트) 통지
        changeSupport.firePropertyChange(propName, oldValue, newValue)
    }
}

class Person(
    val name: String, age: Int, salary: Int
): PropertyChangeAware() {
    val _age = ObservableProperty("age", age, changeSupport)
    var age: Int
        get() = _age.getValue()
        set(value) { _age.setValue(value) }

    val _salary = ObservableProperty("salary", salary, changeSupport)
    var salary: Int
        get() = _salary.getValue()
        set(value) { _salary.setValue(value) }
}

이 코드는 코틀린의 위임이 실제로 작동하는 방식과 비슷합니다. 프로퍼티 값을 저장하고 그 값이 바뀌면 자동으로 변경 통지를 전달해주는 클래스를 만들었고, 로직의 중복을 상당 부분 제거했습니다. 하지만 아직도 각각의 프로퍼티마다 ObservableProperty를 만들고 게터와 세터에서 ObservableProperty에 작업을 위임하는 코드가 상당합니다. 코틀린의 위임 프로퍼티를 사용하면 이러한 것을 모두 없애는 것입니다..!!

리팩토링 - 2

하지만 위임 프로퍼티를 사용하기 전에 ObservableProperty(도우미 클래스 - 위임 객체)에 있는 두 메소드의 시그니처를 관례에 맞게 수정해야 합니다.

ObservableProperty를 프로퍼티 위임에 사용할 수 있게 바꾼 모습

class ObservableProperty(
    var propValue: Int, val changeSupport: PropertyChangeSupport
) {
    operator fun getValue(p: Person, prop: KProperty<*>): Int = propValue
    operator fun setValue(p: Person, prop: KProperty<*>, newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
}

이전 코드와 비교하면 아래와 같은 차이가 있습니다.

  • 코틀린 관례에 사용하는 다른 함수와 마찬가지로 getValue와 setValue 함수에도 operator 변경자가 붙습니다.

  • getValue와 setValue는 프로퍼티가 포함된 객체(여기서는 Person 타입인 p)와 프로퍼티를 표현하는 객체를 파라미터로 받습니다. 코틀린은 KProperty 타입의 객체를 사용해 프로퍼티를 표현합니다. 리플렉션에 해당하는 것으로 단지 여기서는 KProperty.name을 통해 메소드가 처리할 프로퍼티 이름을 얻었습니다. 리플렉션 정리 후 링크 달기

  • KProperty 인자를 통해 프로퍼티 이름을 전달받으므로 주 생성자에서는 name 프로퍼티를 없앴습니다.

위임 프로퍼티를 통해 프로퍼티 변경 통지 받기

class Person(
    val name: String, age: Int, salary: Int
): PropertyChangeAware() {
    var age: Int by ObservableProperty(age, changeSupport)
    var salary: Int by ObservableProperty(salary, changeSupport)
}

by 키워드를 사용해 위임 객체를 지정하면 이전 예제에서 직접 코드를 짜야 했던 여러 작업을 코틀린 컴파일러가 자동으로 처리해줍니다. 즉, 컴파일러가 만들어주는 코드는 이전에 직접 작성했던 Person 코드와 비슷합니다. by 오른쪽에 오는 객체(예제에서는 ObservableProperty)를 위임 객체(delegate)라고 부릅니다. 코틀린은 위임 객체를 감춰진 프로퍼티에 저장하고, 주 객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue와 setVlaue를 호출해줍니다.

리팩토링 - 3

관찰 가능한 프로퍼티 로직을 직접 작성하는 대신 코틀린 표준 라이브러리를 사용해도 됩니다. 표준 라이브러리에는 이미 ObservableProperty와 비슷한 클래스가 있습니다. 다만 이 표준 라이브러리의 클래스는 PropertyChangeSupport와 연결되어 있지 않아서 프로퍼티 값을 변경을 통지할 때 PropertyChangeSupport를 사용하는 방법을 알려주는 람다를 그 표준 라이브러리 클래스에 넘겨야 합니다.

Delegates.observable을 사용해 프로퍼티 변경 통지 구현

class Person(
    val name: String, age: Int, salary: Int
): PropertyChangeAware() {
    // 람다 작성
    private val observer = {
        prop: KProperty<*>, oldValue: Int, newValue: Int ->
        changeSupport.firePropertyChange(prop.name, oldValue, newValue)
    }
    
    // Delegates.observable을 사용
    var age: Int by Delegates.observable(age, observer)
    var salary: Int by Delegates.observable(salary, observer)
}

by 오른쪽에 있는 식이 꼭 새 인스턴스를 만들 필요는 없습니다. 함수 호출, 다른 프로퍼티, 다른 식 등이 by의 우항에 올 수 있습니다. 다만 우항에 있는 식을 계산한 결과인 객체는 컴파일러가 호출할 수 있는 올바른 타입의 getValue와 setValue를 반드시 제공해야 합니다. 다른 관례와 마찬가지로 getValue와 setValue 모두 객체 안에 정의된 메소드이거나 확장 함수일 수 있습니다.

예제에서는 Int 타입의 프로퍼티 위임만 사용했지만, 프로퍼티 위임 메커니즘을 모든 타입에 사용할 수 있습니다.


위임 프로퍼티 컴파일 규칙

위임 프로퍼티가 어떤 방식으로 동작하는지 알아보겠습니다. 아래와 같은 위임 프로퍼티가 있는 클래스가 있다고 가정하겠습니다.

class C {
    var prop: Type by MyDelegate()
}

val c = C()

컴파일러는 MyDelegate 클래스의 인스턴스를 감춰진 프로퍼티에 저장하며 그 감춰진 프로퍼티를 <delegate>라는 이름으로 부릅니다. 또한 컴파일러는 프로퍼티를 표현하기 위해 KProperty 타입의 객체를 사용합니다. 이 객체를 <property>라고 부릅니다.

컴파일러는 아래와 같은 코드를 생성하는 것입니다.

class C {
    private val <delegate> = MyDelegate()
    var prop: Type
        get() = <delegate>.getValue(this, <property>)
        set(value: Type) = <delegate>.setValue(this, <property>, value)
}

컴파일러는 모든 프로퍼티 접근자 안에 아래의 그림처럼 getValue와 setValue 호출 코드를 생성해주는 것입니다.

이 메커니즘은 통해 많은 활용을 할 수 있습니다. 프로퍼티 값이 저장되는 장소를 바꿀 수도 있고(맵, 데이터베이스 테이블, 사용자 세션의 쿠키 등) 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수도 있습니다(값 검증, 변경 통지 등). 이 모두를 간결한 코드로 달성하는 것입니다.


활용 예 - 프로퍼티 값을 맵에 저장

자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 활용하는 경우가 자주 있습니다. 그런 객체를 확장 가능한 객체(expando object)라고 부르기도 합니다. 예를 들어 연락처 관리 시스템에서 연락처별로 임의의 정보를 저장할 수 있게 허용하는 경우를 살펴보겠습니다. 시스템에 저장된 연락처에는 특별히 처리해야 하는 일부 필수 정보(이름 등)가 있고, 사람마다 달라질 수 있는 추가 정보가 있습니다.

그런 시스템을 구현하는 방법 중에는 정보를 모두 맵에 저장하되 그 맵을 통해 처리하는 프로퍼티를 통해 필수 정보를 제공하는 방법이 있습니다. 예를 살펴보겠습니다.

값을 맵에 저장하는 프로퍼티 정의

class Person {
    // 추가 정보
    private val _attributes = hashMapOf<String, String>()
    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    // 필수 정보
    val name: String
    get() = _attributes[name]!! // 수동으로 맵에서 정보를 꺼냄
}

fun main() {
    val p = Person()
    val data = mapOf("name" to "Dmitry", "company" to "JetBrains")
    // 값 셋팅
    for((attrName, value) in data) {
        p.setAttribute(attrName, value)
    }
    println(p.name)
}

이 코드는 추가 데이터를 저장하기 위해 일반적인 API를 사용하고(실제 프로젝트에서는 JSON 역직렬화 등의 기술을 활용할 수 있음), 특정 프로퍼티(name)을 처리하기 위해 구체적인 API를 제공합니다. 이를 쉽게 위임 프로퍼티를 활용하게 변경할 수 있습니다. by 키워드 뒤에 맵을 직접 넣으면 됩니다.

값을 맵에 저장하는 위임 프로퍼티 사용하기

class Person {
    private val _attributes = hashMapOf<String, String>()
    
    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }
    // 위임 프로퍼티를 맵에 사용
    val name: String by _attributes
}

이런 코드가 작동하는 이유는 표준 라이브러리가 Map과 MutableMap 인터페이스에 대해 getValue와 setValue 확장 함수를 제공하기 때문입니다. getValue에서 맵에 프로퍼티를 값을 저장할 때는 자동으로 프로퍼티 이름을 키로 활용합니다. 여기서 p.name은 _attributes.getValue(p, prop)라는 호출을 대신하고, _attributes.getValue(p, prop)는 다시 _attributes[prop.name]을 통해 구현됩니다.


참조
Kotlin in action
코틀린 - lazy
Delegation Pattern

틀린 부분을 댓글로 남겨주시면 수정하겠습니다..!!

profile
되새기기 위해 기록

1개의 댓글

comment-user-thumbnail
2022년 12월 31일

책의 내용을 그대로 올리시면 참조를 걸어도 저작권에 문제가 있을듯합니다..

답글 달기