메인 콘텐츠로 이동하기

Jetpack Compose 상태 관리

·10 분
cover
Image generated by Gemini

$$ F(State) = View $$

이 함수는 선언형 UI(Declarative UI)를 표현하는 식이다. UI 컴포넌트(F)에 상태(State)를 전달하면 그 상태값에 따라 UI를 그리는(View) 방식을 취한다. 이 공식은 플랫폼을 떠나서 선언형 UI의 방식을 가지는 프레임워크라면(Jetpack Compose 뿐 아니라 React, Flutter, Switft UI 등) 모두 공유한다.

Jetpack Compose도 전혀 다르지 않다. Composable 함수(F)에 상태값(State)을 매개변수로 전달하면 그에 맞춰 UI를 렌더링(View)한다.

Jetpack Compose의 상태 #

컴포저블(Composable) 함수는 그 함수에 전달된 매개변수가 바뀌어야 리컴포지션1을 수행한다.

@Composable
fun InputField() {
    var text by remember { mutableStateOf("") }

    TextField(
        modifier = Modifier.fillMaxWidth(),
        value = text,
        onValueChange = { text = it }
    )
}

위의 코드는 TextField를 사용해 입력을 받는 UI를 그리는 간단한 예시이다. Jetpack Compose에서 TextField는 사용자가 입력한다고 자동으로 업데이트되지 않는다. 사용자 입력이 발생하면 onValueChange를 통해 변경된 값이 전달되고, 그 값을 text에 반영한다. 그러면 변경된 text가 다시 TextField로 전달되며 리컴포지션을 수행하고 UI가 업데이트된다.

예전의 명령형 UI 방식인 XML View에서는 UI 업데이트를 위해서 사용자가 입력한 값을 View 외부에 저장할 필요가 없었다. 하지만 선언형 UI 방식인 Jetpack Compose는 외부에 상태값을 두고 전달해야 UI가 업데이트 된다.

Jetpack Compose는 상태 저장을 위해 State라는 객체를 사용한다. State와 이를 상속하는 MutableState는 값이 변경되면 리컴포지션을 일으켜 변경된 상태값으로 UI를 업데이트하도록 유도한다. 때문에 상태가 바뀌면 그에 맞춰 UI가 변화하는 선언형 UI 패러다임에 따르기 위해 상태는 State로 만들어 주어야 한다.

상태 저장 #

Jetpack Compose에서 상태값은 보통 일반 변수로 생성하지 않는다. 위에서 설명한 대로 State로 만들어야 한다.

Jetpack Compose에서는 상태를 만들기 위해 mutableStateOf와 같은 여러 가지 API를 제공한다. 그리고 Compose의 영역이 아닌 여러 가지 다른 상태 객체와의 상호운용성을 위해 호환성 API를 제공한다.

val stateFromFlow by stateFlow.collectAsState() // Coroutines Flow
val stateFromLiveData by liveData.observeAsState() // LiveData
val stateFromRx by observable.subscribeAsState() // RxJava

위와 같은 호환성 API는 Jetpack Compose와 Non-Compose 코드와의 연결을 손쉽게 만들어준다. 이를 통해서 ViewModel 등에 선언된 상태값을 Compose UI에서 편리하게 반영할 수 있다.

기본적으로 Coroutines Flow의 StateFlow 값을 State로 바꿔주는 API는 포함되어 있다. 하지만 LiveData를 위한 observeAsState나 RxJava의 Observable을 위한 subscribeAsState는 추가 디펜던시가 필요할 수 있다.
@Composable
fun InputField() {
    var text by remember { mutableStateOf("") } // <-- remember를 사용해 저장.

    TextField(
        modifier = Modifier.fillMaxWidth(),
        value = text,
        onValueChange = { text = it }
    )
}

다시 같은 코드를 살펴보면 텍스트를 저장 할 상태값을 var text = ""와 같이 일반 변수로 선언하지 않고 mutableStateOf를 사용해 만든 것을 알 수 있다. MutableState로 만듦으로서 text가 변할 때 자동으로 리컴포지션을 일으키며 UI에 반영할 수 있다.

