아티클 목록으로 가기

Runtime Saveable: Compose가 프로세스 종료 이후에도 상태를 보존하는 방법

skydovesJaewoong Eum (skydoves)||17분 소요

Runtime Saveable: Compose가 프로세스 종료 이후에도 상태를 보존하는 방법

Jetpack Compose는 안드로이드 UI 개발에 선언적(declarative) 패러다임을 도입했지만, 선언적이라고 해서 상태가 없는 것은 아닙니다. 사용자 인터랙션은 스크롤 위치, 텍스트 필드 내용, 확장된 섹션 등 다양한 상태를 만들어 내며, 이러한 상태는 구성 변경(configuration changes)과 프로세스 종료 상황에서도 반드시 유지되어야 합니다. remember는 리컴포지션(Recomposition) 간에 상태를 보존해 주지만, 액티비티가 재생성되는 상황에서는 무력합니다. 바로 이 지점에서 runtime saveable 모듈이 등장합니다. 이 모듈은 Compose의 반응형 세계와 안드로이드의 saved instance state 메커니즘을 연결하는 정교한 상태 영속화(state persistence) 시스템입니다.

이 글에서는 Compose saveable API의 내부 메커니즘을 깊이 있게 살펴봅니다. rememberSaveable이 컴포지션 위치 키를 통해 상태를 추적하고 복원하는 방식, Saver 인터페이스가 임의의 객체에 대해 타입 안전한 직렬화(serialization)를 구현하는 원리, SaveableStateRegistry가 여러 프로바이더를 관리하면서 등록 순서를 보존하는 구조, SaveableStateHolder가 화면 키를 기준으로 상태를 스코핑하여 내비게이션 패턴을 지원하는 방법, 그리고 이 모든 컴포넌트가 어떻게 조화롭게 협력하여 UI 상태를 매끄럽게 보존하는지를 폭넓게 다룹니다. 이 글은 rememberSaveable의 사용법 가이드가 아닌, 상태 영속화를 개발자에게 투명하게 만드는 런타임 내부 구조에 대한 탐구입니다.

근본적인 문제: 프로세스 종료에서도 살아남는 상태

다음과 같은 간단한 Compose 코드를 살펴보겠습니다.

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Count: $count")
    }
}

이 코드는 리컴포지션에서는 완벽하게 동작합니다. 버튼을 클릭하면 count가 증가하고, UI가 업데이트됩니다. 하지만 디바이스를 회전하면 count는 다시 0으로 초기화됩니다. 액티비티가 파괴되고 재생성되었기 때문인데, remember는 단일 컴포지션 생명주기 내에서만 상태를 유지할 수 있기 때문입니다.

전통적인 안드로이드 해결 방식은 onSaveInstanceState를 사용하는 것입니다.

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

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

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        count = savedInstanceState?.getInt("count") ?: 0
    }
}

하지만 이 접근 방식은 Compose와 잘 맞지 않습니다. 상태가 컴포저블이 아닌 Activity에 존재하기 때문에, 컴포지션 계층 구조를 통해 상태를 수동으로 전달해야 합니다. 게다가 수십 개의 상태를 가진 컴포저블이 있다면, 보일러플레이트 코드가 감당할 수 없을 정도로 늘어나게 됩니다.

Compose의 saveable API는 saved instance state를 컴포지션 모델에 직접 통합함으로써 이 문제를 우아하게 해결합니다. 각 rememberSaveable 호출은 컴포지션 트리에서의 위치를 키로 사용하여, 저장/복원 사이클에 자동으로 참여합니다. 이는 수동으로 키를 관리하거나 상태를 전달할 필요 없이 상태 영속화가 이루어진다는 것을 의미합니다.

Saver 인터페이스: 타입 안전한 상태 직렬화

saveable 시스템의 핵심에는 Saver 인터페이스가 있습니다. 이 인터페이스는 도메인 타입과 Bundle 호환 표현 간의 변환 방법을 정의합니다.

핵심 추상화

Saver 인터페이스는 매우 간결하면서도 우아합니다.

public interface Saver<Original, Saveable : Any> {
    public fun SaverScope.save(value: Original): Saveable?
    public fun restore(value: Saveable): Original?
}

두 개의 메서드가 왕복 변환(round-trip)을 처리합니다.

  1. save(): 도메인 타입을 Bundle 호환 타입으로 변환합니다. null을 반환하면 "이 값은 저장하지 않는다"는 의미입니다.
  2. restore(): 저장된 값을 원래의 도메인 타입으로 다시 변환합니다. null을 반환하면 "init 람다를 대신 사용한다"는 의미입니다.

