읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

Delegate(위임)는 객체가 직접 작업을 수행하지 않고 다른 곳에서 작업을 하게끔 맡기는 디자인을 의미한다. 코틀린에서는 기본적으로 Delegation Pattern 구현에 필요한 기능을 지원한다. 그리고 비슷한 맥락으로 변수 선언과 동시에 초기화를 하지 않고 변수만 미리 선언하고 초기화를 뒤로 미룰 수 있다. 초기화를 늦추면 사용하지 않을 데이터를 미리 초기화할 필요가 없어 성능향상에 도움이 된다. 그 중에서도 두 가지 방법이 있다.

Lazy Initialization(By Lazy Delegate) : 변수 선언 시 초기화 코드도 함께 정의하며 사용될 때 초기화 코드를 실행하여 초기화

Late Initialization : 필요할 때 초기화하여 사용하나 초기화하지 않고 사용하면 Exception이 발생함

기능적으로 초기화를 지연한다는 점에서 유사하나 초기화 과정에 조금 차이가 발생한다. 좀 더 제약사항과 동작 방식을 정리해보자.

Lazy Initialization

Lazy Initialization을 사용하기 위해선 val 프로퍼티에서만 사용해야 한다. 그리고 by lazy { ... }라는 구문을 사용하여 초기화 구문을 작성해야 한다. Lambda 구문은 변수를 처음 사용할 때 단 한번 호출되며 마지막 값이 초기값으로 할당된다. 아래 예제 구문에서는 foo 변수를 호출 시 초기화 구문에 따라 100이 할당될 것이다.

val foo: Int by lazy {
    println("foo value is 100!")
    100
}

초기화 구문이 단 한번 호출됨을 확인하기 위해 아래 코드를 작성하였다.

class Bar {
    val foo: Int by lazy {
        println("Setting foo 100!")
        100
    }
}

fun main() {
    val buf = Bar()
    println(buf.foo)
    println(buf.foo)
}

실행 결과는 다음과 같이 출력될 것이다. 초기화 블럭에 정의했던 println 구문이 한 번만 실행되었음을 확인할 수 있다.

Setting foo 100!
100
100

Lazy Initialization 내부 동작

public final class Bar {
   @NotNull
   private final Lazy foo$delegate;

   public final int getFoo() {
      Lazy var1 = this.foo$delegate;
      Object var3 = null;
      return ((Number)var1.getValue()).intValue();
   }

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

Bar 클래스 생성 시 {변수이름}$delegate라는 이름을 가진 Lazy 타입의 객체를 선언한다. Bar 클래스 객체를 생성하면서 해당 객체를 정의한다. LazyKt 파일의 lazy 함수를 이용하여 변수에 값을 할당한 뒤 getFoo 함수를 호출하면 객체의 변수를 조회한 뒤 Lazy 타입의 내부 변수에 할당하여 값을 조회하는 함수를 호출하며 리턴한다.

null.INSTANCE는 뭔가요?

직접 컴파일된 파일을 decompile한 경우와는 달리 Kotlin bytecode inspector로 디컴파일을 수행한 경우 실행이 불가능한 코드로 보이는 경우가 있다. 이는 컴파일러가 요구하는 방식과는 다르게 코드를 작성했기 때문인데 따라서 JVM bytecode != Java language specific임을 먼저 알아야 하고 그에 따라 Kotlin generated JVM bytecode != Java generated JVM bytecode라는 사실을 이해해야 한다.

위 사실에 따라 NULL 인스턴스는 디컴파일러가 잘못 인터프리팅한 결과로 값을 제대로 설정하는 항목에서의 모든 참조값이 NULL 인스턴스로 출력될 것이다.

lazy 함수의 thread-safe

public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)

파라미터로 전달받은 initializer를 사용하여 Lazy 타입의 객체를 생성하는 함수다. 그리고 별도로 입력한 initializer 말고도 기본적으로 적용되는 thread-safe한 mode인 LazyThreadSafetyMode.SYNCHRONIZED를 사용한다. 내부적으로 동기화를 구현하므로 외부에서 동기화 구현을 권장하지 않으며 구현하더라도 데드락 발생에 주의해야 한다.

