🖋️ Kotlin의 지연 초기화: lateinit vs by lazy 정리
Kotlin으로 개발할 때, 프로퍼티(변수)를 선언과 동시에 초기화할 수 없는 경우가 종종 있다. 예를 들어 테스트 코드 작성 시 특정 생명주기 시점에서 초기화해야 할 때가 그런 경우가 많다.
이럴 때 Kotlin 에서 제공하는 lateinit과 by lazy를 사용하면 깔끔하게 선언과 초기화 시점을 분리할 수 있다.
1. 문제 상황: 불필요한 인스턴스화
PersonLazy라는 클래스가 있고, 이 클래스를 테스트하는 두 개의 테스트 메서드가 있다고 가정한다.
// 초기 PersonLazy 클래스
class PersonLazy(
private val name: String,
) {
val isKim: Boolean
get() = this.name.startsWith("김")
val maskingName: String
get() = name[0] + (1 until name.length).joinToString("") {"*"}
}
// 테스트 클래스
class PersonTest1 {
@Test
fun test() {
// 첫 번째 인스턴스화
val person = PersonLazy("김태일")
person.isKim shouldBe true
}
@Test
fun maskingNameTest() {
// 두 번째 인스턴스화
val person = PersonLazy("김태일2")
person.isKim shouldBe "김***"
}
}
위 코드에서 PersonTest1의 두 테스트 메서드는 각각 다른 초기값으로 PersonLazy 객체를 생성하고 있다. 만약 테스트 클래스 레벨에서 person 변수를 선언하고 각 테스트 메서드에서 초기화하여 사용하고 싶다면 "인스턴스화 시점과 변수 초기화 시점을 분리" 할 필요가 생긴다.
2. 해결책 1: lateinit
lateinit은 "late initialization(늦은 초기화)"의 줄임말로, 초기값 없이 non-null 타입의 프로퍼티를 선언할 수 있게 해준다.
PersonLazy 클래스를 lateinit을 사용하도록 수정한다면 아래와 같이 수정할 수 있다.
class PersonLazy {
// 1. var로 선언
// 2. non-null 타입이어야 한다.
// 3. 초기값을 할당하지 않는다.
lateinit var name: String
val isKim: Boolean
get() = this.name.startsWith("김")
val maskingName: String
get() = name[0] + (1 until name.length).joinToString("") {"*"}
}
테스트 코드에서는 PersonLazy 객체를 먼저 생성하고, name 프로퍼티는 나중에 원하는 시점에 초기화할 수 있다.
lateinit 동작 원리 (Decompiled Java)
lateinit은 내부적으로 어떻게 동작할까?
lateinit 키워드가 붙은 코틀린 코드를 디컴파일하여 Java 코드로 변환해 보면 그 원리를 알 수 있다.
// Kotlin의 PersonLazy 클래스를 Java로 디컴파일한 코드 (일부)
public static final class PersonLazy {
// 1. name 프로퍼티가 public 필드로 변환
public String name;
@NotNull
public final String getName() {
String var1 = this.name;
// 2. GETTER 호출 시 null 체크
if (var1 != null) {
return var1;
} else {
// 3. null이면 예외를 던짐
Intrinsics.throwUninitializedPropertyAccessException("name");
return null;
}
}
public final void setName(@NotNull String var1) {
Intrinsics.checkNotNullParameter(var1, "<set-?>");
this.name = var1;
}
// ... (isKim, getMaskingName)
}
- 내부적으로는 null을 가질 수 있는 변수로 선언
- 해당 변수에 접근하는 get() 메서드가 호출될 때, 변수가 null인지 체크
- 만약 null이라면 (초기화가 안 되었다면) UninitializedPropertyAccessException 예외 발생
즉, 컴파일 단계에서 null을 허용하는 변수로 바꾸고, 런타임에 null 체크 로직을 추가하는 방식으로 동작한다.
lateinit의 한계
lateinit은 Primitive Type (원시 타입) 에는 사용할 수 없다. (예: Int, Long, Double, Boolean)
그 이유는 lateinit이 null을 통한 초기화 여부 판단 로직을 사용하기 때문이다. Kotlin의 Int, Long 등은 Java의 int, long과 같은 primitive type으로 변환되는데, 이들은 null 값을 가질 수 없다.
(물론 Int?처럼 Nullable Wrapper Type을 쓰면 되지만, lateinit은 non-null 타입에만 사용 가능하다.)
중간 정리: lateinit
- var 키워드와 함께 사용한다.
- 인스턴스 생성 시점과 변수 초기화 시점을 분리할 수 있다.
- 초기화 로직이 여러 곳에 위치할 수 있다. (@BeforeEach)
- 초기화 전에 접근하면 예외가 발생한다.
3. 또 다른 문제: 비싼(Expensive) 초기화
만약 프로퍼티를 초기화하는 데 많은 비용(시간, 리소스)이 들고, 이 작업이 단 1회만 실행되어야 한다면? 예를 들어, 파일을 읽어오거나, 네트워크 통신을 하거나, 무거운 연산을 수행하는 경우이다.
class PersonLazy2Sec {
// 이 name을 가져오는데 2초가 걸린다고 가정
val name: String
get() {
Thread.sleep(2_000L) // 2초간 대기 (무거운 작업 이라고 가정)
return "김태일"
}
}
위 코드의 name 프로퍼티는 호출될 때마다 2초가 걸립니다. 우리가 원하는 것은 "최초 1회" 만 2초가 걸리고, 그 이후로는 저장된 값을 즉시 반환하는 것이다.
이것을 Backing Property를 이용해 수동으로 구현할 수 있다.
class PersonLazy2Sec {
// 1. 값을 저장할 실제 프로퍼티 (private, nullable)
private var _name: String? = null
val name: String
get() {
// 2. 저장된 값이 null일 때만 (최초 1회)
if (_name == null) {
Thread.sleep(2_000L)
this._name = "김태일" // 3. 연산 후 값 저장
}
return this._name!! // 4. 저장된 값 반환
}
}
이렇게 하면 최초 1회만 sleep이 실행되고, 이후에는 저장된 _name을 반환한다. 하지만 프로퍼티가 많아질수록 이 코드를 반복 작성할수는 없다
4. 해결책 2: by lazy
이러한 "비용이 큰 초기화" 문제를 간단하게 해결할 수 있는 것이 by lazy다. by lazy는 프로퍼티 위임(Delegated Properties)의 한 종류이다.
위의 Backing Property 코드를 by lazy로 바꾸면 다음과 같이 단 한 줄로 사용할 수 있다.
class PersonLazy2Sec {
// by lazy를 사용한 구현
val name2: String by lazy {
Thread.sleep(2_000L)
"김태일" // 이 블록의 마지막 표현식이 초기값이 된다.
}
}
by lazy는 다음과 같은 특징을 가진다.
- val 키워드와 함께 사용 (한 번 초기화되면 불변)
- 람다({}) 블록을 파라미터로 받으며, 이 블록이 초기화 로직
- 프로퍼티의 get() 메서드가 최초로 호출되는 시점에 람다 블록이 실행된다.
- 실행 결과는 내부에 저장(Memoization) 되고, 이후 get() 호출 시에는 저장된 값을 즉시 반환한다.
- 기본적으로 Thread-Safe 하게 동작한다.
정리
- lateinit: "나중에 초기화 하겠다."
- 초기화 시점을 제어해야 하고, 값이 변할 수 있으며, non-null 참초 타입일 때 사용한다 (주로 var)
- by lazy: "필요할 때 딱 한 번만 만들겠다"
- 초기화 비용이 비싸고, 최초 접근 시 1회만 초기화하며, 값이 변하지 않을 때 사용한다 (주로 val)
lazy 관련해서는 다음 글에서 조금 더 작성해보도록 한다.
'kotlin' 카테고리의 다른 글
| [Kotlin] Scope function (0) | 2025.09.08 |
|---|---|
| [Kotlin] 코틀린의 이것저것 문법 (0) | 2025.09.07 |
| [Kotlin] 컬렉션을 함수형으로 다루기 (1) | 2025.08.28 |
| [Kotlin] 람다 (Lambda) (0) | 2025.08.20 |
| [Kotlin] 다양한 함수 (1) | 2025.08.18 |