지난 편에서는 코틀린이 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)』을 참고하여 작성했습니다.
이번 편에서 다룬 내용은 아래 문서로도 검증했습니다.
- Delegated properties (Kotlin 공식 문서)
- Properties — late-initialized properties, backing properties (Kotlin 공식 문서)
- lazy (Kotlin Core API)
- LazyThreadSafetyMode (Kotlin Core API)
- Delegates.notNull (Kotlin Core API)
- Map.getValue — 프로퍼티 위임용 (Kotlin Core API)
- Dependency Injection — 생성자/세터 주입 (Spring Framework)
- Using @Autowired (Spring Framework)
- State and Jetpack Compose (Android Developers)
- Entity definition (Exposed)
'Language > Kotlin' 카테고리의 다른 글
| [Kotlin] 코틀린 온보딩 11편 - 널이 될 수 있는 값: null 안전성 한 번에 정리하기 (0) | 2026.10.05 |
|---|---|
| [Kotlin] 코틀린 온보딩 10편 - object와 companion object: 싱글톤부터 value class까지 이해하기 (0) | 2026.10.04 |
| [Kotlin] 코틀린 온보딩 9편 - 생성자: 정의와 초기화를 한 번에 이해하기 (1) | 2026.10.04 |
| [Kotlin] 코틀린 온보딩 8편 - 클래스 계층 구조: 인터페이스부터 sealed class까지 한눈에 정리하기 (0) | 2026.10.04 |
| [Kotlin] 코틀린 온보딩 7편 - 확장 함수: 바이트코드로 확장 프로퍼티까지 이해하기 (0) | 2026.09.26 |