Skip to main content

화면 이동과 앱 내비게이션 설계

Views 6약 11분생성형 AI 활용
Summary

화면이 늘어나면 이동 구조가 필요하다

지금까지는 하나의 화면 안에서 상태를 만들고, 사용자의 입력에 따라 UI를 바꾸는 방법을 배웠습니다. 하지만 실제 앱은 대부분 한 화면으로 끝나지 않습니다.

학습 관리 앱을 예로 들면 다음과 같은 화면이 필요합니다.

  • 로그인 화면

  • 홈 화면

  • 강의 목록 화면

  • 강의 상세 화면

  • 학습 기록 화면

  • 설정 화면

이 화면들이 서로 어떻게 연결되는지 정리하는 일이 내비게이션(navigation) 입니다. 내비게이션은 단순히 "버튼을 누르면 다른 화면으로 간다"는 기능이 아닙니다. 사용자가 앱 안에서 길을 잃지 않게 만드는 구조입니다.

앱이 작을 때는 화면 이동 코드를 여기저기 넣어도 당장은 동작합니다. 하지만 화면이 늘어나면 문제가 생깁니다.

  • 어떤 화면에서 어떤 화면으로 갈 수 있는지 파악하기 어렵습니다.

  • 뒤로 가기 동작이 예측하기 어려워집니다.

  • 로그인 여부에 따라 첫 화면을 바꾸기 어렵습니다.

  • 같은 화면 이동 코드가 여러 곳에 반복됩니다.

  • 화면 이름이나 전달값이 바뀌면 여러 코드를 함께 수정해야 합니다.

그래서 앱에는 명확한 이동 설계가 필요합니다. Compose에서는 보통 Navigation Compose를 사용해 화면 이동을 구성합니다.

화면 이동과 앱 내비게이션 설계

Navigation Compose에서 가장 중요한 요소는 세 가지입니다.

  • NavController: 화면 이동을 실행하고 백 스택을 관리하는 객체

  • NavHost: 이동 가능한 화면들을 등록하는 영역

  • route: 각 화면을 구분하는 문자열 경로

간단한 구조는 다음과 같습니다.

@Composable
fun AppNavigation() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        composable("home") {
            HomeScreen(
                onLessonClick = { lessonId ->
                    navController.navigate("lesson/$lessonId")
                }
            )
        }

        composable("profile") {
            ProfileScreen()
        }

        composable("lesson/{lessonId}") { backStackEntry ->
            val lessonId = backStackEntry.arguments?.getString("lessonId")
            LessonDetailScreen(lessonId = lessonId)
        }
    }
}

처음 보면 낯설 수 있지만 구조는 단순합니다.

먼저 rememberNavController()로 NavController를 만듭니다.

val navController = rememberNavController()

그리고 NavHost 안에 앱에서 이동할 수 있는 화면을 등록합니다.

NavHost(
    navController = navController,
    startDestination = "home"
) {
    composable("home") { HomeScreen() }
    composable("profile") { ProfileScreen() }
}

startDestination은 앱의 시작 화면입니다. 위 코드에서는 앱이 처음 열릴 때 home 화면이 표시됩니다.

화면 이동은 navigate()로 합니다.

navController.navigate("profile")

이 코드는 현재 화면 위에 profile 화면을 쌓습니다. 사용자가 뒤로 가기를 누르면 이전 화면으로 돌아갈 수 있습니다.

route는 화면의 주소다

Navigation Compose에서 route는 화면을 구분하는 주소입니다.

composable("home") { HomeScreen() }
composable("settings") { SettingsScreen() }
composable("lesson/{lessonId}") { LessonDetailScreen() }

여기서 home, settings, lesson/{lessonId}가 route입니다. 웹에서 /home, /settings 같은 주소로 페이지를 구분하는 것과 비슷하게 생각하면 됩니다.

작은 예제에서는 문자열을 직접 써도 됩니다.

navController.navigate("settings")

하지만 앱이 커지면 문자열을 여기저기 직접 쓰는 방식은 위험합니다. 오타가 나도 컴파일 단계에서 잡기 어렵기 때문입니다.

그래서 보통 route를 한곳에 모아 둡니다.

object Routes {
    const val HOME = "home"
    const val SETTINGS = "settings"
    const val LESSON_LIST = "lessonList"
    const val LESSON_DETAIL = "lesson/{lessonId}"

