본문 바로가기

Language/Kotlin

[Kotlin] 코틀린 제대로 활용하기 1편 - 위임 프로퍼티: by 키워드로 lazy부터 맵 위임까지 이해하기

지난 편에서는 코틀린이 String과 String?를 구분해 null 안전성을 타입 시스템으로 다루는 방법을 살펴봤습니다.

https://dmoritle.tistory.com/293

 

[Kotlin] 코틀린 온보딩 11편 - 널이 될 수 있는 값: null 안전성 한 번에 정리하기

지난 편에서는 object와 companion object, 객체 식, value class를 살펴봤습니다.https://dmoritle.tistory.com/292 [Kotlin] 코틀린 온보딩 10편 - object와 companion object: 싱글톤부터 value class까지 이해하기지난 편에서

dmoritle.tistory.com

 

 

이번 편에서는 프로퍼티의 getter/setter 로직을 다른 객체에게 맡기는 by 키워드, 즉 위임 프로퍼티(Delegated Properties)를 정리합니다. 다루는 내용은 다음과 같습니다.

  • 위임 프로퍼티의 기본 구조와 by가 컴파일되는 방식
  • by lazy로 지연 초기화하기
  • Delegates.observable/vetoable로 변경 감지·검증하기
  • Delegates.notNull()과 lateinit의 차이
  • 맵에 위임해서 동적 데이터 다루기
  • 다른 프로퍼티에 위임해 하위 호환성 유지하기

 

한눈에 보기

이런 상황이라면 이렇게 합니다 자세히
값을 만드는 방법은 아는데, 처음 쓸 때까지 미루고 싶다 val x by lazy { ... } 5-1장
참조 타입이고, 외부(DI, 테스트 셋업 등)가 나중에 넣어준다 lateinit var x: T (위임 프로퍼티는 아님) 6장
원시 타입(Int 등)을 나중에 초기화해야 한다 var x: Int by Delegates.notNull() 5-4장, 6장
값이 바뀔 때 알림이나 로깅이 필요하다 Delegates.observable 5-3장
대입하기 전에 값을 검증하고 싶다 Delegates.vetoable 5-3장
맵이나 JSON 같은 동적 데이터를 객체처럼 읽고 싶다 맵 위임 (by map) 7장
프로퍼티 이름을 바꾸되 옛 이름도 한동안 유지해야 한다 다른 프로퍼티에 위임 (by this::newName) 8장
프로퍼티를 DB 컬럼, UI 상태 같은 외부 대상과 연결한다 라이브러리가 제공하는 위임 10장

 

1. 들어가며: 왜 위임 프로퍼티가 필요할까?

1-1. 자바에서 지연 초기화는 어떻게 했을까?

자바에서 "처음 접근할 때 한 번만 값을 만들고 싶다"는 요구는 보통 이런 코드로 해결합니다.

public class Person {
    private List<String> emails;

    public List<String> getEmails() {
        if (emails == null) {
            emails = loadEmails();
        }
        return emails;
    }
}

 

동작은 하지만 문제가 몇 가지 있습니다.

  • 지연 초기화가 필요한 필드마다 같은 패턴을 반복해야 합니다.
  • 멀티스레드 환경에서는 loadEmails()가 두 번 호출될 수 있어서, 동기화를 직접 챙겨야 합니다.
  • 이 패턴을 재사용 가능한 형태로 뽑아내기가 어렵습니다.

 

코틀린에서는 이 코드를 한 줄로 쓸 수 있습니다.

class Person {
    val emails: List<String> by lazy { loadEmails() }
}

 

1-2. 지연 초기화만의 문제가 아닙니다

프로퍼티를 읽고 쓸 때 부가 로직이 필요한 경우는 많습니다. 대입할 때 값을 검증하거나, 공백을 제거하거나, 값이 바뀌면 알림을 보내는 식입니다. 이걸 커스텀 getter/setter로 작성하면 프로퍼티마다 같은 코드를 복붙해야 합니다.

// 위임 없이: 프로퍼티마다 같은 로직을 반복
class Member {
    private var _name: String = ""
    var name: String
        get() = _name
        set(value) { _name = value.trim() }

