
사진: Unsplash의Vidar Nordli-Mathisen
이번 게시글에서 Compose Navigation을 사용하면서 발생한 Recomposition 문제, 그리고 그 원인과 해결방안에 대해 이야기해보려고 합니다.
Recomposition으로 인한 문제 발생
어느 때와 같이 안드로이드 개발을 하던 도중 문제가 발생했습니다.
바로 NavHost가 리컴포지션 되면, 현재 화면이 계속 startDestination으로 되돌아간다는 문제였습니다.
제가 정의한 NavHost는 다음과 같습니다.
NavHost(
navController = navigator.navController,
startDestination = navigator.startDestination,
) {
composable<Route.Home> {
HomeScreen()
}
composable<Route.Detail>(
typeMap = mapOf(typeOf<DetailInfo>() to customNavType<DetailInfo>()),
) {
DetailScreen()
}
}
// customNavType은 특정 데이터 클래스를 Navigation 인자로 주고받을 수 있게 하는 함수입니다.
inline fun <reified T : Any> customNavType(): NavType<T> {
return object : NavType<T>(true) {
override fun get(bundle: Bundle, key: String): T? {
val json = bundle.getString(key)
return json?.let {
Json.decodeFromString(serializer(), URLDecoder.decode(it, "UTF-8"))
}
}
override fun parseValue(value: String): T {
return Json.decodeFromString(serializer(), URLDecoder.decode(value, "UTF-8"))
}
override fun put(bundle: Bundle, key: String, value: T) {
val json = Json.encodeToString(serializer(), value)
bundle.putString(key, URLEncoder.encode(json, "UTF-8"))
}
override fun serializeAsValue(value: T): String {
return URLEncoder.encode(Json.encodeToString(serializer(), value), "UTF-8")
}
}
}
어디가 문제인지 바로 보이시나요? 만약 그렇다면, 이미 Compose Navigation의 동작 방식을 깊게 이해하고 계신 분이라 생각합니다.
NavHost가 리컴포지션 된 이유에 대해 궁금하실 수 도 있을거 같아서 말씀드리자면, uiMode 변경을 위해서 테마를 변경해야 할 때 NavHost가 리컴포지션 되었습니다
NavHost를 들여다보기
오랜 시간 삽질 끝에 NavHost의 내부 구현을 다시 보다가, 놓치고 있던 핵심을 발견했습니다.
Compose Navigation의 NavHost는 내부에서 다음과 같이 NavGraph를 생성합니다.
NavHost.kt
@Composable
public fun NavHost(
navController: NavHostController,
startDestination: Any,
modifier: Modifier = Modifier,
...
builder: NavGraphBuilder.() -> Unit
) {
NavHost(
navController,
remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, typeMap, builder)
},
modifier,
contentAlignment,
enterTransition,
exitTransition,
popEnterTransition,
popExitTransition,
sizeTransform
)
}
여기서 중요한 부분은 바로 이 코드입니다.
remember(route, startDestination, builder) {
navController.createGraph(startDestination, route, typeMap, builder)
}
즉, NavGraph는 route, startDestination, 그리고 builder 람다의 변경 여부를 기준으로 재성성됩니다.
문제의 핵심: builder 람다
컴포저블 함수 내부에서 람다를 정의하면, 기본적으로 리컴포지션마다 새로운 람다 인스턴스가 생성됩니다.
단, 캡처(capture)가 없는 람다의 경우 Kotlin 컴파일러가 이를 최적화하여 단일 인스턴스로 재사용할 수 있게 합니다.
그럼 다시 한번 제가 작성한 NavHost를 보겠습니다. 제가 작성한 NavHost의 builder에서는 `customNavType()` 메서드를 호출하면서 캡처를 하고 있습니다.
NavHost(
navController = navigator.navController,
startDestination = navigator.startDestination,
) {
composable<Route.Home> {
HomeScreen()
}
composable<Route.Detail>(
// 캡처를 하고 있다......
typeMap = mapOf(typeOf<DetailInfo>() to customNavType<DetailInfo>()),
) {
DetailScreen()
}
}
여기서 customNavType()을 호출하는 순간 builder 람다는 외부 변수를 참조하며 캡처된 람다가 되고, 따라서 리컴포지션이 발생할 때마다 새로운 람다 인스턴스가 생성됩니다.
그러면 builder 키가 변경되었다고 인식되어 remember가 NavGraph를 새로 만들게 됩니다.
그래서 화면이 계속 startDestination으로 되돌아가는 현상이 나타난 것입니다.
해결방안
1차원적인 해결 방안은 NavHost builder 내부에서 캡처가 일어나지 않도록 만드는 것입니다.
그러나, 제 코드처럼 캡처를 해야할 상황이 발생한다면, builder 람다를 remember를 통해 리컴포지션이 발생하더라도 동일한 인스턴스가 유지되로록하여 NavGraph가 새로 만들어지지 않게 할 수 있습니다.
val graphBuilder: NavGraphBuilder.() -> Unit = remember(navigator.navController) {
composable<Route.Home> {
HomeScreen()
}
composable<Route.Detail>(
// 캡처를 하고 있다......
typeMap = mapOf(typeOf<DetailInfo>() to customNavType<DetailInfo>()),
) {
DetailScreen()
}
}
이렇게하면 빌더 람다는 NavHost가 리컴포지션 되어도 새로운 인스턴스로 교체되지 않고, NavGraph가 불필요하게 재성성되는 문제도 해결됩니다.
추가로 Navigation3가 나오면서 이제 BackStack을 직접 제어하게 되었습니다. 그러니 Navigation으로 마이그레이션을 진행해도 문제 해결이 가능합니다.
"난 이런 문제가 없었는데" 라고 생각하셨다면
혹시 이렇게 생각하신 분들이 있으시다면, 상황이 안만들어졌을 확률이 높습니다.
AndroidManifest에서 구성 변경 관련 설정을 하지 않았다면, 구성 변경 시 Activity는 재생성됩니다.
이때 Compose Navigation은 NavController의 상태 저장 매커니즘을 활용하여 현재 navigation back stack 상태를 savedInstanceState에 저장합니다.
Activity가 다시 생성된 후 onCreate()에 전달된 savedInstanceState를 기반으로 NavController는 back stack을 복구합니다.
이 시점에서는 기존 Composition이 유지되는 것이 아니라 완전히 새 Composition이 시작되기 때문에, 이전에 리컴포지션 과정에서 발생하던 문제는 동일하게 발생하지 않습니다.
마무리
해당 내용은 일반적으로 발생하는 상황은 아닙니다. 여러 조건이 우연히 겹치며 생각지도 못한 부작용이 발생한 케이스였습니다.
다만 이런 경험을 통해 예상하지 못했던 부분들을 되짚어보고 더 깊게 이해할 수 있었고, 이러한 내용을 공유하면서 저와 비슷한 문제를 겪고 있거나 앞으로 겪게 될 분들에게 도움이 될 수 있을 것 같아 이렇게 게시글을 작성하게 되었습니다.
'Android > Compose' 카테고리의 다른 글
| Recomposition 최적화와 Stable 타입의 관계 (0) | 2026.01.07 |
|---|---|
| Compose TextField를 TextFieldState로 사용하는 것에 대해 (0) | 2025.09.09 |
| ViewModel 톺아보기 (1) - ViewModel의 생성과 관리 (0) | 2024.11.18 |
| SavedStateHandle을 통해 Compose Navigation간 데이터 전달하기 (3) | 2024.09.23 |
| Compose의 Side-effects (0) | 2024.08.17 |