save() 메서드의 리시버인 SaverScopecanBeSaved(value: Any): Boolean 메서드에 대한 접근을 제공하여, 직렬화를 시도하기 전에 중첩된 값의 저장 가능 여부를 검증할 수 있게 해줍니다.

팩토리 함수

편의를 위해, 람다로부터 Saver 구현체를 생성하는 팩토리 함수가 제공됩니다.

public fun <Original, Saveable : Any> Saver(
    save: SaverScope.(value: Original) -> Saveable?,
    restore: (value: Saveable) -> Original?,
): Saver<Original, Saveable> {
    return object : Saver<Original, Saveable> {
        override fun SaverScope.save(value: Original) = save.invoke(this, value)
        override fun restore(value: Saveable) = restore.invoke(value)
    }
}

이 팩토리 함수를 활용하면 간결하게 Saver를 정의할 수 있습니다.

val UserSaver = Saver<User, Bundle>(
    save = { user ->
        bundleOf("id" to user.id, "name" to user.name)
    },
    restore = { bundle ->
        User(bundle.getLong("id"), bundle.getString("name")!!)
    }
)

AutoSaver: 기본 구현체

rememberSaveable을 호출할 때 별도의 Saver를 지정하지 않으면 autoSaver()가 사용됩니다.

public fun <T> autoSaver(): Saver<T, Any> =
    @Suppress("UNCHECKED_CAST") (AutoSaver as Saver<T, Any>)

private val AutoSaver = Saver<Any?, Any>(save = { it }, restore = { it })

auto saver는 변환을 수행하지 않고 값을 그대로 통과시킵니다. 이는 Bundle이 이미 지원하는 타입, 즉 원시 타입(primitive type), String, Parcelable, Serializable 객체 등에 대해서만 동작합니다. 커스텀 타입의 경우에는 반드시 커스텀 Saver를 구현해야 합니다.

listSaver: 순서가 있는 값 목록으로 분해

listSaver 헬퍼는 객체를 저장 가능한 값의 리스트로 변환합니다.

public fun <Original, Saveable> listSaver(
    save: SaverScope.(value: Original) -> List<Saveable>,
    restore: (list: List<Saveable>) -> Original?,
): Saver<Original, Any> =
    Saver(
        save = {
            val list = save(it)
            for (index in list.indices) {
                val item = list[index]
                if (item != null) {
                    require(canBeSaved(item)) { "item at index $index can't be saved: $item" }
                }
            }
            if (list.isNotEmpty()) ArrayList(list) else null
        },
        restore = restore as (Any) -> Original?,
    )

이 구현은 리스트의 각 항목이 저장 가능한지 검증한 뒤, Bundle 호환성을 위해 ArrayList로 래핑합니다. 빈 리스트는 저장 공간 최적화를 위해 null을 반환합니다. 이러한 세밀한 최적화 덕분에 불필요한 데이터가 Bundle에 포함되는 것을 방지할 수 있습니다.

mapSaver: 키-값 직렬화

mapSaverlistSaver를 기반으로 영리한 인코딩 방식을 사용합니다.

public fun <T> mapSaver(
    save: SaverScope.(value: T) -> Map<String, Any?>,
    restore: (Map<String, Any?>) -> T?,
): Saver<T, Any> =
    listSaver<T, Any?>(
        save = {
            mutableListOf<Any?>().apply {
                save(it).forEach { entry ->
                    add(entry.key)
                    add(entry.value)
                }
            }
        },
        restore = { list ->
            val map = mutableMapOf<String, Any?>()
            check(list.size.rem(2) == 0) { "non-zero remainder" }
            var index = 0
            while (index < list.size) {
                val key = list[index] as String
                val value = list[index + 1]
                map[key] = value
                index += 2
            }
            restore(map)
        },
    )

Map 객체를 직접 직렬화하는 대신(이는 오버헤드가 발생합니다), mapSaver는 키와 값을 번갈아 배치하는 평탄한 리스트 구조로 변환합니다. 즉 [key1, value1, key2, value2, ...] 형태입니다. 이 방식은 HashMap 직렬화의 복잡성을 피하면서도 공간 효율성을 높여 줍니다. 복원 함수는 이 리스트를 쌍(pair)으로 순회하며 맵을 재구성합니다.

SaveableStateRegistry: 상태 조정의 중심부

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

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

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