하지만 여기서 remember에 대한 의문점이 생길 수 있다. 왜 상태 변수를 var text by mutableStateOf("")가 아니라 var text by remember { mutableStateOf("") }로 선언했을까? 그 이유는 리컴포지션이 일어나도 값을 기억해야 하기 때문이다.

컴포저블 함수가 리컴포지션 되어 재호출 될 때, 컴포저블 함수 내부에서 선언된 지역 변수는 다시 초기화된다. 초기화 되는 것을 피하려면 그 상태값을 매개변수로 받도록 다시 말해 더 바깥에서 관리하게 만들어 리컴포지션 범위를 벗어나게 만들거나(상태 호이스팅), remember와 같은 상태 저장 API를 활용해 리컴포지션을 넘어 기억할 수 있게 만들어야 한다.

상태 호이스팅 #

위에서 계속 사용한 예시 코드는 InputField 내부에 text라는 상태값을 관리하고 있다. 이렇게 내부적으로 상태를 관리하고 있는 컴포저블을 Stateful 이라고 부른다.

Stateful 방식으로 컴포넌트를 설계하면 외부에서의 사용은 편해질 수 있다. 내부적으로 스스로 상태를 관리하기 때문이다. 하지만 상태가 외부의 다른 컴포넌트와 공유되어야 하거나 더욱 유연한 사용을 할 수 있게 만드려면 상태를 외부로 분리하는 것이 좋다.

@Composable
fun InputScreen() {
    var text by remember { mutableStateOf("") }

    Column {
        InputField(
            modifier = Modifier.fillMaxWidth(),
            text = text,
            onTextChange = { text = it }
        )

        // ...
    }
}

@Composable
fun InputField(
    text: String,
    onTextChange: (String) -> Unit,
    modifier: Modifier = Modifier
) {
    TextField(
        modifier = modifier,
        value = text,
        onValueChange = onTextChange
    )
}

상태를 호이스팅2해서 코드를 변경해 보았다.

InputField 내부에 있던 text상태를 바깥에서 받고 text를 업데이트 하던 이벤트도 람다로 전달하도록 만들었다. 이제 상태는 외부에서 전달받기 때문에 InputField는 상태 관리에 대한 부담이 사라졌다. 또한 InputField의 재활용이 쉬워졌다. 여러 개의 InputField를 만들어도 상태값만 전달하면 쉽게 재사용 할 수 있다. 테스트 용이성도 높아졌다. InputField에 여러 가지 경우에 따른 상태값을 전달해보며 테스트하기 더 쉽다.

이렇게 상태를 가지지 않는 컴포저블을 Stateless라고 부른다.

unidirectional-data-flow
단방향 데이터 흐름

상태를 호이스팅 하면서 자연스럽게 단방향 데이터 흐름(Unidirectional Data Flow)3 이 이루어졌다. 선언형 UI에서는 가능하다면 단방향으로 흐름을 만들어 하위 요소는 UI를 업데이트 하는데에 집중하고 이벤트 처리는 상위로 끌어올려 UI의 재사용성과 테스트 용이성을 높이는 편이 좋다.

remember #

앞에서는 Jetpack Compose에서 상태란 무엇이고 상태를 어디에서 관리하는지에 대해서 간략히 다루었다. 그리고 그 과정에서 remember와 같은 상태 저장 API를 활용하는 예시 코드를 보았다. 이제는 그 remember에 대해서 간략히 정리해 보려고 한다.

remember는 가장 기본적인 상태 관리 API로, 컴포저블이 유지되는 한 리컴포지션 전반에서 값을 기억할 수 있게 해준다. remember를 사용하지 않고 컴포저블 내부에서 값을 선언하면 리컴포지션이 일어날 때 다시 초기화되어 사라질 수 있다. 저장해야 할 변수가 초기화되지 않고 이전의 값을 유지하게 하기 위해서 필요한 API이다.

@Composable
inline fun <T> remember(key1: Any?, crossinline calculation: @DisallowComposableCalls () -> T): T {
    return currentComposer.cache(currentComposer.changed(key1), calculation)
}

