본문 바로가기

코틀린

코틀린(Kotlin) - 프로퍼티 접근자 로직 재사용 : 위임 프로퍼티

코틀린이 제공하는 convention에 의존하는 특성 중 가장 독특하면서 강력한 기능인 위임 프로퍼티(delegated property)에 대해서 살펴보도록 하겠습니다.

 

"위임"은 자신이 직접 작업을 수행하지 않고 다른 객체에게 그 작업을 처리하도록 맡기는 디자인 패턴을 말합니다.

즉, 프로퍼티 필드에 접근하는 getter/setter 메소드를 가지는 다른 객체를 만들어서 그 객체에 프로퍼티 필드 접근 로직을 위임하는 것입니다. 

이때 이러한 작업을 위임받아서 처리하는 중간자 역할인 서포트 객체를 위임 객체(delegate) 라고 하며,

위임하도록 선언한 객체는 위임 객체의 멤버를 참조없이 호출이 가능하고, getter/setter 로직을 다른 객체 간에 공통으로 재사용해야 하는 경우 매우 유용합니다.

 

왜 위임을 사용해야 할까요?

프로그래밍을 하다보면 하위 클래스가 상위 클래스를 상속해서 상위 클래스의 프로퍼티들을 오버라이드 하는 경우가 많습니다.

이런 오버라이딩 상황에서 상위 클래스의 내용이 변경되는 경우 하위 클래스가 상위 클래스에 의존하고 있던 내용들이 완전히 틀어지면서 에러가 발생하게 됩니다.

즉, 결국엔 상속의 대표적인 문제점이라고 할 수 있는데요

- 상위 클래스와 하위 클래스의 의존도가 높아져서 상위 클래스의 내용 변경 시 하위 클래스에 영향이 가게 됩니다.

- 불필요한 상위 클래스의 메소드까지 구현해야 합니다.

- 상속의 Depth가 깊어질수록 기능 파악이 어려워집니다.

 

그렇기 때문에 이러한 종속성, 의존성 문제를 해결하기 위해 위임 패턴(Delegation Pattern)이 상속의 좋은 대안으로 증명이 되었기 때문에, 코틀린 언어 자체에서 이러한 위임 패턴을 지원하게 되었고 상속보다는 위임(Delegation) 사용을 권장하고 있습니다! 

 

상속과의 차이

- 상속은 상위 클래스의 변수와 메소드를 모두 받기 때문에 재 구현할 필요가 없어서 편리합니다.

- 그렇기 때문에 오히려 의존도가 높아서 많은 문제가 발생하게 됩니다. 

- 상속은 단 하나의 상위 클래스(부모)만 가능하지만, 위임은 여러개의(복수개)의 interface가 가능합니다.

 

위임 프로퍼티

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

 

class Example {
   var property: Type by Delegate()
}

 

여기서 by 뒤에 있는 Delegate 가 위임 객체로 사용되며, property의 게터/세터를 Delegate 객체에게 위임하게 되는 코드입니다.

 

프로퍼티 위임 객체인 Delegate 클래스는  getValue와 setValue 메소드를 제공해야 하며, Delegate 클래스를 단순화하면 아래와 같습니다.

 

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

class Example {
   var property: Type by Delegate()
}

>>> val example = Example()
>>> val oldValue = example.property  //호출 내부에서는 delegate.getValue(...)가 호출됩니다.
>>> example.property = newValue //내부에서는 delegate.setValue(..., newValue)가 호출됩니다.

 

example.property는 일반 프로퍼티 같아 보이지만, 실제로는 property 변수의 getter/setter는 Delegate 객체에 있는 getValue/setValue 로 위임되어 해당 메소드를 호출하게 됩니다.

 

실제로 이런 구조가 어떻게 작동하는지 살펴보기 위해 위임 프로퍼티의 강력함을 보여주는 지연 초기화(lazy) 예제를 살펴보도록 하겠습니다.

 

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

지연 초기화(lazy initialization)는 객체의 일부분을 초기화하지 않고 지연시켜뒀다가 그 값이 실제로 쓰이는 경우 초기화하기위해 흔히 쓰이는 패턴입니다.

초기화 과정에 리소스를 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 사용하곤 합니다.

 

예를 들어 Business 클래스에서 사용자가 작성한 이메일의 목록을 제공한다고 가정하겠습니다.
이메일은 데이터베이스에 들어있고 조회하려면 시간이 오래 걸리는 작업입니다.
그렇기 때문에 이메일 프로퍼티의 값을 사용할 때 최초로 단 한번만 가져오고 싶게 하고 싶습니다.
class Email {...}

fun loadEmails(biz: Business): List<Email> { //이메일 가져오는 메소드
   println("${biz.name}의 이메일 조회")
   return listOf(...)
}

 

위의 요구사항을 먼저 lazy의 사용 없이 지연 초기화 되도록 일반적인 코드로 구현하면 다음과 같습니다.

 

/* 지연 초기화를 lazy 사용 없이 일반적인 backing 프로퍼티로 구현하기 */
class Business(val name: String) {
   private var _emails: List<Email>? = null  //데이터를 저장하고 emails 변수의 위임 객체 역할을 한다.
   val emails: List<Email>
      get() {
         if(_emails == null) {
            _emails = loadEmails(this)  //최초 호출 시 이메일 정보를 가져온다.
         }
         return _emails!!  //이메일 데이터 반환
      }
}

>>> val biz = Business("홍길동")
>>> biz.emails  //최초 읽을 때 단 한번만 이메일 정보를 가져온다.

홍길동의 이메일을 가져옴

 

위의 예제에서는 뒷받침하는 프로퍼티(backing property) 라는 기법을 사용하였습니다.

_emails 라는 프로퍼티는 값을 저장하고, emails는 _emails 프로퍼티에 대한 읽기 기능만 제공합니다.

이러한 기법은 자주 사용되므로 잘 알아두는 것이 좋습니다! 👍

 

아무튼, 이런 코드를 만드는 일은 약간 성가십니다...☹

지금은 프로퍼티가 하나인 경우지만 지연 초기화해야 하는 프로퍼티가 많아지면 어떻게 될까요?

 

이럴 때! 코틀린은 아주 좋은 해법을 제공해줍니다! 👍

위임 프로퍼티를 사용하면 이 코드가 훨씬 더 간단해지며, 오직 한 번만 초기화됨을 보장하는 게터(getter)를 함께 캡슐화해서 제공해줍니다.

위의 예제와 같은 경우를 위해 위임 객체를 반환해주는 코틀린 라이브러리 함수가 바로 lazy 입니다!

 

/* lazy 지연 초기화를 위임 프로퍼티를 통해 구현하기 */
class Business(val name: String) {
   val emails by lazy { loadEmails(this) }
}

lazy 패턴 확인

public fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)


private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
    private var initializer: (() -> T)? = initializer
    @Volatile private var _value: Any? = UNINITIALIZED_VALUE
    // final field is required to enable safe publication of constructed instance
    private val lock = lock ?: this

    override val value: T
        get() {
            val _v1 = _value
            if (_v1 !== UNINITIALIZED_VALUE) {
                @Suppress("UNCHECKED_CAST")
                return _v1 as T
            }

            return synchronized(lock) {
                val _v2 = _value
                if (_v2 !== UNINITIALIZED_VALUE) {
                    @Suppress("UNCHECKED_CAST") (_v2 as T)
                } else {
                    val typedValue = initializer!!()
                    _value = typedValue
                    initializer = null
                    typedValue
                }
            }
        }

    override fun isInitialized(): Boolean = _value !== UNINITIALIZED_VALUE

    override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."

    private fun writeReplace(): Any = InitializedLazyImpl(value)
}

 

lazy 함수에게 프로퍼티 생성을 위임하고 getValue 메소드가 들어있는 위임 객체를 반환하기 때문에 lazy를 by 키워드와 함께 사용해서 위임 프로퍼티를 만들 수 있습니다.

 

이제 이러한 위임 프로퍼티가 어떻게 동작하는지에 대해 자세히 살펴보도록 하겠습니다.

 

위임 프로퍼티 구현

위임 프로퍼티 구현을 살펴보기 위해 예제를 하나 가지고 알아보도록 하겠습니다.

[ 예제 설명 ]
어떤 객체의 프로퍼티가 바뀔 때마다 리스너에게 변경 이벤트를 보내고 싶습니다.
예를 들어, 어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 자동으로 UI도 바뀌어야 합니다.
PropertyChangeSupport 클래스는 리스너들을 관리하고, PropertyChangeEvent 이벤트가 들어오면 모든 리스너에게 이벤트를 보냅니다.

 

안드로이드 앱 개발에 있어서는 이런 기능이 유용할 때가 굉장히 많습니다.

기존에 자바에서는 주로 PropertyChangeSupportPropertyChangeEvent 클래스를 사용해서 이러한 변경 사항에 대한 이벤트를 처리하는 경우가 자주 있는데, 이제는 코틀린에서 위임 프로퍼티를 사용해서 구현해보도록 하겠습니다.

 

시작은 코틀린으로 위임 프로퍼티 없이 기능을 먼저 구현하고, 나중에 이 코드를 위임 프로퍼티를 사용해서 리팩토링 해보도록 하겠습니다.

 

/* PropertyChangeSupport를 사용하기 위한 클래스 */
open class PropertyChangeHelper {  //상속을 위해 open으로 열어줌
   protected val changeSupport = PropertyChangeSupport(this)
   
   fun addPropertyChangeListener(listener: PropertyChangeListener) {
      changeSupport.addPropertyChangeListener(listener)
   }
   
   fun removePropertyChangeListener(listener: PropertyChangeListener) {
      changeSupport.removePropertyChangeListener(listener)
   }
}

 

이제 Person 클래스를 하나 작성하겠습니다.

읽기 전용 프로퍼티인 name과 변경 가능한 프로퍼티인 age, salary 를 정의하도록 하겠습니다.

이 클래스는 나이나 급여가 바뀌면 리스너에게 이벤트를 전달합니다.

 

/* 프로퍼티 변경 이벤트 전달 직접 구현 예제 */
class Person (val name: String, age: Int, salary: Int) : PropertyChangeHelper() {
   var age: Int = age
      set(newValue) {
         val oldValue = field  //해당 프로퍼티값에 접근할 때 "field" 를 사용하면 됩니다.
         field = newValue
         changeSupport.firePropertyChange("age", oldeValue, newValue)
      }
      
   var salary: Int = salary
      set(newValue) {
         val oldValue = field
         field = newValue
         changeSupport.firePropertyChange("salary", oldValue, newValue)
      }
}

>>> val person = Person("James", 31, 8000)
>>> person.addPropertyChangeListener(
    ... PropertyChangeListener { event ->
    ...    println("프로퍼티 ${event.propertyName} 변경 from ${event.oldValue} to ${event.newValue}")
        }
    )

>>> person.age = 32
프로퍼티 age 변경 from 31 to 32

>>> person.salary = 9000
프로퍼티 salary 변경 from 8000 to 9000

 

이 코드는 field 키워드를 사용해서 age와 salary 프로퍼티의 setter에 접근하는 방법을 보여줍니다.

여기서 setter 코드를 보면 중복이 많이 보입니다. 🧐

이제 중복된 코드를 분리해서 1차 리팩토링을 해보도록 하겠습니다.

 

/* 프로퍼티 변경 이벤트 클래스 구현하기 */
class ObservableProperty(
   val propName: String,
   var propValue: Int,
   val changeSupport: PropertyChangeSupport
) {
   fun getValue() = propValue
   fun setValue(newValue: Int) {
      val oldValue = propValue
      propValue = newValue
      chaneSupport.firePropertyChane(propName, oldValue, newValue)
   }
}