    fun lessonDetail(lessonId: Long): String {
        return "lesson/$lessonId"
    }
}

이제 화면 등록과 이동 코드에서 같은 값을 사용할 수 있습니다.

NavHost(
    navController = navController,
    startDestination = Routes.HOME
) {
    composable(Routes.HOME) {
        HomeScreen()
    }

    composable(Routes.LESSON_DETAIL) { backStackEntry ->
        val lessonId = backStackEntry.arguments?.getString("lessonId")
        LessonDetailScreen(lessonId = lessonId)
    }
}

이동할 때도 함수로 경로를 만들 수 있습니다.

navController.navigate(Routes.lessonDetail(lessonId = 10L))

이렇게 하면 화면 경로가 바뀌어도 수정할 곳이 줄어듭니다. 화면 이동 구조가 코드 안에서 더 명확해집니다.

화면은 이동 방법을 몰라도 된다

Composable 화면 안에서 navController를 직접 사용하는 예제를 자주 볼 수 있습니다.

@Composable
fun HomeScreen(navController: NavController) {
    Button(onClick = {
        navController.navigate("settings")
    }) {
        Text("설정")
    }
}

이 방식도 동작합니다. 하지만 화면이 NavController에 직접 의존하게 됩니다. 그러면 HomeScreen을 미리보기하거나 테스트하기 어려워지고, 같은 화면을 다른 이동 구조에서 재사용하기도 불편합니다.

더 좋은 방식은 화면이 "무엇이 일어났는지"만 밖으로 알려주는 것입니다.

@Composable
fun HomeScreen(
    onSettingsClick: () -> Unit,
    onLessonClick: (Long) -> Unit
) {
    Column {
        Button(onClick = onSettingsClick) {
            Text("설정")
        }

        Button(onClick = { onLessonClick(10L) }) {
            Text("10번 강의 보기")
        }
    }
}

그리고 NavHost를 구성하는 곳에서 실제 이동을 연결합니다.

composable(Routes.HOME) {
    HomeScreen(
        onSettingsClick = {
            navController.navigate(Routes.SETTINGS)
        },
        onLessonClick = { lessonId ->
            navController.navigate(Routes.lessonDetail(lessonId))
        }
    )
}

이 구조는 지난 장에서 배운 상태 호이스팅과 비슷합니다.

  • 화면은 UI와 사용자 이벤트에 집중합니다.

  • 이동 결정은 상위 내비게이션 영역에서 처리합니다.

  • 화면은 어디로 이동하는지 직접 몰라도 됩니다.

이렇게 만들면 화면 코드가 단순해지고 재사용성이 좋아집니다.

목록에서 상세 화면으로 이동하기

많은 앱은 "목록 → 상세" 구조를 가집니다.

예를 들어 강의 목록 화면에서 특정 강의를 누르면 강의 상세 화면으로 이동합니다. 이때 상세 화면은 어떤 강의를 보여줘야 하는지 알아야 합니다. 보통 목록 화면에서 상세 화면으로 id를 전달합니다.

먼저 강의 데이터를 간단히 만들어 보겠습니다.

data class Lesson(
    val id: Long,
    val title: String,
    val description: String
)

목록 화면은 강의 목록과 클릭 이벤트를 받습니다.

@Composable
fun LessonListScreen(
    lessons: List<Lesson>,
    onLessonClick: (Long) -> Unit
) {
    LazyColumn {
        items(lessons) { lesson ->
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .clickable { onLessonClick(lesson.id) }
                    .padding(16.dp)
            ) {
                Text(text = lesson.title)
                Text(text = lesson.description)
            }
        }
    }
}

목록 화면은 직접 이동하지 않습니다. 사용자가 어떤 강의를 눌렀는지만 onLessonClick으로 알려줍니다.

NavHost에서는 클릭 이벤트를 실제 이동으로 바꿉니다.

composable(Routes.LESSON_LIST) {
    LessonListScreen(
        lessons = sampleLessons,
        onLessonClick = { lessonId ->
            navController.navigate(Routes.lessonDetail(lessonId))
        }
    )
}

상세 화면 route는 다음처럼 정의할 수 있습니다.

object Routes {
    const val LESSON_LIST = "lessonList"
    const val LESSON_DETAIL = "lesson/{lessonId}"

