
Navigation Compose 타입 안전 라우트는 Compose 화면 이동을 문자열 조합으로 관리할 때 생기는 실수를 줄이기 위한 방법입니다. 화면 수가 적을 때는 route 문자열 몇 개로도 충분해 보이지만, argument와 deep link가 붙는 순간 실수 지점이 빠르게 늘어납니다.
핵심은 화면 이동을 문자열 규칙이 아니라 타입이 있는 계약으로 다루는 것입니다. 이 글은 Navigation Compose에서 타입 안전 route를 왜 검토해야 하는지, 어떤 실수를 줄이는지, 어디까지 적용하면 좋은지 단계적으로 정리합니다.

문자열 route는 단순합니다. `profile/{userId}` 같은 문자열을 정해 두고, 이동할 때 같은 규칙으로 값을 끼워 넣으면 됩니다. 문제는 이 규칙이 컴파일러가 이해하는 계약이 아니라는 점입니다.
화면 이름이 바뀌었는데 이동 코드 일부만 남아 있거나, argument 이름이 달라졌거나, nullable 여부를 잘못 처리해도 많은 경우 실행 전까지 알아차리기 어렵습니다. 작은 앱에서는 금방 찾을 수 있지만 화면이 늘면 이런 버그가 테스트와 QA 단계까지 내려갑니다.
- route 문자열 오타
- argument 이름 불일치
- 필수 argument 누락
- 인코딩이 필요한 값을 문자열로 직접 이어 붙이는 실수
- 화면별 route 규칙이 여러 파일에 흩어지는 문제
문자열 route의 약점
문자열 route의 가장 큰 약점은 화면 목적지와 인자 구조가 분리된다는 점입니다. route를 정의한 곳, navigate를 호출한 곳, destination에서 argument를 읽는 곳이 서로 다른 문자열 규칙에 의존합니다.
navController.navigate("profile/${userId}")
composable("profile/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
}이 코드는 간단하지만 문자열 세 군데가 같은 의미를 공유합니다. `userId`를 `memberId`로 바꾸거나 profile route를 user route로 바꾸면 관련 코드를 모두 정확히 고쳐야 합니다.
타입 안전 route는 무엇을 바꾸나
타입 안전 route는 화면 목적지를 Kotlin 타입으로 표현합니다. 목적지와 argument를 data class나 object로 정의하면 이동 코드와 destination 선언이 같은 타입을 기준으로 맞춰집니다.
@Serializable
data class Profile(val userId: String)
@Serializable
object Home
NavHost(navController, startDestination = Home) {
composable<Home> {
HomeScreen(onProfileClick = { id ->
navController.navigate(Profile(id))
})
}
composable<Profile> { backStackEntry ->
val route = backStackEntry.toRoute<Profile>()
ProfileScreen(userId = route.userId)
}
}이렇게 잡으면 화면 이동이 문자열 조립이 아니라 `Profile(id)` 같은 타입 생성으로 바뀝니다. route 구조가 코드에서 더 분명해지고, argument 추가나 변경이 더 눈에 잘 보입니다.
argument 전달 기준
타입 안전 route를 쓴다고 모든 데이터를 navigation argument로 넘겨도 된다는 뜻은 아닙니다. navigation argument는 화면을 다시 찾는 데 필요한 최소 식별자에 가깝게 두는 편이 안전합니다.
- 화면을 식별하는 id는 argument로 전달한다
- 큰 객체나 자주 바뀌는 UI state는 ViewModel이나 repository에서 다시 읽는다
- 비밀번호, 토큰, 민감 정보는 route에 넣지 않는다
- 프로세스 재생성 후에도 복원 가능한 값인지 확인한다
deep link와 함께 볼 때
deep link가 들어오면 route는 앱 내부 이동 규칙을 넘어 외부 진입 계약이 됩니다. 이때 문자열 규칙이 흩어져 있으면 링크 경로, argument, 화면 복원 흐름을 추적하기 어렵습니다.
타입 안전 route를 쓰더라도 deep link는 별도 URI 계약을 신중히 다뤄야 합니다. 내부 타입과 외부 링크 형식을 무리하게 하나로 합치기보다, 외부에서 들어오는 값은 검증하고 내부 route 타입으로 변환하는 경계를 두는 편이 좋습니다.
실무 적용 순서
- 화면별 route 문자열을 한곳에 모아 현재 규칙을 확인한다
- argument가 있는 화면부터 타입 안전 route 후보로 잡는다
- 필수 id와 UI state를 분리한다
- deep link 진입 화면은 외부 URI 검증 경계를 따로 둔다
- 테스트에서 navigate 호출과 destination argument 해석을 함께 확인한다
바로 바꾸지 않아도 되는 경우
모든 프로젝트가 당장 타입 안전 route로 갈 필요는 없습니다. 화면 수가 적고 argument가 거의 없으며, route 상수가 잘 관리되고 있다면 문자열 route만으로도 운영할 수 있습니다.
다만 화면 수가 늘고, 프로필/상세/결제/설정처럼 argument가 붙는 화면이 많아진다면 빨리 정리하는 편이 좋습니다. route는 한번 흩어지면 나중에 바꾸는 비용이 큽니다.
실무에서 가장 먼저 터지는 문제
Navigation Compose를 처음 붙일 때는 화면 이동이 잘 되는지만 확인하는 경우가 많습니다. 하지만 실제 서비스에서는 화면 이동보다 화면을 다시 여는 상황이 더 어렵습니다. 알림에서 들어오거나, deep link로 진입하거나, 프로세스가 죽었다가 복원될 때 route 설계가 드러납니다.
문자열 route는 이런 상황에서 문제가 숨어 있다가 늦게 보입니다. 예를 들어 상세 화면으로 이동할 때 `postId`를 넘기기로 했는데 어떤 코드에서는 `id`로 읽고, 어떤 deep link에서는 값이 빠져 있으면 원인을 찾는 데 시간이 걸립니다.