remember의 실제 코드는 위와 같이 생겼다. 실제로는 key가 없는 remember부터 여러 개 있는 remember까지 더 다양한 바리에이션이 존재하지만 key의 개수 차이 정도이므로 생략한다. 하나하나 살펴보자.

  • currentComposer는 현재 사용할 수 있는 Composer를 가져오는 API이다. Composer는 Jetpack Compose에서 UI 트리 구성부터 업데이트, 각 컴포저블의 생명주기 등을 관리하는 핵심 컴포넌트다. Composer는 상태 관리도 담당하기 때문에 rememberComposer를 통해 값을 기억한다.

  • key는 선택적으로 줄 수 있는 파라미터로 remember가 값을 다시 계산해야 할 조건이 있을 때 사용하는 파라미터다. 만약 key 변경되면 remembercalculation을 다시 계산한다.

  • calculationremember가 기억해야 할 값을 계산하는 람다 파라미터다. 컴포지션이 일어났을 때 기억해야 할 값을 계산하고 메모리에 저장한 후 리컴포지션 시 다시 계산하지 않고 저장해 두었던 값을 반환한다. 만약 key가 변경되면 다시 실행된다.

calculation 람다에는 @DisallowComposableCalls 어노테이션이 붙어 있는데, 이 어노테이션은 calculation람다 안에서 컴포저블 함수를 호출하지 않아야 한다는 것을 표시하는 마커다.
remember를 사용한 코드는 리컴포지션을 일으킬 가능성이 높은데 만약 calculation 안에서 컴포저블 함수가 호출되면 리컴포지션이 무한 루프에 빠질 수도 있기 때문에 원천 방지되어 있다.
또한 remember는 상태 관리 API이지 UI를 그리는 API가 아니다. remember 내부에서 UI를 그리는 것은 의도와 맞지 않다.
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let { // <-- 1
        if (invalid || it === Composer.Empty) { // <-- 2
            val value = block()                 // <--┒
            updateRememberedValue(value)        //    | 3
            value // <--------------------------------┚
        } else it // <-- 4
    } as T
}

한 단계 더 들어가 Composer.cache의 코드를 보면 위와 같이 구현되어 있다. 실제로 이 함수를 직접 사용할 일은 없지만 이해를 위해 살짝만 살펴보자.

  1. rememberedValueComposer에 기억된 값을 꺼내는 함수다. 만약 기억된 값이 없으면 Composer.Empty를 반환한다.

  2. 이전에 기억한 값이 없거나(Composer.Empty) remember에서 key가 변경되었다면(currentComposer.changed(key1)) 3번, 아니면 4번 동작으로 간다.

  3. block을 실행해 값을 계산하고 updateRememberedValue를 통해 저장한 뒤 반환한다.

  4. 꺼낸 값을 그대로 반환한다.

여기까지만 살펴봐도 remember의 동작에 대해서 간단히 이해해볼 수 있다.

remember의 한계 #

remember는 리컴포지션이 일어나도 이전의 값을 기억하고 재사용할 수 있게 해주는 API이지만 한계점도 존재한다. 위에서 살짝 언급한 대로 remember는 자신이 속한 컴포저블이 유지되는 동안 값을 기억한다. 다시 말하면 자신이 속한 컴포저블이 더 이상 유지되지 않을 때, remember에 저장 된 값도 함께 정리된다.

@Composable
fun Screen() {
    LazyColumn {
        items(100) { index ->
            Item(index = index)
        }
    }
}

@Composable
fun Item(
    index: Int,
    modifier: Modifier = Modifier
) {
    var count by remember { mutableIntStateOf(0) }

    Column(modifier = modifier) {
        Text(text = "[$index] count=$count")

        Button(
            onClick = { 
                count++
                Log.d("test", "[$index] count=$count")
            }
        ) {
            Text(text = "Click!")
        }
    }
}

위의 코드로 컴포저블이 정리되었을 때 remember에 기억된 값도 함께 정리되는 것을 간단하게 확인해 볼 수 있다.

