위임 프로퍼티(delegated property)를 사용하면 값을 뒷받침하는 필드에 단순히 저장하는 것보다 더 복잡한 방식으로 작동하는 프로퍼티를 쉽게 구현할 수 있다. 또한 그 과정에서 접근자 로직을 매번 재구현할 필요도 없다. 예를 들어, 프로퍼티는 위임을 사용해 자신의 값을 필드가 아니라 데이터베이스 테이블이나 브라우저 세션, 맵 등에 저장할 수 있다.
이런 특성의 기반에는 위임이 있다. 위임은 객체가 직접 작업을 수행하지 않고 다른 도우미 객체가 그 작업을 처리하게 맡기는 디자인 패턴을 말한다. 이 때 작업을 처리하는 도우미 객체를 위임 객체(deletgate)라고 부른다.
7.5.1 위임 프로퍼티 소개
// 위임 프로퍼티의 일반적인 문법
class Foo {
var p: Type by Delegate()
}
p 프로퍼티는 접근자 로직을 다른 객체에게 위임한다. 여기서는 Delegate 클래스의 인스턴스를 위임 객체로 사용한다. by 뒤에 있는 식을 계산해서 위임에 쓰일 객체를 얻는다. 프로퍼티 위임 객체가 따라야 하는 관례를 따르는 모든 객체를 위임에 사용할 수 있다.
다음과 같이 컴파일러는 숨겨진 도우미 프로퍼티를 만들고 그 프로퍼티를 위임 객체의 인스턴스로 초기화한다. p 프로퍼티는 바로 그 위임 객체에게 자신의 작업을 위임한다. 설명을 편하게 하기 위해 이 감춰진 프로퍼티 이름을 delegate라고 하자.
class Foo {
private val delegate = Delegate() // 컴파일러가 새성한 도우미 프로퍼티다.
var p: Type // "p" 프로퍼티를 위해 컴파일러가 생성한 접근자는 "delegate"의 getValue와 setValue 메소드를 호출한다.
set(value: Type) = delegate.setValue(..., value)
get() = delegate.getValue(...)
}
프로퍼티 위임 관례를 따르는 Delegate 클래스는 getValue와 setValue 메소드를 제공한다. 관례를 사용하는 다른 경우와 마찬가지로 getValue와 setValue는 멤버 메소드이거나 확장 함수일 수 있다. 일단은 설명을 단순화하기 위해 이 두 메소드의 파라미터를 생략한다. 하지만 나중에 각 메소드 파라미터의 정확한 의미를 설명할 것이다. Delegate 클래스를 단순화하면 다음과 같다.
class Delegate {
operator fun getValue(...) { ... } // getValue는 게터를 구현하는 로직을 담는다.
operator fun setValue(..., value: Type) { ... } // setValue 메소드는 세터를 구현하는 로직을 담는다.
}
class Foo {
var p: Type by Delegate() // "by" 키워드는 프로퍼티와 위임 객체를 연결한다.
}
>>> val foo = Foo()
>>> val oldValue = foo.p // foo.p라는 프로퍼티 호출은 내부에서 delegate.getValue(...)를 호출한다.
>>> foo.p = newValue // 프로퍼티 값을 변경하는 문장은 내부에서 delegate.setValue(..., newValue)를 호출한다.
foo.p는 일반 프로퍼티처럼 쓸 수 있고, 일반 프로퍼티 같아 보인다. 하지만 실제로 p의 게터나 세터는 Delegate 타입의 위임 프로퍼티 객체에 있는 메소드를 호출한다.
7.5.2 위임 프로퍼티 사용: by lazy()를 사용한 프로퍼티 초기화 지연
지연 초기화(lazy initialization)는 객체의 일부분을 초기화하지 않고 남겨뒀다가 실제로 그 부분의 값이 필요할 경우 초기화할 때 흔히 쓰이는 패턴이다. 초기화 과정에 자원을 많이 사용하거나 객체를 사용할 때마다 꼭 초기화하지 않아도 되는 프로퍼티에 대해 지연 초기화 패턴을 사용할 수 있다.
위임 프로퍼티를 사용하면 지연 초기화 패턴을 쉽게 구현할 수 있다. 위임 프로퍼티는 데이터를 저장할 때 쓰이는 뒷받침하는 프로퍼티와 값이 오직 한 번만 초기화됨을 보장하는 게터 로직을 함께 캡슐화해준다. 예제와 같은 경우를 위한 위임 객체를 반환하는 표준 라이브러리 함수가 바로 lazy다.
// 지연 초기화를 위임 프로퍼티를 통해 구현하기
class Person(val name: String) {
val emails by lazy { loadEmails(this) }
}
lazy 함수는 코틀린 관례에 맞는 시그니처의 getValue 메소드가 들어있는 객체를 반환한다. 따라서 lazy를 by 키워드와 함께 사용해 위임 프로퍼티를 만들 수 있다. lazy 함수의 인자는 값을 초기화할 때 호출할 람다다. lazy 함수는 기본적으로 스레드 안전하다. 하지만 필요에 따라 동기화에 사용할 락을 lazy 함수에 전달할 수도 있고, 다중 스레드 환경에서 사용하지 않을 프로퍼티를 위해 lazy 함수가 동기화를 하지 못하게 막을 수도 있다.
7.5.3 위임 프로퍼티 구현
어떤 객체의 프로퍼티가 바뀔 때마다 리스너에게 변경 통지를 보내고 싶다. 예를 들어, 어떤 객체를 UI에 표시하는 경우 객체가 바뀌면 자동으로 UI도 바뀌어야 한다. 자바에서는 PropertyChangeSupport와 PropertyChangeEvent 클래스를 사용해 이런 통지를 처리하는 경우가 자주 있다. 이제 코틀린에서 위임 프로퍼티 없이 이런 기능을 구현하고 나중에 그 코드를 위임 프로퍼티를 사용하게 리팩토링 해보자.
PropertyChangeSupport 클래스는 리스너의 목록을 관리하고 PropertyChangeEvent 이벤트가 들어오면 목록의 모든 리스너에게 이벤트를 통지한다. 자바 빈 클래스의 필드에 PropertyChangeSupport 인스턴스를 저장하고 프로퍼티 변경 시 그 인스턴스에게 처리를 위임하는 방식으로 이런 통지 기능을 주로 구현한다.
필드를 모든 클래스에 추가하고 싶지는 않으므로 PropertyChangeSupport 인스턴스를 changeSupport라는 필드에 저장하고 프로퍼티 변경 리스너를 추적해주는 작은 도우미 클래스를 만들자. 리스너 지원이 필요한 클래스는 이 도우미 클래스를 확장해서 changeSupport에 접근할 수 있다.
// ProertyChangeSupport를 사용하기 위한 도우미 클래스
open class PropertyChangeAware {
protected val changeSupport = PropertyChangeSupport(this)
fun addPropertyChangeListener(listener: PropertyChangeListener) {
changeSupport.addPropertyChangeListener(listener)
}
fun removePropertyChangeListener(listener: PropertyChangerListener) {
changeSupport.removePropertyChangeListener(listener)
}
}
이제 Person 클래스를 작성하자. 읽기 전용 프로퍼티(사람의 이름)와 변경 가능한 프로퍼티 둘(나이와 급여)을 정의한다. 이 클래스는 나이나 급여가 바뀌면 그 사실을 리스너에게 통지한다.
// 프로퍼티 변경 통지 구현
class Person {
val name: String, age: Int, salary: Int
} : PropertyChangeAware() {
var age: Int = age
set(newValue) {
val oldValue = field // 뒷받침하는 필드에 접근할 때 "field" 식별자를 사용한다. (여기서는 age)
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
Property age changed from 34 to 35
>>> p.salary = 2100
Property salary changed from 2000 to 2100
세터 코드를 보면 중복이 많아 보인다. 이제 프로퍼티의 값을 저장하고 필요에 따라 통지를 보내주는 클래스를 추출해보자.
// 도우미 클래스를 통해 프로퍼티 변경 통지 구현
class ObservablePropert(
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() {
val _age = ObservablePropery("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에 작업을 위임하는 준비 코드가 상당 부분 필요하다. 코틀린의 위임 프로퍼티 기능을 활용하면 이런 준비 코드를 없앨 수 있다.
하지만 위임 프로퍼티를 사용하기 전에 ObservableProperty에 있는 두 메소드의 시그니처를 코틀린의 관례에 맞게 수정해야한다.
// ObservableProperty를 프로퍼티 위임에 사용할 수 있게 바꿈
class ObservableProperty(
var propValue: Int, val changeSupport: PropertyChangeSuppor†
) {
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가 생소하지만, 일단 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 코드의 이전 버전과 비교해보라. 코틀린 컴파일러가 만들어주는 코드는 여러분이 직접 작성했던 이전 Person과 비슷하다. by 오른쪽에 오는 객체를 위임 객체(delegate)라고 부른다. 코틀린은 위임 객체를 감춰진 프로퍼티에 저장하고, 주 객체의 프로퍼티를 읽거나 쓸 때마다 위임 객체의 getValue와 setValue를 호출해준다.
관찰 가능한 프로퍼티 로직을 직접 작성하는 대신 코틀린 표준 라이브러리를 사용해도 된다. 표준 라이브러리에는 이미 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)
}
var age: Int by Delegates.observable(age, observer)
var salary: Int by Delegates.observable(salary, observer)
}
by의 오른쪽에 있는 식이 꼭 새 인스턴스를 만들 필요는 없다. 함수 호출, 다른 프로퍼티, 다른 식 등이 by의 우항에 올 수 있다. 다만 우항에 있는 식을 계산한 결과인 객체는 컴파일러가 호출할 수 있는 올바른 타입의 getValue와 setValue를 반드시 제공해야한다. 다른 관례와 마찬가지로 getValue와 setValue 모두 객체 안에 정의된 메소드이거나 확장 함수일 수 있다.
7.5.4 위임 프로퍼티 컴파일 규칙
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 호출 코드를 생성해준다.
7.5.5 프로퍼티 값을 맵에 저장
자신의 프로퍼티를 동적으로 정의할 수 있는 객체를 만들 때 위임 프로퍼티를 활용하는 경우가 자주 있다. 그런 객체를 확장 객체(expando object)라고 부르기도 한다. 예를 들어, 연락처 관리 시스템에서 연락처별로 임의의 정보를 저장할 수 있게 허용하는 경우를 살펴보자. 시스템에 저장된 연락처에는 특별히 처리해야 하는 일부 필수 정보가 있고, 사람마다 달라질 수 있는 추가 정보가 있다.
그런 시스템을 구현하는 방법 중에는 정보를 모두 저장하되 그 맵을 통해 처리하는 프로퍼티를 통해 필수 정보를 제공하는 방법이 있다.
// 값을 맵에 저장하는 프로퍼티 정의하기
class Person {
// 추가 정보
private val _attributees = hashMapOf<String, String>()
fun setAttribute(attrname: String, value: String) {
_attributes[attrName] = value
}
// 필수 정보
val name: String
get() = _attributes["name"]!! // 수동으로 맵에서 정보를 꺼낸다.
}
>>> val p = Person()
>>> val data = mapOf("name" to "Dmitry", "company" to "JetBrains")
>>> for ((attrName, value) in data)
... p.setAttribute(attrName, value)
>>> println(p.name)
Dmitry
이 코드느 추가 데이터를 저장하기 위해 일반적인 API를 사용하고(실제 프로젝트에서는 JSON 역직렬화 등의 기술을 활용할 수 있다), 특정 프로퍼티(nam)을 처리하기 위해 구체적인 개별 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]을 통해 구현된다.
7.5.6 프레임워크에서 위임 프로퍼티 활용
데이터베이스에 User라는 테이블이 있고 그 테이블에는 name이라는 문자열 타입의 컬럼과 age라는 정수 타입의 열이 있다고 가정하자. Users와 User라는 클래스를 코틀린에서 정의할 수 있다. 그리고 데이터베이스에 들어있는 모든 사용자 엔티티를 User 클래스를 통해 가져오고 저장할 수 있다.
// 위임 프로퍼티를 사용해 데이터베이스 컬럼 접근하기
object Users : IdTable() {
val name = varchar("name", length = 50).index()
val age = integer("age")
}
class User(id: EntityID) : Entity(id) {
var name: String by Users.name
var age: Int by Users.age
}
Users 객체는 데이터베이스 테이블을 표현한다. 데이터베이스 전체에 단 하나만 존재하는 테이블을 표현하므로 Users를 싱글턴 객체로 선언했다. 객체의 프로퍼티는 테이블 컬럼을 표현한다.
User의 상위 클래스인 Entity 클래스는 데이터베이스 컬럼을 엔티티의 속성 값으로 연결해주는 매핑이 있다. 각 User의 프로퍼티 중에는 데이터베이스에서 가져온 name과 age가 있다.
이 프레임워크를 사용하면 User의 프로퍼티에 접근할 때 자동으로 Entity 클래스에 정의된 데이터베이스 매핑으로부터 필요한 값을 가져오므로 편리하다. 어떤 User 객체를 변경하면 그 객체는 변경됨 dirty 상태로 변하고, 프레임워크는 나중에 적절히 데이터베이스에 변경 내용을 반영한다. user.age += 1을 코틀린 코드에서 사용하면 user에 해당하는 데이터베이스 엔티티가 자동으로 갱신된다.
각 엔티티 속성(name, age)은 위임 프로퍼티며, 컬럼 객체(Users.name, Users.age)를 위임 객체로 사용한다.
class User(id: EntityID) : Entity(id) {
var name: String by Users.name // Userse.name은 "name" 프로퍼티에 해당하는 위임 객체다.
var age: Int by Users.age
}
프레임워크는 Column 클래스 안에 getValue와 setValue 메소드를 정의한다. 이 두 메소드는 코틀린의 위임 객체 관례에 따른 시그니처 요구 사항을 만족한다.
operator fun <T> Column<T>.getValue(o: Entity, desc: KProperty<*>): T {
// 데이터베이스에서 컬럼 값 가져오기
}
operator fun <T> Column<T>.setValue(o: Entity, desc: KProperty<*>, value: T) {
// 데이터베이스의 값 변경하기
}
Column 프로퍼티(User.name)를 위임 프로퍼티(name)에 대한 위임 객체로 사용할 수 있다. user.age += 1이라는 식을 코드에서 사용하면 그 식은 user.ageDelegate.setValue(user.ageDelegate.getValue() + 1)과 비슷한 코드로 변환된다(객체 인스턴스와 프로퍼티 파라미터는 생략). getValue와 setValue 메소드는 데이터베이스에서 데이터를 가져오고 기록하는 작업을 처리한다.
'Kotlin' 카테고리의 다른 글
[Kotlin] object는 어떻게 자바로 디컴파일 되는가 (0) | 2025.03.27 |
---|---|
[Kotlin in Action] 8.1 고차 함수 정의 (0) | 2025.03.27 |
[Kotlin in Action] 7.4 구조 분해 선언과 component 함수 (0) | 2025.03.27 |
[Kotlin in Action] 7.3 컬렉션과 범위에 대해 쓸 수 있는 관례 (0) | 2025.03.27 |
[Kotlin in Action] 7.2 비교 연산자 오버로딩 (0) | 2025.03.26 |
댓글