route에 실으면 안 되는 값
타입 안전 route를 쓰면 data class에 여러 값을 넣기 쉬워집니다. 하지만 route는 화면을 식별하는 최소 정보만 담는 편이 좋습니다. 화면 전체 상태를 route로 옮기면 navigation graph가 상태 저장소처럼 변합니다.
- 서버에서 다시 읽을 수 있는 객체 전체
- 사용자가 입력 중인 긴 form 상태
- 토큰, 이메일 인증 코드, 결제 관련 민감 정보
- 이미지 URL처럼 길고 인코딩 실수가 나기 쉬운 값
- 화면 전환 순간의 일시적 UI 상태
대부분의 상세 화면은 `id`만 받아도 충분합니다. id로 repository에서 다시 읽고, 화면 상태는 ViewModel에서 관리하면 navigation과 상태 관리의 책임이 분리됩니다.
문자열 route에서 옮기는 순서

- 현재 route 문자열과 argument 이름을 한 파일에 모은다
- argument가 없는 화면은 object route로 먼저 옮긴다
- argument가 있는 화면은 data class로 옮기고 nullable 여부를 명확히 한다
- navigate 호출부를 문자열 조합에서 타입 생성으로 바꾼다
- destination 내부에서 argument를 직접 꺼내는 코드를 toRoute 기반으로 정리한다
- deep link에서 들어온 값은 내부 route 타입으로 변환하기 전에 검증한다
중첩 graph에서는 어떻게 볼까
앱이 커지면 로그인 graph, 홈 graph, 설정 graph처럼 navigation graph가 나뉩니다. 이때 route 타입도 한 파일에 모두 몰아넣기보다 feature 단위로 나누는 편이 관리하기 쉽습니다.
다만 feature 간 이동이 많다면 public route와 internal route를 구분해야 합니다. 외부 feature가 직접 알아야 하는 목적지만 공개하고, 내부 화면 이동은 해당 feature 안에 숨기는 방식입니다.
// account feature가 외부에 공개하는 route
@Serializable
data class AccountDetail(val accountId: String)
// account feature 내부에서만 쓰는 route
@Serializable
internal data class AccountEditStep(val accountId: String, val step: Int)테스트 관점에서 달라지는 점
타입 안전 route를 도입하면 테스트가 필요 없어지는 것이 아닙니다. 대신 테스트해야 할 지점이 더 명확해집니다. 문자열이 맞는지보다, route 타입이 어떤 값을 허용하고 어떤 값에서 실패해야 하는지를 확인하게 됩니다.

