| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
- Android ProgressBar
- 영어독립365
- android recyclerview
- scope function
- 알고리즘 자바
- Kotlin
- Android Navigation
- coroutine
- Android 12
- 안드로이드 갤러리 접근
- Android Interceptor
- 프로그래머스 알고리즘
- 안드로이드 카카오 로그인
- WebView
- 안드로이드
- Android 12 대응
- 66챌린지
- Kotlin FCM
- OkHttp Interceptor
- Android ViewPager2
- Android
- MVP Architecture
- 카카오 알고리즘
- 습관만들기
- 영어공부
- Android WebView
- Java
- Android Jetpack
- 코틀린 코루틴
- DataBinding
- Today
- Total
주맨의 개발노트
[Navigation3] NavController 명령에서 back stack 소유로의 전환 본문
들어가며
Android에서 navigation을 이야기하면 보통 "어떤 화면으로 이동할 것인가"를 먼저 떠올립니다. 상품 목록에서 상품 상세로 이동한다면 기존 Jetpack Navigation에서는 NavController에게 특정 route로 이동하라고 명령하는 코드가 자연스럽습니다.
navController.navigate("productDetail/$productId")
Navigation 3는 같은 장면을 조금 다르게 바라봅니다. 화면으로 이동하라는 명령을 보내기보다, 앱이 소유한 back stack에 화면을 식별하는 key를 추가합니다.
backStack.add(ProductDetail(productId))
겉으로는 리스트에 값을 하나 추가하는 코드처럼 보입니다. 하지만 Navigation 3에서는 바로 이 상태 변경이 "상품 상세 화면으로 이동한다"는 의미가 됩니다.
Navigation 3의 핵심은 화면 이동 API를 바꾸는 데 있지 않고, back stack의 소유권을 앱이 직접 갖는 데 있습니다.
기존 Navigation에서는 이동을 명령으로 생각하기 쉽습니다
기존 Jetpack Navigation에서는 NavController가 중심에 있습니다. 개발자는 navigate()로 앞으로 이동하고, popBackStack()으로 뒤로 이동하며, 필요하면 popUpTo 같은 옵션으로 back stack을 정리합니다.
navController.navigate("productDetail/$productId")
navController.popBackStack()
navController.navigate("home") {
popUpTo("login") {
inclusive = true
}
}
이 방식은 편리합니다. 특히 작은 앱이나 단순한 화면 흐름에서는 route를 정의하고, NavHost에 destination을 등록하고, 필요한 곳에서 navigate()를 호출하면 충분합니다.
다만 앱이 커질수록 navigation은 점점 "어딘가에 명령을 보내는 일"처럼 느껴질 수 있습니다. 지금 back stack에 어떤 화면이 쌓여 있는지, 로그인 성공 후 뒤로 가면 어디로 가야 하는지, 주문 완료 후 결제 화면으로 돌아가지 못하게 하려면 어떤 상태가 되어야 하는지 같은 질문이 생깁니다.
NavController에게 route 이동을 요청합니다. 이동 흐름은 API 호출과 옵션 조합 안에 흩어지기 쉽습니다.
앱이 back stack을 직접 들고, 원하는 화면 흐름을 key 리스트의 최종 상태로 표현합니다.
기존 Navigation에서도 이런 문제를 해결할 수 있습니다. 최근 Navigation Compose는 Kotlin Serialization 기반의 type-safe route도 지원합니다. 그래서 Navigation 3의 차이를 "타입 안정성을 지원하느냐"만으로 보면 정확하지 않습니다. 더 중요한 차이는 navigation state를 누가 소유하고, 앱 설계 안에서 얼마나 직접적으로 다루는가에 있습니다.
Navigation 3에서는 이동을 상태 변경으로 봅니다
Navigation 3를 이해할 때 가장 중요한 표현은 "you own the back stack"입니다. back stack을 라이브러리가 내부적으로 숨겨 관리하는 것이 아니라, 앱 코드가 직접 소유합니다.
Compose 앱에서 UI state를 직접 다루는 것처럼, Navigation 3에서는 navigation state도 직접 다룹니다. 예를 들어 쇼핑 앱의 back stack은 다음처럼 시작할 수 있습니다.
val backStack = remember {
mutableStateListOf<ShopRoute>(Home)
}
여기서 backStack은 사용자가 앱 안에서 이동해온 흐름을 나타내는 리스트입니다. 사용자가 상품 목록으로 이동하면 key를 추가하고, 상품 상세로 이동하면 또 다른 key를 추가합니다.
backStack.add(ProductList)
backStack.add(ProductDetail(productId = "coffee-001"))
backStack.removeLastOrNull()
이렇게 보면 navigation은 특별한 명령 체계라기보다 앱 상태의 한 종류가 됩니다. 리스트로 쓰면 [Home, ProductList, ProductDetail("coffee-001")]이지만, back stack이라는 이름 그대로 세로로 세워 보면 의미가 더 분명해집니다.
TOP
+--------------------------------------------------+
| ProductDetail(productId = "coffee-001") | ← 현재 화면
+--------------------------------------------------+
| ProductList |
+--------------------------------------------------+
| Home | ← root
+--------------------------------------------------+
BOTTOM
새 화면으로 이동한다는 것은 이 스택의 위에 key를 하나 더 올리는 일입니다. 반대로 뒤로 가기는 top에 있는 key를 제거하는 일입니다. 위 예시에서 back을 누르면 ProductDetail("coffee-001")이 제거되고, 그 아래에 있던 ProductList가 다시 현재 화면이 됩니다.
TOP
+------------------+
| ProductList | ← 현재 화면
+------------------+
| Home | ← root
+------------------+
BOTTOM
이 구조를 보면 사용자가 어떤 흐름 위에 있는지 바로 알 수 있습니다. Home에서 시작했고, ProductList를 거쳐, 지금은 가장 위에 쌓인 ProductDetail("coffee-001")에 있습니다.
화면은 key로 표현됩니다
Navigation 3의 back stack은 실제 Composable 함수를 담지 않습니다. 대신 화면을 식별하는 key를 담습니다. 쇼핑 앱이라면 key 타입을 다음처럼 정의할 수 있습니다.
sealed interface ShopRoute
data object Home : ShopRoute
data object ProductList : ShopRoute
data class ProductDetail(
val productId: String,
) : ShopRoute
data object Cart : ShopRoute
data class OrderComplete(
val orderId: String,
) : ShopRoute
ProductDetail(productId = "coffee-001")은 상품 상세 화면 자체가 아닙니다. 이 값은 "어떤 상품 상세 화면을 열어야 하는지"를 나타내는 key입니다.
Composable content를 back stack에 직접 넣지 않고 key를 넣기 때문에 navigation state가 데이터가 됩니다. 데이터가 되면 비교와 테스트가 쉬워지고, 저장과 복원도 더 명확하게 설계할 수 있습니다.
assertThat(backStack).isEqualTo(
listOf(
Home,
ProductList,
ProductDetail(productId = "coffee-001"),
),
)
Navigation 3에서 화면 이동은 숨겨진 route 호출보다 명시적인 key 리스트에 가깝게 모델링됩니다.
물론 저장과 복원이 필요한 back stack, 특히 rememberNavBackStack을 사용하는 구조라면 NavKey, @Serializable 같은 조건도 함께 고려해야 합니다. 하지만 출발점은 단순합니다. 화면 이동을 데이터로 표현한다는 것입니다.
NavDisplay는 이동을 결정하지 않습니다
back stack을 앱이 소유한다면, 화면은 누가 그릴까요? Navigation 3에서는 NavDisplay가 back stack을 보고 UI를 표시합니다. 하지만 NavDisplay가 이동 정책을 소유한다고 보기는 어렵습니다.
backStack.add(ProductDetail(productId))를 실행합니다.
개념적으로는 다음과 같은 구조입니다.
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
}
},
entryProvider = entryProvider {
entry<Home> {
HomeScreen()
}
entry<ProductList> {
ProductListScreen(
onProductClick = { productId ->
backStack.add(ProductDetail(productId))
},
)
}
entry<ProductDetail> { key ->
ProductDetailScreen(productId = key.productId)
}
},
)
이 코드에서 NavDisplay는 back stack을 소유하지 않습니다. 이동 정책도 직접 결정하지 않습니다. 앱이 넘긴 back stack을 관찰하고, 앱이 정의한 entryProvider를 통해 key에 맞는 화면을 찾습니다.
따라서 NavDisplay는 이동을 지시하는 controller라기보다, 주어진 back stack을 화면으로 풀어내는 역할에 가깝습니다. 제어권은 NavDisplay가 아니라 back stack을 변경하는 앱 코드에 있습니다.
back stack을 직접 소유하면 정책이 코드에 드러납니다
Navigation 3의 장점은 단순히 navigate() 대신 add()를 쓴다는 데 있지 않습니다. 중요한 점은 navigation 정책을 back stack의 최종 상태로 표현할 수 있다는 것입니다.
예를 들어 로그인 성공 후에는 로그인 화면으로 돌아가지 못하게 하고 싶습니다. 기존 Navigation에서는 보통 popUpTo 옵션으로 이 의도를 표현합니다.
navController.navigate("home") {
popUpTo("login") {
inclusive = true
}
}
Navigation 3에서는 원하는 back stack 상태를 직접 만들 수 있습니다.
backStack.clear()
backStack.add(Home)
결과 상태는 [Home]입니다. 사용자가 뒤로 가더라도 Login으로 돌아갈 수 없습니다. back stack에 Login이 없기 때문입니다.
주문 완료 흐름
주문 완료 흐름도 같은 방식으로 바라볼 수 있습니다. 사용자가 아래처럼 Checkout이 top인 스택 위에 있다가 주문을 완료했다고 해보겠습니다. 주문 완료 후 결제 화면으로 뒤로 돌아가지 못하게 하고, 홈 위에 주문 완료 화면만 남기고 싶을 수 있습니다.
fun onOrderCompleted(orderId: String) {
backStack.clear()
backStack.add(Home)
backStack.add(OrderComplete(orderId))
}
TOP
+-----------+
| Checkout | ← 현재 화면
+-----------+
| Cart |
+-----------+
| Home | ← root
+-----------+
BOTTOM
TOP
+--------------------------------+
| OrderComplete("ORDER-100") | ← 현재 화면
+--------------------------------+
| Home | ← root
+--------------------------------+
BOTTOM
이 상태를 보면 정책이 바로 보입니다. 사용자는 주문 완료 화면을 보고 있고, 뒤로 가면 홈으로 갑니다. 결제 화면이나 장바구니 화면으로는 돌아가지 않습니다. navigation 정책이 추상적인 옵션 조합이 아니라 back stack 데이터의 형태로 드러나는 것입니다.
root back 처리도 앱의 정책이 됩니다
back stack을 직접 소유한다는 것은 그만큼 앱이 책임져야 할 부분도 늘어난다는 뜻입니다. 대표적인 예가 root 화면에서의 back 처리입니다.
onBack = {
backStack.removeLastOrNull()
}
이 코드는 단순하지만 실제 앱에서는 위험할 수 있습니다. back stack이 [Home]인 상태에서 마지막 항목을 제거하면 표시할 화면이 없어집니다. 그래서 보통은 root 상태를 확인해야 합니다.
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
// Activity 종료, 종료 확인 dialog 표시, 또는 아무 동작 안 함
}
}
뒤로 가기 동작도 라이브러리의 기본 동작에만 맡기는 것이 아니라, 앱의 navigation 정책으로 표현합니다. 앱 구조에 따라 root back 정책은 달라질 수 있습니다.
| 앱 구조 | root back 정책 예시 |
|---|---|
| 일반 단일 Activity 앱 | Activity 종료 |
| 홈 탭 기반 앱 | 현재 탭 root면 이전 탭 또는 기본 탭으로 이동 |
| 작성 화면 | 변경사항이 있으면 확인 dialog 표시 |
| 검색 화면 | query가 있으면 query clear, 없으면 pop |
Navigation 3는 이런 결정을 앱 바깥으로 숨기지 않습니다. 앱이 back stack을 소유하는 만큼, 뒤로 가기 정책도 앱의 설계 안에 들어옵니다.
Compose의 상태 모델과 잘 맞는 이유
Compose에서는 UI를 상태의 함수로 바라봅니다. Navigation 3는 navigation에도 비슷한 관점을 적용합니다.
UI = f(state)
Navigation UI = f(backStack)
back stack이 [Home]이면 홈을 보여줍니다. back stack이 [Home, ProductDetail("coffee-001")]이면 상품 상세를 보여줍니다. 기본 single pane 구성에서는 최상단 entry가 화면에 표시됩니다.
하지만 큰 화면이나 adaptive layout에서는 같은 back stack을 보고 여러 destination을 동시에 보여줄 수도 있습니다. 예를 들어 메일 앱의 back stack이 [Inbox, MailDetail("mail-100")]이라고 해보겠습니다.
좁은 화면에서는 최상단 key인 MailDetail("mail-100")만 보여주는 구성이 자연스럽습니다.
넓은 화면에서는 같은 back stack을 바탕으로 inbox와 detail을 함께 보여줄 수 있습니다.
여기서 중요한 점은 navigation state와 layout strategy가 분리된다는 것입니다. back stack은 사용자가 어떤 흐름 위에 있는지 나타냅니다. 그 흐름을 한 화면으로 보여줄지, 두 pane으로 보여줄지, dialog나 bottom sheet로 보여줄지는 별도의 표시 전략이 결정할 수 있습니다.
사용자의 이동 흐름은 back stack이 표현하고, 화면에 배치하는 방식은
NavDisplay와 scene strategy가 해석합니다.
하지만 모든 것을 back stack에 넣으면 안 됩니다
back stack을 직접 소유한다고 해서 모든 상태를 back stack에 넣어야 하는 것은 아닙니다. Navigation 3에서 back stack은 navigation state입니다. 화면 내부의 모든 UI state를 담는 저장소가 아닙니다.
상품 상세 화면을 key로 표현할 때는 보통 다음 정도가 적절합니다.
data class ProductDetail(
val productId: String,
)
productId는 이 화면을 다시 열기 위한 최소 정보입니다. 반대로 다음과 같은 key는 부담스럽습니다.
data class ProductDetail(
val product: Product,
val selectedTab: ProductTab,
val scrollOffset: Int,
val loadedReviews: List<Review>,
)
이 key에는 화면 식별 정보, 서버 데이터, UI 표현 상태가 모두 섞여 있습니다. 이렇게 되면 back stack이 navigation state가 아니라 화면 snapshot 저장소처럼 변합니다.
| 상태 | 어울리는 위치 |
|---|---|
| 화면을 식별하는 ID | navigation key |
| 서버에서 다시 가져올 수 있는 데이터 | repository, ViewModel |
| 스크롤 위치, 화면 내부 선택 탭 | screen state, saveable state |
| 로그인 세션 | app-level auth state |
| 화면 표시 힌트 | metadata |
back stack을 잘 소유하려면, 오히려 back stack에 넣지 말아야 할 것도 분명히 해야 합니다. 탭 자체가 독립적인 destination이거나 deep link와 복원의 기준이 된다면 key 후보가 될 수 있지만, 기준은 같습니다. 그 값이 화면의 위치를 식별하는지, 아니면 화면 안의 표현 상태에 가까운지를 봐야 합니다.
Navigation 3가 항상 더 단순하다는 뜻은 아닙니다
Navigation 3의 사고방식은 Compose와 잘 맞습니다. 그렇다고 기존 Navigation보다 항상 더 쉽거나, 모든 앱에 무조건 더 적합하다는 뜻은 아닙니다.
기존 Navigation은 많은 결정을 프레임워크 안에 숨겨줍니다. NavController, navigation graph, route, argument, popUpTo 같은 API를 익히면 일정한 방식으로 화면 흐름을 만들 수 있습니다. 반면 Navigation 3는 더 많은 제어권을 앱에 줍니다.
back stack을 어디에 둘 것인가
앱 루트, feature boundary, ViewModel 등 navigation state의 생명주기를 명확히 정해야 합니다.
key 타입을 어떻게 설계할 것인가
화면 식별에 필요한 최소 정보를 담고, 저장과 복원이 필요한 경우 직렬화 조건도 함께 고려해야 합니다.
뒤로 가기와 흐름 정리를 어떻게 처리할 것인가
root back, 로그인 이후, 주문 완료 이후처럼 사용자가 돌아갈 수 있는 위치를 앱 정책으로 드러내야 합니다.
같은 back stack을 화면 크기에 따라 어떻게 보여줄 것인가
single pane, two pane, dialog, bottom sheet 같은 표시 전략을 navigation state와 분리해 생각할 수 있습니다.
제어권이 커지면 설계 자유도도 커집니다. 하지만 책임도 함께 커집니다. 그래서 Navigation 3를 "navigation이 리스트 조작으로 쉬워졌다"라고만 이해하면 조금 위험합니다.
Navigation 3는 navigation을 쉽게 숨겨주는 도구라기보다, navigation state를 앱 설계의 일부로 드러내는 도구입니다.
핵심 역할을 다시 정리해보겠습니다
앞의 예제를 한 문장으로 정리하면 이렇습니다. back stack에는 화면을 식별하는 key가 쌓이고, 앱은 그 리스트를 변경해 이동 흐름을 표현합니다. NavDisplay는 그 결과를 화면으로 해석합니다.
| 요소 | 역할 |
|---|---|
ShopRoute |
앱에서 이동 가능한 화면의 key |
backStack |
현재 navigation state |
backStack.add(...) |
앞으로 이동 |
backStack.removeLastOrNull() |
뒤로 이동 |
entryProvider |
key를 화면 내용으로 해석 |
NavDisplay |
back stack을 보고 UI 표시 |
이 표를 보면 Navigation 3가 기존 Navigation의 책임을 어떻게 다시 배치하는지 보입니다. NavDisplay가 중심이 아니라 back stack이 중심입니다. route 문자열보다 key가 중심입니다. 이동 명령보다 상태 변경이 중심입니다.
정리하며
Navigation 3를 이해할 때 가장 중요한 기준은 API 이름이 아니라 소유권입니다. 기존 방식에서는 NavController가 navigation 흐름의 중심에 있었고, 앱은 그 컨트롤러에게 이동을 명령했습니다. Navigation 3에서는 앱이 back stack을 직접 소유하고, 그 상태를 변경합니다.
이 차이는 단순한 문법 변화가 아닙니다. NavController에게 이동을 명령하는 방식에서, 앱이 navigation state를 직접 모델링하는 방식으로 관점이 바뀌는 것입니다.
NavKey는 화면을 데이터로 식별하기 위한 장치입니다. rememberNavBackStack은 저장 가능한 back stack을 만들기 위한 도구입니다. NavDisplay는 back stack을 UI로 해석합니다. metadata와 scene strategy는 같은 navigation state를 화면 크기나 표현 방식에 맞게 다르게 보여줄 수 있도록 돕습니다.
결국 Navigation 3가 말하는 것은 꽤 단순합니다. 화면 이동은 key의 리스트를 바꾸는 일이고, UI는 그 리스트를 해석한 결과입니다. 이 문장을 받아들이면 Navigation 3는 새로운 navigation 문법이 아니라, Compose 시대에 navigation state를 어디에 두고 어떻게 책임질 것인지 묻는 설계로 보이기 시작합니다.
참고 문서
아래 문서는 Navigation 3의 핵심 개념과 type-safe route 맥락을 함께 확인할 때 유용합니다.
- Android Developers - Navigation 3 overview
- Android Developers - Understand and implement the basics
- Android Developers - Type safety in Navigation Compose
- Navigation 3의 핵심은 back stack 소유권입니다. 앱이 navigation state를 직접 들고 변경합니다.
- 이동은 명령보다 상태 변경에 가깝습니다.
backStack.add(...)와removeLastOrNull()이 화면 흐름을 표현합니다. - back stack에는 Composable이 아니라 key가 쌓입니다. 화면을 데이터로 식별하면 비교, 테스트, 복원 전략이 더 명확해집니다.
NavDisplay는 상태를 해석해 화면을 그립니다. 이동 정책의 중심은NavDisplay가 아니라 back stack을 변경하는 앱 코드입니다.- 소유권은 자유와 책임을 함께 가져옵니다. root back, 복원, adaptive layout, key 경계까지 앱 설계 안에서 의식적으로 결정해야 합니다.
'안드로이드 > Jetpack' 카테고리의 다른 글
| [Android] ViewModel 객체 생성 방법 (0) | 2022.08.17 |
|---|---|
| Android Navigation NavHostFragment Component (0) | 2022.07.18 |
| Android Navigation Graph Component (0) | 2022.07.17 |
| Navigation Basic Sample in Kotlin (0) | 2022.07.15 |
| [Android] Custom Binding Adapter Example in Kotlin (0) | 2022.07.05 |