    fun lessonDetail(lessonId: Long): String {
        return "lesson/$lessonId"
    }
}

그리고 NavHost에서 전달된 값을 읽습니다.

composable(Routes.LESSON_DETAIL) { backStackEntry ->
    val lessonId = backStackEntry.arguments
        ?.getString("lessonId")
        ?.toLongOrNull()

    LessonDetailScreen(lessonId = lessonId)
}

lesson/{lessonId}에서 {lessonId}는 변하는 값이 들어갈 자리입니다. 실제 이동할 때 lesson/10으로 이동하면 lessonId는 10이 됩니다.

상세 화면에서는 전달받은 lessonId로 필요한 데이터를 찾습니다.

@Composable
fun LessonDetailScreen(lessonId: Long?) {
    if (lessonId == null) {
        Text("잘못된 강의입니다.")
        return
    }

    Text(text = "강의 ID: $lessonId")
}

처음에는 이렇게 단순하게 시작해도 됩니다. 뒤에서 Room 데이터베이스와 네트워크를 배우면, 상세 화면은 lessonId를 이용해 데이터베이스나 서버에서 실제 강의 정보를 불러오게 됩니다.

화면 사이에는 큰 데이터를 직접 넘기지 않는다

목록에서 상세 화면으로 이동할 때 강의 객체 전체를 넘기고 싶을 수 있습니다.

// 피하는 편이 좋은 생각
navController.navigate("lesson/${lesson.title}/${lesson.description}")

하지만 이런 방식은 문제가 많습니다.

  • 문자열에 공백, 특수문자, 슬래시가 들어가면 경로가 깨질 수 있습니다.

  • 데이터가 길어지면 route가 복잡해집니다.

  • 화면 이동 코드가 데이터 구조에 강하게 묶입니다.

  • 객체 내용이 바뀌었을 때 일관성을 유지하기 어렵습니다.

화면 이동에서는 보통 식별자만 전달하는 것이 좋습니다.

navController.navigate(Routes.lessonDetail(lesson.id))

그리고 상세 화면에서 id를 기준으로 데이터를 다시 가져옵니다.

LessonDetailScreen(lessonId = lessonId)

이 방식이 더 안정적입니다. 목록 화면과 상세 화면의 책임도 분리됩니다.

  • 목록 화면: 어떤 항목을 선택했는지 알려준다.

  • 내비게이션: 선택된 항목의 id를 전달한다.

  • 상세 화면: id로 필요한 데이터를 불러온다.

데이터가 앱 전체에서 공유되어야 한다면 ViewModel이나 Repository를 사용합니다. 화면 route는 데이터를 담는 저장소가 아니라, 화면을 찾아가는 주소에 가깝게 사용하는 편이 좋습니다.

뒤로 가기와 백 스택 이해하기

안드로이드 앱에서 뒤로 가기는 매우 중요합니다. 사용자는 뒤로 가기 버튼을 눌렀을 때 "방금 전 화면으로 돌아갈 것"이라고 기대합니다.

Navigation Compose는 화면 이동을 백 스택(back stack) 으로 관리합니다. 백 스택은 화면을 순서대로 쌓아 두는 구조입니다.

예를 들어 사용자가 다음 순서로 이동했다고 해 보겠습니다.

홈 → 강의 목록 → 강의 상세

백 스택은 대략 다음처럼 쌓입니다.

[홈, 강의 목록, 강의 상세]

이때 뒤로 가기를 누르면 맨 위의 강의 상세가 제거되고 이전 화면인 강의 목록이 보입니다.

[홈, 강의 목록]

한 번 더 뒤로 가기를 누르면 강의 목록이 제거되고 홈으로 돌아갑니다.

[홈]

즉, navigate()는 새 화면을 위에 쌓고, 뒤로 가기는 맨 위 화면을 제거합니다.

코드로 직접 이전 화면으로 돌아가고 싶다면 popBackStack()을 사용할 수 있습니다.

navController.popBackStack()

예를 들어 상세 화면의 상단 앱 바에 뒤로 가기 버튼을 만들 수 있습니다.

@Composable
fun LessonDetailScreen(
    lessonId: Long?,
    onBackClick: () -> Unit
) {
    Column {
        Button(onClick = onBackClick) {
            Text("뒤로")
        }

        Text(text = "강의 ID: $lessonId")
    }
}

