Skip to content

refactor(app): 비로그인 상태 딥링크 수신 시 pending 저장 후 로그인 완료 시점에 처리하도록 개선 #140

Description

@Minsu-Lee

배경

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).ktpreferencesDataStore(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으로 이동"하는 극단적 케이스는 이 설계로 커버되지 않습니다. 다만 기존에도 처리되지 않던 케이스라 새로운 리그레션은 아닙니다.

Metadata

Metadata

Assignees

No one assigned

    Labels

    refactor코드 리팩토링

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions