Android

navigateUp과 popBackStack 비교 (뒤로가기 클릭 금지)

Mash-Up Android 2025. 4. 5. 15:00

 

안녕하세요! Mash-Up 15기로 활동하고 있는 이재성입니다.

 

화면 간 전환을 하실때 Jetpack Navigation을 정말 많이 사용하고 있으실텐데요. 이 글에서는 Navigation에서 제공하는 뒤로가기 API인 navigateUp과 popBackStack에 대한 올바른 사용법과 함께 내부 구현을 비교합니다.

 

개발자 문서에서 설명하고 있는 navigateUp과 popBackStack

개발자 문서에서는 아래처럼 설명하고 있습니다.

Popping top destination: Tapping Up or Back calls the NavController.navigateUp() and NavController.popBackStack() methods, respectively.
They pop the top destination off the stack. See the Principles of Navigation page for more information about the difference between Up and Back.

 

"위로(Up)"와 "뒤로(Back)"를 클릭하면 각각 navigateUp, popBackStack을 실행한다고 설명하고 있습니다.

 

여기서 말하는 "위로"는 AppBar를 통해 구현할 수 있는 꺾쇠 아이콘에 해당합니다. 설명에 따르면 해당 아이콘을 통해 뒤로가기를 할 수 있게 되고, 구현상으로는 navigateUp을 호출하는 것을 권장하고 있습니다.

"뒤로"는 안드로이드 단말기 하단의 NavigationBar에 있는 물리 백 버튼을 의미합니다. 

 

일반적으로는 두 버튼 모두 History Back에 해당하게 됩니다. History Back은 사용자가 앱에 구성된 화면 간 Navigation 상에서 뒤로가기를 할 때, 최근에 방문한 화면의 사용 순서를 역순으로 이동하는 것을 의미합니다.

 

약간의 차이점이 있다면, 사용자가 앱의 Navigation 상에서 최상단 화면에 있다면, "위로"버튼이 표시될 필요가 없겠죠. 이 때는 "뒤로"버튼을 통해 이탈할 수 있게 됩니다. 즉, "위로"버튼으로는 최상단 화면에서 이탈할 수 없다는 것을 의미합니다.

 

popBackStack의 잘못된 사용?

어차피 navigateUp과 popBackStack 모두 History Back에 사용할 수 있고, BackStack 상에서 현재 화면을 pop시키는 것인데 굳이 불편하게 두개를 나눠서 사용해야 하나? 하는 생각이 드실 수도 있습니다.

 

위 이미지는 뒤로가기를 함과 동시에 갑자기 빈 화면이 나오는 잘못된 부작용이 발생하는 예시입니다.

popBackStack을 잘못 사용했을 때 발생할 수 있는데요, 저도 회사에서 Navigation 작업을 하다가 마주친 적이 있어 적잖게 당황한 적이 있었습니다.

 

뒤로가기 버튼을 클릭해 popBackStack을 호출하게 되면 이전 화면으로 전환하는 애니메이션이 시작되게 됩니다.

이 애니메이션이 실행되는 동안에 사용자는 뒤로가기를 두번 호출할 수도 있는 시간적 여유가 생기는 셈인데요, 이 과정에서 사용자는 뒤로가기를 무한하게 클릭할 수도 있게 됩니다.

 

기본적으로 popBackStack은 현재 백스택에서 해당 화면을 pop하려고 하는 것을 시도하기 때문에, 만약 사용자가 최상단 화면에 있다 하더라도 pop을 시도하게 됩니다. 이 경우, 최상단 화면이 pop되고 결과적으로는 화면을 구성하는 NavHost까지 pop될 수도 있는 상황이 만들어지게 됩니다.

즉, Composable 계층에 아무 컴포넌트가 존재하지 않기 때문에 위 예시처럼 빈 화면이 나올 수도 있게 되며, 이러한 경우는 사용자 입장에서 좋지않은 UX로 직결될 것입니다.

 

이러한 상황은 어떻게 해결할 수 있을까요? 지금부터는 각 API의 내부구현을 통해 알아보겠습니다.

 

popBackStack 내부 동작

@MainThread
public open fun popBackStack(): Boolean {
    return if (backQueue.isEmpty()) {
        // Nothing to pop if the back stack is empty
        false
    } else {
        popBackStack(currentDestination!!.id, true)
    }
}



popBackStack은 현재 NavController 상의 BackStack에서 pop을 시도하는 메서드입니다.

backQueue가 비어있을 때 false를 반환하게 되는데, 이는 현재 NavController가 제어하는 Graph 상에 Destination이 모두 비어있음을 의미하며, NavController#getCurrentDestination이 null을 반환하는 것과 동일한 의미를 갖게됩니다.

즉, 이 경우에는 아무것도 pop을 할 수 없으므로 false를 반환하게 됩니다. 

 