public final class MainKt {
   public static final void main() {
      Bar buf = new Bar();
      int var1 = buf.getFoo();
      System.out.println(var1);
      var1 = buf.getFoo();
      System.out.println(var1);
   }

   // $FF: synthetic method
   public static void main(String[] var0) {
      main();
   }
}

main 함수를 디컴파일한 결과를 보면 Bar 타입 객체를 선언한 뒤 출력할때마다 해당 객체의 getFoo 메소드를 호출하여 출력함을 볼 수 있다.

Lazy Initialization의 특징

  1. val 프로퍼티에서만 사용 가능
  2. 앞에서 기술하였듯, 객체를 생성하고 초기화가 선행되어야 함에 따라 Immutable한 변수인 val 프로퍼티에서만 사용할 수 있다. 그리고 내부적으로 final 키워드를 사용함에 따라 immutable을 보장해야 함을 알 수 있다.
  3. Thread-safe함
  4. 내부적으로 초기화 시 thread-safe한 모드를 사용함에 따라 lazy 함수를 별도로 overloading하지 않는 이상 thread-safe하다. 따라서, initializer가 최대한 단 한 번 수행됨을 보장할 수 있다.
  5. Java primitive type(Int, Boolean) 사용 가능
  6. Non-null, Nullable 사용 가능

Lazy Initialization의 초기화 확인

Lazy Initialization 변수의 초기화 여부를 확인할 수 있다.

    val lazyFoo = lazy{
        100
    }
    val foo by lazyFoo
    println(foo) // use foo variable (initialize lazyFoo)
    if (lazyFoo.isInitialized()) {
        println(foo)        
    }

우선 lazyFoo 변수를 lazy 키워드를 사용하여 할당하였다. 다만 이전에 초기화했던 방식과는 달리 by 키워드를 사용하지 않아 Lazy<Int> 타입이 할당되었다. 그리고 foo라는 추가 변수를 선언하고 by 키워드를 사용하여 lazyFoo의 초기화 구문을 사용하여 값이 할당되었다. 초기화 여부는 Lazy 타입 변수에 대해 isInitinalized() 함수를 사용하여 확인할 수 있다.

다만, 값 조회 시 Lazy 객체와 값이 서로 분리된다는 점이 마음에 조금 걸린다. 한번에 해결하기 위해 Lazy 객체를 직접 사용하는 방식도 있다.

    val lazyFoo = lazy{
        100
    }
    val foo by lazyFoo
    if (lazyFoo.isInitialized()) {
        println(lazyfoo.value)
    }

특정 상황에서의 안전보장 목적이 아니라면 실질적으로 lazy 키워드와 by 키워드를 모두 사용하여 한번에 변수 호출과 동시에 초기화까지 진행되게끔 하는 것이 훨씬 더 효율적이다.

Late Initialization

Late Initialization을 사용하기 위해선 var 프로퍼티에서만 사용해야 한다. 그리고 lateinit라는 키워드를 사용한다. 초기화 구문을 사용하지 않고 나중에 값을 할당하여 초기화를 수행할 수 있다. 변수를 사용하지 않는 한 컴파일러에게 나중에 초기화하겠다고 전달하므로 컴파일 시 에러가 발생하지 않는다. 아래 예제 구문에서는 Temp 타입의 foo 변수를 lateinit 키워드를 사용하여 우선 선언한 뒤 initFoo함수에 파라미터를 전달하여 값을 할당한다.

class Bar {
    lateinit var foo: Temp
    fun initFoo(param: Temp): Unit {
        this.foo = param
    }
}

class Temp(val bar: Int)

fun main() {
    val buf = Bar()
    buf.initFoo(Temp(100))
    println(buf.foo.bar)
}

