kotlin

[Kotlin] lateinit, by lazy

caporatang 2025. 11. 3. 23:00
반응형

🖋️ Kotlin의 지연 초기화: lateinit vs by lazy 정리

Kotlin으로 개발할 때, 프로퍼티(변수)를 선언과 동시에 초기화할 수 없는 경우가 종종 있다. 예를 들어 테스트 코드 작성 시 특정 생명주기 시점에서 초기화해야 할 때가 그런 경우가 많다.

이럴 때 Kotlin 에서 제공하는 lateinitby 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)
}
  1. 내부적으로는 null을 가질 수 있는 변수로 선언
  2. 해당 변수에 접근하는 get() 메서드가 호출될 때, 변수가 null인지 체크
  3. 만약 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는 다음과 같은 특징을 가진다.

  1. val 키워드와 함께 사용 (한 번 초기화되면 불변)
  2. 람다({}) 블록을 파라미터로 받으며, 이 블록이 초기화 로직
  3. 프로퍼티의 get() 메서드가 최초로 호출되는 시점에 람다 블록이 실행된다.
  4. 실행 결과는 내부에 저장(Memoization) 되고, 이후 get() 호출 시에는 저장된 값을 즉시 반환한다.
  5. 기본적으로 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