|

코틀린 lateinit lazy 차이: 초기화를 미루는 두 방법은 언제 다를까

코틀린 lateinit lazy 차이 초기화 지연 대표 이미지
lateinit과 lazy는 모두 초기화를 미루지만, 쓰는 상황과 안전장치가 다릅니다.

lateinit lazy 차이는 Kotlin에서 초기화를 미루는 코드를 볼 때 가장 자주 헷갈리는 지점입니다. 둘 다 값을 바로 만들지 않는다는 점은 비슷하지만, 실제로는 프로퍼티의 성격과 실패 방식이 다릅니다.

핵심은 나중에 외부에서 넣을 값이면 lateinit, 처음 필요할 때 스스로 만들 값이면 lazy입니다. 이 글은 Kotlin 공식 문서의 properties와 delegated properties 설명을 기준으로 Android 예시까지 함께 정리합니다.

코틀린 lateinit과 lazy 선택 기준 요약 카드
lateinit은 나중에 대입하는 var, lazy는 처음 접근할 때 계산되는 val에 가깝습니다.

lateinit lazy 차이를 먼저 한 줄로 정리하면

`lateinit`은 프로퍼티 선언 시점에는 값을 넣지 않지만, 나중에 개발자가 반드시 값을 대입하겠다고 약속하는 방식입니다. `lazy`는 프로퍼티에 처음 접근하는 순간 계산해서 값을 만들고, 그 값을 이후에 재사용하는 방식입니다.

lateinit var repository: UserRepository

val config: AppConfig by lazy {
    loadConfig()
}

둘 다 초기화를 늦추지만, `repository`는 외부에서 주입되거나 테스트 준비 단계에서 대입되는 값에 가깝고, `config`는 필요해질 때 스스로 만들어지는 값에 가깝습니다.

lateinit은 나중에 대입할 non-null var

Kotlin에서 non-null 프로퍼티는 보통 선언할 때 초기값이 필요합니다. 그런데 Android lifecycle, DI, 테스트 fixture처럼 생성자에서 바로 넣기 어려운 값이 있습니다. 이때 `lateinit var`를 사용할 수 있습니다.

class UserPresenter {
    lateinit var view: UserView

    fun attach(view: UserView) {
        this.view = view
    }

    fun render() {
        view.showName("bscode")
    }
}

문제는 값을 넣기 전에 접근하면 `UninitializedPropertyAccessException`이 발생한다는 점입니다. 그래서 `lateinit`은 null을 피하는 문법이지만, 초기화 순서를 코드로 보장하지 못하면 런타임 오류가 됩니다.

lazy는 처음 접근할 때 값을 만든다

`lazy`는 delegated property입니다. `by lazy { … }` 안의 코드는 프로퍼티에 처음 접근할 때 실행되고, 기본 동작에서는 계산된 값을 저장해 다음 접근부터 같은 값을 돌려줍니다.

class ReportService {
    val formatter: DateTimeFormatter by lazy {
        DateTimeFormatter.ofPattern("yyyy-MM-dd")
    }
}

이 값은 외부에서 나중에 넣는 값이 아니라 객체가 스스로 만들 수 있는 값입니다. 그래서 보통 `val`과 함께 쓰며, 읽는 쪽에서는 이미 초기화된 값처럼 다룰 수 있습니다.

val과 var 기준으로 보면 더 선명하다

`lateinit`은 `var`에만 사용할 수 있습니다. 아직 값이 없고, 나중에 대입해야 하기 때문입니다. 반면 `lazy`는 값을 한 번 계산해 계속 쓰는 성격이라 주로 `val`에 붙습니다.

  • 초기화 이후 값이 바뀔 수 있거나 외부에서 넣어야 하면 `lateinit` 후보
  • 처음 필요할 때 한 번 만들고 계속 같은 값을 쓰면 `lazy` 후보
  • null 가능성을 실제로 표현해야 하면 `String?`처럼 nullable type이 더 솔직한 선택

Android 코드에서 자주 쓰는 위치

Android에서는 `lateinit`을 테스트용 mock, DI가 주입하는 필드, lifecycle 이후에 준비되는 값에 쓰는 경우가 있습니다. 다만 ViewBinding처럼 lifecycle에 따라 해제해야 하는 값에는 무조건 `lateinit`이 답은 아닙니다.

class UserViewModel : ViewModel() {
    val formatter by lazy {
        DateTimeFormatter.ISO_LOCAL_DATE
    }
}

ViewModel 안에서 비용이 크지 않지만 필요할 때만 만들고 싶은 순수 객체라면 `lazy`가 자연스럽습니다. 반대로 Activity나 Fragment의 lifecycle에 묶인 값은 생성과 해제 시점을 명확히 봐야 합니다.

실수하기 쉬운 부분

  • 초기화 순서를 보장하지 못하면서 `lateinit`으로 null을 숨긴다
  • `lateinit`을 쓰고 나서 실제 테스트에서 attach나 inject 단계를 빼먹는다
  • 매번 새 값이 필요해야 하는데 `lazy`로 한 번만 계산해 버린다
  • nullable이 더 정확한 도메인 값을 억지로 non-null처럼 만든다

실무 체크리스트

  1. 값을 외부에서 나중에 넣는가, 내부에서 처음 접근할 때 만드는가
  2. 초기화 전 접근 가능성이 있는가
  3. 초기화 이후 값이 바뀌어야 하는가
  4. lifecycle에 따라 해제되어야 하는 값인가
  5. nullable type이 더 정확한 의미는 아닌가

정리

코틀린 `lateinit`과 `lazy`는 모두 초기화를 늦추지만 문제를 푸는 방향이 다릅니다. `lateinit`은 나중에 대입할 non-null var이고, `lazy`는 처음 접근할 때 계산되는 val에 가깝습니다.

함께 보면 좋은 글은 코틀린 null safety 정리, 코틀린 Nothing 타입이란 무엇인가, 코틀린 let, run, apply, also, with 차이입니다. 외부 기준은 Kotlin Docs – Properties, Kotlin Docs – Delegated properties, Android Developers – Dependency injection를 확인했습니다.

함께보면 좋은 글