    private var _nickname: String = ""
    var nickname: String
        get() = _nickname
        set(value) { _nickname = value.trim() }   // 똑같은 로직
}

// 위임으로: 로직은 한 곳에, 프로퍼티는 한 줄
class Member {
    var name: String by trimmed()
    var nickname: String by trimmed()
}

(trimmed()의 구현은 2-3장에서 봅니다.)

 

위임 프로퍼티의 핵심은 "프로퍼티 하나의 동작"을 "객체 하나"로 추출해서, 여러 프로퍼티와 여러 클래스에서 재사용하는 것입니다.

 

이 글은 by 한 줄 뒤에서 일어나는 일, 표준 라이브러리가 제공하는 위임, 그리고 위임을 언제 쓸지까지 차례로 정리합니다. 이미 by가 익숙하다면 위의 "한눈에 보기"와 6장의 비교만 먼저 읽어도 좋습니다.

 

2. 위임 프로퍼티의 기본 구조

2-1. 문법

class Example {
    var p: String by Delegate()
}

문법은 val/var <프로퍼티 이름>: <타입> by <표현식>입니다. by 뒤에 오는 표현식이 위임 객체(delegate)이고, 프로퍼티의 get()/set()이 위임 객체의 getValue()/setValue()로 넘어갑니다.

 

참고: by는 클래스 위임(class MyList(list: List<Int>) : List<Int> by list)에도 쓰입니다. 이쪽은 인터페이스 구현을 다른 객체에 넘기는 별개의 기능이고, 이 글은 프로퍼티 위임만 다룹니다.

 

2-2. 위임 객체 직접 만들어 보기

위임 객체가 되기 위해 특별한 인터페이스를 구현할 필요는 없습니다. 규약에 맞는 operator 함수만 제공하면 됩니다.

  • val 프로퍼티: getValue()
  • var 프로퍼티: getValue() + setValue()
import kotlin.reflect.KProperty

class LoggingDelegate {
    private var value: String = "초기값"

    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        println("${property.name} 읽기")
        return value
    }

    operator fun setValue(thisRef: Any?, property: KProperty<*>, newValue: String) {
        println("${property.name} 쓰기: $newValue")
        value = newValue
    }
}

class Example {
    var p: String by LoggingDelegate()
}

fun main() {
    val e = Example()
    e.p = "안녕"      // p 쓰기: 안녕
    println(e.p)      // p 읽기 → 안녕
}

 

각 파라미터의 의미는 다음과 같습니다.

파라미터 의미
thisRef 프로퍼티를 가진 객체 (위 예제에서는 Example 인스턴스). 확장 프로퍼티라면 확장 대상 타입
property 프로퍼티 자체에 대한 설명 객체(KProperty<*>). 이름(property.name) 등을 꺼낼 수 있음
value (setValue) 새로 대입되는 값

 

getValue()/setValue()는 위임 클래스의 멤버 함수로 만들 수도 있고, 확장 함수로 제공할 수도 있습니다. 확장 함수가 가능하다는 점 덕분에, 원래 이런 함수가 없던 객체(예: Map)도 위임 객체로 쓸 수 있습니다. 7장 맵 위임에서 이 부분이 다시 나옵니다.

 

2-3. ReadOnlyProperty / ReadWriteProperty

매번 클래스를 만들기 번거롭다면 표준 라이브러리의 ReadOnlyProperty, ReadWriteProperty 인터페이스를 쓸 수 있습니다. getValue()는 ReadOnlyProperty에 선언되어 있고, ReadWriteProperty는 이를 확장해 setValue()를 추가합니다.

import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty

fun trimmed(initial: String = ""): ReadWriteProperty<Any?, String> =
    object : ReadWriteProperty<Any?, String> {
        private var current = initial.trim()

        override fun getValue(thisRef: Any?, property: KProperty<*>): String = current

        override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
            current = value.trim()
        }
    }

class Member {
    var name: String by trimmed()
    var nickname: String by trimmed()
}

fun main() {
    val m = Member()
    m.name = "  모리  "
    m.nickname = " 예진 "
    println("[${m.name}] [${m.nickname}]")   // [모리] [예진]
}