NavHost에서는 다음처럼 연결합니다.

composable(Routes.LESSON_DETAIL) { backStackEntry ->
    val lessonId = backStackEntry.arguments
        ?.getString("lessonId")
        ?.toLongOrNull()

    LessonDetailScreen(
        lessonId = lessonId,
        onBackClick = {
            navController.popBackStack()
        }
    )
}

여기서도 화면은 직접 NavController를 알 필요가 없습니다. "뒤로 가기 버튼이 눌렸다"는 이벤트만 밖으로 전달합니다.

같은 화면이 계속 쌓이는 문제

하단 내비게이션이나 메뉴에서 같은 화면으로 반복 이동하면 백 스택이 불필요하게 길어질 수 있습니다.

예를 들어 홈 화면에서 홈 탭을 여러 번 누를 때마다 새 홈 화면이 쌓이면 이상합니다.

[홈, 홈, 홈, 홈]

이런 상황에서는 launchSingleTop 같은 옵션을 사용할 수 있습니다.

navController.navigate(Routes.HOME) {
    launchSingleTop = true
}

launchSingleTop은 현재 맨 위 화면이 같은 route라면 새로 또 쌓지 않게 합니다.

또 시작 화면까지 백 스택을 정리하고 이동해야 할 때는 popUpTo를 사용할 수 있습니다.

navController.navigate(Routes.HOME) {
    popUpTo(Routes.HOME) {
        inclusive = false
    }
    launchSingleTop = true
}

popUpTo는 특정 화면까지 백 스택을 제거하는 옵션입니다. inclusive가 false이면 지정한 화면은 남기고 그 위의 화면만 제거합니다. true이면 지정한 화면까지 함께 제거합니다.

처음부터 모든 옵션을 외울 필요는 없습니다. 핵심은 이것입니다.

  • navigate(): 새 화면으로 이동하며 백 스택에 쌓는다.

  • popBackStack(): 현재 화면을 제거하고 이전 화면으로 돌아간다.

  • launchSingleTop: 같은 화면을 중복해서 쌓지 않는다.

  • popUpTo: 특정 지점까지 백 스택을 정리한다.

뒤로 가기 동작이 어색하다면 대부분 백 스택이 의도와 다르게 쌓였기 때문입니다.

로그인 후에는 로그인 화면으로 돌아가지 않게 하기

로그인 화면에서 로그인에 성공하면 홈 화면으로 이동합니다. 이때 단순히 다음처럼 이동하면 문제가 생길 수 있습니다.

navController.navigate(Routes.HOME)

백 스택은 이렇게 됩니다.

[로그인, 홈]

사용자가 홈 화면에서 뒤로 가기를 누르면 로그인 화면으로 돌아갑니다. 이미 로그인했는데 로그인 화면이 다시 보이는 것은 자연스럽지 않습니다.

이럴 때는 로그인 화면을 백 스택에서 제거하고 홈으로 이동해야 합니다.

navController.navigate(Routes.HOME) {
    popUpTo(Routes.LOGIN) {
        inclusive = true
    }
}

이 코드는 LOGIN 화면까지 백 스택에서 제거한 뒤 HOME으로 이동합니다. 결과적으로 뒤로 가기를 눌러도 로그인 화면으로 돌아가지 않습니다.

반대로 로그아웃할 때도 비슷합니다. 사용자가 로그아웃하면 홈이나 설정 화면으로 돌아갈 수 없어야 합니다. 로그인 화면으로 이동하면서 기존 화면들을 정리합니다.

navController.navigate(Routes.LOGIN) {
    popUpTo(Routes.HOME) {
        inclusive = true
    }
}

실제 앱에서는 시작 그래프나 루트 route를 기준으로 더 넓게 정리할 수 있습니다. 중요한 생각은 같습니다.

사용자가 뒤로 가기로 접근하면 안 되는 화면은 백 스택에서 제거해야 합니다.

하단 내비게이션은 주요 영역을 오갈 때 쓴다

화면 이동 방식은 하나만 있는 것이 아닙니다. 앱 구조에 따라 하단 내비게이션, 탭, 드로어 메뉴를 선택할 수 있습니다.

먼저 하단 내비게이션(bottom navigation) 은 앱의 주요 영역을 빠르게 오갈 때 사용합니다.

