안드로이드/Compose

[Compose] Composable 함수의 데이터 관리

snaildeveloper 2025. 6. 28. 12:51

Compose는 Composable 함수를 기반으로 UI를 구성한다.

 

UI를 구성하는 과정에서 다양한 데이터를 저장하고 사용하지만, Composable 함수의 특성 상 기존처럼 변수를 선언한다면 원하는 결과 값을 얻을 수 없다.

 

Recomposition이 일어나면 해당 Composable 함수를 다시 재실행하여 기존의 데이터가 다 초기화되기 때문이다.

또한 UI와 직접적으로 연관이 있는 데이터의 경우 값이 변경됨에 따라 Recomposition이 일어나야 하지만 기존의 변수를 사용한다면 값의 변경을 알아차릴 수 없기 때문에 Recomposition 자체가 일어나지 않는다.

 

그렇다면 Composable 함수에서는 데이터를 어떻게 선언해야 할까?

State<T>

Composable 함수가 Recomposition이 되는 이유는 다음과 같다.

  • Composable 함수의 매개 변수가 변경되는 경우
  • 매개 변수가 Unstable 한 경우
  • 관찰 중인 State가 변경되는 경우

데이터의 변경에 따라 UI가 변경되어야 하는 경우 해당 데이터를 State<T> 객체로 감싸 관찰 가능하도록 해야 한다.

mutableStateOf(value)

 

이제 State 객체의 값이 변경되면 Recomposition이 일어나 Composable 함수는 재실행 될 것이다.

그러나 재실행됨에 따라 객체의 값이 초기 값으로 다시 설정되어 Recomposition만 일어나고 UI는 아무런 변동사항이 일어나지 않았다.

그렇다면 Composable 함수 내에서 업데이트 된 데이터를 Recomposition이 일어나도 기억하고 있어야 한다.

 

remember

val intData: Int = remember { 0 }
val stateData: MutableState<Int> = remember { mutableStateOf(0) }
var stateData2: Int by remember { mutableStateOf(0) }

직관적인 함수 이름처럼 remember 함수를 사용하여 업데이트 된 데이터를 Recomposition이 일어나도 기억할 수 있다.

inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

기억할 수 있는 이유는 Composer에 캐시로 저장해놨다가 Recomposition이 일어나면 캐시에서 확인 후 업데이트 해주기 때문이다.

 

Configuration Change가 일어나도 기존의 데이터가 유지될까?

Configuration Change

Compose는 Activity에서 최초 선언된다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        setContent {
            RecompositionTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    ParentCompose(
                        modifier = Modifier.padding(innerPadding),
                    )
                }
            }
        }
    }
}

기존과 마찬가지로 Configuration Change가 발생하면 Activity는 Destroy 후 다시 onCreate가 실행되기 때문에 Composable 함수들은 Recomposition이 아닌 다시 처음부터 Composition이 일어난다. 이 과정에서 Composer의 캐시도 초기화되기 때문에 값이 유지되지 않는다.

@Composable
fun TimerCompose(
    modifier: Modifier = Modifier,
) {
    var timerState by remember { mutableStateOf(false) }
    var time by remember { mutableIntStateOf(0) }

    Column(
        modifier = modifier
            .padding(20.dp)
            .fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        if(timerState){
            Timer(
                plus = { time++ }
            )
        }

        Text("$time")

        Button(
            onClick = { timerState = !timerState }
        ) {
            Text(if(timerState) "Off" else "On")
        }
    }

}

@Composable
private fun Timer(
    plus: () -> Unit
){
    LaunchedEffect(Unit) {
        while(true){
            delay(1000)
            plus()
        }
    }
}

1초마다 time이 증가하면서 ParentCompose 함수가 Recomposition이 일어나지만 time의 데이터는 최신 상태로 유지되고 있다가 다크모드를 해체하는 순간 값이 0으로 초기화 되는 것을 확인할 수 있다.

 

rememberSaveable

Configuration Change가 발생해도 rememberSaveable을 사용하면 기존의 데이터를 유지할 수 있다.

 

rememberSabeable은 어떤 방법으로 값을 저장하는지 살펴보자.

@Composable
fun <T : Any> rememberSaveable(
    vararg inputs: Any?,
    saver: Saver<T, out Any> = autoSaver(),
    key: String? = null,
    init: () -> T
): T {
    val compositeKey = currentCompositeKeyHash
    // key is the one provided by the user or the one generated by the compose runtime
    val finalKey = if (!key.isNullOrEmpty()) {
        key
    } else {
        compositeKey.toString(MaxSupportedRadix)
    }
    @Suppress("UNCHECKED_CAST")
    (saver as Saver<T, Any>)

    val registry = LocalSaveableStateRegistry.current

    val holder = remember {
        // value is restored using the registry or created via [init] lambda
        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
}

 

위의 코드를 간단하게 살펴보면 SaveableHolder에 값을 넣어 holder를 통해 이전 값을 가져오거나 바뀐 데이터를 업데이트 해주는 것을 볼 수 있다. SaveableHolder 내부를 살펴보자.

private class SaveableHolder<T>(
    private var saver: Saver<T, Any>,
    private var registry: SaveableStateRegistry?,
    private var key: String,
    private var value: T,
    private var inputs: Array<out Any?>
) : SaverScope, RememberObserver {
    private var entry: SaveableStateRegistry.Entry? = null
    /**
     * Value provider called by the registry.
     */
    private val valueProvider = {
        with(saver) {
            save(requireNotNull(value) { "Value should be initialized" })
        }
    }
    
    fun update(...){...}
    ...
    
}

 

값을 update하는 함수 등 다양한 메서드가 있는데 값은 Saver 객체에 저장하고 있다.

Saver란?
Original 클래스의 객체를 단순화하고 Saveable로 변환하는 방법을 설명하는 인터페이스
Bundle로 된 savedInstatanceState를 사용하여 상태를 유지한다.

 

rememberSaveable에는 Bundle이 지원하는 데이터만 저장할 수 있다.

Bundle은 원시 데이터만 지원하기 때문에 객체를 저장하기 위해서는 2가지 방법이 있다.

 

1. @Parcelize 어노테이션 사용

parcelize 어노테이션으로 직렬화 시켜 저장하는 방법

@Parcelize
data class User(
    val name: String
)

@Composable
fun Test(){
    val user by rememberSaveable { User(name="Kim") }
}

 

2. Custom Saver 구현

interface Saver<Original, Saveable : Any> {
    /**
     * Convert the value into a saveable one. If null is returned the value will not be saved.
     */
    fun SaverScope.save(value: Original): Saveable?

    /**
     * Convert the restored value back to the original Class. If null is returned the value will
     * not be restored and would be initialized again instead.
     */
    fun restore(value: Saveable): Original?
}

 

  • List
data class User(
    val name: String,
    val age: Int
)

val saver = listSaver(
    save = { user: User ->
        listOf(
            user.name,
            user.age
        )
    },

    restore = {
        User(
            name = it[0] as String,
            age = it[1] as Int
        )
    }
)
  • Map
val saver = mapSaver(
    save = { user: User ->
        mapOf(
            "name" to user.name,
            "age" to user.age
        )
    },

    restore = {
        User(
            name = it["name"] as String,
            age = it["age"] as Int
        )
    }
)

 

위와 같이 Saver를 만들었다면 rememberSaveable 매개 변수로 넘겨주고 사용하면 된다.

val user = rememberSaveable(saver = saver) { User(name = "Kim", age = 20) }