1장에서 본 Member 예제의 trimmed()가 바로 이것입니다. "대입할 때 공백을 제거한다"는 규칙을 한 번만 작성하고 여러 프로퍼티에서 재사용합니다.

 

3. 컴파일러는 by를 어떻게 해석할까?

코틀린 공식 문서가 설명하는 변환 규칙은 이렇습니다. 컴파일러는 prop$delegate라는 숨겨진 프로퍼티를 만들고, 접근자가 그 프로퍼티에 호출을 넘기도록 코드를 바꿉니다.

// 우리가 작성한 코드
class C {
    var prop: Type by MyDelegate()
}

// 컴파일러가 개념적으로 만들어 주는 코드
class C {
    private val prop$delegate = MyDelegate()
    var prop: Type
        get() = prop$delegate.getValue(this, this::prop)
        set(value: Type) = prop$delegate.setValue(this, this::prop, value)
}
  • prop$delegate: 위임 객체를 담는 숨겨진 프로퍼티입니다. 이름에 $가 들어 있어서 코틀린 소스에서는 직접 접근할 수 없습니다.
  • get()/set(): 프로퍼티에 접근할 때 위임 객체의 getValue/setValue를 호출하는 접근자입니다.
  • this는 프로퍼티를 가진 객체, this::prop은 프로퍼티 자체를 설명하는 KProperty 객체로 전달됩니다.

『코틀린 인 액션』에서도 같은 내용을, 위임 프로퍼티가 커스텀 접근자를 가진 감춰진 프로퍼티로 변환된다는 식으로 설명합니다. 위 코드는 그 설명을 코드로 풀어 쓴 것입니다.

 

4. 뒷받침하는 프로퍼티에서 by lazy까지

by lazy가 무엇을 대신해 주는지 알려면 먼저 뒷받침하는 프로퍼티(backing property)를 알아야 합니다.

 

4-1. 뒷받침하는 프로퍼티란?

값을 실제로 저장하는 private 프로퍼티와, 그 값을 외부에 노출하는 public 프로퍼티를 한 쌍으로 두는 패턴입니다. 코틀린 코딩 컨벤션에 따라 private 쪽 이름 앞에 _를 붙입니다.

class Person(val name: String) {
    private var _emails: List<String>? = null     // 실제 저장소 (nullable)

    val emails: List<String>                      // 외부 노출용 (non-null)
        get() {
            if (_emails == null) {
                _emails = loadEmails(this)        // 처음 접근할 때 한 번만 로딩
            }
            return _emails!!
        }
}

내부에서는 nullable로 상태를 관리하고, 외부에는 non-null 타입만 보여주는 것이 핵심입니다. 자바의 지연 초기화 코드와 거의 같은 구조입니다.

 

4-2. 이 방식의 한계

  • 지연 초기화할 프로퍼티마다 _xxx/xxx 쌍이 생기는 보일러플레이트
  • _emails!!처럼 논리상 null이 아닌데도 필요한 !! 단언
  • 두 스레드가 동시에 null 체크를 통과하면 초기화가 중복될 수 있는 스레드 안전성 문제
  • 다른 클래스에서 쓰려면 복붙해야 하는 재사용 불가

 

4-3. by lazy로 바꾸면

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

수동으로 구현한 "nullable 저장소 + null 체크 + 로딩"이 위임 객체 안으로 들어갔고, 기본적으로 스레드 안전합니다. 정리하면 by lazy는 뒷받침하는 프로퍼티 패턴을 재사용 가능한 위임 객체로 추출한 것입니다.

 

4-4. 참고: 뒷받침하는 프로퍼티 vs 뒷받침하는 필드

위임과 직접 관련은 없지만 이름이 비슷해서 자주 헷갈리는 개념이라 짚고 갑니다. 이미 알고 계시다면 건너뛰어도 됩니다.

구분 만드는 주체 접근 방법
뒷받침하는 필드(backing field) 컴파일러 접근자 안에서 field 키워드 (예: set(value) { if (value >= 0) field = value })
뒷받침하는 프로퍼티(backing property) 개발자 이름 앞에 _를 붙인 별도의 private 프로퍼티 (4-1장의 _emails)