예를 들어 학습 관리 앱이라면 다음 항목이 하단 내비게이션에 어울립니다.

  • 홈

  • 강의

  • 기록

  • 설정

하단 내비게이션에 넣을 항목은 너무 많지 않아야 합니다. 보통 3~5개 정도가 적당합니다. 항목이 많아지면 사용자는 무엇을 눌러야 할지 헷갈립니다.

간단한 구조는 다음과 같습니다.

data class BottomNavItem(
    val route: String,
    val label: String
)

val bottomNavItems = listOf(
    BottomNavItem(Routes.HOME, "홈"),
    BottomNavItem(Routes.LESSON_LIST, "강의"),
    BottomNavItem(Routes.PROGRESS, "기록"),
    BottomNavItem(Routes.SETTINGS, "설정")
)

Scaffold의 bottomBar에 하단 내비게이션을 넣을 수 있습니다.

@Composable
fun MainScreen() {
    val navController = rememberNavController()

    Scaffold(
        bottomBar = {
            NavigationBar {
                bottomNavItems.forEach { item ->
                    NavigationBarItem(
                        selected = false,
                        onClick = {
                            navController.navigate(item.route) {
                                launchSingleTop = true
                            }
                        },
                        icon = { /* 아이콘 */ },
                        label = { Text(item.label) }
                    )
                }
            }
        }
    ) { paddingValues ->
        NavHost(
            navController = navController,
            startDestination = Routes.HOME,
            modifier = Modifier.padding(paddingValues)
        ) {
            composable(Routes.HOME) { HomeScreen() }
            composable(Routes.LESSON_LIST) { LessonListScreen(emptyList()) {} }
            composable(Routes.PROGRESS) { ProgressScreen() }
            composable(Routes.SETTINGS) { SettingsScreen() }
        }
    }
}

위 코드는 구조를 보여주기 위한 예제입니다. 실제로는 현재 선택된 route를 읽어 selected 값을 바꿔야 합니다.

val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route

그리고 각 항목이 현재 화면인지 판단합니다.

selected = currentRoute == item.route

하단 내비게이션의 핵심은 "앱의 주요 목적지"를 보여주는 것입니다. 상세 화면, 글쓰기 화면, 로그인 화면처럼 일시적으로 들어가는 화면은 보통 하단 내비게이션 항목으로 만들지 않습니다.

탭은 같은 화면 안의 분류를 나눌 때 쓴다

하단 내비게이션과 탭은 비슷해 보이지만 역할이 다릅니다.

하단 내비게이션은 앱의 큰 영역을 이동할 때 사용합니다.

홈 / 강의 / 기록 / 설정

탭은 보통 한 화면 안에서 관련 있는 내용을 나눌 때 사용합니다.

전체 강의 / 진행 중 / 완료

예를 들어 강의 화면 안에서 강의 상태별로 목록을 나누고 싶다면 탭이 어울립니다.

@Composable
fun LessonTabsScreen() {
    val tabs = listOf("전체", "진행 중", "완료")
    var selectedTabIndex by rememberSaveable { mutableStateOf(0) }

    Column {
        TabRow(selectedTabIndex = selectedTabIndex) {
            tabs.forEachIndexed { index, title ->
                Tab(
                    selected = selectedTabIndex == index,
                    onClick = { selectedTabIndex = index },
                    text = { Text(title) }
                )
            }
        }

        when (selectedTabIndex) {
            0 -> Text("전체 강의 목록")
            1 -> Text("진행 중인 강의 목록")
            2 -> Text("완료한 강의 목록")
        }
    }
}

탭 선택 상태는 이전 장에서 배운 상태 관리와 같습니다. 현재 선택된 탭 번호를 상태로 두고, 사용자가 탭을 누르면 값을 바꿉니다.

탭을 route로 관리할 수도 있지만, 모든 탭을 반드시 내비게이션으로 만들 필요는 없습니다. 단순히 같은 화면 안의 필터나 분류라면 selectedTabIndex 상태만으로 충분합니다.

기준은 간단합니다.

  • 뒤로 가기로 이전 탭으로 돌아가야 하는가?

  • 탭마다 독립적인 화면 상태가 많은가?

  • 외부에서 특정 탭으로 바로 진입해야 하는가?

이런 요구가 강하면 탭을 내비게이션 구조에 포함할 수 있습니다. 그렇지 않다면 화면 내부 상태로 처리해도 됩니다.