@MainThread
private fun popBackStackInternal(
    @IdRes destinationId: Int,
    inclusive: Boolean,
    saveState: Boolean = false
): Boolean {
    if (backQueue.isEmpty()) {
        // Nothing to pop if the back stack is empty
        return false
    }
    val popOperations = mutableListOf<Navigator<*>>()
    val iterator = backQueue.reversed().iterator()
    var foundDestination: NavDestination? = null
    while (iterator.hasNext()) {
        val destination = iterator.next().destination
        val navigator = _navigatorProvider.getNavigator<Navigator<*>>(destination.navigatorName)
        if (inclusive || destination.id != destinationId) {
            popOperations.add(navigator)
        }
        if (destination.id == destinationId) {
            foundDestination = destination
            break
        }
    }
    if (foundDestination == null) {
        // We were passed a destinationId that doesn't exist on our back stack.
        // Better to ignore the popBackStack than accidentally popping the entire stack
        val destinationName = NavDestination.getDisplayName(context, destinationId)
        Log.i(
            TAG,
            "Ignoring popBackStack to destination $destinationName as it was not found " +
                "on the current back stack"
        )
        return false
    }
    return executePopOperations(popOperations, foundDestination, inclusive, saveState)
}

 

실제 popBackStack의 구현체는 위 메서드에 해당합니다.

현재 BackQueue에 담겨있는 NavBackStackEntry의 역순을 기준으로 순회하며, 전달된 destinationId값과 BackQueue에서 순회하며 조회하는 destinationId값이 일치할 경우 해당 Destination에 대해 popOperation을 실행하게 됩니다.

 

private fun executePopOperations(
    popOperations: List<Navigator<*>>,
    foundDestination: NavDestination,
    inclusive: Boolean,
    saveState: Boolean,
): Boolean {
    var popped = false
    val savedState = ArrayDeque<NavBackStackEntryState>()
    for (navigator in popOperations) {
        var receivedPop = false
        navigator.popBackStackInternal(backQueue.last(), saveState) { entry ->
            receivedPop = true
            popped = true
            popEntryFromBackStack(entry, saveState, savedState)
        }
        if (!receivedPop) {
            // The pop did not complete successfully, so stop immediately
            break
        }
    }
    ...
    updateOnBackPressedCallbackEnabled()
    return popped
}

 

popOperation을 실행하는 과정에서 현재 BackQueue의 마지막 NavBackStackEntry에 대해 해당 Navigator에서 먼저 popBackStackInternal을 수행하게 됩니다.

이 때, 정상적으로 Navigator의 BackStack pop이 완료되게 되면 NavController의 BackQueue 상에서 해당 Entry에 대한 pop을 진행합니다.

 

navigateUp 내부 동작

@MainThread
public open fun navigateUp(): Boolean {
    // If there's only one entry, then we may have deep linked into a specific destination
    // on another task.
    if (destinationCountOnBackStack == 1) {
        val extras = activity?.intent?.extras
        if (extras?.getIntArray(KEY_DEEP_LINK_IDS) != null) {
            return tryRelaunchUpToExplicitStack()
        } else {
            return tryRelaunchUpToGeneratedStack()
        }
    } else {
        return popBackStack()
    }
}

 

navigateUp은 popBackStack과 의도 자체가 다릅니다.

navigateUp을 사용하는 경우는 사용자를 특정 화면으로 되돌리려는 의도를 가질 때입니다. 

 

또한, DeepLink 처리 방식에서도 차이점을 갖게 됩니다.

다른 앱에서 DeepLink를 통해 우리 앱으로 들어오게 될 경우 navigateUp을 실행하면 우리 앱을 이탈한 후, 실행시킨 앱으로 이동하게 됩니다.

반면, popBackStack은 우리 앱 내의 BackStack 안에서 뒤로가기를 시도합니다.

 

위 구현은 딥링크에 대한 처리 방식을 나타내고 있습니다.

 

navigateUp과 popBackStack의 올바른 사용 방법

위 내부 구현을 통해 아래 내용들을 확인할 수 있었습니다.

  1. popBackStack은 말 그대로 뒤로가기에 대한 처리를 담당하며, BackStack 상의 뒤로가기를 처리하는 과정에서 여러 번 호출될 경우 의도치 않은 BackStack으로 이동할 수 있다.
  2. navigateUp은 정확하게 사용자가 의도한 화면으로 이동시키기 위한 방식이다.
  3. DeepLink 처리 과정에서 뒤로가기는 navigateUp이 적절한 방식이다. (기획의도에 따라 달라질수는 있음)

 

popBackStack을 사용하는 경우에는 사용자가 의도적으로 여러번 뒤로가기를 클릭할 수 있다는 점을 인지하여 BackStack 제어가 필요할 것입니다.

따라서, 위에 예시로 설명한 빈 화면이 나오는 경우는 아래와 같이 제어할 수 있습니다.

fun NavController.navControl(onFinish: () -> Unit) {
    this.popBackStack().also { if (!it) onFinish() }
}

 

popBackStack을 시도하며, 현재 NavController의 BackQueue에 아무것도 없을 경우에는 onFinish 람다를 호출합니다.

보통 이 경우에는 Activity#finish에 해당할 것입니다.

 

또는 아예 popBackStack을 navigateUp을 대체하는 것도 좋은 대안이 될 수 있습니다.

일반적으로는 의도된 화면으로 정확히 이동시키는 navigateUp을 활용하는 것이 좋아보입니다.

 

Reference.

https://developer.android.com/guide/navigation/backstack

 

탐색 및 백 스택  |  App architecture  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 탐색 및 백 스택 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. NavController에는 '백 스택'이 있습니다.

developer.android.com

https://developer.android.com/guide/navigation/principles

 

탐색 원칙  |  App architecture  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 탐색 원칙 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 다양한 화면 및 앱 간 탐색은 사용자 환경의

developer.android.com