실행결과는 100이 출력될 것이다. initFoo 함수를 호출하지 않고 변수를 참조하면 아직 값이 할당되지 않았기 때문에 Exeception이 발생할 것이다.

fun main() {
    val buf = Bar()
    println(buf.foo.bar)
    buf.initFoo(Temp(100))
}
Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property foo has not been initialized
    at Bar.getFoo(Main.kt:2)
    at MainKt.main(Main.kt:12)
    at MainKt.main(Main.kt)

초기화되지 않은 프로퍼티를 참조했기에 UninitializedPropertyAccessException이 발생함을 볼 수 있다.

Late Initialization 내부 동작

아래 코드는 Bar 클래스를 디컴파일한 코드이다.

public final class Bar {
   public Temp foo;

   @NotNull
   public final Temp getFoo() {
      Temp var10000 = this.foo;
      if (var10000 == null) {
         Intrinsics.throwUninitializedPropertyAccessException("foo");
      }

      return var10000;
   }

   public final void setFoo(@NotNull Temp var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.foo = var1;
   }

   public final void initFoo(@NotNull Temp param) {
      Intrinsics.checkNotNullParameter(param, "param");
      this.foo = param;
   }
}
  1. var에서만 사용

우선 foo 변수가 public Temp foo로 선언되어 있음을 볼 수 있다. 즉, lazy initialization과는 달리 수정할 수 있으므로 var 타입으로만 사용해야 함을 확인할 수 있다.

  1. Non-null 프로퍼티로만 사용 가능

추가로 변수를 조회하는 함수인 getFoo를 보면 foo 변수를 로컬 변수에 할당한 뒤 값을 점검하는데 null 체킹을 하고 있음을 확인할 수 있다. 위 로직에 따라 null을 할당할수 없음을 알 수 있겠다. null을 사용할 수 없는 이유는 Intrinsics.checkNotNullParameter로도 확인할 수 있다. 해당 함수의 내부 구현 코드는 다음과 같다.

    public static void checkNotNullParameter(Object value, String paramName) {
        if (value == null) {
            throwParameterIsNullNPE(paramName);
        }
    }

위 코드에 따라 넘겨받은 파라미터의 값이 null인 경우 Execpetion을 throw하고 있음을 볼 수 있다. 따라서, 조회뿐만 아니라 초기화와 이후에 값을 변경할 때에도 null 값은 할당할 수 없다. 실제로 아래 코드를 작성하면 컴파일 단계부터 에러가 발생한다.

lateinit var foo: Temp?  // compile level error
  1. Custom getter/setter 생성 불가

lateinit으로 선언된 변수는 Custom getter/setter 생성이 불가능하다. 만약 작성하더라도 컴파일 단계에서 에러가 발생한다.

lateinit var foo: Temp?
  get() {
      foo;
  }

그 이유로 lateinit으로 선언한 변수에는 값을 저장하는 backing field가 존재한다. 그리고 lateinit backing field는 노출되어 있기 때문에 원칙적으로 Custom getter/setter를 금지하고 있다.

  1. Java primitive type(Int, Boolean)은 사용이 불가함

Non-null 프로퍼티로만 사용 가능한 이유와 연결된다. lateinit은 null을 변수가 초기화되었는지 그렇지 않는지 판별하기 위한 특별한 값으로 사용한다. 따라서, lateinit 변수에 값을 덮씌우는 것은 가능할지언정 null값의 명시적 선언은 불가하다. 만약 아래 코드가 있다고 해보자.

private lateinit var x: Int

코틀린 컴파일 시 x는 int타입으로 컴파일되어야 한다. 그리고 lateinit 변수이기 때문에 null을 초기화되었는지 판별하기 위해 사용한다. 그러나 null값을 위 코드에 선언한 Int 타입 변수나 다른 primitive 타입에 저장할 수 없다. 만약 null 저장을 위해 아래 코드처럼 변경해보자.

private lateinit var x: Int?

