아티클 목록으로 가기

ViewModel: 구성 변경에서 살아남는 원리

skydovesJaewoong Eum (skydoves)||12분 소요

ViewModel: 구성 변경에서 살아남는 원리

안드로이드의 ViewModel은 가장 널리 사용되는 아키텍처 컴포넌트 중 하나이지만, 핵심 생존 메커니즘(survival mechanism)을 깊이 이해하는 개발자는 많지 않습니다. 클래스에 어노테이션을 달고 Activity에서 viewModels()를 호출하면 화면 회전 후에도 상태가 마치 마법처럼 유지됩니다. 하지만 이 과정에서 내부적으로 실제로 어떤 일이 일어나는 걸까요? 그 해답은 직렬화되지 않고 메모리에 그대로 유지되는 객체, 문자열을 키로 사용하는 단순한 HashMap, 신중하게 설계된 리소스 정리 순서, 그리고 생성과 조회를 분리하는 팩토리 시스템에 있습니다.

이 글에서는 ViewModel이 구성 변경(configuration change)에서 살아남을 수 있게 하는 내부 메커니즘을 심층적으로 살펴봅니다. ComponentActivity가 안드로이드의 NonConfigurationInstances 메커니즘을 통해 ViewModelStore를 유지하는 방법, ViewModelProviderViewModelProviderImpl을 통해 스레드 안전한 조회와 생성을 조율하는 방법, ViewModelImpl이 의도된 정리 순서로 리소스 생명주기를 관리하는 방법, CreationExtras가 무상태 팩토리 주입을 지원하는 방법, 그리고 FragmentFragmentManagerViewModel을 통해 이 전체 시스템에 편승하는 방법까지 다룹니다. 이 글은 ViewModel 사용법에 대한 가이드가 아닙니다. 구성 변경 생존의 근간이 되는 유지(retention), 생성(creation), 소멸(destruction) 메커니즘에 대한 탐구입니다.

근본적인 문제: Activity 인스턴스보다 오래 살아야 하는 상태

다음과 같은 흔한 시나리오를 살펴보겠습니다.

class CounterActivity : ComponentActivity() {
    private var count = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Button(onClick = { count++ }) {
                Text("Count: $count")
            }
        }
    }
}

디바이스를 회전하면 count가 0으로 초기화됩니다. 안드로이드 프레임워크가 구성 변경 시 Activity를 파괴하고 다시 생성하기 때문입니다. 모든 필드, 모든 지역 변수, 모든 참조가 사라집니다.

Bundle을 활용하면 작고 직렬화 가능한 데이터에는 대응할 수 있습니다.

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putInt("count", count)
}

하지만 Bundle에는 엄격한 1MB 트랜잭션 제한이 있고, 원시(primitive) 타입과 Parcelable 타입만 담을 수 있으며, 수동으로 직렬화 및 역직렬화 로직을 작성해야 합니다. 네트워크 요청으로 가져온 10,000개 항목의 리스트는 어떻게 할까요? 데이터베이스 커서나 WebSocket 연결은요? 이러한 데이터는 Bundle로 직렬화할 수 없습니다.

ViewModel은 구성 변경 시 객체를 메모리에 그대로 유지함으로써 이 문제를 해결합니다. 직렬화하지 않고, Parcel로 변환하지도 않습니다. 이전 Activity가 파괴되고 새로운 Activity가 생성되는 동안에도 정확히 같은 객체 인스턴스가 메모리에 보존됩니다. 이 점이 ViewModel의 핵심 설계 철학이며, 이후 살펴볼 내부 구현을 이해하는 출발점이기도 합니다.

ViewModelStore: 유지의 컨테이너

이 시스템의 토대에는 MutableMap을 감싸는 래퍼인 ViewModelStore가 자리하고 있습니다.

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() // 기존 ViewModel이 있으면 즉시 정리
    }

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

    public fun clear() {
        for (vm in map.values) {
            vm.clear() // 저장된 모든 ViewModel의 리소스 해제
        }
        map.clear()
    }
}

여기서 세 가지 설계 결정이 눈에 띕니다.

문자열 키를 사용합니다(타입 키가 아닙니다). ViewModel은 클래스 타입이 아닌 문자열로 저장됩니다. 덕분에 동일한 ViewModel 클래스의 인스턴스를 여러 개 하나의 저장소에 공존시킬 수 있으며, 각각 다른 키를 부여할 수 있습니다. 기본 키는 클래스 이름에서 자동 생성되지만, 커스텀 키를 사용하면 더 고급 패턴도 구현할 수 있습니다.

교체 시 즉각적인 정리가 이루어집니다. 이미 존재하는 키로 put()을 호출하면 이전 ViewModeloldViewModel?.clear()를 통해 즉시 정리됩니다. 팩토리가 동일한 키에 대해 다른 인스턴스를 생성하는 경우에도 리소스 누수를 방지할 수 있습니다.

clear\(\)는 모든 값을 순회합니다. clear() 메서드는 맵을 비우기 전에 저장된 모든 ViewModel에 대해 vm.clear()를 호출합니다. 이를 통해 저장소가 영구적으로 소멸될 때 코루틴 스코프, 데이터베이스 연결, 스트림 등 모든 리소스가 해제되도록 보장합니다.

ViewModelStore 자체는 구성 변경, 생명주기 이벤트, 안드로이드 프레임워크에 대한 인식이 전혀 없습니다. 정리 시맨틱(cleanup semantics)을 갖춘 순수한 컨테이너일 뿐이며, 실제 유지 로직은 다른 곳에 존재합니다.

NonConfigurationInstances: 유지 메커니즘의 핵심

실제 생존 메커니즘은 ComponentActivity에 존재합니다. 구성 변경이 발생하면 안드로이드 프레임워크는 Activity를 파괴하기 전에 onRetainNonConfigurationInstance()를 호출합니다. 이 메서드가 반환하는 객체는 메모리에 유지되며, 새로운 Activity 인스턴스에서 lastNonConfigurationInstance를 통해 접근할 수 있습니다.

ComponentActivity는 이 메커니즘을 활용하여 ViewModelStore를 유지합니다.

internal class NonConfigurationInstances {
    var custom: Any? = null
    var viewModelStore: ViewModelStore? = null
}

NonConfigurationInstances 클래스는 두 가지를 보관합니다. ViewModelStore와 하위 호환성을 위한 더 이상 사용되지 않는(deprecated) custom 필드입니다. 구성 변경이 트리거되면 onRetainNonConfigurationInstance()가 현재 ViewModelStore를 이 컨테이너에 담아 반환합니다.

final override fun onRetainNonConfigurationInstance(): Any? {
    val custom = onRetainCustomNonConfigurationInstance()
    var viewModelStore = _viewModelStore
    if (viewModelStore == null) {
        // 현재 Activity에서 ViewModelStore를 사용하지 않았을 경우
        // 이전 NonConfigurationInstances에서 복원 시도
        val nc = lastNonConfigurationInstance as NonConfigurationInstances?
        if (nc != null) {
            viewModelStore = nc.viewModelStore
        }
    }
    if (viewModelStore == null && custom == null) {
        return null
    }
    val nci = NonConfigurationInstances()
    nci.custom = custom
    nci.viewModelStore = viewModelStore
    return nci
}

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

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

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