Kotlin - lateinit과 lazy로 초기화를 지연하는 방법

코틀린에서는 변수 선언을 먼저하고 초기화는 뒤로 미루는 기능들을 제공합니다. 초기화를 늦추면 좋은 점이, 사용할지 모르는 데이터를 미리 초기화할 필요가 없어서 성능 향상에 도움이 됩니다. 예를 들어, Rest API로 GitHub의 데이터를 가져오는 기능이 있는데, 앱이 실행했을 때 미리 가져오는 것보다 데이터를 화면에 보여줄 때 가져오는 것이 CPU 자원도 아끼고, 네트워크 자원도 아낄 수 있습니다.

코틀린에서 제공하는 초기화 지연은 다음과 같은 것들이 있습니다.

  • Late initialization : 필요할 때 초기화하고 사용할 수 있음. 초기화하지 않고 쓰면 Exception 발생
  • Lazy initialization : 변수를 선언할 때 초기화 코드도 함께 정의. 변수가 사용될 때 초기화 코드 동작하여 변수가 초기화됨

위의 두개의 기능은 초기화를 지연한다는 점에서 유사해보이지만 차이점이 많습니다. 각각의 특징을 알아보고 차이점을 비교해보겠습니다.

Late initialization

late initialization은 var 앞에 lateinit을 붙여 변수를 선언하면 됩니다. late라는 말에서 코드를 늦게 초기화한다는 의미로 생각할 수 있습니다. 코드가 직관적인기 때문에 코드로 먼저 살펴보겠습니다.

class Rectangle {
    lateinit var area: Area
    fun initArea(param: Area): Unit {
        this.area = param
    }
}

class Area(val value: Int)

fun main() {
    val rectangle = Rectangle()
    rectangle.initArea(Area(10))
    println(rectangle.area.value)
}

위의 코드에서는 lateinit var area로 선언하고 초기값을 지정하지 않았습니다. 이 변수는 nullable이 아니기 때문에 초기값을 할당하지 않으면 에러가 발생합니다. 하지만 lateinit를 붙여 나중에 초기화하겠다고 컴파일러에게 말했기 때문에 컴파일 에러가 발생하지 않습니다.

코드를 자세히 보시면, main에서 rectangle.initArea(Area(10))로 늦게 변수를 초기화하고 그 다음 줄에 Area 객체를 사용하고 있습니다. 만약 초기화를 하지 않고 Area 변수에 접근하면 UninitializedPropertyAccessException이 발생합니다.

다음은 초기화보다 Area 객체에 먼저 접근한 코드입니다.

fun main() {
    val rectangle = Rectangle()
    println(rectangle.area.value)
    rectangle.initArea(Area(10))
}

코드를 실행해보면 Area의 value 객체에 접근할 때 초기화가 되어있지 않아 아래와 같이 Exception이 발생하였습니다.

Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property area has not been initialized
	at Rectangle.getArea(test5.kt:2)
	at Test5Kt.main(test5.kt:15)
	at Test5Kt.main(test5.kt)

정리하면, var에 lateinit을 붙이면 초기값을 나중에 설정할 수 있습니다. 하지만, 초기값을 설정하기 전에 사용하면 예외가 발생합니다.

lateinit의 특징

사실, lateinit은 var에만 사용할 수 있습니다. 또한, primitive type에 적용할 수 없습니다. primitive type은 Int, Boolean, Double 등의 코틀린에서 제공하는 기본적인 타입을 말합니다. 따라서, 아래처럼 val을 사용하거나, Int를 사용한 코드는 컴파일 에러가 발생합니다.

lateinit val area : Area    // compile error
lateinit var width : Int    // compile error

그리고 lateinit 프로퍼티는 custom getter/setter를 설정할 수 없습니다. 또한 non-null 프로퍼티만 사용이 가능합니다. 따라서, 아래의 코드들은 컴파일이 안됩니다.

lateinit var area: Area?    // compile error

lateinit var area: Area     // compile error
  get() {
      area;
  }

정리하면 lateinit의 특징은 다음과 같습니다.

  • var 프로퍼티만 사용 가능
  • primitive type(Int, Boolean)은 사용할 수 없음
  • Custom getter/setter를 만들 수 없음
  • Non-null 프로퍼티만 사용 가능

lateinit의 동작 원리

자바 프로그래머를 위해 lateinit을 사용한 코틀린 코드가 자바로 어떻게 변환되는지 살펴보겠습니다. 자바로 디컴파일된 코드를 보면 lateinit이 내부적으로 어떻게 동작하는지 알 수 있습니다.

다음은 위의 예제를 자바로 디컴파일한 코드입니다.

public final class Area {
   private final int value;

   public final int getValue() {
      return this.value;
   }

   public Area(int value) {
      this.value = value;
   }
}

public final class Rectangle {
   @NotNull
   public Area area;

   @NotNull
   public final Area getArea() {
      Area var10000 = this.area;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("area");
      }

      return var10000;
   }

   public final void setArea(@NotNull Area var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      this.area = var1;
   }

   public final void initArea(@NotNull Area param) {
      Intrinsics.checkParameterIsNotNull(param, "param");
      this.area = param;
   }
}

