지연 초기화 (Lazy Initialization) | Kotlin Study

10935 단어 kotlinlateinitlazybyby

평소에 안드로이드 프로젝트를 진행하면서 종종 뜨는 오류가 있었다.

kotlin.UninitializedPropertyAccessException: lateinit property adapter has not been initialized

바로 지연 초기화를 제대로 사용하지 않았을 때 발생하는 오류인데, 이번 기회를 통해 제대로 지연초기화에 대해 알아보고자 한다.


😪 Lazy Initialization

기본적으로 lazy 의 사전적 의미는 게으른, 느긋한, 여유로운 을 의미한다. 한마디로 위의 뜻을 직역하면 게으른 초기화 라는 의미인데, 게으른 초기화는 프로그래밍에서 꽤 긍정적인 의미로 쓰인다.

왜 지연 초기화를 사용할까?

지연 초기화라는 이름만 보아도 알 수 있듯이, 초기화 작업을 극한으로 미루다가 사용자가 필요로 할 때 진행하는데, 이 방법을 사용함으로서 메모리 낭비를 줄일 수 있다는 장점이 있다. 그리고 이는 퍼포먼스의 향상으로 이어진다.

또 코틀린에서 코딩할 때 생각해보면, 코틀린에서 선언하는 프로퍼티 자료형 (val age : Int) 들은 null을 가질 수 없는데, 객체의 정보가 나중에 나타나는 경우 초기화할 때 굉장히 난감해진다. 이럴 때 지연 초기화를 사용함으로서 문제를 극복하는 것이다.

코틀린에서의 지연 초기화는 lateinit 을 사용하는 방법과 lazy를 사용하는 방법이 있다. 이 두 키워드를 천천히 알아보도록 하자.


📌 Lateinit

  • var 로 선언된 프로퍼티만 사용 가능하다.
  • Non-null 타입만 사용 가능하다.
  • 프로퍼티에 대한 getter, setter 를 사용할 수 없다.
  • 클래스 생성자에서 사용이 불가능하다.
  • 초기화 전에는 변수 접근이 불가능하다.
  • 원시 타입 (primitive type) 은 사용이 불가능하다.
  • 지역 변수에서 사용이 불가능하다.

생각보다 조건이 많지만, 막상 실제 코딩을 하다보면 상당히 많이 쓰이는 키워드 중 하나이다. 실제로 코드를 보며 어떤 때에 사용하는지 알아보도록 하자.

class Hoya {
    lateinit var nickname : String // 지연 초기화 선언
    // var nickname : String // 프로퍼티 자료형은 초기화를 해야하므로 오류 
    
    fun test() {
    	if(::nickname.isInitialized) { // 초기화 여부 판단
        	println("초기화 되었음.")
        }
    }
}

fun main() {
    val hoya = Hoya()
    hoya.test() // 초기화 되지 않은 시점
    // println("nickname = ${hoya.nickname}") // 이 시점에서 사용하면 오류 발생
    hoya.nickname = "hoyaho" // 이 시점에서 초기화
    hoya.test() // 초기화가 됐으므로 초기화 되었다고 알림
    println("nickname = ${hoya.nickname}")
}

주석을 읽으면 자연스럽게 이해가 될 것이다. 주의할 점으로, 초기화가 되지 않았는데 변수에 접근하면 오류가 발생하므로 주의해야 한다.


📌 Lazy

  • val 에서만 사용이 가능하다.
  • 이에 따라 값을 다시 변경할 수 없다.
  • 호출 시점에 by lazy { ... } 에 정의해둔 블록 부분의 초기화를 진행한다.
  • 클래스 생성자에서 사용이 불가능하다.
  • 원시 타입 (primitive type) 도 사용이 가능하다.
  • 지역 변수에서도 사용이 가능하다.
class HelloLazy {
    init {
    	println("init block") // 최초 초기화 선언을 알림
    }
    
    val subject : String by lazy { "Lazy Test" }
    
    fun flow() {
    	println("초기화 되지 않았음") // 아직 초기화 되지 않음
    	println("subject one : $subject") // 최초 초기화 시점
      	println("subject two : $subject") // 초기화된 값 사용 (불변)
    }
}

fun main() {
    val test = HelloLazy()
    test.flow()
}

최초 접근 시점에서 초기화하고 사용하는 것이 lazy 키워드의 핵심이라고 볼 수 있다. 또, by lazy에는 동기화와 관련해 여러 모드가 제공된다.

  • SYNCHRONIZED - 락을 사용해 단일 스레드만 사용하는 것을 보장한다. (DEFAULT)
  • PUBLICATION - 여러 군데에서 호출될 수 있으나, 처음 초기화된 반환 값만을 사용한다.
  • NONE - 락을 사용하지 않기 때문에 빠르나, 다중 스레드가 접근하여 값의 일관성을 보장하기 힘들다.

🖐 단일 스레드만 사용하는 것을 보장한다는게 어떤 의미인데?

만약, 한 변수에 여러 스레드가 접근한다면 어떻게 될지 생각해보면 간단하다. Thread1 이 하나의 변수를 건드리고 있는데, 갑자기 Thread2가 그 변수를 건드린다면? 값의 일관성을 보장할 수 없을 것이다. 즉, Thread-safe 를 보장하는 것이다.

    val lazyValue by lazy(LazyThreadSafetyMode.NONE) {
        // Initialize code block
    }

동기화가 필요하지 않은 환경에서 코드를 실행한다면, NONE 모드를 사용하여 성능을 높일 수 있을 것이다. 상황에 맞게 적절한 모드를 사용하도록 하자.

🥸 개인적으로 publication에 대해 조금 헷갈렸는데, 쉽게 이야기해 여러 스레드가 변수에 접근하는 것은 가능하지만 최초 접근한 스레드가 값을 초기화했다면 그 값만을 사용할 수 있다고 보면 된다.


📌 Lateinit vs Lazy


📌 in Android

안드로이드에서 UI 컴포넌트를 선언할 때 지연 초기화를 많이 사용하는데, by lazy 키워드가 효과적인지, lateinit 키워드가 효과적인지 알아보도록 하자.

private val tv : TextView by lazy { findViewByid . . . }
lateinit var tv2 : TextView

override fun onCreate(...) {
    tv.text = "hoya" // 이 시점에서 초기화
    tv2 = findViewById(R.id.tv2) as TextView // 개발자가 다시 초기화 후 사용해야 함
    tv2.text = "hoya" // 초기화 작업없이 실행할 경우 오류 발생
}

위에서 보다시피 by lazy로 지연 초기화를 진행할 경우 개발자가 초기화와 관련된 오류를 범할 일이 없어지는 반면에 lateinit의 경우 개발자가 직접 초기화 작업을 수행해야 하는데, 만약 빠뜨리는 실수를 범하게 되면 오류가 발생하게 되므로 안드로이드에서 UI 컴포넌트를 선언할 때는 by lazy 키워드가 좀 더 안전할 것이다.


참고 및 출처

네이버 부스트코스 - 코틀린 프로그래밍

좋은 웹페이지 즐겨찾기