0. 배경
Compose UI가 대세로 자리잡으면서 기존 Navigation Component의 한계(복잡한 SafeArgs, NavController의 의존성, 생명주기 관리 등)가 더 뚜렷해졌다.
프로젝트를 설계하면서 핵심적으로 고민한 것은
- "복잡한 화면 전환/아키텍처에도 안전하고 일관성 있게 Navigation을 관리할 수 있을까?"
- “ViewModel·DI·멀티모듈 환경에서 네비게이션을 ‘비즈니스 로직’과 확실히 분리할 수 있을까?”
이 고민을 Orbit MVI 패턴과 결합해, DI 컨테이너에서 관장하는 Navigator와 직렬화 가능한 Route 타입들으로 완성형 구조를 도입했다.
1. 전체 Navigation 설계 구조
핵심은 다음 두 가지:
- 직렬화 가능한 sealed Route 타입을 NavKey로 일원화
- Navigator 인터페이스 및 구현체를 의존성 주입(DI)으로 관리
1.1 Route 타입 구조
각 Route는 data class 또는 object 형태로, NavKey를 상속하여 명확하게 식별된다.
복잡한 파라미터(예: 책 상세, 프로필, 숏폼 등)도 타입 세이프하게 선언만 하면 끝!
sealed interface Route : NavKey {
@Serializable
data object Login : Route
@Serializable
data object SignUp : Route
@Serializable
data class BookDetail(val isbn: String? = null, val bookId: Long? = null) : Route
@Serializable
data object Setting : Route
// ...외 추가 Route 타입 생략
}
sealed interface BottomTabRoute : NavKey {
@Serializable
data object Home : BottomTabRoute
@Serializable
data object Search : BottomTabRoute
// ...
}
Navigator는 ViewModel에서 의존성 주입을 받아 사용한다. 이는 리눅스의 Command 패턴과 비슷하게, “ViewModel→Navigator→NavBackStack”으로 역할이 명확하게 분리된다.
interface Navigator {
suspend fun navigate(route: NavKey, saveState: Boolean = false, launchSingleTop: Boolean = true)
suspend fun navigateBack()
suspend fun navigateAndClearBackStack(route: NavKey)
}
실제 Instance는 DI(예: Hilt)로 주입되며, Activity 스코프 내에서 Channel을 통해 이벤트 전달.
@ActivityRetainedScoped
class NavigatorImpl @Inject constructor() : Navigator, InternalNavigator {
override val channel = Channel<InternalRoute>(Channel.BUFFERED)
// navigate, navigateBack, navigateAndClearBackStack 구현
}
2. UI 및 Compose 연동 - Navigator의 동작 원리
2.1 SideEffect 기반 ViewModel 네비게이션
ViewModel에서 navigation은 “비즈니스 로직” 한가운데서 쉽고 안전하게 호출 가능하다.
fun onLoginClicked() = intent {
// ... 인증 성공/실패 및 상태 갱신 생략
if (성공) {
navigator.navigateAndClearBackStack(BottomTabRoute.Home)
} else {
postSideEffect(LoginSideEffect.ShowError(R.string.login_failed))
}
}
추가 화면 이동도 intent에서 navigator.navigate(Route.SignUp) 등으로 호출만 해주면 된다.
2.2 Compose 쪽 NavBackStack 적용
Compose의 NavBackStack 및 NavigatorViewModel이 화면 전환을 담당한다.
@Composable
fun LaunchedNavigator(navBackStack: NavBackStack) {
InternalLaunchedNavigator(navBackStack = navBackStack)
}
@Composable
private fun InternalLaunchedNavigator(
navBackStack: NavBackStack,
routerViewModel: NavigatorViewModel = hiltViewModel(),
) {
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(routerViewModel, lifecycleOwner) {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
routerViewModel.sideEffect.collectLatest { sideEffect ->
when (sideEffect) {
is RouteSideEffect.NavigateBack -> navBackStack.removeLastOrNull()
is RouteSideEffect.Navigate -> {
navBackStack.remove(sideEffect.route)
navBackStack.add(sideEffect.route)
}
is RouteSideEffect.NavigateAndClearBackStack -> {
navBackStack.clear()
navBackStack.add(sideEffect.route)
}
}
}
}
}
}
핵심 포인트:
- “네비게이션은 ViewModel→Navigator가 Channel로 전달→NavBackStack이 실제 화면전환 담당”
- “Compose와 Orbit(혹은 기타 상태 관리) 사이 강한 결합은 없음 → 테스트·유지보수 일관성”
3. 세부 동작 및 장점
- 타입 안정성: Route/BottomTab/UpdateInfo 등 다양한 네비게이션 경로가 sealed class로 하나의 타입 트리로 관리됨. 파라미터 실수 및 런타임 null 문제 최소화.
- 테스트/모킹 편리: ViewModel에서 Navigator를 인터페이스로 받으니 전체 네비게이션 흐름의 테스트/모킹이 손쉽게 가능.
- BackStack 및 싱글탑 등 옵션 제어: saveState, launchSingleTop 등 네비게이션 플래그를 명시적 파라미터로 제어 가능.
- 멀티모듈 - 연결 편의: Route 및 Navigator를 core, feature별로 분리해도 정확히 연결되고, Route도 직렬화(Serializable) 지원되므로 deep link·save/restore 등에서 강함.
- 1회성 SideEffect 자연 처리: 화면간 이동, 토스트 등 UI 이벤트는 MVI에서 “사이드 이펙트”로 다루어 레이스컨디션 없이 처리.
4. 인사이트
- Channel 기반 네비게이션 흐름이 중요
- UI와 ViewModel의 결합 과도/생명주기 버그를 줄였고, DI/테스트가 한결 쉬워짐
- NavBackStack에 직접 접근 없이 “네비게이션 액션→Channel→SideEffect” 흐름으로 안전
- Intent/SideEffect·상태 전이 분리
- Orbit MVI의 intent, reduce, sideEffect 패턴과 잘 맞물려, 화면 전환/알림/에러 처리가 타입 안정+비즈니스 로직 중심으로 가능
- Route 트리 설계와 확장성
- 서브탭, 상세화면, 딥링크, 런칭조건(싱글톱, 백스택 등)까지 모두 명시적으로 설계할 수 있어 실제 대규모 앱/멀티모듈에도 추천
- 테스트 환경
- 네비게이션을 모킹/트래킹하면서 ViewModel 테스트가 안전하고 단순화
5. 결론
DI 기반 Navigator & sealed Route 구조는
- "Business-Driven Navigation"
- "테스트와 유지보수, 확장성"
- "UI 레이어와 상태/비즈니스/네비 액션의 명확한 분리"
복잡하고 동적인 네비게이션 요구사항이 있을 때 오히려 구조가 더 간결해진다.
'Android > Study' 카테고리의 다른 글
| [Android] DataStore를 왜 쓸까? feat. 내부 데이터 저장하기 (0) | 2025.08.24 |
|---|---|
| [Android] `UiState–Intent–SideEffect` MVI 구조를 자체 설계·적용해보자. (0) | 2025.08.24 |
| [Android] Orbit으로 MVI 구현기 (0) | 2025.08.19 |
| [Android] Orbit Multiplatform ViewModel, ComposeUI, Test 탐구하기 (0) | 2025.07.05 |
| [Android] Orbit Multiplatform 개요와 Core 탐구하기 (0) | 2025.07.03 |