100개의 Item이 있는 LazyColumn이 있다. 그리고 각 Item에는 count라는 이름의 클릭 횟수 상태값이 있다. countremember를 통해 저장되어 있다. 만약 위 코드를 실행해서 아래 동작을 수행하면 어떻게 될까?

LazyColumn은 아이템의 개수가 많아도 화면에 보이지 않는 아이템 컴포저블을 그리지 않는다.
  1. index 0인 버튼을 3번 클릭한다.
  2. index 0인 버튼이 보이지 않을 때 까지 스크롤한다.
  3. 다시 index 0인 버튼이 보일 떄 까지 스크롤한다.
  4. index 0인 버튼을 2번 클릭한다.

실제 로그 결과는 아래와 같다.

[0] count=1
[0] count=2
[0] count=3
[0] count=1
[0] count=2

count는 3번 클릭하는 동안 정상적으로 증가했다. 하지만 화면에 보이지 않게 된 후로 컴포저블이 정리되며 저장된 값도 함께 메모리에서 제거되었다. 그 후 다시 화면에 보이며 초기화되었고 다시 2까지 증가했다.

즉, 컴포저블이 정리되면 그에 속해 있던 remember도 함께 정리된다. 따라서 컴포저블이 화면에서 보이지 않게 되어도 값이 유지되어야 하는 경우 상태를 더 상위로 호이스팅 하는 것이 좋다.

rememberSaveable #

앞서 remember는 컴포저블이 유지되어 있는 한 리컴포지션을 넘어 상태값을 유지할 수 있지만, 컴포저블이 정리된 경우 사라진다는 점을 소개했다. 하지만 아무리 최상위 컴포저블에서 remember를 사용해도 프로세스가 중지 후 복원되는 등 더 상위의 과정이 재동작 하는 경우 상태를 유지할 수 없다. 예를 들어 안드로이드에서 Activity가 백그라운드로 유지되다가 복원되는 경우, 화면 회전이 일어나 configuration change가 일어나는 경우 등이 있다. 그런 경우에도 상태를 유지하려면 rememberSaveable을 사용할 수 있다.

@Composable
fun <T : Any> rememberSaveable(
    vararg inputs: Any?,
    saver: Saver<T, out Any> = autoSaver(),
    key: String? = null,
    init: () -> T
): T {
    val compositeKey = currentCompositeKeyHash
    val finalKey = if (!key.isNullOrEmpty()) {
        key
    } else {
        compositeKey.toString(MaxSupportedRadix)
    }
    
    @Suppress("UNCHECKED_CAST") (saver as Saver<T, Any>)

    val registry = LocalSaveableStateRegistry.current // <-- SaveableStateRegistry 사용

    val holder = remember {
        val restored = registry?.consumeRestored(finalKey)?.let { saver.restore(it) }
        val finalValue = restored ?: init()
        SaveableHolder(saver, registry, finalKey, finalValue, inputs)
    }

    val value = holder.getValueIfInputsDidntChange(inputs) ?: init()
    SideEffect { holder.update(saver, registry, finalKey, value, inputs) }

    return value
}

위의 코드는 rememberSaveable의 구현이다. 복잡한 코드처럼 보이지만 중요한 부분은 LocalSaveableStateRegistry를 사용한다는 점이다. 현재 컴포저블 스코프에서 가져올 수 있는 SaveableStateRegistry를 가져오는 코드이며, 이 registry를 통해서 값을 저장하고 복원하는 것을 살펴볼 수 있다. SaveableStateRegistryBundle을 통해 값을 직렬화/역직렬화 해 관리하는 컴포넌트이다. 그리고 이렇게 저장된 값은 configutation change나 프로세스 복원 시에도 읽어서 값을 복원할 수 있다.

정리하자면 rememberSaveableremember처럼 값을 저장하지만 기억하는 방식에서 차이점이 있다. remember가 컴포저블이 유지되는 한 저장한다면 rememberSaveableBundle을 사용한다. 따라서 앱이 중단된 시점의 상태를 유지해야 한다면 remember보다 rememberSaveable가 적합하다.

