Kotlin의 프로퍼티 위임과 초기화 지연은 어떻게 동작하는가

If you wanna read English version, please see here.

프로퍼티에 대한 접근과 초기화는 객체 지향 패러다임을 지원하는 프로그래밍 언어에서는 매우 친숙한 행위입니다. Kotlin 역시 프로퍼티에 대한 여러가지 접근 방법을 제공하고 있으며, 초기화 지연(lazy initialization)를 적용할 수 있는 by lazy 역시 그 좋은 사례일 것입니다.

이 글에서 우리는 코틀린의 delegation을 이용하여 프로퍼티를 다루는 방법과 by lazy가 어떻게 동작하는지를 살펴볼 것입니다.

Nullable type

이미 알고 있는 내용일 수도 있겠지만, Nullable 타입에 대하여 잠시 살펴 보도록 하겠습니다. Kotlin을 이용한 안드로이드 컴포넌트 코드는 아마도 아래와 같이 작성할 수 있을 것입니다.

class MainActivity : AppCompatActivity() {
private var helloMessage : String = "Hello"
}

라이프사이클에 의한 초기화와 Nullable type

객체의 생성과 함께 초기화가 가능한 경우는 위 방식의 코드는 큰 문제가 없습니다. 그러나 고유의 라이프사이클이 존재하여 특정한 초기화 과정 이후에 참조가 생성되는 경우 선언과 동시에 값을 저장하여 사용할 수 없는 문제가 있습니다. 익숙한 형태의 Java 코드를 살펴보도록 하겠습니다.

public class MainActivity extends AppCompatActivity {
private TextView mWelcomeTextView;
    @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
        mWelcomeTextView = (TextView) findViewById(R.id.msgView);
}
}

Kotlin은 null이 가능한 경우 Nullable 타입으로 선언하여 이를 지원할 수 있습니다. 위 코드를 Kotlin으로 재작성해보면 아래와 같습니다.

class MainActivity : AppCompatActivity() {
private var mWelcomeTextView: TextView? = null
    override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
        mWelcomeTextView = findViewById(R.id.msgView) as TextView
}
}

Non-null type

이러한 방식의 코드는 잘 동작하지만 코드에서 필드나 프로퍼티를 사용하기 전에 null 여부를 매번 확인하는 것은 조금 귀찮은 일입니다. 항상 값을 가지고 있는 Non-Null 타입을 사용하면 이를 생략할 수 있습니다.

물론 이것은 null이 아니라는 약속일 뿐입니다. :)
class MainActivity : AppCompatActivity() {
private var mWelcomeTextView: TextView
    ...
}
물론 위젯에 대한 참조를 나중에 할당한다는 것을 알려주기 위해 lateinit를 사용해야 할 것입니다.

lateinit: Non-null 프로퍼티에 대한 수동적 초기화 지연

lateinit는 일반적으로 우리가 얘기하는 lazy initialization과는 달리 Non-null 프로퍼티가 생성자 단계에서 값이 저장되지 않은 상태를 컴파일러가 인정하도록 하여 정상적으로 컴파일이 되도록 합니다.

class MainActivity : AppCompatActivity() {
private lateinit var mWelcomeTextView: TextView
    override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
        mWelcomeTextView = findViewById(R.id.msgView) as TextView
}
}
자세한 내용은 여기를 참조하세요.

Read-only property

일반적으로 컴포넌트의 필드가 기본형(Primitive type) 혹은 내장형(Built-in type)이 아닌 경우 컴포넌트의 생성 시에 정의된 참조가 유지되는 경향을 볼 수 있습니다.

안드로이드 어플리케이션을 예로 들면 대부분의 위젯 참조들은 액티비티의 라이프사이클과 동일하게 유지됩니다. 반대로 말하자면, 이는 처음 할당된 참조를 바꿀 필요가 거의 없다는 의미입니다.

이 지점에서 우리는 다음과 같은 아이디어를 쉽게 떠올릴 수 있습니다.

