안드로이드 개발에서 UI를 구현하는 방식은 시간이 지남에 따라 크게 발전해왔습니다.
이전 View(XML) 기반 시스템에서 Jetpack Compose로의 전환은 단순한 도구 변경이 아닌 패러다임의 변화를 의미합니다.
전통적인 View 시스템에서는 사용자 상호작용으로 앱의 상태가 변경될 때마다 findViewById()와 같은 메서드를 사용해 뷰 트리를 직접 탐색하고 필요한 노드를 수동으로 변경해야 했습니다.
이러한 접근 방식은 여러 문제점을 가지고 있었습니다.
예시 :
val textView = findViewById<TextView>(R.id.text_view)
textView.text = "something"
이러한 방식의 문제점은 수동 조작으로 인한 오류 발생 위험 증가 여러 위치에서 같은 데이터를 렌더링할 때 일관성 유지의 어려움 등이 있었습니다.
이러한 문제를 해결하기 위해 Jetpack Compose라는 선언형 UI 프레임워크가 등장했습니다.
선언형 프로그래밍은 "어떻게(How)" 화면을 그릴지가 아닌 "무엇을(What)" 그릴지에 초점을 맞춥니다. Compose는 화면 전체를 생성한 후 필요한 부분만 효율적으로 재생성하는 방식으로 작동합니다.
예시 :
@Composable
fun ClickCounter(
clicks: Int,
onClick: () -> Unit
) {
Button(onClick = onClick) {
Text("$clicks times")
}
}
Compose의 특성으로 변경된 부분만 업데이트 하기 때문에 사용자가 버튼을 클릭할 때마다 clicks 값이 업데이트되면서 Text 부분만 업데이트되고 Button 자체는 재구성되지 않습니다.
이러한 방식을 지능적 재구성(recomposition)이라고 합니다.
이러한 방식을
지능적 재구성(Intelligent Recomposition)이라고 합니다.
함수에 전달된 데이터가 변경되었을 때만 해당 함수를 다시 호출하므로, 변경되지 않은 부분은 함수 호출을건너뛰게(skip)됩니다. 이는 성능 최적화에 큰 도움이 됩니다.
재구성 과정에서 가장 중요한 것은 사이드 이펙트를 피하는 것입니다.
사이드 이펙트란 Composable 함수의 범위 밖에서 발생하는 앱 상태 변경을 의미합니다.
이러한 변경은 예측하기 어렵기 때문에 UI 렌더링 시 예측할 수 없는 동작이 발생할 수 있습니다.
Compose에서는 상태를 적절히 관리하여 recomposition이 필요한 경우에만 발생하도록 하는 것이 중요합니다.
그럼 Compose에서 왜 recomposition이 일어나고 어떻게 상태를 관리하는 지 알아보겠습니다.
아래의 코드와 같이 사용자는 이벤트가 발생하면 값을 변경해 변경된 값으로 UI에 그리도록 하고 싶습니다.
// Wrong
@Composable
fun ComposeTest(modifier: Modifier = Modifier) {
var clicks = 0
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically)
) {
Button(
onClick = { clicks++ },
modifier = Modifier
.width(120.dp)
.height(70.dp)
){
Text("Click")
}
Text(
text = "I have been clicked $clicks times",
fontSize = 20.sp
)
}
}
하지만 이 코드는 사용자가 원하는 데로 UI가 변경되지 않습니다.
이 경우에는 Button을 몇 번을 눌러도 Text의 상태는 변하지 않습니다. (compose의 특성상 상태가 바뀌었다는 것을 알리지 못함)
이런 결과가 발생하는 이유는 Compose는 선언형 UI이기 때문에 "UI는 상태의 함수다"라는 개념을 기반으로 하기 때문에 상태가 바뀌어야지 UI도 변경됩니다.
그럼 상태를 바꾸기 위해선 mutableStateOf를 이용하면 된다고 나와있는데 이는 관찰 가능한(observable) 상태 객체를 통해서 상태 변경을 감지할 수 있습니다.
그럼 이와 같이 사용해도 될까요?
val clicks = mutableStateOf(0)
clicks.value = 10
이렇게 사용하면 다음과 같은 에러가 발생합니다.
• • •
State objects created during composition need to be remembered, otherwise they will be recreated during recomposition, and lose their state. Either hoist the state to an object that is not created during composition, or wrap the state in a call to remember
컴포지션 중에 생성된 State 객체는 remember를 사용하여 기억되어야 합니다. 그렇지 않으면 리컴포지션 중에 재생성되어 상태를 잃게 됩니다. 상태를 컴포지션 중에 생성되지 않는 객체로 호이스팅(끌어올리기)하거나, 상태를 remember 호출로 감싸십시오
• • •
즉, 상태를 호이스팅(매개변수로 상태 값을 받아옴)하거나 remember를 사용해서 상태를 저장 하도록 합니다.
remember를 사용하면 다음과 같이 변경 할 수 있습니다.
@Composable
fun ComposeTest(modifier: Modifier = Modifier) {
val clicks = remember { mutableStateOf(0) }
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically)
) {
Button(
onClick = { clicks.value++ },
modifier = Modifier
.width(120.dp)
.height(70.dp)
){
Text("Click")
}
Text(
text = "I have been clicked ${clicks.value} times",
fontSize = 20.sp
)
}
}