만약 null을 변수에 저장할 수 있도록 허용하면 초기화되지 않은 경우 특별한 holder로써 null의 동작에 문제가 발생한다. 그렇기 때문에 lateinit 변수에 nullable type을 사용할 수 없다. 그리고 lateinit은 변수 타입을 명확히 하고 null 타입 적용의 모호함을 피하기 위한 목적도 있다. 그렇기에 primitive 타입을 nullable하게 사용함은 lateinit의 목적성을 해친다. 만약 내부적으로 타입별 기본값을 초기화되지 않은 변수 판별에 사용하지 않는 한 null과 primitive type이 허용될 일은 없으며 기본값을 사용하더라도 해당 값이 실제 할당될 값일 수 있기에 문제가 발생한다.

Late Initialization의 특징

  1. var 프로퍼티에서만 사용 가능
  2. Non-null프로퍼티만 사용 가능
  3. Custom getter/setter 생성 불가
  4. Java primitive type(Int, Boolean) 사용 불가

Late Initialization의 초기화 확인

코틀린은 Late Initailization이 완료되었는지 확인을 위해 isInitialized 프로퍼티를 제공한다.

lateinit var file: File
if (this::file.isInitialized) {
    /* ... */
}

lateinit 변수가 선언된 범위 내에서 this 키워드를 사용하여 초기화되었는지 확인 후 특정 동작을 정의할 수 있다.

By Lazy Delegate와 Late Initialization의 장단점

Lazy Initialization의 장단점

  1. (장점) 초기화 단계에서 Thread-safe를 보장하여 유저가 다중 쓰레드 환경에서의 초기화를 신경쓰지 않아도 된다.
  2. (장점) by 키워드를 사용하여 초기화를 한번에 정의할 수 있으므로 initialize 여부를 신경쓰지 않아도 된다.
  3. (장점?) Lazy 타입 인스턴스를 저장할 수 있기에 다른 곳으로 전달이 가능하다.
    • "가능"하다고 장점으로 단언하기 애매한 특징이다. 되려 오용하여 side-effect가 발생할 수 있기 때문이다.
  4. (단점) by lazy { ... }구문을 사용하여 전달된 lambda가 context가 가진 레퍼런스를 들고있을 위험이 존재한다.
    • Android에서 사용 시 lifecycle 객체가 해제되지 않는 문제가 발생할 수 있어 initializer lambda 함수 내부에서 사용되는 객체들에 주의를 기울여야 한다.

Late Initialization의 장단점

  1. (장점) Non-null 프로퍼티만을 허가하여 null허용에 따른 타입 모호성을 해결한다.
  2. (장점) Android의 경우 선언 위치는 상위이나 초기화는 하위 레벨에서 진행해야 하는 경우에 유용하다.
    • 뷰 관련 변수를 선언할 때 특히 많이 사용된다.
  3. (단점?) Lazy 타입과는 달리 lateinit은 오로지 initialize 여부에 따른 null을 저장할 뿐, 인스턴스를 저장할 수 없다.
    • 오히려 이렇게 제약을 걸어버리는 게 장점으로 작용될 수 있어 애매하다.
  4. (단점) 멀티 스레딩 환경에서의 initialization의 안전성은 오로지 유저 코드가 책임진다.

결론

성능에 도움이 된다고 모든 변수에 대해 초기화를 지연함은 코드 관리 측면에서 바람직하지 않다. 특정 변수가 여러 곳에서 쓰이고 참조값이 변경될 일이 없으며 초기화가 단 한번만 발생해야 하는 경우라면 Lazy Initialization을 사용할 수 있겠다. 변수가 여러 곳에서 쓰이나 초기화 코드를 변수 선언시점에서 정의할 수 없다면 Late Initialization을 사용함이 옳다.

참고자료

  1. Stack Overflow - Why Kotlin decompiler generates null.INSTANCE
  2. Baeldung - Why Kotlin lateinit Can’t Be Used With Primitive Types

+ Recent posts