Kotlin 위임 프로퍼티

위임 프로퍼티를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다.

위임?

위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업 처리하게 하는 디자인 패턴을 의미한다. 이때 실직적으로 작업을 처리하는 도우미 객체를 위임 객체(Delegate)라고 한다.

위임 프로퍼티

위임 프로퍼티의 일반적인 문법은 다음과 같다.

class Foo {
    var p: Type by Delegate()
}

p 프로퍼티는 접근자 로직을 다른 객체(Delegate class)에 위임한다. by 뒤의 식을 계산해서 위임에 쓰일 객체를 얻는다.

위임을 처리하기 위해 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 위임 객체의 인스턴스로 초기화한다. p 프로퍼티는 그 위임 객체에서 자신의 작업을 위임한다.

class Foo {
    private val delegate = Delegate()
    var p: Type
    set(value) = delegate.setValue(..., value)
    get() = delegate.getValue()
}

프로퍼티 위임 컨벤션을 다르는 Delegate 클래스는 getValuesetValue(var에서만 사용함) 메소드를 제공해야 한다. 다른 컨벤션과 마찬가지로 getValuesetValue는 멤버 메소드이거나 확장 함수일 수도 있다. Delegate 클래스를 단순화하면 다음과 같다.

class Delegate {
    operator fun getValue(...) { ... }
    operator fun setValue(..., value: Type) { ... }
}

Lazy initialization: by lazy()

지연 초기화(Lazy initialization)는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 사용하는 패턴이다. 초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 적용할 수 있다.

class Email { /*...*/ }
fun loadEmails(person: Person): List<Email> {
    println("${person.name}의 이메일을 가져옴")
    return listOf(/*...*/)
}

class Person(val name: String) {
    private var _emails: List<Email>? = null
    val emails: List<Email>
    get() {
        if(_emails == null) {
            _emails = loadEmails(this)
        }
        return _emails!!
    }
}

여기서는 뒷받침하는 프로퍼티(Backing Property) 기법을 사용한다. _emails라는 프로퍼티는 값을 지정하고 다른 프로퍼티인 emails_emails 프로퍼티에 대한 읽기 연산을 제공한다. _emailsvar인 반면 emailsval이기 때문에 프로퍼티를 2개 사용한다. 이런 기법을 실제로 자주 사용하므로 잘 알아두는 것이 좋다.

표준 라이브러리 함수 lazy는 위 코드를 훨씬 더 간단하게 사용할 수 있게 해준다.

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

위임 프로퍼티 구현

위임 프로퍼티를 예시를 들어 구현해보자. 어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 UI가 자동으로 바뀌어야 한다. 자바에서는 PropertyChangeSupportPropertyChangeEvent 클래스를 사용해 이런 통지를 처리한다.

PropertyChangeSupport 클래스를 리스너의 목록을 관리하고 PropertyChangeEvent는 이벤트가 들어오면 목록의 모든 리스너에게 이벤트를 통지한다.

필드를 모든 클래스에 추가하고 싶지 않으므로 PropertyChangeSupport 인스턴스를 changeSupport라는 필드에 저장하고 프로퍼티 변경 리스너를 추적하는 도우미 클래스 PropertyChangeAware를 만들어보자.

open class PropertyChangeAware {
    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 = newValue
        changeSupport.firePropertyChange("age", oldValue, newValue)
    }

    var salary: Int = salary
    set(newValue) {
        val oldValue = field
        field = newValue
        changeSupport.firePropertyChange("salary", oldValue, newValue)
    }
}
val p = Person("Dmitry", 34, 2000)
p.addPropertyChangeListener(
    PropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    }
)
p.age = 35
p.salary = 2100

/*
Property age changed from 34 to 35
Property salary changed from 2000 to 2100
*/

이제 Person 클래스의 세터 코드의 중복을 제거해보자. 프로퍼티의 값을 저장하고 필요에 따라 통지를 보내주는 클래스를 작성한다.

class ObservableProperty(
    val propertyName: String, var propertyValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    fun getValue(): Int = propertyValue
    fun setValue(newValue: Int) {
        val oldValue = propertyValue
        propertyValue = newValue
        changeSupport.firePropertyChange(propertyName, 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의 두 메소드의 시그니처를 코틀린 컨벤션에 맞게 수정해야 한다.

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

이전 코드랑 비교하면 다음과 같은 차이가 있다.

  • getValuesetValue 함수에 operator 변경자가 붙는다.
  • getValuesetValue는 프로퍼티가 포함된 객체(Person)와 프로퍼티를 표현하는 객체(KProperty)를 받는다.
  • KProperty 인자를 통해 프로퍼티 이름을 전달받을 수 있다.

이제 Person 클래스를 코틀린의 위임 프로퍼티 기능을 이용하여 간단하게 만들 수 있다.

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

위임 프로퍼티 컴파일 규칙

위임 프로퍼티는 어떻게 동작할까? 먼자 아래와 같은 위임 프로퍼티가 있는 클래스가 있다고 가정하자.

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 호출 코드를 생성해준다.

이런 메커니즘은 단순하지만 활용도가 많은데, 프로퍼티 값이 저장된 장소를 바꿀 수 있고(맵, DB, Cookie 등) 프로퍼티를 읽거나 쓸 때 벌어질 일을 변경할 수도 있다(값 검증, 변경 통지 등).


© 2021. All rights reserved.

Powered by Hydejack v9.1.6