rememberSaveableBundle이 지원하는 타입이여야 값을 저장할 수 있다. 하지만 Jetpack Compose를 사용하다 보면 다양한 상태를 저장해야 한다. 그렇기 때문에 rememberSaveable에 값을 저장할 수 있는 여러 가지 방법이 있다.

1. Bundle이 지원하는 타입

val state by rememberSaveable { mutableIntStateOf(0) }

Bundle에서 지원하는 종류의 타입은 특별한 조치 없이도 쉽게 값을 저장할 수 있다.

2. Parcelable 타입

@Parcelize
data class Person(
    val firstName: String,
    val lastName: String
) : Parcelable

val state by rememberSaveable { mutableStateOf(Person("Jaeung", "Cheon")) }

커스텀한 클래스라도 Parcelable 타입은 Bundle로 (역)직렬화할 수 있기 때문에 저장할 수 있다.

3. Saver 구현

복잡한 객체라면 직접 저장/복원 방식을 구현하는 Saver를 지정할 수도 있다. Saver구현을 조금이라도 편하게 할 수 있게 listSavermapSaver API가 제공된다.

data class Person(
    val firstName: String,
    val lastName: String
)

val PersonSaver = listSaver<Person, Any>(
    save = { listOf(it.firstName, it.lastName) },
    restore = { Person(it[0] as String, it[1] as String) }
)

val state by rememberSaveable(saver = PersonSaver) { 
    mutableStateOf(Person("Jaeung", "Cheon"))
}

위의 코드는 listSaver를 사용해 Person객체를 저장하는 예시 코드이다. saverestore를 구현해 Person이라는 객체를 어떻게 List<T> 형태로 분해하고 조립할지를 정의하면 된다.

data class Person(
    val firstName: String,
    val lastName: String
)

val PersonSaver = mapSaver<Person, Any>(
    save = { mapOf("FirstName" to it.firstName, "LastName" to it.lastName) },
    restore = { Person(it["FirstName"] as String, it["LastName"] as String) }
)

val state by rememberSaveable(saver = PersonSaver) { 
    mutableStateOf(Person("Jaeung", "Cheon"))
}

mapSaver도 크게 다르지 않다. saverestore를 구현해 Person이라는 객체를 어떻게 Map<String, T> 형태로 분해하고 조립할지를 정의하면 된다.

listSavermapSaver는 성능 면에서 무의미할 정도로 차이가 없으며 가독성과 편의에 따라 선택해 사용하면 된다.

위와 같이 rememberSaveable을 사용하면 안드로이드에서 Activity가 회전해 configuration change가 발생하는 등 프로세스가 재시작해도 값을 다시 복원할 수 있다. 실제로 LazyColumn, LazyRowLazyListState 또는 바텀시트의 ModalBottomSheetState와 같이 복잡하면서 상태를 유지해야 하는 경우는 내부적으로 rememberSaveable을 사용해 구현하는 것을 확인해 볼 수 있다. 하지만 상태 저장/복원을 하는 데에 필요한 동작이 더 무겁기 때문에 remember에 비해서 오버헤드가 존재한다는 단점이 있다. 필요에 따라 적절하게 선택하며 상태 값을 저장하면 최적화된 앱을 만드는 데에 도움이 될 것이다.


  1. Recomposition. Composable 함수를 다시 재구성하는 동작을 의미하며, 리컴포지션이 일어나면 Composable 함수가 다시 실행되며 변경 된 상태를 반영해 UI를 업데이트한다. ↩︎

  2. 상태 호이스팅이란, 상태 관리 포인트를 컴포넌트를 호출하는 부모 컴포넌트 쪽으로 이동시켜 컴포넌트는 상태를 관리하지 않고 UI 로직에 집중하도록 만드는 패턴을 말한다. ↩︎

  3. 상태 데이터는 아래 방향으로만 전달하고 이벤트는 상위로만 전달하게 만들어 캡슐화, 테스트 용이성을 높이고, 상태 관리 포인트를 집중시키면서 상태를 받는 소스를 하나로 만들어 데이터가 업데이트 되었을 때 일관되게 UI가 업데이트 될 수 있도록 할 수 있는 방식이다. ↩︎