보통 프로퍼티가 가지는 참조가 컴포넌트의 주기와 동일하게 유지된다면 read-only 타입으로도 충분하지 않을까?

그럴 것 같습니다. 언뜻 보기에 이를 위해서는 varval로 바꾸는 약간의 수고만 있으면 될 것 같습니다.

Non-null & read-only 프로퍼티의 딜레마

하지만 val은 선언과 동시에 값을 가져야 하므로, 초기화를 수행하는 위치를 정의할 수 없는 문제에 봉착하게 됩니다.

class MainActivity : AppCompatActivity() {
private val mWelcomeTextView: TextView
    override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
        // 다음 초기화 코드는 어디에?????
// mWelcomeTextView = findViewById(R.id.msgView) as TextView

}
}

이제 아이디어의 실현을 위해 남은 마지막 문제를 해결해보도록 합시다.

“고유의 라이프사이클을 가지는 객체에서 늦게 할당되는 요소를 참조하는 read-only 프로퍼티는 어떻게 적용할 수 있는가”

by lazy를 통한 초기화 지연

by lazy는 Kotlin에서 초기화 지연을 실행하는 읽기 전용의 프로퍼티를 구현할 때 매우 유용하게 사용할 수 있습니다.

by lazy { ... }가 포함하는 코드는 정의된 프로퍼티가 사용되는 최초의 지점에서 초기화 과정을 실행합니다.
class MainActivity : AppCompatActivity() {
private val messageView : TextView by lazy {
// messageView의 첫 액세스에서 실행됩니다
findViewById(R.id.message_view) as TextView
}
    override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
    fun onSayHello() {
messageView.text = "Hello"
}
}

자, 위의 코드를 통해 우리는 messageView 의 초기화 시점을 고민하지 않고, read-only 프로퍼티를 선언할 수 있게 되었습니다. 이제 by lazy의 동작이 어떻게 이루어지는 것인지 알아보도록 하겠습니다.

Delegated property 101

Delegation은 말 그대로 위임을 뜻합니다. A에 대한 b라는 위임(delegation)은 기본적으로 b가 대신 A에 접근하여 어떠한 중간 연산을 하는 등의 처리를 뜻하는 것으로 요약할 수 있습니다.

프로퍼티 위임(Delegated property)은 프로퍼티에 대한 getter/setter를 위임하여 위임받은 객체로 하여금 값을 읽고 쓸 때 어떠한 중간 동작을 수행하는 기능입니다.

사실 Kotlin의 Delegation은 크게 클래스(메소드)와 프로퍼티로 나눌 수 있고, 이 글에서의 설명은 몇가지 사항이 생략되었습니다. 이는 다른 글에서 좀 더 자세히 다루도록 하겠습니다.
위임은 역사가 깊은 행위입니다. :) (Source: Wikipedia commons)

다음과 같이by <delegate> 형식으로 Delegated property를 선언할 수 있습니다.

val/var <property name>: <Type> by <delegate>

프로퍼티를 위임받을 대상은 다음과 같은 형태로 정의할 수 있습니다.

class Delegate {
operator fun getValue(
thisRef: Any?,
property: KProperty<*>
): String {
// return value
}
    operator fun setValue(
thisRef: Any?,
property: KProperty<*>, value: String
) {
// assign
}
}

값에 대한 모든 읽기 동작은 위임된 대상의 getValue()을 호출하는 형태로 변경되며, 쓰기 동작의 경우 setValue()로 전달됩니다.

by lazy는 어떻게 동작하는가

이제 다시 해당 프로퍼티에 대한 코드를 다시 살펴보겠습니다.

쉽게 delegated property 라는 것을 확인할 수 있습니다.

앞에서 프로퍼티 위임(Delegated property)를 알아보았으므로 by lazylazy 를 통해 선언된 프로퍼티를 위임하는 것임을 알 수 있습니다.