class Person(val name: String, age: Int, salary: Int) : PropertyChangeHelper() {
   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를 만들고 getter와 setter에서 ObservableProperty에 작업 처리를 위임하는 코드가 상당 부분 존재합니다.

 

이제 이러한 부분을 코틀린의 위임 프로퍼티 기능을 활용하여 싹 없애는 리팩토링을 해보도록 하겠습니다!

 

먼저, 위임 프로퍼티를 사용하기 전에 ObservableProperty에 있는 두 메소드를 프로퍼티 위임에 사용할 수 있는 convention에 맞게 수정해야 합니다.

 

/* 프로퍼티 위임에 사용할 수 있게 바꾼 모습 */
class ObservableProperty(
   var propValue: Int, val changeSupport: PropertyChangeSupport
) {
   operator fun getValue(person: Person, prop: KProperty<*>) : Int = propValue
   
   operator fun setValue(person: Person, prop: KProperty<*>, newValue: Int) {
      val oldValue = propValue
      propValue = newValue
      changeSupport.firePropertyChange(prop.name, oldValue, newValue)
   }
}

 

이전 코드와 비교해보면 다음과 같은 차이가 있습니다.

- 코틀린 convention에 사용하는 다른 함수들과 마찬가지로 getValue와 setValue 함수에도 operator 변경자가 붙는다.

- getValue와 setValue는 프로퍼티가 포함된 객체(예제에서는 Person 객체인 person)와 프로퍼티를 표현하는 객체를 파라미터로 받는다.
코틀린은 KProperty 타입의 객체를 사용해서 프로퍼티를 표현합니다.
지금은 그냥 KProperty.name을 통해 메소드가 처리할 프로퍼티 이름을 알 수 있다는 점만 기억하도록 합니다.

- KProperty 파라미터를 통해 프로퍼티 이름을 전달받으므로 주 생성자에서는 name 프로퍼티 받는 부분을 없앴습니다. 

 

이제야! 드디어! 비로소! 코틀린이 제공하는 위임 프로퍼티 기능을 사용할 수 있게 되었습니다!

다음은 위임 프로퍼티를 적용한 예제입니다.

 

/* 위임 프로퍼티를 통해 프로퍼티 변경 이벤트 받기 */
class Person(
   val name: String, age: Int, salary: Int
) : PropertyChangeHelper() {
   var age: Int by ObservableProperty(age, changeSupport)
   var salary: Int by ObservableProperty(salary, changeSupport)
}

 

by 키워드를 사용해서 위임 객체를 지정하면 위임을 하지 않았던 이전 예제에서 직접 코드를 짜야했던 여러 작업을 코틀린 컴파일러가 자동으로 처리해줍니다.

 

이렇게 by 오른쪽에 오는 객체를 위임 객체(delegate) 라고 하며, 코틀린은 주 객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue와 setValue를 호출해줍니다.

 

위에서는 예제로 Int 타입의 프로퍼티 위임만 살펴보았는데, 이러한 프로퍼티 위임 메커니즘은 모든 타입에 적용할 수 있습니다.

 

위임 프로퍼티 컴파일 규칙

위임 프로퍼티가 어떤 방식으로 동작하는지 정리해보도록 하겠습니다.

 

/* 위임 프로퍼티가 있는 클래스 예제 */
class Example {
   var property: Type by Delegate()
}

val exam = Example()

 

/* 컴파일러가 생성하는 코드 */
class Example {
   private val <delegate> = Delegate()
   var property: Type
      get() = <delegate>.getValue(this, <property>)
      set(value: Type) = <delegate>.setValue(this, <property>, value)
}

 

컴파일러는 Delegate 클래스의 인스턴스를 외부에서 알 수 없도록 private val 형태로 프로퍼티에 저장하며 그 프로퍼티를 <delegate> 라고 합니다.

또한 컴파일러는 프로퍼티를 표현하기 위해 KProperty 타입의 객체를 사용하며 이 객체를 <property> 라고 합니다.

 

다시 말해 즉, 컴파일러는 모든 프로퍼티 접근자 안에 아래 그림과 같이 getValue와 setValue 호출 코드를 생성해줍니다.

 

프로퍼티를 사용하면 <delegate>에 있는 getValue나 setValue 함수가 호출됩니다.

 

이 메커니즘은 상당히 단순하지만 유용한 활용법이 많습니다.

프로퍼티 값이 저장될 장소를 바꿀 수도 있고(맵, 데이터베이스, 사용자 세션의 쿠키 등...)

프로퍼티를 읽거나 쓸 때 동작할 작업들을 변경할 수도 있으며 (데이터 검증, 이벤트 전달 등...)

공통으로 사용되는 로직을 재사용할 수 있습니다.

 

이러한 작업들을 위임 프로퍼티 기능을 사용하면 간결한 코드로 프로그래밍이 가능해집니다. 😀