뒷받침하는 프로퍼티는 지연 초기화 외에도, 내부에서는 MutableList로 다루고 외부에는 List 타입으로만 노출하는 용도로 자주 쓰입니다.

 

5. 표준 라이브러리가 제공하는 위임들

5-1. lazy - 지연 초기화

lazy()는 람다를 받아 Lazy<T> 인스턴스를 반환하는 함수이고, 이 인스턴스가 위임 객체로 쓰입니다. 첫 번째 get() 호출 때 람다가 실행되어 결과가 기억되고, 이후에는 기억된 결과를 반환합니다.

val lazyValue: String by lazy {
    println("computed!")
    "Hello"
}

fun main() {
    println(lazyValue)   // computed! → Hello
    println(lazyValue)   // Hello (람다 재실행 없음)
}

lazy는 val에만 사용할 수 있습니다. 위임 객체인 Lazy<T>가 읽기만 지원하기 때문입니다.

 

어떤 값에 쓰면 좋을까

생성 비용이 크고, 실제로 쓰이지 않을 수도 있는 값이 대표적입니다. 예를 들어 보고서 서비스가 템플릿 파일을 읽어 오는 경우를 생각해 볼 수 있습니다.

class ReportService(private val templatePath: String) {
    // 서비스 객체를 만들 때가 아니라, 처음 보고서를 만들 때 파일을 읽는다
    private val template: String by lazy { loadTemplate(templatePath) }

    fun render(data: ReportData): String = fill(template, data)
}

선언하면서 바로 읽으면 이 서비스를 한 번도 쓰지 않아도 파일 읽기 비용을 치릅니다. lazy는 그 비용을 처음 필요해진 시점으로 미룹니다.

 

스레드 안전성 모드

모드 동작
SYNCHRONIZED (기본값) 락을 사용해 하나의 스레드만 초기화하고, 초기화된 값이 모든 스레드에서 보이도록 보장
PUBLICATION 동시 접근 시 초기화 람다가 여러 번 호출될 수 있지만, 그중 하나의 결과만 채택되어 모든 스레드에서 보임
NONE 락을 쓰지 않음. 여러 스레드에서 접근하면 동작이 정의되지 않음
val config by lazy(LazyThreadSafetyMode.PUBLICATION) { loadConfig() }
val local by lazy(LazyThreadSafetyMode.NONE) { buildSomething() }

초기화가 항상 프로퍼티를 사용하는 스레드와 같은 스레드에서만 일어난다고 확신할 수 있을 때만 NONE을 선택하는 것이 안전합니다.

 

초기화 중 예외가 나면?

공식 문서에 따르면, 초기화 람다가 예외를 던지면 값이 초기화된 것으로 기록되지 않고 다음 접근 때 초기화를 다시 시도합니다. 한 번 실패했다고 예외가 영구히 굳어지는 것은 아닙니다.

 

지역 변수에도 쓸 수 있습니다

위임 프로퍼티는 클래스 멤버가 아니라 함수 안의 지역 변수에도 쓸 수 있습니다. 다음은 공식 문서의 예제입니다.

fun example(computeFoo: () -> Foo) {
    val memoizedFoo by lazy(computeFoo)

    if (someCondition && memoizedFoo.isValid()) {
        memoizedFoo.doSomething()
    }
}

someCondition이 거짓이면 memoizedFoo는 한 번도 계산되지 않습니다.

 

5-2. lazy vs 커스텀 getter vs 그냥 초기화

자바에서 넘어오면 가장 헷갈리는 지점입니다. 세 방식은 언제, 몇 번 계산하는가가 다릅니다.

방식 선언 계산 시점 계산 횟수
선언하면서 초기화 val x = compute() 객체 생성 시점 한 번
커스텀 getter val x get() = compute() 접근할 때마다 접근할 때마다
by lazy val x by lazy { compute() } 처음 접근할 때 한 번 (결과를 기억)

fun compute(tag: String): Int {
    println("compute($tag)")
    return 42
}

class Demo {
    val eager = compute("eager")                    // 생성할 때 한 번
    val byGetter: Int get() = compute("getter")     // 읽을 때마다
    val byLazy: Int by lazy { compute("lazy") }     // 처음 읽을 때 한 번
}

