아티클 목록으로 가기

ViewModel 내부 메커니즘과 멀티플랫폼 아키텍처 심층 분석

skydovesJaewoong Eum (skydoves)||18분 소요

ViewModel 내부 메커니즘과 멀티플랫폼 아키텍처 심층 분석

안드로이드의 ViewModel은 현대 안드로이드 개발에서 필수적인 컴포넌트로 자리잡았습니다. 구성 변경(configuration changes)에도 유지되는, 생명주기 인식(lifecycle-aware) UI 데이터 컨테이너를 제공하기 때문입니다. API 표면만 보면 단순해 보이지만, 내부 구현을 들여다보면 생명주기 관리, 멀티플랫폼 추상화, 리소스 정리, 스레드 안전 캐싱 등에 대한 정교한 설계 결정이 담겨 있습니다. ViewModel이 내부적으로 어떻게 동작하는지 이해하면, 더 나은 아키텍처 결정을 내리고 미묘한 버그를 사전에 방지하는 데 큰 도움이 됩니다.

이 글에서는 Jetpack ViewModel의 내부 동작 원리를 깊이 있게 탐구합니다. ViewModelStore가 구성 변경 시에도 인스턴스를 어떻게 보존하는지, ViewModelProvider가 생성과 캐싱을 어떻게 조율하는지, 팩토리 패턴이 유연한 인스턴스 생성을 어떻게 지원하는지, CreationExtras가 무상태 팩토리를 어떻게 구현하는지, Closeable 패턴을 통해 리소스 정리를 어떻게 관리하는지, 그리고 viewModelScope가 코루틴과 ViewModel 생명주기를 어떻게 통합하는지 순서대로 살펴봅니다. 이 글은 ViewModel의 사용법을 다루는 가이드가 아닙니다. 생명주기 인식 상태 관리를 가능하게 만드는 내부 메커니즘에 대한 탐구입니다. 안드로이드 프레임워크의 내부 동작 원리에 관심이 있으시다면, 이 글을 통해 평소에 당연하게 사용하던 ViewModel이 얼마나 정교한 시스템 위에서 동작하는지 이해하실 수 있을 것입니다.

근본적인 문제: 구성 변경에서 살아남기

구성 변경은 안드로이드 개발에서 근본적인 도전 과제입니다. 사용자가 디바이스를 회전하거나, 언어 설정을 변경하거나, 기타 구성 변경을 트리거하면 시스템은 Activity를 파괴하고 다시 생성합니다. 이 과정에서 Activity에 저장된 모든 데이터가 소실됩니다.

class MyActivity : ComponentActivity() {
    private var userData: User? = null  // 화면 회전 시 소실됩니다!

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 매번 회전할 때마다 데이터를 다시 로드해야 합니다
        loadUserData()
    }
}

가장 먼저 떠오르는 접근법은 onSaveInstanceState()를 활용하는 것입니다.

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putParcelable("user", userData)
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    userData = savedInstanceState?.getParcelable("user")
}

이 방법은 작고 직렬화 가능한 데이터에는 잘 동작합니다. 그러나 대용량 데이터셋, 네트워크 연결, 또는 직렬화할 수 없는 객체는 어떻게 처리해야 할까요? 진행 중인 네트워크 요청 같은 작업은 어떨까요? Bundle 기반 접근법은 이러한 경우에 한계를 드러냅니다. 크기 제한 때문이기도 하고, 직렬화/역직렬화 자체가 비용이 크기 때문이기도 합니다.

ViewModel은 직렬화가 아닌 보존 객체 패턴(retained object pattern)을 통해 구성 변경에서 살아남는 생명주기 인식 컨테이너를 제공함으로써 이 문제를 해결합니다.

ViewModelStore: 보존 메커니즘

ViewModel이 구성 변경에서 살아남을 수 있는 핵심에는 ViewModelStore가 있습니다. ViewModel 인스턴스를 보관하는 단순한 키-값 저장소입니다.

public open class ViewModelStore {

    private val map = mutableMapOf<String, ViewModel>()

    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun put(key: String, viewModel: ViewModel) {
        val oldViewModel = map.put(key, viewModel)
        oldViewModel?.clear()
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public operator fun get(key: String): ViewModel? {
        return map[key]
    }

    @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
    public fun keys(): Set<String> {
        return HashSet(map.keys)
    }

    public fun clear() {
        for (vm in map.values) {
            vm.clear()
        }
        map.clear()
    }
}

구현이 놀라울 정도로 직관적입니다. 내부적으로 MutableMap<String, ViewModel>을 사용할 뿐입니다. 여기서 핵심은 저장소 자체에 있는 것이 아니라, 이 저장소가 어떻게 보존되느냐에 있습니다.

키 교체 동작

put 메서드의 동작에 주목할 필요가 있습니다.

public fun put(key: String, viewModel: ViewModel) {
    val oldViewModel = map.put(key, viewModel)
    oldViewModel?.clear()
}

동일한 키로 이미 ViewModel이 존재하면, 기존 ViewModel은 즉시 정리(clear)됩니다. ViewModel이 교체될 때 적절한 리소스 정리가 이루어지도록 보장하는 것입니다. 이런 상황이 언제 발생하는지 궁금하실 수 있는데, 동일한 키로 다른 타입의 ViewModel을 요청할 때 발생합니다.

// 첫 번째 요청: "my_key" 키로 TestViewModel1 생성
val vm1: TestViewModel1 = viewModelProvider["my_key", TestViewModel1::class]

// 두 번째 요청: 같은 키지만 다른 타입으로 요청
val vm2: TestViewModel2 = viewModelProvider["my_key", TestViewModel2::class]

// vm1.onCleared()가 호출되었으며, vm1은 더 이상 유효하지 않습니다

이 동작은 테스트 코드에서도 검증되어 있습니다.

@Test
fun twoViewModelsWithSameKey() {
    val key = "the_key"
    val vm1 = viewModelProvider[key, TestViewModel1::class]
    assertThat(vm1.cleared).isFalse()
    val vw2 = viewModelProvider[key, TestViewModel2::class]
    assertThat(vw2).isNotNull()
    assertThat(vm1.cleared).isTrue()
}

ViewModelStoreOwner 계약

ViewModelStoreOwner 인터페이스는 저장소의 소유자를 정의합니다.

public interface ViewModelStoreOwner {
    public val viewModelStore: ViewModelStore
}

이 간결한 인터페이스는 ComponentActivity, Fragment, NavBackStackEntry가 구현합니다. 소유자의 책임은 두 가지입니다.