- 필수 argument가 없을 때 실패를 명확히 처리하는가
- deep link로 들어온 문자열 id가 내부 타입으로 안전하게 변환되는가
- 프로세스 재생성 뒤에도 같은 화면을 복원할 수 있는가
- 잘못된 id일 때 빈 화면이 아니라 오류/뒤로가기 흐름이 있는가
팀 코드 리뷰에서 볼 기준
Navigation 관련 PR을 볼 때는 단순히 화면이 이동하는지만 보면 부족합니다. route가 feature 경계를 새로 노출하는지, 너무 많은 데이터를 싣는지, deep link와 내부 이동을 섞는지 확인해야 합니다.
- route 타입 이름이 화면의 의도를 드러내는가
- argument가 화면을 다시 찾는 데 필요한 최소 정보인가
- 외부 진입 계약과 내부 이동 계약이 섞이지 않았는가
- ViewModel이 navigation argument를 읽는 방식이 일관적인가
- 테스트가 정상 이동뿐 아니라 잘못된 값도 다루는가
실패 사례로 보는 route 설계
타입 안전 route를 도입해도 설계가 흐리면 같은 문제가 다른 모양으로 반복됩니다. 예를 들어 화면 이동 data class에 화면 제목, 탭 위치, 정렬 방식, 필터 조건, 서버 응답 일부를 모두 넣으면 타입은 생겼지만 책임은 여전히 과합니다.
route는 화면을 찾아가기 위한 주소입니다. 화면을 그리는 데 필요한 모든 데이터를 담는 컨테이너가 아닙니다. 주소가 비대해지면 deep link, saved state, analytics, 테스트가 모두 복잡해집니다.
- 상세 화면 route에 전체 DTO를 넣어 앱 버전 변경 때 깨지는 사례
- 검색 화면 route에 복잡한 필터 객체를 그대로 넣어 deep link와 충돌하는 사례
- 탭 이동 같은 내부 UI 상태를 navigation graph에 올려 back stack이 이상해지는 사례
- nullable argument를 아무 곳에서나 허용해 오류 화면과 빈 화면이 섞이는 사례
ViewModel과 SavedStateHandle 연결
Navigation argument는 화면 진입의 입력값이고, ViewModel은 화면 상태를 유지하는 곳입니다. 이 둘을 연결할 때는 SavedStateHandle을 통해 argument를 읽고, 그 값으로 실제 데이터를 로드하는 구조가 흔합니다.
class ProfileViewModel(
savedStateHandle: SavedStateHandle,
private val repository: ProfileRepository
) : ViewModel() {
private val route = savedStateHandle.toRoute<Profile>()
val uiState: StateFlow<ProfileUiState> =
repository.observeProfile(route.userId)
.map { profile -> ProfileUiState.Ready(profile) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ProfileUiState.Loading)
}이 구조의 장점은 route argument와 화면 상태가 섞이지 않는다는 점입니다. route는 `userId`만 알고, ViewModel은 그 id를 이용해 화면에 필요한 상태를 구성합니다.
analytics와 로깅 기준
route 설계는 analytics에도 영향을 줍니다. 문자열 route를 그대로 이벤트 이름으로 쓰면 화면 이름 변경이 분석 데이터까지 흔들 수 있습니다. 타입 안전 route를 쓰더라도 이벤트 이름은 별도 상수나 screen name 정책으로 관리하는 편이 좋습니다.
특히 상세 화면 id처럼 개인정보 또는 민감한 식별자가 될 수 있는 값은 로그에 그대로 남기지 않아야 합니다. navigation argument와 analytics payload를 분리하면 이런 실수를 줄일 수 있습니다.
팀 컨벤션 예시
1. route 타입은 feature 모듈 안에 둔다.
2. 외부 feature가 호출할 route만 public으로 연다.
3. route에는 id와 최소 mode만 둔다.
4. DTO, UI state, token은 route에 넣지 않는다.
5. deep link 입력은 내부 route로 변환하기 전에 검증한다.
6. ViewModel은 SavedStateHandle에서 route를 읽는다.
7. 화면 analytics 이름은 route 문자열과 분리한다.컨벤션은 거창할 필요가 없습니다. 중요한 것은 팀원이 route를 볼 때 어디까지가 navigation 책임이고 어디부터가 화면 상태 책임인지 같은 기준으로 이해하는 것입니다.
정리
Navigation Compose 타입 안전 라우트는 단지 새 문법이 아닙니다. 화면 이동을 문자열 조합이 아니라 타입이 있는 계약으로 다루자는 방향입니다.
기존 Navigation 전환 흐름은 Navigation Compose 전환 순서, Compose 상태 호이스팅은 언제 해야 할까, collectAsStateWithLifecycle 사용 기준와 함께 보면 좋습니다. 외부 기준은 Android Developers – Type safety in Kotlin DSL and Navigation Compose, Android Developers – Navigation with Compose, Kotlinx Serialization Guide를 확인했습니다.