public static final void main() {
   Rectangle rectangle = new Rectangle();
   rectangle.initArea(new Area(10));
   int var1 = rectangle.getArea().getValue();
   System.out.println(var1);
}

디컴파일된 자바 코드를 보면 public Area area로 변수가 초기화되지 않은 상태로 선언되었습니다. 그리고 가장 중요한 것은 다음 코드입니다.

public final Area getArea() {
   Area var10000 = this.area;
   if (var10000 == null) {
      Intrinsics.throwUninitializedPropertyAccessException("area");
   }

   return var10000;
}

Area.getArea()으로 Area 객체를 가져올 때 null check를 하고 있습니다. null이면 Exception을 던집니다. Area를 가져올 때는 모두 getArea()를 사용하기 때문에 초기화 전에 쓰려고 시도하면 예외가 발생하는 것을 알 수 있습니다. 이 때문에 custom getter/setter가 제한하였다는 것을 추측해볼 수 있습니다. 또한 코틀린에서 nullable 프로퍼티를 사용하지 못한 것은 객체가 null로 초기화되면, null check로 초기화가 되었는지 구분을 하지 못하기 때문입니다.

Lazy initialization

Lazy라는 말에서 초기화를 미룰 수 있을 만큼 미루고 어쩔 수 없이 초기화를 해야하는 상황이 올 때 초기화를 한다고 생각할 수 있습니다. Lazy initialization은 프로퍼티를 정의할 때 초기화 코드도 함께 정의합니다. 그리고 프로퍼티가 처음 사용될 때 초기화 구문이 실행되면서 초기값이 할당됩니다.

아래 코드처럼 lazy init을 사용할 수 있습니다. 초기값 대신에 by lazy { ... }를 입력하면 됩니다. { ... } 부분은 변수가 처음 사용될 때 한번 호출되며 마지막의 값이 초기값으로 할당됩니다. 아래 예제에서는 balance에 100이 할당됩니다.

val balance : Int by lazy {
    println("Setting balance!")
    100
}

중요한 것은 {...}는 처음 사용할 때 한번만 호출되고 두번째 사용할 때는 호출되지 않습니다. 다음은 Account.balance를 두번 출력한 코드입니다.

class Account() {
    val balance : Int by lazy {
        println("Setting balance!")
        100
    }
}

fun main() {
    val account = Account()
    println(account.balance)
    println(account.balance)
}

실행 결과를 보시면, Setting balance!로그가 한번만 출력된 것을 알 수 있습니다.

Setting balance!
100
100

lazy init의 구현 원리

아래 코드는 위의 코틀린 코드를 자바로 디컴파일한 결과입니다. 모든 코드를 디컴파일하지 못해서 정확한 것은 알기 어렵지만, getBalance()가 처음 불릴 때 초기화 코드가 동작하여 할당되는 것 같습니다.

public final class Account {
   @NotNull
   private final Lazy balance$delegate;

   public Account() {
      this.balance$delegate = LazyKt.lazy((Function0)null.INSTANCE);
   }

   public final int getBalance() {
      Lazy var1 = this.balance$delegate;
      return ((Number)var1.getValue()).intValue();
   }
}

public static final void main() {
   Account account = new Account();
   int var1 = account.getBalance();
   System.out.println(var1);
   var1 = account.getBalance();
   System.out.println(var1);
}

위의 getBalance()에서 ((Number)var1.getValue()).intValue()balance의 값을 리턴하는 것으로 보이며, 초기값이 할당되지 않았을 때 내부에서 초기화 코드를 호출하는 것 같습니다.

lazy init의 특징

lazy init의 특징을 정리해보면 다음과 같습니다.

  • val 프로퍼티만 사용할 수 있음
  • primitive type(Int, Boolean 등)도 사용 가능
  • Non-null, Nullable 모두 사용 가능

자바로 디컴파일한 코드를 보면 private final Lazy으로 객체를 선언하고 초기화도 한 상태입니다. 이 때문에 변하지 않는 val만 사용가능한 것 같습니다. 또, 그 객체의 메소드가 어떤 값을 리턴하기 때문에 primitive type도 사용할 수 있는 것 같습니다.

lateinit vs lazy

둘은 비슷해보이지만 많은 차이점이 있습니다.

  • lazy는 val 프로퍼티에만 사용할 수 있지만, lateinit은 var에만 사용할 수 있습니다
  • 그렇기 때문에 lateinit은 immutable(불변) 프로퍼티가 아닙니다.
  • lateinit은 nullable 또는 primitive type의 프로퍼티를 사용할 수 없습니다. 반면에 lazy는 모두 가능합니다.
  • lateinit은 직접적으로 프로퍼티를 갖고 있는 구조지만(자바에서 field를 갖고 있음), lazy는 Lazy라는 객체 안에 우리가 선언한 field를 갖고 있습니다. 그래서 lazy의 프로퍼티를 직접 변경할 수 없습니다.

정리

lazy init과 lazy init에 대해서 알아보았습니다. 둘 다 초기화를 늦게 하는 공통점이 있지만 초기화가 되는 방식도 다르고, 서로의 제약사항들도 다릅니다. 간단한 사용 방법과 내부적으로 어떻게 구현되었는지에 초점을 맞추어 설명하였습니다.

참고

Loading script...
codechachaCopyright ©2019 codechacha