fun main() {
    val d = Demo()
    d.byGetter
    d.byGetter
    d.byLazy
    d.byLazy
}

// 출력
// compute(eager)    ← Demo()를 만들 때
// compute(getter)   ← 첫 번째 byGetter
// compute(getter)   ← 두 번째 byGetter (또 계산)
// compute(lazy)     ← 첫 번째 byLazy (두 번째는 기억된 값 사용)

커스텀 getter는 접근할 때마다 실행되므로, 다른 프로퍼티 값에 따라 달라지는 값(예: width * height로 계산하는 area)처럼 매번 최신 상태를 반영해야 하는 경우에 어울립니다. 비싸지만 한 번 계산하면 바뀌지 않는 값은 lazy가 맞습니다.

 

5-3. Delegates.observable / Delegates.vetoable - 변경 감지와 거부

import kotlin.properties.Delegates

class User {
    var name: String by Delegates.observable("<이름 없음>") { prop, old, new ->
        println("${prop.name}: $old -> $new")
    }

    var age: Int by Delegates.vetoable(0) { _, _, new -> new >= 0 }
}

fun main() {
    val user = User()
    user.name = "모리"   // name: <이름 없음> -> 모리

    user.age = 20        // 성공
    user.age = -5        // 거부됨, 여전히 20
    println(user.age)    // 20
}
  • observable: 대입이 수행된 뒤에 핸들러가 호출됩니다. 변경 이력 로깅이나 UI 갱신 알림에 적합합니다.
  • vetoable: 새 값이 대입되기 전에 핸들러가 호출되며, false를 반환하면 대입이 취소됩니다. 값 검증에 적합합니다.

 

5-4. Delegates.notNull() - 나중에 초기화되는 non-null 값

var max: Int by Delegates.notNull()

fun main() {
    // println(max)    // IllegalStateException
    max = 10
    println(max)       // 10
}

생성 시점이 아니라 나중에 초기값이 대입되는 non-null 프로퍼티를 위한 위임입니다. 초기값을 대입하기 전에 읽으면 예외가 발생합니다. lateinit과 어떻게 다른지는 바로 다음 장에서 비교합니다.

 

6. lazy vs lateinit vs Delegates.notNull()

세 가지 모두 "선언 시점에 값을 정하지 않는다"는 공통점이 있어서 헷갈리기 쉽습니다. 참고로 lazy는 Delegates.notNull()과 달리 Delegates 객체의 멤버가 아니라 표준 라이브러리의 최상위 함수입니다. 그래서 Delegates.lazy가 아니라 lazy { }로 바로 씁니다.

 

6-1. 핵심 기준: "값을 만드는 방법을 아는가?"

  • lazy: 값을 어떻게 만드는지 선언 시점에 이미 알고 있고, 비싸니까 실제로 쓸 때까지 미루고 싶을 때
  • lateinit: 참조 타입이고, 값을 선언 시점엔 만들 수 없어서 외부(의존성 주입, 테스트 셋업 등)가 나중에 넣어줄 때
  • Delegates.notNull(): lateinit을 쓰고 싶지만 Int, Boolean 같은 원시 타입이라 쓸 수 없을 때
// 비싼 객체를 쓸 때까지 미루기 → lazy
private val emailRegex by lazy { Regex("^[\\w.]+@[\\w.]+$") }

// 테스트 셋업 → lateinit
class UserServiceTest {
    private lateinit var userRepository: UserRepository

    @BeforeEach
    fun setUp() {
        userRepository = FakeUserRepository()
    }
}

// 원시 타입을 나중에 초기화 → notNull
class Game {
    var score: Int by Delegates.notNull()
}

 

코틀린은 프로퍼티를 선언할 때 바로 초기화하는 것을 권장합니다. 원시 타입이라면 var score = 0처럼 기본값을 두는 편이 더 단순한 경우가 많으니, notNull은 "기본값이 의미 없고 반드시 나중에 설정되어야 한다"는 의도를 강제하고 싶을 때만 쓰는 편이 좋습니다.

 

6-2. 한눈에 비교