  1. 구성 변경 시 저장소 보존: 저장소는 Activity 재생성에서 살아남아야 합니다
  2. 완전히 종료될 때 저장소 정리: 재생성 없이 소유자가 파괴될 때 ViewModelStore.clear()를 호출해야 합니다

Activity의 경우 일반적으로 NonConfigurationInstances를 활용하여 구현합니다. 이는 구성 변경 시에도 객체를 보존할 수 있게 해 주는 특별한 메커니즘입니다. Activity 프레임워크가 onRetainNonConfigurationInstance() 시점에 이 객체들을 보존하고, getLastNonConfigurationInstance()에서 복원합니다.

단순한 Map으로 충분한 이유

정교한 캐싱 메커니즘을 예상하실 수도 있지만, 단순한 MutableMap으로 충분한 이유가 있습니다.

  1. 제한된 크기: 화면당 ViewModel 수는 적습니다 (일반적으로 1~5개)
  2. 문자열 키: 클래스 이름으로부터 키가 생성되므로, 좋은 해시 분포를 가진 O(1) 조회가 가능합니다
  3. 퇴출 불필요: ViewModel은 명시적으로 요청하거나 소유자가 파괴될 때만 정리됩니다
  4. 스레드 안전성: ViewModelProvider 수준에서 동기화가 이루어집니다

ViewModelProvider: 오케스트레이션 계층

ViewModelProviderViewModel 인스턴스를 얻기 위한 주요 API입니다. 저장소, 팩토리, CreationExtras 간의 상호작용을 조율합니다.

public actual open class ViewModelProvider
private constructor(private val impl: ViewModelProviderImpl) {

    public constructor(
        store: ViewModelStore,
        factory: Factory,
        defaultCreationExtras: CreationExtras = CreationExtras.Empty,
    ) : this(ViewModelProviderImpl(store, factory, defaultCreationExtras))

    public constructor(
        owner: ViewModelStoreOwner
    ) : this(
        store = owner.viewModelStore,
        factory = ViewModelProviders.getDefaultFactory(owner),
        defaultCreationExtras = ViewModelProviders.getDefaultCreationExtras(owner),
    )

    @MainThread
    public actual operator fun <T : ViewModel> get(modelClass: KClass<T>): T =
        impl.getViewModel(modelClass)

    @MainThread
    public actual operator fun <T : ViewModel> get(key: String, modelClass: KClass<T>): T =
        impl.getViewModel(modelClass, key)
}

멀티플랫폼 추상화

ViewModelProviderImpl 위임 구조에 주목하실 필요가 있습니다. ViewModel 라이브러리는 JVM, 안드로이드, iOS 등 다양한 플랫폼을 지원하는 코틀린 멀티플랫폼(Kotlin Multiplatform) 라이브러리입니다. 코틀린 멀티플랫폼에서는 아직 기본 구현을 가진 expect 클래스를 지원하지 않으므로, 공통 로직을 내부 구현 클래스로 추출하는 방식을 사용합니다.

internal class ViewModelProviderImpl(
    private val store: ViewModelStore,
    private val factory: ViewModelProvider.Factory,
    private val defaultExtras: CreationExtras,
) {

    private val lock = SynchronizedObject()

    @Suppress("UNCHECKED_CAST")
    internal fun <T : ViewModel> getViewModel(
        modelClass: KClass<T>,
        key: String = ViewModelProviders.getDefaultKey(modelClass),
    ): T {
        return synchronized(lock) {
            val viewModel = store[key]
            if (modelClass.isInstance(viewModel)) {
                if (factory is ViewModelProvider.OnRequeryFactory) {
                    factory.onRequery(viewModel!!)
                }
                return@synchronized viewModel as T
            }

            val modelExtras = MutableCreationExtras(defaultExtras)
            modelExtras[ViewModelProvider.VIEW_MODEL_KEY] = key

            return@synchronized createViewModel(factory, modelClass, modelExtras).also { vm ->
                store.put(key, vm)
            }
        }
    }
}

get-or-create 패턴

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기