드로어 메뉴는 많은 목적지를 담을 때 쓴다

드로어(drawer) 는 화면 왼쪽에서 열리는 메뉴입니다. 앱의 목적지가 많거나, 사용 빈도가 낮은 메뉴를 모아 둘 때 사용할 수 있습니다.

예를 들어 다음 항목은 하단 내비게이션보다 드로어에 더 어울릴 수 있습니다.

  • 공지사항

  • 도움말

  • 문의하기

  • 약관

  • 앱 정보

  • 관리자 메뉴

하단 내비게이션은 항상 보이기 때문에 중요한 항목만 넣어야 합니다. 반면 드로어는 필요할 때 열어 보는 메뉴이므로 더 많은 항목을 담을 수 있습니다.

다만 모바일 앱에서 드로어는 사용자가 메뉴 존재를 놓칠 수 있습니다. 자주 쓰는 핵심 기능을 드로어 안에만 숨겨 두면 사용성이 떨어집니다.

선택 기준은 다음과 같습니다.

  • 하단 내비게이션: 자주 쓰는 최상위 목적지 3~5개

  • 탭: 한 화면 안에서 관련 내용을 분류

  • 드로어: 많지만 자주 쓰지는 않는 목적지, 보조 메뉴

이 기준을 지키면 앱 구조가 더 예측 가능해집니다.

시작 화면을 조건에 따라 바꾸기

앱은 항상 같은 화면에서 시작하지 않을 수 있습니다.

예를 들어 다음과 같은 조건이 있습니다.

  • 로그인하지 않은 사용자: 로그인 화면으로 이동

  • 로그인한 사용자: 홈 화면으로 이동

  • 필수 권한을 허용하지 않은 사용자: 권한 안내 화면으로 이동

  • 온보딩을 보지 않은 사용자: 온보딩 화면으로 이동

가장 단순한 방법은 startDestination을 조건에 따라 정하는 것입니다.

@Composable
fun AppNavigation(
    isLoggedIn: Boolean
) {
    val navController = rememberNavController()

    val startDestination = if (isLoggedIn) {
        Routes.HOME
    } else {
        Routes.LOGIN
    }

    NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        composable(Routes.LOGIN) {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Routes.HOME) {
                        popUpTo(Routes.LOGIN) {
                            inclusive = true
                        }
                    }
                }
            )
        }

        composable(Routes.HOME) {
            HomeScreen()
        }
    }
}

이 방식은 이해하기 쉽습니다. 앱이 시작될 때 이미 로그인 여부를 알고 있다면 충분히 사용할 수 있습니다.

하지만 실제 앱에서는 로그인 상태를 저장소에서 비동기로 읽어야 할 때가 많습니다. 예를 들어 토큰을 DataStore에서 읽거나, 서버에 세션이 유효한지 확인해야 할 수 있습니다. 이때는 시작 화면을 바로 결정하기 어렵습니다.

그럴 때는 스플래시 또는 로딩 화면을 먼저 보여준 뒤 상태에 따라 이동하는 방식이 자연스럽습니다.

sealed interface AuthState {
    data object Loading : AuthState
    data object LoggedIn : AuthState
    data object LoggedOut : AuthState
}

앱 시작 시에는 Loading 상태로 두고, 확인이 끝나면 LoggedIn 또는 LoggedOut으로 바꿉니다.

@Composable
fun AppNavigation(
    authState: AuthState
) {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = Routes.SPLASH
    ) {
        composable(Routes.SPLASH) {
            SplashScreen()

            LaunchedEffect(authState) {
                when (authState) {
                    AuthState.Loading -> Unit
                    AuthState.LoggedIn -> {
                        navController.navigate(Routes.HOME) {
                            popUpTo(Routes.SPLASH) { inclusive = true }
                        }
                    }
                    AuthState.LoggedOut -> {
                        navController.navigate(Routes.LOGIN) {
                            popUpTo(Routes.SPLASH) { inclusive = true }
                        }
                    }
                }
            }
        }

        composable(Routes.LOGIN) {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Routes.HOME) {
                        popUpTo(Routes.LOGIN) { inclusive = true }
                    }
                }
            )
        }

        composable(Routes.HOME) {
            HomeScreen()
        }
    }
}