구분 lazy Delegates.notNull() lateinit
선언 val x by lazy { } var x: T by Delegates.notNull() lateinit var x: T
변수 종류 val var var
초기화 주체 선언부의 람다가 자동으로 개발자가 수동 대입 개발자가 수동 대입
초기화 시점 처음 읽을 때 개발자가 정한 시점 개발자가 정한 시점
재할당 불가 가능 가능
원시 타입(Int 등) 가능 가능 불가
nullable 타입 가능 불가 불가
미초기화 상태에서 읽기 해당 없음 IllegalStateException UninitializedPropertyAccessException
초기화 여부 확인 해당 없음 불가 ::x.isInitialized
스레드 안전성 기본 보장 (SYNCHRONIZED) 별도 보장 없음 별도 보장 없음
위임 프로퍼티인가? 예 예 아니오 (언어 기능)

 

6-3. lateinit 사용 시 알아둘 점

초기화하기 전에 읽으면 어떤 프로퍼티인지 알려 주는 UninitializedPropertyAccessException이 발생합니다. 메시지는 lateinit property latestReading has not been initialized와 같은 형태입니다. 초기화 여부는 프로퍼티 참조의 isInitialized로 확인할 수 있습니다.

class WeatherStation {
    lateinit var latestReading: String

    fun printReading() {
        if (this::latestReading.isInitialized) {
            println("Latest reading: $latestReading")
        } else {
            println("No reading available")
        }
    }
}
  • isInitialized는 그 프로퍼티에 코드상 접근할 수 있는 위치(같은 클래스, 바깥 클래스, 같은 파일의 최상위 프로퍼티)에서만 쓸 수 있습니다. 위 예제처럼 프로퍼티를 선언한 클래스 안에서 쓰면 됩니다.
  • 클래스 프로퍼티라면 주 생성자에 선언할 수 없고, 커스텀 getter/setter를 가질 수 없습니다. 최상위 프로퍼티와 지역 변수에는 쓸 수 있습니다.
  • isInitialized를 자주 써야 한다면 설계를 다시 볼 신호일 수 있습니다. 그럴 때는 lazy나 nullable 타입이 더 맞는지 고민해 보세요.

 

6-4. Spring에서는 lateinit을 어떻게 쓸까?

코틀린 공식 문서는 lateinit이 필요한 대표 상황으로 의존성 주입과 단위 테스트의 setup 메서드를 듭니다. 스프링에서도 @Autowired lateinit var로 필드 주입을 하는 코드를 볼 수 있습니다.

 

다만 스프링 공식 문서는 생성자 주입을 일반적으로 권장합니다. 애플리케이션 컴포넌트를 불변 객체로 만들 수 있고, 필수 의존성이 null이 아님이 보장되기 때문입니다. 코틀린에서는 생성자 주입이 훨씬 간결합니다.

@Service
class OrderService(
    private val orderRepository: OrderRepository,
    private val paymentClient: PaymentClient,
)

 

스프링 문서에 따르면 클래스에 생성자가 하나뿐일 때는 @Autowired를 붙이지 않아도 그 생성자가 사용됩니다(스프링 4.3부터). 두 방식의 차이는 이렇습니다.

방식 의존성이 채워지는 시점 의존성 없이 직접 만들면 (예: 테스트 코드)
생성자 주입 객체를 만들 때 생성자 파라미터가 필수라서 컴파일 에러
@Autowired lateinit var 객체 생성 후 컴파일은 통과하고, 접근 시점에 UninitializedPropertyAccessException

기본은 생성자 주입으로 하고, lateinit은 "생성자로 받기 어려운 경우"의 선택지로 남겨 두는 편이 안전합니다.

 

7. 맵에 위임해서 동적으로 프로퍼티 접근하기

여기서부터는 lazy 외에 자주 쓰이는 위임 패턴을 봅니다. 먼저 맵 위임입니다.

 

맵 인스턴스 자체를 위임 객체로 사용하면, 프로퍼티 이름을 키로 삼아 맵의 값을 읽고 쓰는 프로퍼티를 만들 수 있습니다. JSON 파싱처럼 동적인 데이터를 다룰 때 자주 등장하는 패턴입니다.

 

