[Android] sealed class 기반 Navigation 설계하기 (MVI 패턴 적용까지)

이도연·2026년 1월 7일

android studio

목록 보기
34/34

Compose에서 화면이 많아질수록
NavHost 안에 composable {} 가 비대해지고,
Navigation 책임이 한 곳에 몰리는 문제가 생깁니다.

1. 문제점: Navigation이 커질수록 생기는 혼란

일반적인 Compose Navigation은 보통 이렇게 시작합니다.

NavHost(navController, startDestination = "home") {
    composable("home") { HomeScreen() }
    composable("detail") { DetailScreen() }
    composable("setting") { SettingScreen() }
}

하지만 화면이 늘어나면
1. NavHost가 비대해지며
2. feature 단위 분리가 어렵고
3. 테스트, 유지보수 난이도 증가합니다.

2. 해결 전략: Navigation도 “Feature 단위”로 분리

각 Feature가 자신의 Navigation을 직접 등록한다

이를 위해 공통 인터페이스를 하나 정의합니다.

3. NavigationDestination 인터페이스

interface NavigationDestination {
    val route: String

    fun register(
        builder: NavGraphBuilder,
        navController: NavHostController
    )
}

각 feature는 자신의 route, 자신의 composable 등록 책임만 가지게 됩니다.

4. sealed class 로 Route 정의하기

Route 문자열을 직접 쓰는 대신
sealed class로 타입 안전한 Route를 만듭니다.

sealed class MiruniRoute(val route: String) {

    data object Home : MiruniRoute("home")

    data object HomeDndTimerSetting :
        MiruniRoute("home/dnd/timer")

    data object HomeDndPause :
        MiruniRoute("home/dnd/pause")

    data object HomeDndComplete :
        MiruniRoute("home/dnd/complete/{hour}/{minute}") {

        fun createRoute(hour: Int, minute: Int): String {
            return "home/dnd/complete/$hour/$minute"
        }
    }
}

5. Feature 단위 Navigation 구현

class HomeNavigation @Inject constructor() : NavigationDestination {

    override val route: String = MiruniRoute.Home.route

    override fun register(
        builder: NavGraphBuilder,
        navController: NavHostController
    ) {
        builder.composable(MiruniRoute.Home.route) {
            HomeScreen(navController)
        }

        builder.composable(MiruniRoute.HomeDndTimerSetting.route) {
            DndTimerScreen(navController)
        }

        builder.composable(MiruniRoute.HomeDndPause.route) {
            DndPauseScreen(navController)
        }
    }
}

Home 관련 화면은 HomeNavigation이 전담합니다.

6. Hilt DI 로 Navigation 자동 수집

Navigation들을 Set으로 주입받습니다.

@Module
@InstallIn(SingletonComponent::class)
abstract class NavigationModule {

    @Binds
    @IntoSet
    abstract fun bindHomeNavigation(
        navigation: HomeNavigation
    ): NavigationDestination
}

이제 App 레벨에서는

@Composable
fun AppNavHost(
    navController: NavHostController,
    destinations: Set<NavigationDestination>
) {
    NavHost(
        navController = navController,
        startDestination = MiruniRoute.Home.route
    ) {
        destinations.forEach {
            it.register(this, navController)
        }
    }
}

Feature가 추가되어도
NavHost 수정 필요하지 않습니다.

MVI 패턴과 Navigation 연결하기

MVI 에서 Navigation 은 Effect 로 정의합니다.

sealed interface DndContract {
    sealed interface Effect {
        data object NavigateToPause : Effect
        data object TimeFinished : Effect
    }
}

ViewModel 에서 Effect 발생

setEffect { DndContract.Effect.NavigateToPause }

Composable 에서 Effect 수신 -> Navigate 실행

LaunchedEffect(Unit) {
    viewModel.effect.collect { effect ->
        when (effect) {
            DndContract.Effect.NavigateToPause -> {
                navController.navigate(MiruniRoute.HomeDndPause.route)
            }

            DndContract.Effect.TimeFinished -> {
                navController.navigate(
                    MiruniRoute.HomeDndComplete.createRoute(
                        state.hours,
                        state.minutes
                    )
                )
            }
        }
    }
}

ViewModel 은 이동 명령만 내리며
실제 Navigation 실행은 UI 에 있다.

8. 구조 정리

ViewModel
 ├─ State (화면 상태)
 └─ Effect (이동 / 토스트 / 다이얼로그)

Composable
 ├─ State → UI 렌더링
 └─ Effect → Navigation 실행

Navigation
 ├─ sealed class Route
 ├─ Feature별 NavigationDestination
 └─ DI로 자동 등록

0개의 댓글