그렇다면 lazy는 어떻게 동작하는 것일까요?lazy() 는 Kotlin 표준 라이브러리 내 함수로 이에 대한 설명은 다음과 같이 요약할 수 있습니다.

  1. lazy()는 람다를 전달받아 저장한 Lazy<T> 인스턴스를 반환합니다.
  2. 최초 getter 실행은 lazy()에 넘겨진 람다를 실행하고, 결과를 기록합니다.
  3. 이후 getter 실행은 기록된 값을 반환합니다.
즉, lazy는 프로퍼티의 값에 접근하는 최초 시점에 초기화를 수행하고 이 결과를 저장한 뒤 기록된 값을 재반환하는 인스턴스를 생성하는 함수입니다.

lazy가 적용된 delegated property

lazy의 내부 구조를 확인하기 위해 간단한 Kotlin 코드를 작성해보도록 하겠습니다.

class Demo {
val myName : String by lazy { "John" }
}

이를 Java 코드로 디컴파일해보면 다음과 같은 코드를 볼 수 있습니다.

public final class Demo {
@NotNull
private final Lazy myName$delegate;

// $FF: synthetic field
static final KProperty[] $$delegatedProperties = ...
    @NotNull
public final String getMyName() {
Lazy var1 = this.myName$delegate;
KProperty var3 = $$delegatedProperties[0];
return (String)var1.getValue();
}
    public Demo() {
this.myName$delegate =
LazyKt.lazy((Function0)null.INSTANCE);
}
}
  • myName$delegate를 붙인 필드(myName$delegate)를 생성합니다.
  • myName$delegate의 타입이 String이 아닌 Lazy라는 점에 주의합니다.
  • 생성자에서 myName$delegate에 대해 LazyKt.lazy()를 할당합니다.
  • LazyKt.lazy()는 주어진 초기화 블록을 실행하는 역할을 합니다.

실제 동작은 getMyName()의 호출 시 Lazy로 선언된 myName$delegate를 가져와서 이로부터 getValue() 를 통해 초기화된 값을 가져옵니다.

$$delegatedProperties 는 위임된 프로퍼티들의 정보(owner class, 필드명, accessor, modifier, getter의 named signature 등)를 가지고 있는 KProperty[] 타입의 필드입니다.

Lazy 구현체의 종류

lazy는 초기화를 수행할 람다 함수(Initializer)를 스레드 실행 모드(LazyThreadSafetyMode)에 따라 조금씩 다른 방식으로 처리하는 객체 Lazy<T>를 반환합니다.

@kotlin.jvm.JvmVersion
public fun <T> lazy(
mode: LazyThreadSafetyMode,
initializer: () -> T
): Lazy<T> =
when (mode) {
LazyThreadSafetyMode.SYNCHRONIZED ->
SynchronizedLazyImpl(initializer)
LazyThreadSafetyMode.PUBLICATION ->
SafePublicationLazyImpl(initializer)
LazyThreadSafetyMode.NONE ->
UnsafeLazyImpl(initializer)
}

이들은 모두 주어진 초기화 람다 블록을 호출하는 역할을 수행합니다.

SYNCHRONIZED → SynchronizedLazyImpl

  • 초기화가 최초 호출되는 단 하나의 스레드에서만 처리됩니다.
  • 다른 스레드는 이후 그 값을 그대로 참조합니다.
  • 기본값입니다. (LazyThreadSafetyMode.SYNCHRONIZED)

PUBLICATION → SafePublicationLazyImpl

  • 여러 스레드에서 동시에 호출될 수 있으며, 초기화도 모든 혹은 일부의 스레드들에서 동시에 실행이 가능합니다.
  • 다만, 다른 스레드에서 이미 초기화된 값이 할당되었다면 별도의 초기화를 수행하지 않고, 그 값을 반환합니다.

NONE → UnsafeLazyImpl

  • 초기화가 되지 않은 경우 무조건 초기화를 실행하여 값을 기록합니다.
  • 멀티스레딩에서는 NPE 발생 가능성이 있어 안전하지 않습니다.

Lazy 구현체의 기본 동작

SynchronizedLazyImplSafePublicationLazyImpl, UnsafeLazyImpl 모두 다음 과정을 통해 늦은 초기화를 수행합니다. 위에서 보았던 예제로 이 과정을 통해 풀어보겠습니다.

쉬운 코드 예제는 언제나 옳습니다. :)

1. 전달된 초기화 람다를 initializer 프로퍼티에 저장합니다.

초기화 람다는 실행되지 않고, 이후 초기화 시점에 실행하기 위해 저장됩니다.

2. _value 프로퍼티를 통해 값을 저장할 것이지만, 초기화 전이므로 이 프로퍼티의 초기값은 UNINITIALIZED_VALUE 입니다.

초기화가 되지 않은 상태임을 표시하기 위해 UNINITIALIZED_VALUE가 할당됩니다.

3. 읽기 접근이 일어나 _value의 값이 UNINITIALIZED_VALUE라면 초기화가 되지 않은 상태로 판단하고, 초기화 블록을 실행하는 과정이 시작됩니다.

최초 접근 시 저장된 initializer의 실행을 통해 초기화가 진행됩니다.

4. 3번의 과정이 이미 발생되었다면 값이 UNINITIALIZED_VALUE가 아니므로 이후 읽기 접근은 저장된 _value의 값이반환이 일어납니다.

이제 저장된 값이 존재하므로, getValue()의 호출은 “John”을 반환할 것입니다.

SynchronizedLazyImpl

특별히 모드를 지정하지 않으면 기본적으로 단 한번만 초기화를 수행하는 SynchronizedLazyImpl이 람다 초기화를 실행합니다. 구현 코드를 보도록 하겠습니다.

private object UNINITIALIZED_VALUE
private class SynchronizedLazyImpl<out T>(
initializer: () -> T,
lock: Any? = null
) : Lazy<T>,
Serializable {
private var initializer: (() -> T)? = initializer
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
// final field is required
// to enable safe publication of constructed instance
private val lock = lock ?: this
    override val value: T
get() {
val _v1 = _value
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}
            return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
}
else {
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}
    override fun isInitialized(): Boolean =
_value !== UNINITIALIZED_VALUE
    override fun toString(): String =
if (isInitialized()) value.toString()
else "Lazy value not initialized yet."
    private fun writeReplace(): Any =
InitializedLazyImpl(value)
}

조금 복잡해보이지만 단지 멀티스레드를 고려한 초기화 코드일 뿐입니다.

  • synchronized()를 통해 초기화 블록을 실행합니다.
  • 단, 다른 스레드에서 synchronized() 블록에 진입하여 이미 초기화가 끝났을 수도 있으므로, _valueUNINITIALIZED_VALUE 여부를 체크해서 값이 있으면 해당 값을 그대로 반환합니다.
  • 아직 초기화가 되어 있지 않다면, 람다식을 처리하고 반환값을 저장합니다. 그리고 초기화 완료에 따라 불필요해진 initializer는 null로 초기화됩니다.

Kotlin delegated properties make you 😀

물론 초기화 지연은 때로 적정한 흐름을 무시하고 정상적으로 보이는 값을 생성하여 문제를 발생하거나 디버깅을 어렵게 만들 수 있습니다.

하지만, 이것을 주의한다면 Kotlin에서 by lazy에 의한 초기화 지연은 스레드에 안정적으로 프로퍼티 초기화를 최초 접근 시점까지 유예하여 성능 혹은 적정한 실행 흐름에 대한 고민에서 우리를 좀 더 자유롭게 만들어 줄 수 있습니다.

또한, 우리는 초기화 지연이 언어에서 제공하는 by와 코틀린으로 작성된 lazy라는 함수로 구성된 결과임을 확인하였습니다. 실제로lazy 외에도 by에 의해 프로퍼티를 위임하여 Observable과 같이 구현할 수 있는 더 많은 기능들이 존재하며 필요하다면 흥미로운 delegated property의 구현 역시 가능할 것입니다.

이 글에 대한 이상한 점이나 오탈자, 추가 사항 등은 언제든지 댓글이나 메일로 보내주시기 바랍니다. :)