7-1. 먼저 뒷받침하는 프로퍼티로 직접 구현해 보면

class Person {
    private val _attributes = hashMapOf<String, String>()

    fun setAttribute(attrName: String, value: String) {
        _attributes[attrName] = value
    }

    val name: String
        get() = _attributes["name"]!!
}

필수 속성은 고정이고 나머지는 동적으로 확장되는 객체를 만드는 예입니다. 프로퍼티가 늘어날 때마다 get() = _attributes["xxx"]!!를 반복해야 합니다.

 

7-2. 위임으로 바꾸면

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 확장 함수를 제공하기 때문입니다. 2-2장에서 말한 "확장 함수로 위임 객체를 만들 수 있다"는 점이 여기서 쓰입니다.

 

7-3. val은 Map, var는 MutableMap

class User(val map: MutableMap<String, Any?>) {
    var name: String by map
    var age: Int by map
}

fun main() {
    val map = mutableMapOf<String, Any?>("name" to "예진", "age" to 25)
    val user = User(map)

    user.age = 26
    println(map["age"])   // 26 — 맵이 실제로 바뀜
}
프로퍼티 필요한 맵
val Map 또는 MutableMap
var MutableMap만 가능

 

7-4. 주의할 점

class Profile(map: Map<String, Any?>) {
    val name: String by map
    val age: Int by map
}

val p = Profile(mapOf("name" to "예진"))
p.age   // 키가 없어서 예외 발생 (NoSuchElementException)
  • 키가 없으면 런타임 예외: 컴파일 시점에는 검증되지 않고, 프로퍼티에 접근하는 시점에 NoSuchElementException이 발생합니다. 표준 라이브러리 문서에 따르면 맵에 암묵적 기본값(withDefault)이 없을 때 이 예외가 납니다.
  • 타입 안전성 없음: 값의 실제 타입이 선언한 타입과 다르면 접근 시점에 ClassCastException이 발생할 수 있습니다.
  • 이름 변경에 취약: 프로퍼티 이름이 곧 키이므로, 리팩터링으로 이름을 바꾸면 맵의 키와 어긋납니다.

스키마가 고정된 도메인 모델이라면 data class와 직렬화 라이브러리로 역직렬화하는 편이 컴파일 시점 검증 면에서 낫습니다. 맵 위임은 스키마가 느슨하거나 동적인 경우에 어울립니다.

 

8. 다른 프로퍼티에 위임하기

프로퍼티의 getter/setter를 다른 프로퍼티에 위임할 수도 있습니다. :: 한정자를 사용합니다.

var topLevelInt: Int = 0

class MyClass(var memberInt: Int) {
    var delegatedToMember: Int by this::memberInt
    var delegatedToTopLevel: Int by ::topLevelInt
}

 

위임 대상은 최상위 프로퍼티, 같은 클래스의 멤버/확장 프로퍼티, 다른 클래스의 멤버/확장 프로퍼티가 될 수 있습니다.

 

가장 흔한 용도는 하위 호환성을 유지하면서 프로퍼티 이름을 바꾸는 것입니다.

class MyClass {
    var newName: Int = 0

    @Deprecated("Use 'newName' instead", ReplaceWith("newName"))
    var oldName: Int by this::newName
}

새 프로퍼티를 만들고, 기존 프로퍼티에는 @Deprecated를 붙인 뒤 구현을 새 프로퍼티에 위임하면, 기존 코드를 깨뜨리지 않고 점진적으로 이전할 수 있습니다.

 

9. 더 알아보기: provideDelegate

by 오른쪽 객체가 provideDelegate 연산자를 정의하면, 위임 객체를 만드는 시점에 로직을 끼워 넣을 수 있습니다. getValue와 같은 파라미터(thisRef, property)를 받기 때문에, 프로퍼티 이름 같은 정보를 보고 접근 시점이 아니라 생성 시점에 검증하는 용도로 쓸 수 있습니다.

// by 오른쪽에 오는 객체(provider)에 정의합니다
operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): ReadOnlyProperty<Any?, String>