remember 함수는 어떻게 상태를 저장할까요?
내부 구조를 살펴보겠습니다
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
currentComposer.cache(false, calculation)
remember 함수는 Composable 함수가 재구성될 때 현재 composer의 캐시에서 값을 가져옵니다. 이를 통해 매 재구성마다 동일한 계산이 반복되는 것을 방지합니다.
여기서 crossinline 키워드는 calculation 람다가 비지역 반환(non-local return)을 사용할 수 없도록 제한하여 실행 흐름의 안전성을 보장합니다.
즉, calculation 람다는 반드시 실행되며 람다 내에서 외부 함수를 중단시키는 return을 사용할 수 없습니다.
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
@Suppress("UNCHECKED_CAST")
return rememberedValue().let {
if (invalid || it === Composer.Empty) {
val value = block()
updateRememberedValue(value)
value
} else it
} as T
}
처음에는 remember 함수가 자체적으로 값을 캐싱 한다고 생각했으나, 실제로는 Composer의 cache 메서드를 통해 객체의 존재 여부를 확인하고 필요할 때만 새 객체를 생성합니다.
그럼 객체 안의 값이 변경됐을 때 어떻게 알 수 있을까요?
값이 변경됐을 때 알 수 있는 방법은 mutableStateOf에 있습니다.
@StateFactoryMarker
fun <T> mutableStateOf(
value: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)
내부 구조에 대해서 아래와 같이 설명합니다.
Return a new MutableState initialized with the passed in value
The MutableState class is a single value holder whose reads and writes are observed by Compose. Additionally, writes to it are transacted as part of the Snapshot system.
A value holder where reads to the value property during the execution of a Composable function, the current RecomposeScope will be subscribed to changes of that value.
전달받은 값으로 초기화된 새 MutableState를 반환합니다.
MutableState 클래스는 단일 값을 보유하며, 이 값에 대한 읽기와 쓰기는 Compose에 의해 관찰됩니다. 또한, 이 값에 대한 쓰기 작업은 Snapshot 시스템의 일부로 처리됩니다.
MutableState는 값을 보유하는 홀더로, Composable 함수 실행 중에 value 속성을 읽으면 현재 RecomposeScope가 해당 값의 변경사항을 구독하게 됩니다.
• • •
즉, Composable 함수 실행 중에 value 속성을 읽으면 현재 RecomposeScope가 해당 값의 변경사항을 구독하게 됩니다.
value 속성이 쓰여지고 변경되면, 구독된 모든 RecomposeScope들의 재구성이 예약되며 만약, value에 동일한 값이 쓰여진다면, 재구성은 예약되지 않습니다.
mutableStateOf에서 사용되는 SnapshotMutationPolicy의 내부 구조도 살펴보겠습니다.
@JvmDefaultWithCompatibility
interface SnapshotMutationPolicy<T> {
fun equivalent(a: T, b: T): Boolean
fun merge(previous: T, current: T, applied: T): T? = null
}
equivalent 메서드를 통해 값이 동등한지 판단하고, 동등하지 않을 경우 recomposition을 트리거합니다.
merge 메서드를 통해 상태 변경 충돌을 해결합니다.
충돌이 발생하기 전의 원래 값(previous)과 현재 스냅샷의 값(current)을 비교해 다른 스냅샷에서 값을 적용합니다(applied).
기본적으로 structuralEqualityPolicy()를 사용하는데, 이는 Compose의 효율적인 렌더링과 상태 관리를 위해 구조적 동등성을 검사합니다.
Compose에서는 상태 변화를 감지하기 위해
mutableStateOf를 사용하고, 상태 객체가 재구성 시 유지되도록remember로 감싸야 합니다.
mutableStateOf는 값 변경을 관찰하고 필요할 때만 UI를 재구성하며,remember는 상태 객체를 메모리에 캐싱하여 재구성 간에 보존합니다.
remember를 통해 상태를 가지고 있고 상태 내부의 값이 변경 되면 재구성(recomposition)이 일어 난다는 것을 알았습니다.
안드로이드 공식 문서에서 Compose의 상태 복원에서는 rememberSavable를 소개하고 있습니다.
remember도 상태를 저장하고 있는데 필요 없는 것 아닌가? 하는 생각이 들 수도 있습니다.
그럼 rememberSaveable은 remember와 어떤 차이가 있는 지 알아보겠습니다.
먼저 rememberSaveable에 대한 설명을 먼저 읽어 보겠습니다.
• • •
Remember the value produced by init.
It behaves similarly to remember, but the stored value will survive the activity or process recreation using the saved instance state mechanism (for example it happens when the screen is rotated in the Android application)
init에 의해 생성된 값을 저장합니다.
이 함수는 remember와 비슷하게 동작하지만, 저장된 값은 액티비티나 프로세스가 재생성될 때도 saved instance state 메커니즘을 사용하여 유지됩니다 (예를 들어, 안드로이드 앱에서 화면이 회전될 때 발생합니다)
• • •
즉, remember 함수는
컴포저블 내에서 상태를 저장하지만, 구성 변경(configuration changes) 시 값이 소멸됩니다.
화면 회전이나 다크 모드 전환 등으로 액티비티가 재생성되면 저장된 값이 초기화됩니다.
rememberSaveable 함수는
remember의 기능을 확장해 구성 변경 후에도 값을 유지합니다.
안드로이드의 savedInstanceState 메커니즘을 활용하여 액티비티나 프로세스가 재생성될 때도 상태를 보존합니다.
화면 회전, 언어 변경, 다크 모드 전환 등의 상황에서도 데이터가 유지됩니다.
정말 rememberSaveable의 상태가 잘 유지 되는지 확인 해보겠습니다.
아래의 코드는 Click 버튼을 클릭했을 때 remember와 rememberSaveable의 상태가 바뀌면서 recomposition되어 상태를 업데이트하는데 remeberSaveable은 어떻게 상태를 복원할 수 있는 지 보겠습니다.
@Composable
fun FirstScreen(
onNavigateClick: () -> Unit,
modifier: Modifier = Modifier
) {
val rememberClicks = remember { mutableStateOf(0) }
val rememberSavableClicks = rememberSaveable { mutableStateOf(0) }
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically)
) {
Button(
onClick = {
rememberClicks.value++
rememberSavableClicks.value++
},
modifier = Modifier
.width(120.dp)
.height(70.dp)
){
Text("Click")
}
Text(
text = "rememberClicked : ${rememberClicks.value} times",
fontSize = 20.sp
)
Text(
text = "rememberSaveableClicked : ${rememberSavableClicks.value} times",
fontSize = 20.sp
)
Spacer(modifier = Modifier.height(50.dp))
Button(
onClick = onNavigateClick,
modifier = Modifier
.width(120.dp)
.height(70.dp)
) {
Text("화면 이동")
}
}
}
@Composable
fun SecondScreen(
onNavigateClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically)
) {
Text(
"SecondScreen"
)
Button(
onClick = onNavigateClick
) {
Text("뒤로 이동")
}
}
}
@Composable
fun MainNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = "FirstScreen",
modifier = modifier.fillMaxSize()
) {
composable(route = "FirstScreen") {
FirstScreen(
modifier = Modifier
.fillMaxSize(),
onNavigateClick = { navController.navigate("SecondScreen")}
)
}
composable(route = "SecondScreen") {
SecondScreen(
onNavigateClick = { navController.popBackStack() }
)
}
}
}

