본문 바로가기
kotlin

[Kotlin in Action] 7.5: by 키워드를 통해 위임 프로퍼티 우아하게 구현하기

by 권성호 2023. 5. 29.

사실이 아니라 공부한 내용과 생각을 정리한 글입니다. 언제든 가르침을 주신다면 감사하겠습니다.

 

코틀린이 지원하는 위임 프로퍼티는 말 그대로 프로퍼티가 하는 일인 값을 저장하고(setter) 가져오는(getter) 역할을 다른 누군가(객체)에게 위임하는 것을 말한다.

이렇게 프로퍼티가 하는 일을 다른 객체에 위임함으로써 얻을 수 있는 이익이 무엇일까?

우선 이 질문에 공감하기 위해 한 가지 예시를 들어보겠다.


위임프로퍼티 예시: 특정 프로퍼티의 변경을 감지해 이벤트를 발행하고 핸들러에서 이를 처리해야 하는 요구사항이 있다고 가정해 보자

예를 들어, 어떤 객체를 UI에 표시해야 하는 경우 객체가 바뀌면 자동으로 UI도 바뀌어야 한다.

이를 코틀린이 지원하는 키워드 없이 그냥 구현하면 아래와 같이 구현할 수 있다.

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport

open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this)

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

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

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 = age
        set(newValue) {
            val oldValue = field
            field = newValue
            changeSupport.firePropertyChange("salary", oldValue, newValue)
        }
}

fun main() {
    val p = Person("soungho", 29, 2000)
    p.addPropertyChangeListener(PropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    })

    p.age = 35
    p.salary = 3000
}

위 코드를 실행하면 결과는 아래와 같다.

Property age changed from 29 to 35
Property salary changed from 29 to 3000

위 코드를 통해 age와 salary 프로퍼티 변경 시 이벤트를 발생시키고 리스너에서 이를 처리한다는 목표를 달성할 수 있다. 

하지만 age와 salary의 setter 로직이 중복되고 있다. 

이를 개선하기 위한 방법으로 아래처럼, 이벤트를 전달하고 값을 세팅하는 역할을 ObservableProperty라는 별도 클래스로  중복된 로직을 분리할 수 있다.

package com.example.demo.ex

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport

open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this)

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

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

class ObservableProperty(
    val propName: String,
    var propValue: Int,
    val changeSupport: PropertyChangeSupport
) {
    fun getValue(): Int = propValue
    fun setValue(newValue: Int) {
        val oldValue = propValue
        propValue = newValue
        changeSupport.firePropertyChange(propName, oldValue, newValue)
    }
}

class Person(
    val name: String,
    age: Int,
    salary: Int
) : PropertyChangeAware() {

    var _age = ObservableProperty("age", age, changeSupport)
    var _salary = ObservableProperty("salary", age, changeSupport)

    var age: Int
        get() = _age.getValue()
        set(value) { _age.setValue(value) }

    var salary: Int
        get() = _salary.getValue()
        set(value) { _salary.setValue(value) }
}

fun main() {
    val p = Person("soungho", 29, 2000)
    p.addPropertyChangeListener(PropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    })

    p.age = 35
    p.salary = 3000
}

마지막으로 구현된 코드를 보면 Person의 age라는 프로퍼티는 실제로는 _age에게 getter와 setter 로직을 위임하고 있는 것을 볼 수 있다.

위 예시를 통해 우리는 아래와 같은 사항에 대해 공감할 수 있을 것이다.

  1. 특정 프로퍼티의 값을 얻거나 변경할 때 특정 로직을 끼워 넣고 싶은 니즈가 있을 수 있다.
  2. 그러한 끼워 넣어지는 로직의 관심사는 실제 프로퍼티를 가지고 있는 클래스의 관심사와 분리될 수 있어야 하고 때로는 공통화되어서 재 사용성이 보장되어야 할 수 있다.

지금까지 작성한 코드는 위 두 가지 조건을 만족한다. 

하지만, 여전히 _age, _salary를 별도로 선언해야 한다는 점과 age, salary의 getter & setter 로직을 일일이 작성해야 한다는 점이 문제로 남아있다.

이런 문제를 해결하기 위한 기능을 코틀린은 언어 차원에서 지원한다.

지금부터 코틀린이 지원하는 by 키워드에 대해 알아보고 위 예시를 좀 더 개선해 보겠다.

 


코틀린이 지원하는 위임: By 키워드

by 키워드에 대한 설명은 말보단 코드를 먼저 보는 게 훨씬 와닫을것 같아서 코드부터 보자.

import kotlin.reflect.KProperty