클래스를 새로 만들지 않고 provider를 만들고 싶다면 표준 라이브러리의 PropertyDelegateProvider를 쓸 수 있습니다. 라이브러리나 프레임워크를 만드는 입장이 아니라면 직접 구현할 일은 드물기 때문에, 자세한 예제는 공식 문서를 참고하세요.

 

10. 위임을 쓸지 말지 판단하기

10-1. 라이브러리가 제공하는 위임을 가져다 쓰는 경우가 대부분입니다

위임 객체를 직접 구현하는 일은 생각보다 드뭅니다. 실무에서는 표준 라이브러리(lazy, observable 등)나 프레임워크가 만들어 둔 위임을 by로 붙여 쓰는 경우가 대부분입니다.

  • Jetpack Compose: 공식 문서에서 var value by remember { mutableStateOf(default) } 형태를 안내합니다. 이 문법을 쓰려면 getValue/setValue를 import해야 하는데, 2-2장에서 말한 확장 함수 방식과 같은 원리입니다.
  • Exposed(ORM): 엔티티에서 var sequelId by StarWarsFilmsTable.sequelId처럼 프로퍼티를 테이블 컬럼에 연결하면, 접근할 때 해당 컬럼의 값을 읽고 씁니다.

 

10-2. 직접 만들기 전에 생각해 볼 점

직접 만드는 경우는 "같은 접근자 로직이 여러 군데에서 반복된다"는 신호가 보일 때 정도입니다. 반대로 아래 경우에는 위임까지 갈 필요가 없습니다.

  • 한 프로퍼티에서만 쓰는 로직이라면 커스텀 getter/setter로 충분합니다.
  • 생성 시점에 값을 정할 수 있다면 선언하면서 바로 초기화하는 편이 가장 단순합니다.
  • 위임은 접근할 때마다 위임 객체를 거치기 때문에, 이유 없이 쓰면 코드를 따라가기 어려워집니다.

프로퍼티의 읽기/쓰기에 붙는 부가 동작이 있고, 그 동작이 다른 프로퍼티나 클래스에서도 반복된다면 위임을 쓸 후보입니다.

 

정리

  • 위임 프로퍼티는 프로퍼티의 getter/setter를 getValue()/setValue()를 가진 위임 객체에 맡겨서, 프로퍼티 하나의 동작을 재사용하는 기능입니다.
  • 컴파일러는 prop$delegate라는 숨겨진 프로퍼티를 만들고, 접근자가 이 객체에 호출을 넘기도록 바꿔 줍니다.
  • by lazy 는 뒷받침하는 프로퍼티 패턴을 위임 객체로 추출한 것이고, 기본적으로 스레드 안전합니다.
  • 계산 시점은 선언하면서 초기화하면 객체 생성 시 한 번, 커스텀 getter는 접근할 때마다, lazy는 처음 접근할 때 한 번입니다.
  • 늦은 초기화 선택 기준은 다음과 같습니다.
    • 만드는 법을 알고 미루고 싶다 → lazy
    • 참조 타입이고 외부가 나중에 넣어준다 → lateinit (스프링에서는 생성자 주입이 기본)
    • 원시 타입이고 나중에 초기화한다 → Delegates.notNull()
  • 맵 위임은 프로퍼티 이름을 키로 맵을 읽고 쓰고, 없는 키를 읽으면 NoSuchElementException이 납니다.
  • 다른 프로퍼티 위임은 이름을 바꾸면서 하위 호환성을 유지할 때 유용합니다.
  • 위임은 같은 접근자 로직이 반복될 때 씁니다. 직접 만들기보다 라이브러리가 제공하는 위임을 가져다 쓰는 경우가 대부분이고, 한 곳에서만 쓰는 로직은 커스텀 getter/setter로 충분합니다.

 

마무리

위임 프로퍼티가 getValue()/setValue()를 가진 객체에 접근자를 맡기는 원리부터, lazy·observable·vetoable·notNull() 같은 표준 위임, 맵 위임과 다른 프로퍼티 위임까지 by 키워드를 한 번에 정리했습니다. 다음 편에서는 고차 함수를 다뤄보겠습니다.

 

이 시리즈는 『코틀린 인 액션(Kotlin in Action)』을 참고하여 작성했습니다.

이번 편에서 다룬 내용은 아래 문서로도 검증했습니다.