
DearShot은 주변 장면을 분석해 사진 구도를 추천하고, 촬영할 때 참고할 가이드를 보여주는 Android 앱이다. UI는 Jetpack Compose로 작성하고, MVVM과 Hilt를 사용하고 있다.
카메라 화면을 만들기 전에 먼저 정리한 것은 앱에 들어오는 과정이었다. 처음 사용하는 사람은 로그인 화면으로, 이미 로그인한 사람은 카메라 화면으로 이동해야 한다. 서버가 점검 중이거나 필수 업데이트가 필요한 경우에는 그 안내를 먼저 보여줘야 한다.
이번 글에서는 이 흐름을 Navigation 3로 연결하면서 공부한 개념과 실제 적용 코드를 정리한다.
작성 기준: 2026년 10월 12일. 프로젝트의 Navigation 3 버전은
1.1.7이다. 로그인·카메라·템플릿 화면은 아직 임시 화면이며, 이 글은 시작 상태와 화면 이동을 연결한 단계까지 다룬다. 코드 블록은 해당 프로젝트에서 필요한 부분을 발췌했다.
앱을 실행하면 서버 운영 설정과 저장된 로그인 상태를 확인한다. 결과에 따라 시작 화면 안에서 안내를 보여주거나 다음 화면으로 이동한다.
앱 실행
└─ StartRoute
├─ 로딩 → 시작 화면에서 표시
├─ 서비스 점검 → 점검 화면 표시
├─ 필수 업데이트 → 업데이트 화면 표시
├─ 조회 실패 → 오류 안내와 재시도
├─ 로그인 필요 → LoginRoute(provider)
└─ 로그인 복원 완료 → CameraRoute
CameraRoute
├─ TemplatesRoute
└─ LoginRoute(provider)여기서 로딩·점검·업데이트·오류는 각각 독립적인 네비게이션 목적지로 만들지 않았다. 모두 앱 시작 과정의 상태이므로 StartRoute 안에서 StartUiState에 따라 표시한다.
사용자가 별도 화면으로 이동하는 로그인·카메라·템플릿 목록은 Route로 구분했다. 이렇게 하면 점검 화면에서 재시도했다고 뒤로 가기 목록에 로딩 화면이 쌓이지 않는다.
Navigation 3에서 관심이 갔던 부분은 앱이 백 스택을 직접 소유한다는 점이었다. 화면을 나타내는 키를 목록에 추가하거나 제거하면 NavDisplay가 그 변경을 화면에 반영한다. Compose와 함께 사용하도록 설계된 구조다. Navigation 3 공식 개요
DearShot에서는 특히 시작 상태에 따라 기존 화면을 교체하는 과정이 필요했다.
backStack.clear()
backStack.add(CameraRoute)이 코드를 보면 시작 화면을 남기지 않고 카메라를 새로운 루트 화면으로 삼는다는 의도가 바로 드러난다. 상태를 읽고 그에 맞게 UI를 보여주는 Compose 방식과 연결해서 이해하기도 좋았다.
구분 | Navigation 2의 일반적인 Compose 구성 | 이번 Navigation 3 구성 |
|---|---|---|
화면 이동 상태 |
| 앱이 백 스택을 직접 관리 |
화면 등록·표시 |
|
|
앞으로 이동 |
| 백 스택에 키 추가 |
뒤로 이동 |
| 백 스택 마지막 키 제거 |
이 비교는 API 구조를 이해하기 위한 것이다. Navigation 2에서도 타입 안전한 Route를 사용할 수 있으므로, “Navigation 3부터 문자열 없이 이동할 수 있다”는 식으로 구분하면 정확하지 않다. Navigation 2의 타입 안전한 네비게이션, 공식 마이그레이션 가이드
이미 Navigation 2로 잘 동작하는 앱을 반드시 바꿔야 한다고 판단한 것은 아니다. DearShot은 새 Compose 프로젝트였고, 화면 이동 상태를 직접 구성하고 이해해보려는 목적에 Navigation 3가 맞았다. 성능이 더 좋다는 측정 결과를 근거로 선택한 것도 아니다.
프로젝트에서는 Version Catalog로 버전을 관리한다. 아래는 이번 글과 관련된 설정만 추린 것이다.
[versions]
navigation3 = "1.1.7"
lifecycle = "2.10.0"
androidxHilt = "1.3.0"
kotlin = "2.2.10"
kotlinxSerialization = "1.9.0"
[libraries]
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }앱 모듈에는 다음 항목을 추가했다. Compose·Hilt 자체의 기본 설정은 이미 적용된 상태다.
plugins {
// 기존 플러그인 설정은 유지한다.
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.kotlinx.serialization.core)
}navigation3-runtime은 키와 엔트리 같은 기본 API를, navigation3-ui는 NavDisplay 같은 화면 표시 기능을 제공한다. lifecycle-viewmodel-navigation3는 화면 엔트리에 ViewModel 범위를 연결하는 데 사용한다. Navigation 3 설정 문서
위 버전은 이 프로젝트에서 사용한 조합이다. 다른 시점에 적용한다면 공식 릴리스 정보와 해당 버전의 SDK 요구사항을 함께 확인해야 한다.
먼저 이동할 화면을 키로 정의했다.
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
private data object StartRoute : NavKey
@Serializable
private data object CameraRoute : NavKey
@Serializable
private data object TemplatesRoute : NavKeyStartRoute는 시작 화면을 식별하는 값이다. 화면 UI 자체를 담은 객체는 아니다.
data object를 사용한 이유는 이 화면들에 별도로 전달할 인자가 없기 때문이다. 시작 화면을 나타내는 키 하나면 충분하다. private은 현재 Route를 같은 AppNavigation.kt 안에서만 사용하기 때문에 붙였다.
NavKey는 rememberNavBackStack에서 사용할 수 있는 키임을 나타내는 인터페이스다. 해당 함수로 백 스택을 저장·복원하려면 키가 NavKey를 구현하고 @Serializable로 직렬화 가능해야 한다. 네비게이션 상태 저장 문서
여기에서 사용하는 @Serializable은 kotlinx.serialization.Serializable이다. java.io.Serializable과 이름은 같지만, 연결되는 직렬화 방식과 사용법이 다르다.
직렬화는 메모리에 있는 객체의 정보를 저장하거나 전송할 수 있는 형태로 변환하는 작업이다. 역직렬화는 그 정보로 객체를 다시 구성하는 작업이다. 네비게이션에서는 어떤 화면에 있었는지, 그 화면에 어떤 인자를 전달했는지를 복원하는 데 필요하다.
kotlinx.serialization.Serializable은 애너테이션이다. Kotlin Serialization 컴파일러 플러그인이 이 표시를 보고 해당 타입을 읽고 쓰는 KSerializer 구현을 생성한다. 단순히 “저장 가능한 객체”라고 표시하는 데서 끝나지 않고, Kotlin Serialization이 사용할 변환 코드를 준비하는 것이다. Kotlin Serializable 문서
import kotlinx.serialization.Serializable
@Serializable
private data class ExampleRoute(
val id: String,
) : NavKey반면 java.io.Serializable은 Java 객체 직렬화에서 사용하는 인터페이스다. 클래스가 이를 구현하면 ObjectOutputStream·ObjectInputStream을 사용하는 Java 직렬화 체계에서 처리할 수 있음을 나타낸다. Kotlin의 KSerializer를 생성해주는 기능은 아니다. Java Serializable 문서
// Java 직렬화를 위한 선언이다.
// 위 @Serializable Route 선언을 대체하는 코드가 아니다.
private data class JavaSerializableData(
val id: String,
) : java.io.Serializable구분 | kotlinx.serialization.Serializable | java.io.Serializable |
|---|---|---|
형태 | 애너테이션 | 인터페이스 |
선언 방식 | 클래스 위에 | 클래스 뒤에 |
연결되는 기능 | Kotlin Serialization의 serializer 생성 | Java 객체 직렬화 지원 표시 |
이번 프로젝트의 용도 |
| 해당 Route 설정에 사용하지 않음 |
이번 프로젝트의 rememberNavBackStack은 Kotlin Serialization을 이용하는 설정이므로, Java의 Serializable을 구현하는 것만으로는 필요한 Route serializer가 준비되지 않는다. import와 Serialization 플러그인 설정을 함께 확인해야 하는 이유다.
또한 @Serializable을 붙였다고 객체가 즉시 파일이나 DB에 저장되는 것은 아니다. 직렬화할 수 있도록 준비하는 표시이고, 실제 백 스택의 저장·복원은 rememberNavBackStack과 Android의 상태 복원 과정이 담당한다. 저장 대상도 Composable 화면 자체가 아니라 ExampleRoute(id = "...") 같은 화면 식별 정보다. 네비게이션 상태 저장 문서
로그인 화면에는 표시할 소셜 로그인 제공자 정보가 필요하다. 한국어 앱에서는 카카오를, 그 외에는 구글을 표시하려고 한다.
@Serializable
enum class SocialLoginProvider {
KAKAO,
GOOGLE,
}
@Serializable
private data class LoginRoute(
val provider: SocialLoginProvider,
) : NavKey이 경우에는 인스턴스마다 전달할 값이 있으므로 data class를 사용했다.
LoginRoute(provider = SocialLoginProvider.KAKAO)
LoginRoute(provider = SocialLoginProvider.GOOGLE)Route에는 화면을 구성하는 데 필요한 작은 값만 담았다. 토큰이나 이미지 원본처럼 민감하거나 큰 데이터는 넣지 않는다. 이후 템플릿 상세 화면을 추가한다면 전체 응답 객체 대신 템플릿 ID와 버전 같은 식별자를 전달하는 방식으로 구성할 생각이다.
백 스택은 사용자가 이동할 화면의 순서를 나타낸다. 실제 화면 대신 앞에서 만든 Route를 담는다.
val backStack = rememberNavBackStack(StartRoute)처음에는 다음 상태다.
[StartRoute]카메라에서 템플릿 목록을 열 때는 마지막에 키를 추가한다.
backStack.add(TemplatesRoute)[CameraRoute]
↓ 템플릿 목록 열기
[CameraRoute, TemplatesRoute]뒤로 이동할 때는 마지막 키를 제거한다.
if (backStack.size > 1) {
backStack.removeLastOrNull()
}[CameraRoute, TemplatesRoute]
↓ 뒤로 가기
[CameraRoute]size > 1은 직접 작성한 제거 로직이 마지막 루트 키까지 삭제하지 않게 하기 위한 조건이다. 이번 글에서는 두 화면 이상인 스택에서 이전 화면으로 돌아가는 동작을 다룬다. 루트 화면에서 앱을 나가는 동작과 시스템 뒤로 가기는 별도로 검증해야 한다.
관찰 가능한 목록을 remember로 만들어서 화면 이동을 구성할 수도 있다. 다만 remember만으로는 화면 회전이나 시스템에 의한 프로세스 종료 후 상태 복원까지 해결되지 않는다.
rememberNavBackStack은 직렬화 가능한 키를 사용해 Android의 저장된 상태 복원 과정에 백 스택을 연결한다. 모든 앱 데이터를 저장하는 DB나 토큰 저장소 역할을 하는 것은 아니다. 네비게이션 상태 저장 문서
백 스택이 이동 상태라면, NavDisplay는 그 상태에 맞는 화면을 표시하는 역할이다. entryProvider는 키를 받아 어떤 UI를 보여줄지 연결한다. 기본 단일 화면 구성에서는 백 스택의 맨 위 엔트리를 표시한다. Navigation 3 기본 동작
다음은 프로젝트의 네비게이션 구조를 간단한 텍스트 화면으로 줄인 예시다. 이 예시에서는 핵심 구조를 보기 위해 ViewModel 데코레이터와 실제 화면 연결을 생략했다.
@Composable
fun AppNavigationExample() {
val backStack = rememberNavBackStack(StartRoute)
NavDisplay(
backStack = backStack,
onBack = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
}
},
entryProvider = entryProvider {
entry<StartRoute> {
Button(
onClick = {
backStack.clear()
backStack.add(CameraRoute)
},
) {
Text("촬영 시작")
}
}
entry<CameraRoute> {
Button(
onClick = {
backStack.add(TemplatesRoute)
},
) {
Text("템플릿 보기")
}
}
entry<TemplatesRoute> {
Text("템플릿 목록")
}
entry<LoginRoute> { route ->
Text("로그인 제공자: ${route.provider}")
}
},
)
}entry<LoginRoute> { route -> ... }의 route는 백 스택에 넣었던 LoginRoute 값이다. route.provider로 전달된 로그인 제공자를 읽는다.
entryProvider에서 화면 이동 순서까지 자동으로 만들어주는 것은 아니다. 어떤 Route를 언제 추가하거나 제거할지는 앱에서 결정한다.
실제 프로젝트에서는 NavDisplay에 다음 두 데코레이터를 전달했다.
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
)데코레이터는 엔트리의 UI를 감싸 필요한 상태 관리 환경을 제공하는 장치다. 이름이 길어서 처음에는 둘 다 화면을 기억하는 함수처럼 보이지만 실제로는 담당하는 상태가 다르다.
설정 | 담당하는 것 |
|---|---|
| 화면 키와 이동 순서 |
| 엔트리 안의 |
| 엔트리별 ViewModel 보관 범위 |
첫 번째 데코레이터는 각 엔트리에 SaveableStateProvider를 제공한다. 엔트리 안에서 rememberSaveable로 관리하는 입력값 같은 상태를 보관·복원하는 데 사용한다. 일반 변수나 모든 remember 값을 자동 저장하는 것은 아니다. 다른 데코레이터를 직접 지정할 때도 이 기본 데코레이터를 먼저 두도록 구성했다. NavEntryDecorator 문서
두 번째 데코레이터는 엔트리별 ViewModelStoreOwner를 제공한다. 해당 범위에서 생성한 ViewModel은 그 엔트리의 수명에 연결되며, 엔트리가 제거되고 정리되면 함께 정리된다. ViewModel 인스턴스 자체가 프로세스 종료 후 복원되는 것은 아니다. 필요한 데이터 복원은 별도로 설계해야 한다. 엔트리별 ViewModel 범위
이 두 가지를 구분하니 “현재 어떤 화면인가”, “그 화면의 UI 상태는 무엇인가”, “그 화면의 ViewModel은 얼마나 유지되는가”를 각각 생각할 수 있었다.
시작 화면에서는 Hilt로 StartViewModel을 생성하고, 그 ViewModel이 제공하는 StateFlow를 구독한다.
entry<StartRoute> {
val viewModel: StartViewModel = hiltViewModel()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
StartScreen(
uiState = uiState,
onRetry = viewModel::retry,
onUpdate = {
// 프로젝트에서는 Play 스토어를 여는 코드와 실패 안내를 연결했다.
},
onReady = {
backStack.clear()
backStack.add(CameraRoute)
},
onLoginRequired = { provider ->
backStack.clear()
backStack.add(LoginRoute(provider = provider))
},
)
}Hilt용 함수는 다음 import를 사용하고 있다.
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel이번 구성에서는 엔트리 안에서 hiltViewModel()을 호출하므로 앞에서 설정한 엔트리의 ViewModel 범위를 사용한다. Hilt는 ViewModel 생성에 필요한 Repository 등의 의존성을 제공하고, 데코레이터는 그 ViewModel을 보관할 범위를 제공한다. Compose의 Hilt 통합
collectAsStateWithLifecycle()은 Android 생명주기를 고려해 Flow를 수집하고 Compose가 읽을 수 있는 State로 변환한다. 기본적으로 생명주기가 STARTED 이상일 때 수집한다. 여기에서 수집을 중단한다고 ViewModel의 모든 작업이 자동으로 취소되는 것은 아니다. Compose에서 Flow 수집하기
시작 상태 확인이 끝나면 사용자가 뒤로 가기를 눌러 시작 로딩으로 돌아갈 필요가 없다. 그래서 기존 스택에 화면을 추가하는 것이 아닌 새 루트로 교체한다.
추가만 할 경우
[StartRoute] → [StartRoute, CameraRoute]
현재 구현
[StartRoute] → [CameraRoute]로그인이 필요한 경우에도 같은 방식으로 [LoginRoute(provider)]가 남는다. 반면 카메라에서 로그인 화면을 여는 경우에는 기존 카메라 화면으로 돌아올 수 있도록 add()만 사용한다.
같은 로그인 화면이라도 진입한 상황에 따라 돌아갈 경로가 다르다는 점을 백 스택으로 표현한 것이다.
DearShot에서는 역할을 다음처럼 나눴다.
StartViewModel
→ 운영 설정과 로그인 상태를 확인해 StartUiState를 만든다.
StartScreen
→ 상태에 맞는 UI를 표시하고 이동이 필요하면 콜백을 호출한다.
AppNavigation
→ 콜백을 받아 백 스택을 변경한다.StartScreen이 NavDisplay나 백 스택을 직접 알 필요는 없다. onReady와 onLoginRequired 같은 함수만 전달받는다.
val currentOnReady by rememberUpdatedState(onReady)
val currentOnLoginRequired by rememberUpdatedState(onLoginRequired)
LaunchedEffect(uiState) {
when (uiState) {
is StartUiState.Ready -> {
currentOnReady()
}
is StartUiState.LoginRequired -> {
currentOnLoginRequired(uiState.provider)
}
is StartUiState.RecoveryRequired -> {
currentOnLoginRequired(uiState.provider)
}
else -> Unit
}
}화면 이동은 UI를 그리는 작업과 구분되는 부수 효과다. 그래서 Composable 본문에서 상태를 읽을 때마다 콜백을 직접 실행하는 대신 LaunchedEffect 안에서 처리했다.
LaunchedEffect(uiState)는 처음 Composition에 들어올 때 실행하고, 키인 uiState가 바뀌면 이전 작업을 취소한 뒤 다시 실행한다. 동일한 키를 유지하는 단순 재구성에서는 다시 시작하지 않는다. 다만 Composition에서 나갔다가 다시 들어오면 실행될 수 있으므로, 이것만으로 앱 전체에서 정확히 한 번 이동한다는 보장이 생기는 것은 아니다. Compose의 부수 효과 문서
rememberUpdatedState는 콜백이 바뀌었다는 이유로 Effect를 재시작하지 않으면서 최신 콜백을 참조할 때 사용한다. 현재처럼 즉시 콜백을 호출하는 코드에서 필수인 것은 아니지만, Effect의 실행 기준과 참조하는 콜백을 분리해두었다. rememberUpdatedState 설명
화면 이동 코드를 추가하는 과정에서 GitHub Actions의 Android 빌드가 실패한 적이 있다. 로그에는 일부 Compose 라이브러리가 SDK 37 이상을 요구하지만 프로젝트의 compileSdk는 36이라는 메시지가 있었다.
해당 커밋에서는 기존 Compose BOM 설정 외에 Compose UI 1.12.1을 직접 지정한 의존성이 추가돼 있었다.
// 기존 설정
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
// 추가로 들어간 설정
implementation(libs.androidx.ui)추가된 별칭은 아래처럼 버전을 직접 지정하고 있었다.
ui = "1.12.1"
androidx-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }추가 의존성과 해당 별칭을 제거해 기존 BOM으로 버전을 관리하도록 되돌렸고, 수정 커밋의 CI는 통과했다.
이 오류는 Navigation 3의 화면 이동 로직이 잘못되어 발생한 것이 아니었다. 라이브러리를 추가할 때는 import 문제뿐 아니라 실제로 선택되는 의존성 버전과 SDK 요구사항도 확인해야 한다는 점을 배웠다.
현재는 시작 상태에 따라 안내 화면을 표시하고 로그인 또는 카메라 화면으로 이동하는 코드를 연결했다. 로그인 화면에서는 Route로 전달한 제공자에 맞는 문구를 표시한다. 실제 소셜 로그인, CameraX 촬영, 템플릿 목록 기능은 다음 단계에서 구현한다.
화면 복원에 필요한 API를 사용했다고 해서 복원 시나리오를 모두 검증한 것은 아니다. 다음 내용을 직접 확인할 예정이다.
시작 설정 조회 실패 후 재시도할 때 정상 경로로 돌아오는지
점검·필수 업데이트 상태에서 로그인 화면으로 잘못 이동하지 않는지
화면 회전과 백그라운드 복귀 후 Route와 인자가 유지되는지
시스템에 의한 프로세스 종료 후 로그인 화면 복원과 세션 재확인 정책이 맞는지
카메라에서 로그인 화면을 연 경우 뒤로 가기로 카메라에 돌아오는지
시작 화면을 교체한 이후 뒤로 가기로 시작 로딩 화면에 돌아가지 않는지
로그인 성공·로그아웃 이후 백 스택을 어떤 루트로 구성할지
이번 단계에서 가장 도움이 된 것은 화면 이동을 목록의 변화로 직접 표현해본 경험이었다. 카메라에서 템플릿을 여는 동작은 목록에 화면을 추가하는 것이고, 앱 시작 확인을 마치는 동작은 목록의 루트를 교체하는 것이다.
다음에는 이 구조에 게스트 로그인을 연결해서, 처음 실행한 사용자가 촬영을 시작하고 앱을 다시 실행했을 때 저장된 세션으로 진입하는 흐름을 구현하려고 한다.
원문: Velog에서 읽기