LaunchedEffect는 Compose에서 특정 값이 바뀌었을 때 한 번 실행해야 하는 작업에 사용할 수 있습니다. 여기서는 authState가 바뀌면 그 결과에 따라 이동합니다.

핵심은 시작 조건을 UI 코드 여기저기에 흩뿌리지 않는 것입니다. 로그인 여부, 권한 여부, 온보딩 완료 여부는 앱의 입구에서 정리하는 편이 좋습니다.

권한 상태에 따른 분기

기기 기능을 사용하는 앱은 권한 상태에 따라 화면 흐름이 달라질 수 있습니다. 예를 들어 카메라를 사용하는 앱이라면 다음 흐름이 필요합니다.

권한 확인 → 권한 안내 → 권한 요청 → 기능 화면

권한을 아직 허용하지 않은 사용자를 곧바로 기능 화면으로 보내면 오류가 발생하거나 빈 화면이 보일 수 있습니다. 그래서 권한이 필요한 기능에 들어가기 전에 상태를 확인해야 합니다.

단순한 route 구조는 다음과 같습니다.

object Routes {
    const val PERMISSION_GUIDE = "permissionGuide"
    const val CAMERA = "camera"
}

권한이 없으면 안내 화면으로 보내고, 권한이 있으면 기능 화면으로 보냅니다.

fun navigateToCamera(
    hasCameraPermission: Boolean,
    navController: NavController
) {
    if (hasCameraPermission) {
        navController.navigate(Routes.CAMERA)
    } else {
        navController.navigate(Routes.PERMISSION_GUIDE)
    }
}

실제 권한 요청 API는 뒤의 장에서 다룹니다. 지금은 내비게이션 관점만 이해하면 됩니다.

권한이나 로그인처럼 앱 흐름을 바꾸는 조건은 화면 이동 설계의 일부입니다. "어떤 화면으로 갈 수 있는가"뿐 아니라 "어떤 조건에서 갈 수 있는가"까지 함께 생각해야 합니다.

내비게이션과 ViewModel 연결하기

이전 장에서는 ViewModel이 화면 상태와 이벤트를 관리한다고 했습니다. 내비게이션에서도 같은 원칙을 적용할 수 있습니다.

예를 들어 로그인 화면을 생각해 보겠습니다.

  • 사용자가 이메일과 비밀번호를 입력합니다.

  • 로그인 버튼을 누릅니다.

  • ViewModel이 로그인 요청을 처리합니다.

  • 성공하면 홈 화면으로 이동합니다.

  • 실패하면 오류 메시지를 보여줍니다.

여기서 주의할 점이 있습니다. ViewModel이 직접 NavController를 가지게 만들지는 않는 편이 좋습니다. ViewModel은 안드로이드 UI 이동 도구를 몰라도 되어야 테스트하기 쉽습니다.

대신 ViewModel은 "로그인 성공" 같은 상태나 이벤트를 노출하고, Composable이 그 이벤트를 보고 이동합니다.

간단한 상태는 다음처럼 표현할 수 있습니다.

data class LoginUiState(
    val email: String = "",
    val password: String = "",
    val isLoading: Boolean = false,
    val errorMessage: String? = null,
    val isLoginSuccess: Boolean = false
)

Composable은 상태를 보고 이동할 수 있습니다.

@Composable
fun LoginRoute(
    viewModel: LoginViewModel = viewModel(),
    onLoginSuccess: () -> Unit
) {
    val uiState = viewModel.uiState

    LaunchedEffect(uiState.isLoginSuccess) {
        if (uiState.isLoginSuccess) {
            onLoginSuccess()
        }
    }

    LoginScreen(
        uiState = uiState,
        onEmailChange = viewModel::onEmailChange,
        onPasswordChange = viewModel::onPasswordChange,
        onLoginClick = viewModel::onLoginClick
    )
}

NavHost에서는 로그인 성공 이벤트를 실제 이동으로 연결합니다.

composable(Routes.LOGIN) {
    LoginRoute(
        onLoginSuccess = {
            navController.navigate(Routes.HOME) {
                popUpTo(Routes.LOGIN) { inclusive = true }
            }
        }
    )
}

이 구조에서는 역할이 분명합니다.

  • LoginViewModel: 로그인 상태와 로직 관리

  • LoginRoute: ViewModel과 화면 연결, 이동 이벤트 전달

  • LoginScreen: 실제 UI 표시

  • NavHost: route 등록과 이동 처리

