앞서 navOptions와 restoreState에 대해 다루었다면,
이번에는 Type-Safe Navigation에서 route의 파라미터 값에 따라 달라지는 id 문제로 인해
restoreState가 의도한 대로 동작하지 않았던 케이스를 정리해보려 합니다.
Matching 탭의 navigation 코드는 다음과 같습니다.
// MatchingNavigation.kt 일부
fun NavController.navigateToMatching(
initTab: MatchingType = MatchingType.RECEIVE,
navOptions: NavOptions? = null,
) = navigate(Matching(initTab = initTab), navOptions)
@Serializable
data class Matching(
val initTab: MatchingType = MatchingType.RECEIVE,
) : MainTabRoute
그리고 ViewModel에서는 다음과 같이 초기 탭을 설정합니다.
private val initTab = savedStateHandle.toRoute<Matching>().initTab
private val _uiState = MutableStateFlow(
MatchingContract.State(selectedType = initTab)
)
Type-Safe Navigation을 활용해 Matching(initTab = ACCEPTED)와 같은 형태로
직렬화된 route를 생성하고, 이를 기반으로 ViewModel의 초기 상태를 구성하는 방식입니다.
다음과 같은 흐름에서는 문제가 없었습니다.
홈 → 매칭(확정) → 홈 → 매칭
이 경우, 매칭 화면에서 ACCEPTED로 탭을 변경한 뒤 홈으로 이동했다가 다시 매칭으로 돌아오면 기존에 선택한 ACCEPTED 탭이 그대로 유지됩니다.
이는 restoreState 로 인해 기존 NavBackStackEntry와 ViewModel이 복구되었기 때문입니다.
즉, ViewModel이 재생성되지 않고 복구되기 때문에
selectedType = ACCEPTED 상태가 그대로 유지되는 것이죠.
문제는 다음 흐름에서 발생합니다.
홈 → 매칭(확정) → 홈 → 알림 → 매칭(받은) → 홈 → 매칭
여기서 기대했던 동작은 다음과 같습니다.
navigate(initTab = RECEIVE)를 호출하면 RECEIVE 탭이 선택됨하지만 실제 동작은 다음과 같았습니다.
ACCEPTED → RECEIVE → ACCEPTED
알림에서 RECEIVE는 정상 적용되지만, 이후 탭으로 매칭에 진입하면 기존에 선택했던 ACCEPTED가 다시 선택되는 현상이 발생했습니다.

홈 → 매칭(확정) → 홈 → 알림 → 매칭(받은) → 홈 → 매칭(기존: 확정)
로그를 찍어보니 결정적인 힌트가 있었습니다.
route의 파라미터 값에 따라 NavBackStackEntry의 id 값이 달라지고 있었습니다.
Matching(initTab = ACCEPTED)Matching(initTab = RECEIVE)
서로 다른 route 문자열을 생성하고, Navigation 내부적으로도 서로 다른 엔트리로 취급하고 있었습니다.

엔트리ID 값이 달라짐..
그 이유를 찾아보니, 바텀 탭 이동에는 restore 로직이 존재(restoreState = true) 하였으나 알림 화면에서의 navigate에는 restore 로직이 없었습니다.
결과적으로,
즉, 매칭 화면이 2개의 다른 BackStackEntry로 공존하는 상태가 되었던 것입니다.
처음에는 알림에서 매칭으로 이동할 때도 restoreState = true를 적용하면 문제가 해결될 것이라 생각했습니다.
하지만 이 방식은 구조적으로 한계가 있었습니다.
restoreState는 단순히 화면을 다시 그리는 옵션이 아니라, 기존 NavBackStackEntry 자체를 복구하는 동작입니다.
즉, 엔트리를 새로 생성하는 것이 아니라 이전에 저장된 엔트리를 그대로 되살립니다.
이 경우, 해당 엔트리에 연결된 ViewModel과 SavedStateHandle도 함께 복구됩니다.
savedStateHandle.toRoute<Matching>().initTab
이 코드는 이 엔트리가 처음 생성될 때 전달된 route 파라미터를 읽습니다.
하지만 restore가 개입하면 엔트리는 이전에 설정된 값인 initTab 의 초기 파라미터 값으로 설정되어, 가장 최신에 전달된 파라미터 값으로 복구하기는 어려웠습니다.
route는 초기 생성 시점의 입력값restore는 기존 상태의 복구라는 서로 다른 역할을 가지게 되고, restore가 적용된 구조에서는 route 파라미터를 통해 상태를 갱신할 수 없게 됩니다.
따라서 navigate(initTab = RECEIVE)를 호출하더라도, 이미 복구된 엔트리는 최초에 설정된 값(예: ACCEPTED)을 그대로 유지하게 됩니다.
결론적으로, restore가 적용되는 구조에서는 route 파라미터 방식만으로 상태를 변경할 수 없었습니다.
결국 해결 방법은 다음과 같이 수정했고, 해당 PR 에 적용되어 있습니다.
savedStateHandle에 값을 명시적으로 setget하여 ViewModel 상태를 업데이트private object MatchingArgs {
const val INIT_TAB = "matching_init_tab"
}
fun NavController.setMatchingArgs(tab: MatchingType?) { // navigate 시 저장
getBackStackEntry(Matching).savedStateHandle[MatchingArgs.INIT_TAB] = tab
}
fun SavedStateHandle.getMatchingArgs(): MatchingType? { // Matching 화면 get
return get<MatchingType>(MatchingArgs.INIT_TAB)
}
fun SavedStateHandle.removeMatchingArgs() { // Matching 화면 get 이후 삭제
remove<MatchingType>(MatchingArgs.INIT_TAB)
}
탭 전환 시에는 다음과 같은 NavOptions를 사용하도록 정리했습니다.
/**
* 백스택을 초기화하면서도 이전 Destination의 상태를 저장하고 복원하는 NavOptions를 생성합니다.
*
* 사용 사례:
* - 탭 전환 시 백스택 클리어 + 이전 탭 상태 복원 (Bottom Navigation)
* - 특정 화면에서 메인으로 복귀하되 상태 유지가 필요한 경우
*/
fun clearBackStackWithRestoreNavOptions() = navOptions {
popUpTo(0) {
saveState = true
inclusive = true
}
launchSingleTop = true
restoreState = true
}
해당 방식은 다음과 같은 역할을 합니다.
그리고 상태 변경이 필요한 경우에는 route가 아닌 SavedStateHandle을 통해 명시적으로 제어하도록 구조를 분리했습니다.
이번 이슈를 통해 정리할 수 있었던 포인트는 다음과 같습니다.
savedStateHandle을 직접 제어하는 방식이 더 명확하다.