[Kotlin] Kotlin Lazy Initialization
오늘은 Lazy Initialization에 대해서 알아볼 것이다. Initialization는 초기화를 뜻하니 익숙하겠지만 Lazy에 대해선 익숙하지 않은 분들도 있을 것이다. Lazy의 사전적 의미는 다음과 같다.
게으른, 태만한, 느릿느릿 움직이는, 느긋한, 성의가 부족한
Lazy는 일반적인 언어에서는 부정적인 의미로 사용된다. 하지만 프로그래밍 언어 세계에서의 Lazy는 부정적인 것보다는 긍정적인 것에 가깝다. 프로그램의 퍼포먼스를 높이기 위해 처음부터 모든 것들을 초기화하는 것이 아니라 필요한 순간까지 최대한 초기화를 지연시킨다는 의미로 사용한다. 이 말이 쉽게 다가오지 않는 분들은 이런 질문을 할 수 있을 것이다.
그래서 지연 초기화를 하면 어떤 점이 좋나요?
지연 초기화를 잘 사용했다고 가정했을 때 소프트웨어의 성능 측면에서 이점이 있다. 클래스가 초기화될 때 모든 것들을 동시에 초기화하는 코드와 필요한 순간까지 초기화를 최대한 미루는 코드 중에 어떤 것이 성능 측면에서 좋을지 생각해보면 답은 쉽게 나온다. 그렇기 때문에 지연 초기화를 사용할 경우 소프트웨어의 실행 시간(시간) 및 메모리 효율(공간) 을 개선할 수 있다.
자 그럼 Kotlin에서는 지연 초기화를 어떠한 방법으로 제공하는지 살펴보자.
lateinit
초기화 지연 프로퍼티(Late-initialized property)라고 하며 프로퍼티의 초기화를 나중에 하기 위해 사용하는 키워드다. 프로퍼티 선언에 사용되며 항상 사용 가능한 것은 아니다. 사용하기에 몇 가지 제약사항이 있다.
- var(mutable) 프로퍼티만 사용 가능
- non-null 프로퍼티만 사용 가능
- 커스텀 getter/setter가 없는 프로퍼티만 사용 가능
- primitive type 프로퍼티는 사용 불가능
- 클래스 생성자에서 사용 불가능
- 로컬 변수로 사용 불가능
제약사항이 생각보다 많다. 하지만 그럼에도 불구하고 안 쓰기에는 아까운 기능이다. 간단한 사례를 살펴보자.
Kotlin에서는 Non-null
, Nullable
에 대한 검사가 엄격하다. 그래서 위와 같이 Non-null
로 선언한 프로퍼티를 선언과 동시에 초기화하지 않았을 경우 Property must be initialized or be abstract
란 메시지와 함께 컴파일 에러를 발생시킨다. 하지만 우리는 멤버 변수가 클래스 초기화와 함께 초기화되지 않길 원하는 경우가 훨씬 많다. Java에서는 멤버 변수로 선언하며 동시에 초기화를 하지 않아도 컴파일은 가능했기 때문에 만약 Kotlin에서 지연 초기화 기법을 제공하지 않았다면 불편해졌다고 생각할 수도 있었을 것이다.
위의 컴파일 에러를 고치기 위해 우리는 lateinit 키워드만 붙여주면 된다.
컴파일 오류는 사라지고 name
이라는 프로퍼티를 우리가 필요한 순간 적절히 초기화해주면 된다. 그럼 lateinit
은 어떨 때 사용하는 게 좋을까?
lateinit
은 이 프로퍼티는 절대로Null
이 될 수 없는 프로퍼티인데 초기화를 선언과 동시에 해줄 수 없거나 성능이나 기타 다른 조건들로 인해 최대한 초기화를 미뤄야 할 때
실무에서 사용하는 것 중에 예로 들자면 Dependency Injection이 있을 것이다. 아래 코드는 필자가 Java에서 Dagger2를 사용하여 Presenter
를 DI 하는 코드다.
DI를 통해 SimpleSignInPresenter
를 외부에서 주입받기 때문에 별도로 해당 프로퍼티에 명시적으로 null
을 대입하지 않는 이상 Non-null
이라고 확신을 한다. 우리는 저 프로퍼티가 Non-null
이라고 알고 있고 확신하지만 위 코드를 Kotlin에서 그대로 사용하면 Kotlin에서는 컴파일 에러가 발생할 것이다.
그렇기에 우리는 Kotlin 컴파일러에게 “이 프로퍼티는 Non-null
이고 초기화는 DI를 통해 나중에 할 거야” 라고 알려줘야 한다. 그 역할을 lateinit
이 담당하는 것이다.
lazy
lazy
도 lateinit
과 마찬가지로 초기화를 지연시킬 때 사용하며 lateinit
은 Modifier 지만 lazy
는 람다를 파라미터로 받고 Lazy<T>
인스턴스를 반환하는 함수다. lazy
도 사용에 제약사항이 있는데 lateinit
과 차이점이 있다.
- val(immutable) 프로퍼티만 사용 가능
- primitive type에도 사용 가능
- 커스텀 getter/setter가 없는 프로퍼티만 사용 가능
- Non-null, Nullable 둘 다 사용 가능
- 클래스 생성자에서 사용 불가능
- 로컬 변수에서 사용 가능
lateinit
과 제약사항에 있어서 몇 가지 차이점이 있는데 이를 간단히 정리하자면 다음과 같다.
lateinit
은 var 타입만 가능하고lazy
는 val 타입만 가능lateinit
은 primitive type은 불가능하나lazy
는 가능lateinit
은 Non-null 타입만 가능하나lazy
는 둘 다 가능lateinit
은 로컬 변수에서는 불가능 하나lazy
는 가능
위와 같은 제약사항의 차이가 아니라도 사용에 있어서 어느 시점에 어떤 기능을 이용하여 초기화 지연을 해야 할지 감이 안 올 수 있는데 간단한 예를 통해 쉽게 이해할 수 있다.
위 코드는 Toolbar
변수를 lateinit
으로 선언한 코드다. 지연 초기화 타입으로 선언했으므로 어딘가에서 해당 변수를 초기화해주는 코드가 존재해야 한다. 이런 코드에선 실수를 유발하기 쉽다. 지연 초기화를 한다고 컴파일러에게 얘기해놓고 만약 어딘가에서 초기화 코드를 빼먹는다면 런타임에 에러가 발생하게 될 것이다. 한마디로 컴파일러에게 거짓말을 하는 것이다. 이런 케이스는 프로그래머의 실수에 의해서 발생할 수 있는데 이를 방지하기 위해 위와 같은 케이스는 lazy
를 사용하는 것이 적절하다.
프로퍼티 선언과 동시에 lazy
블록 안에서 실제 toolbar
를 초기화해주는 코드가 같이 있다. lateinit
처럼 선언과 초기화가 분리되어 있는 것이 아니라 선언과 초기화가 함께 있다. Android를 개발해보신 분이라면 위와 같이 UI 컴포넌트를 바인딩 하는 과정에 있어서 lateinit
보다 lazy
가 적절하다고 생각할 것이다.
참고로 lazy
블록 안에 findViewById(R.id.home_toolbar) as Toolbar
가 아니라 home_toolbar
로 표현한 것은 kotlin-android-extensions 플러그인을 이용한 것이다.
그런데 과연 이 정도의 차이로 lateinit
과 lazy
의 차이를 명확하게 구분 지을 수 있을까? lateinit
이 Kotlin 언어에서 제공하는 기본 Modifier라면 lazy
는 Kotlin에서 제공하는 유틸 함수다. 그렇기 때문에 앞으로 변경 가능성도 있고 기능이 추가될 여지도 있다. 이게 다 일까? 아니다 가장 중요한 게 남아 있다.
lazy
프로퍼티 연산은 기본적으로 동기화된다.
동기화된다는 것은 Thread-safe
하다는 말이다. 또한 동기화에 따른 오버헤드도 존재한다. lazy
는 함수라고 설명하였으니 함수의 원형을 살펴보자.
두 개의 함수 중에 우리가 기본적으로 사용하는 것은 람다 인자 하나를 받는 함수다. 그리고 기본적으로 SynchronizedLazyImpl
의 인스턴스를 반환하게 된다. 두 번째 함수를 보면 알 수 있듯이 Kotlin의 lazy
연산에서는 세 가지 동기화 관련 전략을 제공한다.
- SYNCHRONIZED : 한 스레드만 값을 계산할 수 있으며 모든 스레드는 같은 값을 읽음.
- PUBLICATION : 초기화 과정을 여러 스레드가 동시에 수행할 수 있으나 만약 다른 스레드에서 초기화하여 할당된 값이 있다면 그 값을 반환.
- NONE : 별도의 동기화 처리가 없음.
동기화가 필요 없는 싱글 스레드 기반의 코드에서 사용한다고 가정했을 때 성능 순서를 나열하면 NONE > PUBLICATION > SYNCHRONIZED
순이 될 것이다.
그렇지만 위의 설명 가지고는 약간 부족한 느낌이 있으니 실제 구현체를 살펴보자.
SYNCHRONIZED
한 스레드만 값을 계산할 수 있고 모든 스레드가 같은 값을 보게 된다고 했으니 read와 write 두 곳에 동기화 처리가 되어있을 것이다.
위 코드에서 살펴볼만 한 점은 @Volatile
어노테이션과 synchronized
블록이다. Volatile
어노테이션은 Java의 volatile
과 같은 역할을 한다. 별도의 커스텀 setter
는 없으며 getter
내부에서 우선 초기화 여부를 판단하고 초기화가 되어 있지 않다면 synchronized
블록에 진입하게 된다. synchronized
블록 안에서 다시 체크를 하고 초기화가 되어 있지 않다면 초기화를 진행하게 된다. 변수가 @Volatile
로 선언되어 있으므로 변수의 가시성이 보장되기 때문에 성능을 고려하여 synchronized
블록에 진입하기 전에 초기화 체크를 한다.
PUBLICATION
SYNCHRONIZED보다는 덜 강력한 동기화 전략이다. 간단하게 설명하면 여러 스레드에서 write는 가능하게 하더라도 read는 처음 생성된 인스턴스만 반환하는 전략이다.
SYNCHRONIZED와 마찬가지로 _value
는 @Volatile
로 선언되어 있으며 getter
함수 내에서 우선 _value
를 체크하여 초기화가 되어 있으면 그대로 반환하고 그게 아니면 initializer
를 체크하는데 만약 initializer
가 null
이면 이미 다른 스레드에서 초기화가 되었다고 판단하여 더 이상 초기화 로직을 수행하지 않고 그대로 반환한다.
NONE
단어 그대로 아무것도 하지 않는 전략이다. 동기화 관련 처리가 없으며 lazy
연산을 적용하려는 프로퍼티가 멀티 스레드 환경이 아닌 단일 스레드 환경이라면 이 옵션을 사용하는 것을 추천한다.
코드를 보면 알 수 있듯이 @Volatile
도 없고 synchronized
블록도 없다. 멀티 스레드 환경이 아니라면 이 옵션이 가장 빠르기 때문에 자신의 코드 환경에 맞게 동기화 전략을 설정하는 것이 좋다.
마치며
lateinit
과 lazy
는 상황에 맞게 적절하게 사용하는 게 중요한 것 같다. 무엇이든 다 그렇겠지만 그래도 여러 가지 제약사항과 동기화 전략 등을 알고 사용한다면 오류를 발생시킬 수 있는 요소를 최소화할 수 있고 문제 발생 시 어떻게 해결해야 할지 도움이 될 수 있을 것이다.