코틀린 lateinit과 by lazy 완전 정복: UninitializedPropertyAccessException 원인과 해결
TL;DR — 코틀린에서 자주 마주치는 "lateinit property has not been initialized" 에러의 근본 원인을 짚고, lateinit과 by lazy를 상황에 맞게 선택하는 실무 기준과 안전한 초기화 패턴을 정리했습니다.
안드로이드나 순수 코틀린 프로젝트를 진행하다 보면 어느 순간 다음과 같은 빨간 로그를 만나게 됩니다.
kotlin.UninitializedPropertyAccessException: lateinit property binding has not been initialized
빌드는 멀쩡히 통과했는데 실행하자마자 앱이 죽어버리니 당황스럽습니다. 컴파일러가 잡아주지 못하고 런타임에서 터지는 대표적인 코틀린 에러이기 때문입니다. 이 글에서는 이 예외가 왜 발생하는지 코틀린의 널 안전성(null safety) 설계 관점에서 풀어보고, lateinit과 by lazy를 어떤 기준으로 골라야 하는지, 그리고 실무에서 재발을 막는 패턴까지 단계별로 정리하겠습니다.
1. 왜 이 에러가 생기는가 — 코틀린의 초기화 규칙
코틀린은 자바와 달리 모든 프로퍼티가 선언 시점에 초기값을 가져야 한다는 원칙을 강제합니다. 즉 아래 코드는 컴파일 에러입니다.
// 컴파일 에러: 프로퍼티가 초기화되지 않음
class UserProfile {
var nickname: String // ❌ 초기값이 없다
}
이 규칙 덕분에 코틀린은 "값이 없는데 접근하는" 상황을 원천 차단합니다. 하지만 현실에서는 객체를 만드는 시점에는 값을 모르고, 나중에(예: 안드로이드의 onCreate, 의존성 주입, 네트워크 응답 이후)에야 값이 정해지는 경우가 많습니다.
이때 개발자는 두 가지 우회로를 씁니다. 하나는 nullable 타입(String?)으로 선언하고 매번 ?나 !!로 처리하는 방법이고, 다른 하나가 바로 lateinit 또는 **by lazy**로 "나중에 초기화하겠다"고 컴파일러와 약속하는 방법입니다.
문제는 lateinit의 경우 이 약속을 어겼을 때, 즉 초기화를 끝내기 전에 그 값을 읽으려 하면 런타임에 UninitializedPropertyAccessException이 발생한다는 점입니다. 컴파일러는 약속을 믿고 넘어가지만, 실행 시점에는 봐주지 않습니다.
2. lateinit의 동작 원리와 제약
lateinit은 "지금은 값이 없지만 반드시 나중에 채워 넣겠다"는 선언입니다. 다음과 같은 제약이 있습니다.
var에만 사용 가능 (val불가) — 나중에 대입해야 하므로 가변이어야 합니다.- 원시 타입(Int, Boolean, Double 등) 불가 —
String이나 사용자 정의 클래스 같은 참조 타입에만 씁니다. - nullable 타입 불가 —
lateinit var x: String?는 허용되지 않습니다.
올바른 사용 흐름은 다음과 같습니다.
class CheckoutScreen {
// 선언: 아직 값 없음, 그러나 곧 채울 것을 약속
private lateinit var cartTotal: String
fun onScreenReady() {
// 실제 사용 전에 반드시 초기화
cartTotal = "42,000원"
}
fun render() {
// 여기서 cartTotal을 읽기 전에 onScreenReady()가 먼저 호출돼 있어야 안전
println("결제 금액: $cartTotal")
}
}
반대로 아래는 전형적인 사고 패턴입니다.
class CheckoutScreen {
private lateinit var cartTotal: String
fun render() {
// ❌ cartTotal에 한 번도 대입하지 않은 채 읽음 → 예외 발생
println("결제 금액: $cartTotal")
}
}
초기화 여부가 의심스럽다면 코틀린이 제공하는 isInitialized 검사를 쓸 수 있습니다. 이것이 lateinit의 안전장치입니다.
fun render() {
// 백킹 필드를 ::로 참조해 초기화 여부를 먼저 확인
if (::cartTotal.isInitialized) {
println("결제 금액: $cartTotal")
} else {
println("아직 금액이 준비되지 않았습니다")
}
}
3. by lazy의 동작 원리와 제약
by lazy는 접근 방식이 정반대입니다. "값을 어떻게 계산할지는 지금 정해두되, 실제로 처음 읽히는 순간 단 한 번만 계산하라"는 지연 초기화입니다.
val에만 사용 가능 — 한 번 계산되면 바뀌지 않기 때문에 불변이 자연스럽습니다.- 람다 블록의 결과가 곧 프로퍼티 값이 되고, 그 값은 캐싱되어 다음 접근부터는 재계산하지 않습니다.
- 기본적으로 스레드 안전(
LazyThreadSafetyMode.SYNCHRONIZED)이라 여러 스레드가 동시에 접근해도 한 번만 초기화됩니다.
class ReportBuilder {
// 무거운 계산을 미루다가, 처음 호출될 때 한 번만 실행
val summary: String by lazy {
println("summary 계산 시작") // 최초 1회만 출력됨
buildExpensiveSummary()
}
private fun buildExpensiveSummary(): String = "월간 리포트"
}
핵심은 by lazy를 쓰면 초기화 누락으로 인한 예외 자체가 발생하지 않는다는 점입니다. 읽는 순간 람다가 알아서 값을 만들어 주기 때문입니다. 단, 람다 안에서 외부 상태(예: 아직 준비 안 된 다른 객체)에 의존하면 그 시점에 또 다른 문제가 생길 수 있으니 주의가 필요합니다.
4. 실전 해결 단계
UninitializedPropertyAccessException을 만났다면 다음 순서로 점검하세요.
- 에러 메시지의 프로퍼티 이름 확인 — "lateinit property
XXXhas not been initialized"에서XXX가 문제의 프로퍼티입니다. - 그 프로퍼티에 대입하는 코드가 실제로 실행됐는지 추적 — 안드로이드라면
onCreate/onViewCreated안에서 대입이 누락됐거나, 대입 코드가 도달하기 전에 다른 메서드가 먼저 호출된 경우가 많습니다. - 읽기와 쓰기의 순서를 보장 — 초기화 메서드가 사용 메서드보다 먼저 호출되도록 호출 흐름을 정리합니다.
- 순서 보장이 어렵다면
isInitialized로 방어 — 또는 애초에 값 계산이 가능하다면by lazy로 전환합니다. - 값이 진짜로 없을 수 있는 상태라면 nullable 설계 고려 —
lateinit은 "반드시 채워진다"는 가정이 깨지면 안 되는 자리에만 써야 합니다.
5. lateinit vs by lazy 선택 기준표
| 상황 | 권장 | 이유 |
|---|---|---|
| 외부에서 나중에 값을 주입해야 함 (DI, findViewById 등) | lateinit var |
값을 외부에서 대입해야 하므로 가변 필요 |
| 값을 스스로 계산할 수 있고 한 번만 만들면 됨 | by lazy |
누락 위험 없이 안전, 캐싱까지 자동 |
| 값이 자주 바뀜 | lateinit var |
by lazy는 불변이라 부적합 |
| 무거운 초기화를 첫 사용까지 미루고 싶음 | by lazy |
지연 평가로 시작 비용 절감 |
| 원시 타입(Int 등) | 둘 다 부적합 → 기본값 대입 | lateinit은 원시 타입 불가 |
6. 흔한 실수와 엣지 케이스
by lazy를var에 쓰려는 시도 — 컴파일 에러입니다. 값이 바뀌어야 한다면lateinit var를 쓰세요.- 생명주기로 인한 값 손실 — 안드로이드
Activity/Fragment에서 화면이 파괴됐다 재생성될 때,lateinit프로퍼티는 재초기화되기 전 접근하면 다시 같은 예외가 납니다. 뷰 바인딩은onDestroyView에서 해제하고, 사용 전 항상 초기화 시점을 보장하세요. by lazy람다 안의 예외 — 람다 실행 중 예외가 나면 그 예외가 호출부로 전파되고, 기본 모드에서는 다음 접근 때 다시 시도됩니다. 람다 안에서 무거운 I/O나 실패 가능한 작업은 피하는 편이 좋습니다.- 스레드 모드 오해 — 단일 스레드에서만 접근한다면
by lazy(LazyThreadSafetyMode.NONE)로 락 비용을 줄일 수 있지만, 멀티스레드 접근 시 값이 중복 생성될 수 있으니 확신이 있을 때만 쓰세요.
7. 요약
- 이 예외는
lateinit프로퍼티를 초기화하기 전에 읽어서 발생합니다. lateinit은var+ 참조 타입 전용,by lazy는val전용이라는 점이 가장 기본적인 구분입니다.- 외부 주입이 필요하면
lateinit, 스스로 계산 가능하면by lazy가 정석입니다. - 초기화 순서가 불안하면
::프로퍼티.isInitialized로 방어하거나, 가능하면 누락 위험이 없는by lazy로 옮기세요.
AI에게 물어볼 때 (프롬프트 팁)
이런 런타임 에러는 AI에게 물어볼 때 맥락과 코드, 호출 순서를 함께 주면 정확도가 크게 올라갑니다. Prompt Architect 기준으로 잘 작동하는 프롬프트 예시를 소개합니다.
역할: 너는 코틀린/안드로이드 시니어 개발자다.
상황: 아래 코드에서 UninitializedPropertyAccessException이 런타임에 발생한다.
[여기에 클래스 전체 코드 + 에러 스택트레이스 붙여넣기]
요구사항:
1) 예외가 발생하는 정확한 호출 순서를 단계별로 설명
2) lateinit 유지안과 by lazy 전환안을 각각 코드로 제시
3) 두 방법의 트레이드오프를 표로 비교
내 코드에서 lateinit 프로퍼티가 "가끔" 초기화 안 된 채 접근된다.
생명주기(Activity/Fragment) 관점에서 어느 시점에 값이 손실되는지 진단하고,
isInitialized 방어 코드와 구조적 리팩토링안을 둘 다 제안해줘.
이 프로퍼티를 lateinit var에서 by lazy val로 바꾸려 한다.
바꿀 때 깨지는 코드(값 재대입, 멀티스레드 접근 등)를 먼저 찾아주고,
안전하게 전환하는 순서를 체크리스트로 만들어줘.
핵심은 "코드 + 에러 + 호출 순서 + 원하는 출력 형식"을 한 번에 명시하는 것입니다. 같은 질문이라도 이렇게 구조화하면 AI가 추측 대신 정확한 진단을 내놓습니다.