restoreState로 인해 발생하는 SideEffect와 해결 방법 in Compose Navigation

Ryan·2026년 3월 20일

Android

목록 보기
4/5
post-thumbnail

현재 스프린트를 진행중인 스매싱 프로젝트에서 navigation 리팩 작업을 하면서 저도 헷갈리는 것들이 있고, 앞으로 화면 라우팅을 세팅할때 어떻게 하면 좋을 지 고민해 본 내용을 적어보았습니다.

기존 메인 탭 화면의 경우는 아래와 같았습니다.

val navOptions = navOptions {
    navController.currentDestination?.route?.let {
        popUpTo(it) {
            saveState = true
            inclusive = true
        }
        launchSingleTop = true
        restoreState = true
    }
}

when (tab) {
    MainTab.HOME -> navController.navigateToHome(navOptions = navOptions)
    MainTab.SEARCH -> navController.navigateToSearch(navOptions = navOptions)
    MainTab.MATCHING -> navController.navigateToMatching(navOptions = navOptions)
    MainTab.PROFILE -> navController.navigateToMyProfile(navOptions = navOptions)
}

여기서 navOptions란?

navOptionsnavigate() 호출 시 네비게이션 동작을 제어하기 위한 옵션 모음입니다.
(이동하면서 백스택을 어떻게 다룰지 / 기존 상태를 어떻게 처리할지를 결정)

  • popUpTo(id/route): 특정 목적지까지 백스택을 pop(제거)하겠다는 의미입니다.
    • inclusive: popUpTo로 지정한 목적지까지 포함해서 제거할지 여부
    • saveState: popUpTo로 인해 제거되는 목적지의 SavedState 기반 상태를 저장합니다. 예를 들어 home → search로 이동할 때, home 화면의 rememberSaveable 상태나 스크롤 위치, viewmodel 등 복원 가능한 UI 상태가 저장될 수 있습니다.
  • launchSingleTop: 이동하려는 목적지가 이미 백스택 top에 있다면 중복으로 쌓지 않습니다.
  • restoreState: 과거에 saveState = true로 저장해둔 상태가 있다면, navigate() 시점에 이를 복원합니다.
    • saveState와 함께 사용될 때 의미가 있으며, 저장된 상태가 없으면 복원되지 않습니다.

기존 메인 탭 이동 시 실제 동작 정리

위 내용을 통해, 정리하자면 기존 메인 탭의 경우는 탭 간 이동시 아래와 같이 동작합니다.

  • popUpTo(it) { inclusive = true } → 현재 화면을 스택에서 제거
  • popUpTo(it) { saveState = true } → 현재 화면의 상태를 저장
  • launchSingleTop = true → 지금 이동하는 라우트는 inclusive = true 로 항상 새로 생성되기 때문에 스택에 존재하는 라우트가 없음. 현재로서는 큰 의미 없음
  • restoreState = true → 만약 과거에 saveState로 저장된 상태가 있다면 이를 복구

여기서 주의 깊게 봐야할 부분은 saveStaerestoreState 부분 입니다.

해당 값으로 인해 NavBackStackEntry 는 복구되고 viewmodel 은 다시 복구 됩니다.

(viewmodel 은 ViewmodelStore 를 key 처럼 사용해서 기존에 값이 있으면 복구하고, NavBackStackEntry 는 ViewmodelStore 를 가지고 있는 ViewModelStoreOwner 를 상속 받고 있습니다.)

viewmodel 복구 방식 참고: https://yangsooplus.tistory.com/8

Viewmodel 의 복구로 인해 생기는 SideEffect

상태가 유지되는 방식(특히 스크롤 위치 유지)은 UX 관점에서는 장점이 될 수 있지만, 구현 방식에 따라 의도치 않은 사이드 이펙트를 만들 수 있습니다.

대표적으로 Home에서 Search 화면으로 이동한 뒤 스크롤을 내렸다가, 다시 탭 이동/복귀를 반복하는 경우:

  • 스크롤 위치가 유지되면서 화면이 “이전 상태 그대로”로 복구됨
  • 그 결과, UI 진입 시점에 실행되는 로직(예: LaunchedEffect 기반 fetch)이 무한 스크롤 조건과 결합되면 연쇄적으로 fetch가 발생하게 됩니다.

정리하자면, 기존에 저희는 LaunchedEffect화면이 구성될 때마다 fetch 하는 로직을 넣어두었습니다.

이로 인해, 탭 이동/복귀 과정에서 UI 상태가 유지된 채로 재구성되는 상황이 생기다 보니, 무한 스크롤 조건과 결합되면 연쇄적으로 fetch하는 케이스가 발생했습니다.

그렇다면 해결 방법은?

  1. savedStateHandle 로 refresh 트리거 하기
    이전 화면에서 Search를 새로고침 해야 하는 상황이 명확한 경우, SavedStateHandle로 refresh 플래그를 전달하고 Search 쪽에서 이를 받아 refresh()를 수행하는 방법입니다.

    • 장점: 어떤 이벤트로 refresh가 발생하는지가 명확해지고, 네트워크 호출을 강하게 제어할 수 있음
    • 단점: Search로 이동하는 경로(혹은 Search로 돌아오는 경로)마다 플래그 세팅이 필요해져 전파 비용이 늘고, 흐름이 많아지면 복잡성이 올라갈 수 있음
  2. 상태 값을 기준으로 fetch 트리거를 제한하기

    Search에서 fetch가 필요해지는 조건이 실제로는 아래처럼 정해져 있었습니다.

    • 지역 변경(Region 복귀 이후)
    • 티어 변경
    • 성별 변경
    • 바텀탭 이동(탭 복귀)

    이 중에서 지역/티어/성별 변경은 검색 조건이 바뀐 것이기 때문에,
    해당 값이 변경될 때는 아래를 함께 수행하도록 했습니다.

    • 스크롤을 최상단으로 초기화
    • fetch 실행

    반대로 바텀탭 이동/복귀는 조건이 바뀐 것이 아니라 화면을 다시 보는 것이므로, 탭 복귀 시에는 스크롤 상태를 그대로 유지하고 fetch를 실행하지 않는 방향을 선택할 수 있습니다.

결론적으로 제가 선택한 방식은 바텀탭 클릭 시마다 자동으로 fetch를 수행하기보다는,

  • 조건(지역/티어/성별) 변경 시에만 fetch
  • 바텀탭 복귀 시에는 상태 유지
  • 데이터 최신화는 추후 사용자 액션 기반(끌어서 새로고침 등) 으로 제공

이 방식이 네트워크 호출을 예측 가능하게 만들고, 상태 복원의 장점도 살릴 수 있다고 판단했습니다.



PS. 최근 Navigation3와 같은 새로운 네비게이션 구조가 등장했지만, 이번에는 기존 Navigation 구조를 충분히 이해하고 문제를 해결하는 데 집중했습니다.
추후 안정화 시점에 맞춰 Navigation3 도입을 고려해 리팩토링할 예정입니다 :)

profile
Seungjun Gong

0개의 댓글