사용자가 화면이 동 버튼을 누르게 되면 FirstScreen의 Composition은 파괴되고 SecondScreen의 Composition이 새롭게 생성됩니다.
remember와 rememberSaveable 현재 Composition의 생명주기에 묶여있기 때문에 Composition이 파괴되면서 상태를 가지고 있는 값도 초기화됩니다.
하지만 rememberSaveable은 먼저 백스택이나 SavedInstanceState에 저장된 값이 있는지 확인합니다.
만약, 저장된 값이 있으면 그 값으로 객체를 초기화합니다.
결론적으로 SecondScreen에서 돌아왔을 rememberSavableClicks의 상태 값이 3으로 초기화되는 걸 볼 수 있습니다.

화면 회전 시 액티비티와 함께 Composition이 파괴됩니다.
하지만 rememberSaveable은 savedInstanceState에 저장된 값을 불러오기 때문에 액티비티가 재생성 되어도 3으로 다시 초기화되는 걸 볼 수 있습니다.
그럼 어떻게 다시 savedInstanceState에 저장된 값을 불러오는 지 내부구조를 보겠습니다.
@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
}
여기서 핵심만 살펴보면 LocalSaveableStateRegistry.current를 통해 현재 상태를 저장합니다.
이는 액티비티의 onSaveInstanceState와 연결되어 구성 변경 시에도 값을 유지합니다.
remember를 사용하여 컴포지션 내에서 상태 홀더를 유지하며 registry의 키에 해당하는 저장된 값이 있으면 saver.restore()를 사용하여 복원합니다.
이후 새로운 컴포지션이 일어나면 **SideEffect를 통해 상태를 업데이트합니다.
** SideEffect : 컴포지션 및 리컴포지션이 일어날 때마다 발생하는 사이드 이펙트
결론적으로 키에 해당하는 값을 복원하여 컴포지션 및 리컴포지션 될때마다 상태를 업데이트 할 수 있습니다.
rememberSaveable은 Bundle에 추가되는 모든 데이터 유형은 자동으로 저장됩니다.
Bundle에 추가할 수 없는 항목을 저장하려는 경우 몇 가지 옵션이 있는데 Parcelize, MapSaver, ListSaver 등을 이용하여 저장할 수 있습니다 -> 사용방법 보러가기
특히 제가 유용하게 사용했던 곳이 스크롤 상태를 유지할 때 사용했습니다.
기획적으로 어떠한 화면을 갔다가 다시 뒤로 돌아 왔을 때 스크롤의 상태가 유지되어 있어야 한다는 점에서 rememberSaveable은 강력한 상태 저장 방법이라고 생각합니다.
그럼 무조건 rememberSaveable만 사용하면 되는 거 아닌가요?
꼭 그렇지만은 않습니다.
rememberSaveable은 상태를 복원하는 과정에서 오버헤드가 크기 때문에 단순히 Composition 내에서만 필요한 상태라면 remember가 더 효율적입니다.
각각에 상황에서 알맞게 사용하는 것이 바람직합니다.
remember의 특징 :
화면 회전이나 언어 변경 같은 구성 변경이 발생하면 상태가 초기화됩니다.
저장/복원 과정이 없어rememberSaveable보다 더 가볍고 빠릅니다.
rememberSaveable 특징 :
화면 회전, 액티비티가 재생성되는 등의 상황에서도 상태가 보존됩니다.
기본적으로 Bundle에 추가되는 모든 데이터 유형은 자동으로 저장할 수 있으며, 저장하고 싶은 데이터를 커스텀하여 Saver에 저장하는 것도 가능합니다.
compose를 사용하면서 상태가 어떤 식으로 저장되고 또 어떻게 상태를 불러오는 건지 모른채 remember와 rememberSaveable를 사용했던 것 같습니다.
이제 두 개의 차이점도 알게 되었고, rememberSaveable은 remember를 기반으로 확장된 함수이며, remember는 상태를 컴포지션 내에서 저장하는 함수입니다.
그리고 MutableState와 같은 Compose 상태 객체를 통해 상태 변경을 감지하고 관리한다는 것을 알았습니다.