화면이 커질수록 이런 분리가 중요해집니다.

화면 단위 이름을 분리하면 구조가 선명해진다

실전 Compose 앱에서는 HomeScreen과 HomeRoute를 나누는 방식이 자주 사용됩니다.

HomeScreen은 순수한 UI에 가깝습니다.

@Composable
fun HomeScreen(
    uiState: HomeUiState,
    onLessonClick: (Long) -> Unit,
    onSettingsClick: () -> Unit
) {
    // 상태를 화면에 표시하고 이벤트를 밖으로 전달
}

HomeRoute는 ViewModel, 상태 수집, 내비게이션 이벤트 연결을 담당합니다.

@Composable
fun HomeRoute(
    viewModel: HomeViewModel = viewModel(),
    onLessonClick: (Long) -> Unit,
    onSettingsClick: () -> Unit
) {
    val uiState = viewModel.uiState

    HomeScreen(
        uiState = uiState,
        onLessonClick = onLessonClick,
        onSettingsClick = onSettingsClick
    )
}

NavHost에서는 Route를 등록합니다.

composable(Routes.HOME) {
    HomeRoute(
        onLessonClick = { lessonId ->
            navController.navigate(Routes.lessonDetail(lessonId))
        },
        onSettingsClick = {
            navController.navigate(Routes.SETTINGS)
        }
    )
}

처음부터 모든 화면을 이렇게 나눌 필요는 없습니다. 하지만 앱이 커질수록 Screen과 Route를 구분하면 코드가 정리됩니다.

  • Screen: 어떻게 보일 것인가

  • Route: 어떤 상태와 이동을 연결할 것인가

이 구분은 유지보수에 큰 도움이 됩니다.

내비게이션 설계 체크리스트

화면 이동을 만들기 전에 다음 질문을 해 보세요.

  1. 이 앱의 최상위 화면은 무엇인가?

  2. 사용자가 가장 자주 오가는 화면은 무엇인가?

  3. 어떤 화면은 뒤로 가기로 돌아가면 안 되는가?

  4. 상세 화면에는 어떤 식별자를 전달해야 하는가?

  5. 로그인, 권한, 온보딩 여부에 따라 시작 흐름이 달라지는가?

  6. 하단 내비게이션, 탭, 드로어 중 어떤 구조가 가장 자연스러운가?

  7. 화면이 직접 이동을 처리하는가, 아니면 이벤트만 밖으로 전달하는가?

이 질문에 답하면 이동 구조가 훨씬 명확해집니다.

내비게이션은 나중에 붙이는 부가 기능이 아닙니다. 앱의 사용 흐름을 결정하는 뼈대입니다.

이번 장에서 기억할 것

Navigation Compose에서는 NavController, NavHost, route를 중심으로 화면 이동을 구성합니다. NavHost에는 이동 가능한 화면을 등록하고, navController.navigate()로 새 화면으로 이동합니다.

목록에서 상세 화면으로 갈 때는 객체 전체보다 id 같은 식별자를 전달하는 편이 좋습니다. 상세 화면은 그 식별자를 기준으로 필요한 데이터를 가져옵니다.

뒤로 가기는 백 스택을 기반으로 동작합니다. navigate()는 화면을 쌓고, popBackStack()은 현재 화면을 제거합니다. 로그인 성공이나 로그아웃처럼 이전 화면으로 돌아가면 안 되는 경우에는 popUpTo로 백 스택을 정리해야 합니다.

하단 내비게이션은 자주 쓰는 최상위 화면에, 탭은 한 화면 안의 분류에, 드로어는 보조 메뉴나 많은 목적지에 적합합니다. 이동 방식은 UI 모양보다 앱의 정보 구조를 기준으로 선택해야 합니다.

마지막으로, 화면이 직접 NavController에 강하게 묶이지 않도록 이벤트를 밖으로 전달하는 구조를 권장합니다. 화면은 UI를 보여주고, ViewModel은 상태를 관리하며, 내비게이션 영역은 실제 이동을 처리합니다. 이 역할 분리가 앱이 커졌을 때 유지보수성을 높여 줍니다.

책 목록

처음부터 출시까지 배우는 안드로이드 앱 개발

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기