class TestPerson(name: String) {
    var name: String by NamePropertyDelegate(name)
}

class NamePropertyDelegate(
    var targetValue: String,
) {
    operator fun getValue(p: TestPerson, prop: KProperty<*>): String {
        println("Call NamePropertyDelegate#getValue")
        return "delegate:${targetValue}"
    }

    operator fun setValue(p: TestPerson, prop: KProperty<*>, newValue: String) {
        println("Call NamePropertyDelegate#setValue")
        targetValue = newValue
    }
}

fun main() {
    val person = TestPerson("initialName")
    println(person.name)
    person.name = "haah"
    println(person.name)
}

실행결과

Call NamePropertyDelegate#getValue
delegate:initialName
Call NamePropertyDelegate#setValue
Call NamePropertyDelegate#getValue
delegate:haah

먼저 by 키워드가 사용된 TestPerson 클래스에 주목해보자.

class TestPerson(name: String) {
    var name: String by NamePropertyDelegate(name)
}

// 아래는 디컴파일한 버전

public final class TestPerson {
   // $FF: synthetic field
   static final KProperty[] $$delegatedProperties = new KProperty[]{(KProperty)Reflection.mutableProperty1(new MutablePropertyReference1Impl(TestPerson.class, "name", "getName()Ljava/lang/String;", 0))};
   @NotNull
   private final NamePropertyDelegate name$delegate;

   @NotNull
   public final String getName() {
      return this.name$delegate.getValue(this, $$delegatedProperties[0]);
   }

   public final void setName(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.name$delegate.setValue(this, $$delegatedProperties[0], var1);
   }

   public TestPerson(@NotNull String name) {
      Intrinsics.checkNotNullParameter(name, "name");
      super();
      this.name$delegate = new NamePropertyDelegate(name);
   }
}

${프로퍼티} by ${위임할 오브젝트} 형태로 사용되는 것을 알 수 있는데 디컴파일해보면 규칙은 아래와 같다.

  1. 프로퍼티에 접근할 때는 ${위임할 오브젝트}. getValue 메서드로 getter 로직을 위임한다.
  2. 프로퍼티에 값을 설정할 때는 ${위임할 오브젝트}. setValue 메서드로 setter 로직을 위임한다.

즉, 앞선 예시에서 봤던 _age, _salary를 별도로 선언해야 한다는 점과 age, salary의 getter & setter 로직을 일일이 작성하던 문제를 by 키워드를 통해 해결하고 있다.

주의해야 할 점이 하나 있는데, 위임의 대상이 되는 ${위임할 오브젝트의} getValue와 setValue의 메서드 시그니쳐는 코틀린이 정해놓은 아래의 관용을 따라야 한다. 

operator fun getValue(${대상오브젝트}, ${대상오브젝트의 프로퍼티})

operator fun setValue(${대상오브젝트}, ${대상오브젝트의 프로퍼티}, ${변경할값})

이제 코틀린의 by 키워드를 사용하여 앞서 말했던 예제를 조금 더 개선해 보자


위임프로퍼티 예시에 대한 최종 개선 버전

import java.beans.PropertyChangeListener
import java.beans.PropertyChangeSupport
import kotlin.reflect.KProperty

open class PropertyChangeAware {
    protected val changeSupport = PropertyChangeSupport(this)

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

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

class ObservableProperty(
    val propName: String,
    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(propName, oldValue, newValue)
    }
}

class Person(
    val name: String,
    age: Int,
    salary: Int
) : PropertyChangeAware() {

    var age: Int by ObservableProperty("age", age, changeSupport)
    var salary: Int by ObservableProperty("salary", salary, changeSupport)
}

fun main() {
    val p = Person("soungho", 29, 2000)
    p.addPropertyChangeListener(PropertyChangeListener { event ->
        println("Property ${event.propertyName} changed from ${event.oldValue} to ${event.newValue}")
    })

    p.age = 35
    p.salary = 3000
}

코틀린에서 사용하는 관용에 맞게 ObservableProperty 클래스의 getValue와 setValue 메서드를 변경했고 Person의 age와 salary 프로퍼티의 getter & setter를 ObservablePropery 오브젝트에게 위임한 것을 볼 수 있다.

이렇게 함으로써 Person 클래스 내부에 있는 위임을 위한 객체 선언과 로직이 by 키워드 하나로 간결하게 정리된 것을 확인할 수 있다.

이러한 위임은 다방면에서 사용될 수 있는데, 대표적으로 코틀린에서 프로퍼티에 대한 지연 초기화를 지원하기 위해 사용되는 lazy 가 그 예시가 될 수 있다.

댓글