배경
NeveraApp.kt의 딥링크(NavigateToIngredientDetail) 처리 로직, 특히 ensureHomeAsBackStackRoot()(L105-127)를 개발하는 과정에서 로직이 너무 복잡해졌습니다. 복잡해진 원인을 검토해본 결과 별도 리팩토링이 필요하다고 판단되어, 추후 개선 사항으로 정리해 남겨둡니다.
문제
ensureHomeAsBackStackRoot()는 다음과 같이 if/else 두 분기로 나뉘어 있습니다:
// 콜드 스타트(앱 프로세스가 종료된 상태에서 알림 클릭)로 시작하면 SplashRoute부터 시작하므로
// HomeRoute가 백스택에 없을 수 있다.
private fun NavHostController.ensureHomeAsBackStackRoot() {
val hasHomeInBackStack = runCatching {
getBackStackEntry<HomeRoute>()
true
}.getOrDefault(false)
if (hasHomeInBackStack) {
// top-level 화면이 아닌 화면에서 알림을 받으면, top-level에 도달할 때까지 pop한다.
while (currentDestination?.let { dest ->
TopLevelDestination.entries.none { dest.matchesRoute(it.screenRouteClass) }
} == true
) {
if (!popBackStack()) break
}
} else {
// HomeRoute를 백스택 루트로 세워야 Fridge 진입 후 백키로 Home에 도달할 수 있다.
navigate(HomeRoute) {
popUpTo(graph.findStartDestination().id) { inclusive = true }
}
}
}
이 함수가 복잡한 이유는 else 분기 하나의 문제가 아니라, 하나의 LaunchedEffect 핸들러가 서로 다른 진입 경로(entry point)의 백스택 상태를 모두 정규화해야 하기 때문입니다.
if 분기 (while pop): 주로 인앱 NotificationScreen에서 알림 목록 아이템을 클릭한 경우를 위한 코드입니다. 이 시점에 HomeRoute는 이미 백스택 루트에 있고, 현재 destination은 NotificationRoute이므로 while 루프가 이를 pop해 top-level 화면으로 복귀시킵니다.
else 분기: 콜드 스타트(앱 프로세스 종료 상태)에서 FCM 알림 클릭 + 자동 로그인 실패로 Splash → AuthGraphRoute(Login)로 이동한 경우를 위한 코드입니다. awaitSplashExit()을 통과했지만 HomeRoute가 백스택에 없는 유일한 시점입니다.
그런데 else 분기는 이 경우에도 HomeRoute를 강제로 백스택 루트로 세워 Fridge까지 진입시킵니다. 비로그인 사용자를 Login 화면을 건너뛰고 Home/Fridge로 강제 진입시키는 셈이라 적절하지 않습니다.
근본 원인은 딥링크 처리(MainViewModel.dispatchDeeplink)가 로그인 여부와 무관하게 항상 즉시 navigate를 시도한다는 점입니다. 이 else 분기는 그 결과로 생긴 땜질에 가깝고, 동시에 서로 다른 진입 경로(인앱 알림함 클릭 / 콜드 스타트 알림 클릭)를 한 함수가 떠안고 있어 "지금 이 분기가 어떤 상황을 위한 것인지" 추론하는 비용이 큽니다.
제안 해결책
핵심 방향은 **"로그인 상태일 때만 딥링크를 즉시 처리하고, 비로그인 상태면 pending으로 저장해 두었다가 로그인 완료 시점에 처리"**하는 것입니다. 이렇게 하면 딥링크 side effect는 항상 HomeRoute가 백스택에 존재하는 시점에만 발생함이 보장되어, 위 else 분기 자체가 불필요해집니다.
최종 결과 — ensureHomeAsBackStackRoot() 단순화 (L105-127)
else 분기가 사라지면 남는 로직은 "Home 탭에서 깊이 들어간 화면들을 정리해 깨끗한 루트로 재설정"뿐이며, popUpTo<HomeRoute> { inclusive = true }로 한 번에 처리할 수 있습니다. 인앱 알림함 클릭(NotificationRoute 1단계)이든 콜드 스타트든 진입 경로를 구분할 필요 없이 동일하게 처리됩니다.
// Home 탭에서 깊은 화면까지 진입한 상태로 알림을 받으면, 이후 popUpTo<HomeRoute>{saveState=true}가
// 그 화면들을 Home에 연결해 저장해버려 Home 탭 복귀 시 엉뚱한 화면이 보이는 문제를 방지한다.
private fun NavHostController.resetHomeAsBackStackRoot() {
navigate(HomeRoute) {
popUpTo<HomeRoute> { inclusive = true }
}
}
호출부:
is DeeplinkAction.NavigateToIngredientDetail -> {
navController.awaitSplashExit()
navController.resetHomeAsBackStackRoot()
navController.navigate(TopLevelDestination.Fridge.route) {
popUpTo<HomeRoute> { saveState = true }
launchSingleTop = true
restoreState = true
}
}
runCatching, getBackStackEntry<HomeRoute>(), TopLevelDestination.entries/matchesRoute 의존, findStartDestination import가 모두 제거되어 함수가 훨씬 단순해집니다. (단, matchesRoute/findStartDestination은 다른 곳에서도 쓰이는지 확인 후 미사용 시에만 import 정리)
이를 위한 변경 사항
위 결과를 얻기 위해서는 "비로그인 시 pending 저장 → 로그인 완료 시 처리" 메커니즘이 먼저 필요합니다.
1. Pending Deeplink 저장소 추가
FcmTokenLocalDataSourceImpl(push_token DataStore)과 동일한 패턴으로 별도 DataStore를 추가합니다.
domain/repository/PendingDeeplinkRepository.kt
interface PendingDeeplinkRepository {
suspend fun save(deeplink: String)
suspend fun getAndClear(): String?
suspend fun clear()
}
data/datasource/PendingDeeplinkLocalDataSource(Impl).kt — preferencesDataStore(name = "pending_deeplink"), stringPreferencesKey로 마지막 1건만 저장(여러 알림 수신 시 최신 것만 유지)
data/repository/PendingDeeplinkRepositoryImpl.kt + DI 등록(RepositoryModule)
2. MainViewModel.dispatchDeeplink — 로그인 여부 분기
CheckAutoLoginUseCase로 로그인 여부를 확인해, 비로그인 상태면 즉시 처리하지 않고 저장만 합니다.
fun dispatchDeeplink(deeplink: String) {
viewModelScope.launch {
if (checkAutoLoginUseCase() == null) {
savePendingDeeplinkUseCase(deeplink)
return@launch
}
processDeeplink(deeplink)
}
}
fun processPendingDeeplinkIfAny() {
viewModelScope.launch {
val pending = getAndClearPendingDeeplinkUseCase() ?: return@launch
processDeeplink(pending)
}
}
private suspend fun processDeeplink(deeplink: String) {
when (val action = resolveDeeplinkUseCase(deeplink)) {
is DeeplinkAction.NavigateToIngredientDetail -> {
_sideEffectChannel.send(action)
dispatchIngredientFocusRequest(action)
}
null -> Timber.w("알 수 없는 deeplink 형식: $deeplink")
}
}
3. 로그인 완료 시점에 pending deeplink 처리 — NeveraApp.kt
HomeRoute에 도달하는 모든 경로(Splash→Home 자동 로그인 성공, Auth→Home 수동 로그인 성공, 탭 전환 등)에서 pending deeplink 유무를 확인합니다. pending이 없으면 단순 DataStore read라 비용은 무시할 수 있는 수준입니다.
LaunchedEffect(Unit) {
navController.currentBackStackEntryFlow
.filter { it.destination.hasRoute(HomeRoute::class) }
.collect { mainViewModel.processPendingDeeplinkIfAny() }
}
4. 로그아웃 시 pending deeplink 함께 제거 — LogoutUseCase
다른 사용자로 재로그인했을 때 이전 사용자의 알림 딥링크가 처리되는 것을 방지하기 위해, clearLoginInfo/clearFcmData와 함께 PendingDeeplinkRepository.clear()를 호출합니다.
class LogoutUseCase @Inject constructor(
private val authRepository: AuthRepository,
private val tokenRepository: TokenRepository,
private val fcmTokenRepository: FcmTokenRepository,
private val fcmSyncScheduler: FcmSyncScheduler,
private val pendingDeeplinkRepository: PendingDeeplinkRepository,
) {
suspend operator fun invoke(isDebug: Boolean): NeveraResult<MessageResult, LogoutError> {
return authRepository.logout()
.onSuccess {
cancelFcmSyncWork(isDebug)
clearLoginInfo(isDebug)
clearFcmData(isDebug)
clearPendingDeeplink(isDebug)
}
}
// ... clearPendingDeeplink(isDebug)도 동일한 try/catch 패턴으로 추가
}
참고
- 이 이슈는
app(MainViewModel, NeveraApp), domain, data 세 레이어에 걸친 변경입니다.
CheckAutoLoginUseCase는 로컬 토큰 존재 여부만 확인하므로, "토큰은 있지만 서버에서 만료되어 결국 Login으로 이동"하는 극단적 케이스는 이 설계로 커버되지 않습니다. 다만 기존에도 처리되지 않던 케이스라 새로운 리그레션은 아닙니다.
배경
NeveraApp.kt의 딥링크(NavigateToIngredientDetail) 처리 로직, 특히ensureHomeAsBackStackRoot()(L105-127)를 개발하는 과정에서 로직이 너무 복잡해졌습니다. 복잡해진 원인을 검토해본 결과 별도 리팩토링이 필요하다고 판단되어, 추후 개선 사항으로 정리해 남겨둡니다.문제
ensureHomeAsBackStackRoot()는 다음과 같이if/else두 분기로 나뉘어 있습니다:이 함수가 복잡한 이유는
else분기 하나의 문제가 아니라, 하나의LaunchedEffect핸들러가 서로 다른 진입 경로(entry point)의 백스택 상태를 모두 정규화해야 하기 때문입니다.if분기 (while pop): 주로 인앱 NotificationScreen에서 알림 목록 아이템을 클릭한 경우를 위한 코드입니다. 이 시점에HomeRoute는 이미 백스택 루트에 있고, 현재 destination은NotificationRoute이므로 while 루프가 이를 pop해 top-level 화면으로 복귀시킵니다.else분기: 콜드 스타트(앱 프로세스 종료 상태)에서 FCM 알림 클릭 + 자동 로그인 실패로 Splash →AuthGraphRoute(Login)로 이동한 경우를 위한 코드입니다.awaitSplashExit()을 통과했지만HomeRoute가 백스택에 없는 유일한 시점입니다.그런데
else분기는 이 경우에도HomeRoute를 강제로 백스택 루트로 세워 Fridge까지 진입시킵니다. 비로그인 사용자를 Login 화면을 건너뛰고 Home/Fridge로 강제 진입시키는 셈이라 적절하지 않습니다.근본 원인은 딥링크 처리(
MainViewModel.dispatchDeeplink)가 로그인 여부와 무관하게 항상 즉시 navigate를 시도한다는 점입니다. 이else분기는 그 결과로 생긴 땜질에 가깝고, 동시에 서로 다른 진입 경로(인앱 알림함 클릭 / 콜드 스타트 알림 클릭)를 한 함수가 떠안고 있어 "지금 이 분기가 어떤 상황을 위한 것인지" 추론하는 비용이 큽니다.제안 해결책
핵심 방향은 **"로그인 상태일 때만 딥링크를 즉시 처리하고, 비로그인 상태면 pending으로 저장해 두었다가 로그인 완료 시점에 처리"**하는 것입니다. 이렇게 하면 딥링크 side effect는 항상
HomeRoute가 백스택에 존재하는 시점에만 발생함이 보장되어, 위else분기 자체가 불필요해집니다.최종 결과 —
ensureHomeAsBackStackRoot()단순화 (L105-127)else분기가 사라지면 남는 로직은 "Home 탭에서 깊이 들어간 화면들을 정리해 깨끗한 루트로 재설정"뿐이며,popUpTo<HomeRoute> { inclusive = true }로 한 번에 처리할 수 있습니다. 인앱 알림함 클릭(NotificationRoute 1단계)이든 콜드 스타트든 진입 경로를 구분할 필요 없이 동일하게 처리됩니다.호출부:
runCatching,getBackStackEntry<HomeRoute>(),TopLevelDestination.entries/matchesRoute의존,findStartDestinationimport가 모두 제거되어 함수가 훨씬 단순해집니다. (단,matchesRoute/findStartDestination은 다른 곳에서도 쓰이는지 확인 후 미사용 시에만 import 정리)이를 위한 변경 사항
위 결과를 얻기 위해서는 "비로그인 시 pending 저장 → 로그인 완료 시 처리" 메커니즘이 먼저 필요합니다.
1. Pending Deeplink 저장소 추가
FcmTokenLocalDataSourceImpl(push_tokenDataStore)과 동일한 패턴으로 별도 DataStore를 추가합니다.domain/repository/PendingDeeplinkRepository.ktdata/datasource/PendingDeeplinkLocalDataSource(Impl).kt—preferencesDataStore(name = "pending_deeplink"),stringPreferencesKey로 마지막 1건만 저장(여러 알림 수신 시 최신 것만 유지)data/repository/PendingDeeplinkRepositoryImpl.kt+ DI 등록(RepositoryModule)2.
MainViewModel.dispatchDeeplink— 로그인 여부 분기CheckAutoLoginUseCase로 로그인 여부를 확인해, 비로그인 상태면 즉시 처리하지 않고 저장만 합니다.3. 로그인 완료 시점에 pending deeplink 처리 —
NeveraApp.ktHomeRoute에 도달하는 모든 경로(Splash→Home 자동 로그인 성공, Auth→Home 수동 로그인 성공, 탭 전환 등)에서 pending deeplink 유무를 확인합니다. pending이 없으면 단순 DataStore read라 비용은 무시할 수 있는 수준입니다.4. 로그아웃 시 pending deeplink 함께 제거 —
LogoutUseCase다른 사용자로 재로그인했을 때 이전 사용자의 알림 딥링크가 처리되는 것을 방지하기 위해,
clearLoginInfo/clearFcmData와 함께PendingDeeplinkRepository.clear()를 호출합니다.참고
app(MainViewModel,NeveraApp),domain,data세 레이어에 걸친 변경입니다.CheckAutoLoginUseCase는 로컬 토큰 존재 여부만 확인하므로, "토큰은 있지만 서버에서 만료되어 결국 Login으로 이동"하는 극단적 케이스는 이 설계로 커버되지 않습니다. 다만 기존에도 처리되지 않던 케이스라 새로운 리그레션은 아닙니다.