Kotlin은 기본적으로 프로퍼티를 선언함과 동시에 초기화하도록 강제한다(Null Safety). 하지만 안드로이드 개발을 하다보면 Activity가 onCreate()되기 전 까지는 알 수 없는 값이나, 당장 쓰지 않는데 미리 생성하기에는 무거운 객체들이 존재한다. 이럴 경우 주로 사용하게 되는 키워드가 lateinit 과 by lazy 이다. 둘 다 초기화를 미루는 기능인데, 내부적으로 어떻게 다르게 동작할까?
1. 한 줄 요약
lateinit 은 컴파일 타임에 null로 초기화해두고 접근 시마다 null 체크를 수행하는 가변(var) property 에약어이며, by lazy 는 property의 getter를 Lazy<T> 위임 객체에 연결하여 최초 호출 시점에 단 한 번만 람다를 실행하고 그 결과를 캐싱하는 read-only(val) property이다.
2. 내부 동작 원리
lateinit var의 내부 동작
class LateInitTest {
lateinit var text: String
fun printText() {
if (::text.isInitialized) {
println(text)
}
}
}
위 코드를 Java 바이트코드로 디컴파일하면 다음과 같다.
public final class LateInitTest {
public String text; // 초기값 없이 선언됨 (기본값 null)
@NotNull
public final String getText() {
String var10000 = this.text;
if (var10000 == null) {
// 초기화 전에 접근하면 여기서 예외를 던진다
Intrinsics.throwUninitializedPropertyAccessException("text");
}
return var10000;
}
public final void setText(@NotNull String var1) {
this.text = var1;
}
public final void printText() {
// ::text.isInitialized는 단순한 null 체크이다.
if (this.text != null) {
String var1 = this.getText();
System.out.println(var1);
}
}
}
디컴파일 코드를 보면 왜 lateinit 에 원시 타입(Int, Boolean 등)을 쓸 수 없는지 알 수 있다.
- lateinit은 내부적으로 해당 필드에 일단 null을 넣어두고, getText()가 호출될 때 필드가 null이면 아직 초기화되지 않았다고 판단하여 UninitializedPropertyAccessException 을 던진다.
- 또, ::text.isInitialized 역시 내부적으로는 this.text != null 로 변환된다.
- Java의 원시타입(int, boolean)은 null을 가질 수 없고 기본값(0, false)을 가지고 있으므로, 컴파일러 입장에서 이 변수가 초기화가 안 된 건지 실제로 값을 넣은 건지 구분할 방법이 없어 원시타입을 금지한 것이다. (똑같은 이유로 Nullable 타입( String? 등)에도 lateinit을 사용할 수 없다.)
by lazy의 내부 동작
이번에는 by lazy를 디컴파일해보자.
class LazyTest {
val text: string by lazy { "Hello" }
}
이 코드를 Java 바이트코드로 디컴파일하면 다음과 같다.
public final class LazyTest {
@NotNull
private final Lazy text$delegate;
public LazyTest() {
// 생성 시점에는 값을 만들지 않고 Lazy 객체(SynchronizedLazyImpl)만 생성해둔다.
this.text$delegate = LazyKt.lazy((Function0)null.INSTANCE);
}
@NotNull
public final String getText() {
Lazy var1 = this.text$delegate;
// 프로퍼티에 접근할 때 비로소 Lazy 객체의 getValue()를 호출한다.
return (String)var1.getValue();
}
}
- by lazy 는 Kotlin 문법 키워드가 아니라, 표준 라이브러리의 lazy() 고차 함수와 위임 프로퍼티(by) 기능을 결합한 것이다.
- lazy { ... } 를 기본값으로 호출하면 내부적으로 SynchronizedLazyImpl 이라는 객체가 생성된다.
- 이 객체는 처음에는 UNINITIALIZED_VALUE 라는 더미 객체를 들고 있다가, 최초에 getValue() 가 호출되면 synchronized(lock) 블록 안에서 람다식을 실행해 값을 계산하고, 그 결과를 내부 필드에 저장해둔 뒤 이후 호출부터는 저장된 값을 그대로 반환한다.
3. 비교와 트레이드오프
| lateinit | by lazy | |
| 키워드 종류 | 언어 내장 변경자 | 위임 프로퍼티( by ) + 표준 라이브러리 함수( lazy ) |
| 변수 선언 | var 만 가능(이후에도 값 변경 가능) | val 만 가능(최초 1회 초기화 후 변경 불가) |
| 원시 타입 지원 | 불가(Int, Long, Boolean 등 사용 불가) | 가능(제네릭 Lazy<T>로 박싱되어 처리됨) |
| Nullable 지원 | 불가 | 가능 |
| 초기화 시점 | 외부에서 원하는 시점에 직접 할당 | 프로퍼티에 최초로 접근(호출)하는 시점 |
| 스레드 안정성 | 보장하지 않음(멀티스레드 접근 시 직접 동기화 필요) | 기본적으로 스레드 안전(Thread-safe) 보장 |
최적화 포인트 LazyThreadSafetyMode
앞에서 언급했듯 by lazy 는 기본적으로 멀티스레드 환경에서도 안전하게 단 한번만 초기화되도록 synchronized lock을 사용한다. 하지만 안드로이드의 Activity나 Fragment 프로퍼티는 오직 메인 스레드(UI Thread)에서만 접근하는 경우가 대부분이다.
이럴 때 불필요한 동기화 오버헤드를 없애기 위해 LazyThreadSafetyMode 를 지정할 수 있다.
- SYNCHRONIZED(default): synchronized 락을 통해 하나의 스레드만 초기화하도록 보장한다.
- PUBLICATION: 락을 걸지 않고 여러 스레드가 동시에 초기화 람다를 실행할 수 있게 하되, 가장 먼저 완료된 결과값 하나만 채택한다.
- NONE: 어떠한 락이나 동기화 처리도 하지 않는다. 멀티스레드에서는 위험하지만, 안드로이드 UI 스레드 전용 프로퍼티에서 사용할 때 성능상 가장 빠르고 가볍다.
// 오직 메인 스레드에서만 접근하는 UI 관련 프로퍼티라면 NONE 모드로 Lock 비용을 제거한다.
private val adapter by lazy(LazyThreadSafetyMode.NONE) {
MyRecyclerAdapter()
}
4. 꼬리질문
① ViewBinding을 쓸 때, by lazy를 쓰면 왜 메모리 누수가 발생하나요? lateinit을 쓰면 괜찮을까요?
Fragment의 생명주기와 내부 View의 생명주기가 일치하지 않기 때문에 발생하는 문제입니다. by lazy 는 한 번 초기화하면 Fragment 인스턴스가 소멸할 때까지 값을 유지합니다. Fragment가 백스택으로 가면 onDestroyView()가 호출되어 뷰는 파괴되고 가비지 컬렉션 대상이 되어야 하지만, by lazy가 들고 있는 Binding 객체가 이미 파괴된 뷰 계층 전체를 계속 참조하고 있어 심각한 메모리 누수가 발생합니다. 다시 말해, View는 파괴되었지만 Fragment는 살아있기 때문에 by lazy가 뷰 계층 전체를 계속 참조하게 되고 메모리 누수가 발생합니다. 심지어 onCreateView()가 호출되어 새로운 뷰가 생겨도 이전의 죽은 뷰 바인딩을 가리키는 좀비 뷰 버그가 발생합니다.
lateinit을 쓰더라도 재할당이 가능할 뿐 뷰가 파괴될 때 객체가 자동으로 비워지는 것이 아니기 때문에, onDestroyView() 시점에 binding = null 처리를 해주어야 합니다. 하지만 lateinit은 Nullable이 불가능하기 때문에 실무에서는 아래와 같이 Nullable과 백킹 프로퍼티 조합을 사용하여 구현합니다.
class SomeFragment: Fragment() {
private var _binding: SomeFragmentBinding? = null
private val binding get() = _binding!!
}
② Hilt같은 의존성 주입 프레임워크를 사용할 때, 필드 주입에 왜 by lazy 대신 lateinit var를 쓸까요?
인스턴스 생성 및 주입의 제어권 차이 때문입니다. by lazy는 객체 내부에서 어떤 구현체를 어떻게 초기화할지 람다 형태로 직접 알고 있어야 동작합니다. 이는 외부에서 의존성을 동적으로 주입하는 DI 철학(느슨한 결합)과 맞지 않습니다.
반면 lateinit var는 필드만 선언해두고, 컴파일 시점에 생성된 Hilt Injector 클래스가 객체 외부에서 리플렉션 없이 해당 필드에 인스턴스를 직접 대입할 수 있기 때문에 프레임워크 차원에서 lateinit을 사용하도록 설계되었습니다. 단, 컴파일러가 직접 필드에 접근해야하기 때문에 private 선언이 불가능합니다.
'Programming > Android 100서' 카테고리의 다른 글
| [Day 4] Kotlin의 List는 Immutable일까? (0) | 2026.10.28 |
|---|---|
| [Day 2] Kotlin의 Primitive Type은 어떻게 동작할까? (0) | 2026.10.20 |
| [Day 1] Kotlin에서 val은 상수인가? (0) | 2026.10.19 |
| [Day 0] 100일 동안 안드로이드 파보기 (0) | 2026.10.19 |