오늘은 Navigation Component를 살펴봅니다.
서론
안드로이드 앱 개발에서 화면 간 이동 로직은 사용자 경험의 핵심 요소입니다. 초기 Intent 기반 화면 전환부터 Fragment Transaction, 그리고 현재의 Navigation Component까지 모든 변화를 지켜봐 왔습니다. 이 글에서는 Google이 Jetpack의 일부로 제공하는 Navigation Component의 실제 사용 경험과 패턴을 공유해보려 합니다.
Navigation
왜 Navigation Component인가?
기존 화면 전환 방식(Intent, FragmentTransaction)은 다음과 같은 여러 문제점을 가지고 있었습니다:
- 분산된 네비게이션 로직: 앱 전체에 화면 전환 코드가 흩어져 있어 유지보수가 어려움
- 타입 안정성 부재: Intent Extra나 Fragment Arguments 사용 시 런타임 오류 가능성
- 딥링크 처리 복잡성: 외부에서 앱 특정 화면으로 진입 시 처리 로직이 복잡
- 애니메이션 일관성: 화면 전환 애니메이션을 일관되게 적용하기 어려움
- 백 스택 관리: 복잡한 화면 흐름에서 백 스택 관리가 어려움
Navigation Component는 이러한 문제를 해결하면서 선언적 방식으로 앱 네비게이션을 관리할 수 있게 해줍니다.
이전에 사용 예제
// Activity에서 다른 Activity로 이동
Intent(this, SecondActivity::class.java).apply {
putExtra("key", "value")
startActivity(this)
}
// Fragment 전환
supportFragmentManager.beginTransaction()
.replace(R.id.container, SecondFragment())
.addToBackStack(null)
.commit()
// Fragment 데이터 전달
val personFragment = PersonFragment()
personFragment.arguments = Bundle().apply {
putString("name", "Goom")
}
childFragmentManager.beginTransaction()
.setCustomAnimations(R.anim.slid_in_lift, R.anim.slide_out_right)
.add(personFragment, "personFragment")
.commitNowAllowingStateLoss()
이와 같은 문제가 발생되어 있다고 볼 수 있습니다. 이 중에서 Fragment Manager가 프로그래먼트를 추가, 삭제, 교체하고 백스택에 작업을 하는 관리하는 클래스로 사용되었고, 경험이 상대적으로 부족할 경우 메모리 누수 및 앱이 종료되는 불상사도 발생되기도 합니다.
해당 라이브러리를 통해 Fragment Manager와 상호작용하지 않아도 되고, Single Activity로 더 쉽게 Fragment를 관리할 수 있게 됩니다.
// Navigation Component 간단한 예
val bundle = Bundle().apply {
putString("name", "Goom")
}
val navOptions = NavOptions.Builder()
.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.build()
findNavController().navigate(R.id.action_to_blank, bundle, navOptions)
Navigation을 사용하므로, 이전 소스보다 현저하게 줄어들고 명확해진 모습을 볼 수 가 있습니다.
그럼 주요 요소를 살펴보고, 어떤 점이 좋아졌는지 간단한 코드로 살펴봅니다.
Navigation Component 핵심 요소
- NavHostFragment : 현재 탐색 대상이 포함된 UI 요소. 즉, 사용자가 앱을 탑색할 때 앱은 기본적으로 탐색 호소트 안팎으로 대상을 전환하며, NavGraph로 부터 destination이 나타나고 사라지는 컨테이너에 해당
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
app:navGraph="@navigation/nav_graph" />
- NavGraph : 앱 내의 모든 탐색 대상과 연결 방법을 정의하는 데이터 구조로 모든 네비게이션 관련 정보를 포함하는 XML 리소스 파일.
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.example.app.HomeFragment"
android:label="Home"
tools:layout="@layout/fragment_home">
<action
android:id="@+id/action_home_to_detail"
app:destination="@id/detailFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right" />
</fragment>
<fragment
android:id="@+id/detailFragment"
android:name="com.example.app.DetailFragment"
android:label="Detail"
tools:layout="@layout/fragment_detail">
<argument
android:name="itemId"
app:argType="integer" />
</fragment>
</navigation>
- NavController : 대상 간 탐색을 관리하는 중앙 코디네이터로 사용자가 방문한 대상을 추적하고 사용자가 목적지 간에 이동할 수 있도록 함. 컨트롤러는 대상 간 탐색, 딥 링크 처리, 백 스택 관리 등의 작업을 위한 메서드를 제공
// Fragment 내에서 NavController 획득
val navController = findNavController()
// 화면 이동
navController.navigate(R.id.action_home_to_detail)
// 인자 전달
val bundle = bundleOf("itemId" to 123)
navController.navigate(R.id.action_home_to_detail, bundle)
- NavDestination : 탐색 그래프의 노드. 사용자가 이 노드로 이동하면 호스트가 콘텐츠를 표시
간단하게 어떻게 사용되고, 활용 패턴으로 어떤 것들이 있는지를 살펴보겠습니다.
UI 화면
Navigation Component를 사용하기 전에 상단에 Toolbar 와 NaviagtionView를 사용해서 한다면 신경써야 하는 곳이 많으나, Navigation를 사용하면 편리하게 사용할 수 있습니다.
// 사용하기 전
class MainActivity : AppCompatActivity() {
private lateinit var drawer: DrawerLayout
private lateinit var navigationView: NavigationView
private lateinit var toggle: ActionBarDrawerToggle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Toolbar 설정
val toolbar = findViewById<Toolbar>(R.id.toolbar)
setSupportActionBar(toolbar)
// DrawerLayout 설정
drawer = findViewById(R.id.drawer_layout)
navigationView = findViewById(R.id.nav_view)
// 햄버거 아이콘 설정
toggle = ActionBarDrawerToggle(
this, drawer, toolbar,
R.string.navigation_drawer_open,
R.string.navigation_drawer_close
)
// DrawerListener 설정
drawer.addDrawerListener(object : DrawerLayout.DrawerListener {
override fun onDrawerOpened(drawerView: View) {
// 드로어가 열릴 때
invalidateOptionsMenu() // 메뉴 갱신
}
override fun onDrawerClosed(drawerView: View) {
// 드로어가 닫힐 때
invalidateOptionsMenu() // 메뉴 갱신
}
override fun onDrawerStateChanged(newState: Int) {
// 드로어 상태 변경시
}
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
// 드로어가 슬라이드될 때
// toolbar나 컨텐츠 영역 애니메이션 처리 등
toolbar.alpha = 1 - slideOffset
}
})
drawer.addDrawerListener(toggle)
toggle.syncState()
// NavigationView 클릭 처리
navigationView.setNavigationItemSelectedListener { menuItem ->
when (menuItem.itemId) {
R.id.nav_home -> {
supportFragmentManager.beginTransaction()
.replace(R.id.container, HomeFragment())
.commit()
}
R.id.nav_profile -> {
supportFragmentManager.beginTransaction()
.replace(R.id.container, ProfileFragment())
.commit()
}
}
drawer.closeDrawer(GravityCompat.START)
true
}
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
// ActionBarDrawerToggle 상태 동기화
toggle.syncState()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
// ActionBarDrawerToggle 설정 변경 처리
toggle.onConfigurationChanged(newConfig)
}
override fun onBackPressed() {
if (drawer.isDrawerOpen(GravityCompat.START)) {
drawer.closeDrawer(GravityCompat.START)
} else {
super.onBackPressed()
}
}
}
// 사용 후
class MainActivity : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// Toolbar 설정
setSupportActionBar(binding.toolbar)
// NavController 가져오기
val navController = findNavController(R.id.nav_host_fragment)
// AppBarConfiguration 설정 (top-level destinations 정의)
appBarConfiguration = AppBarConfiguration(
setOf(R.id.homeFragment, R.id.profileFragment, R.id.settingsFragment),
binding.drawerLayout
)
// Toolbar와 Navigation 연결
setupActionBarWithNavController(navController, appBarConfiguration)
// NavigationView와 Navigation 연결
binding.navView.setupWithNavController(navController)
// 필요한 경우 추가 리스너 설정
navController.addOnDestinationChangedListener { _, destination, _ ->
when(destination.id) {
R.id.homeFragment -> {
// 홈 화면에서 특별한 처리가 필요한 경우
}
R.id.profileFragment -> {
// 프로필 화면에서 특별한 처리가 필요한 경우
}
}
}
}
override fun onSupportNavigateUp(): Boolean {
val navController = findNavController(R.id.nav_host_fragment)
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
}
}
이전의 코드와 달라진 부분들 이 있습니다. 조금 더 하나하나 살펴보려 합니다.
1. DrawerLayout 설정 간소화
// 이전: 복잡한 리스너 설정
drawer.addDrawerListener(object : DrawerLayout.DrawerListener {
override fun onDrawerOpened(drawerView: View) { ... }
override fun onDrawerClosed(drawerView: View) { ... }
override fun onDrawerStateChanged(newState: Int) { ... }
override fun onDrawerSlide(drawerView: View, slideOffset: Float) { ... }
})
// 이후: 한 줄로 처리
appBarConfiguration = AppBarConfiguration(setOf(R.id.homeFragment), binding.drawerLayout)
2. Fragment 전환 자동화
// 이전: 수동으로 Fragment 전환
navigationView.setNavigationItemSelectedListener { menuItem ->
when (menuItem.itemId) {
R.id.nav_home -> {
supportFragmentManager.beginTransaction()
.replace(R.id.container, HomeFragment())
.commit()
}
}
drawer.closeDrawer(GravityCompat.START)
true
}
// 이후: 자동 처리
binding.navView.setupWithNavController(navController)
3. 백스택 & Up 버튼 자동 처리
// 이전: 수동으로 백스택 처리
override fun onBackPressed() {
if (drawer.isDrawerOpen(GravityCompat.START)) {
drawer.closeDrawer(GravityCompat.START)
} else {
super.onBackPressed()
}
}
// 이후: 한 줄로 처리
override fun onSupportNavigateUp() =
navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
4. 화면 전환 & 데이터 전달
// 이전: Intent나 Bundle로 데이터 전달
val bundle = Bundle().apply { putString("userId", "123") }
fragment.arguments = bundle
// 이후: SafeArgs로 타입 안전한 전달
val action = HomeFragmentDirections.actionHomeToDetail("123")
findNavController().navigate(action)
Safe Args
이전에는 Bundle을 이용해 Fragment 간의 데이터를 전달할 때 데이터의 Key-Value를 지정하여 데이터를 가져오는 과정에 데이터의 이름이나 타입이 안맞을 경우 에러 발생 하거나 다른 상황이 발생됩니다. Navigation Component 에서 Safe Args Gradle 플러그인을 사용하여 대상 간에 유형 안전 탐색을 할 수 있도록 하는 객체 및 빌더 클래스를 생성합니다.
// SafeArgs 사용 이전
// 데이터 전달 (보내는 쪽)
class ListFragment : Fragment() {
private fun moveToDetail(user: User) {
val bundle = Bundle().apply {
putString("userId", user.id) // 키값 오타 위험
putString("userName", user.name)
putInt("userAge", user.age)
// putSerializable/Parcelable 등 타입 맞춰야 함
}
val fragment = DetailFragment().apply {
arguments = bundle
}
parentFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.addToBackStack(null)
.commit()
}
}
// 데이터 받는 쪽
class DetailFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 널 체크 필요, 타입 캐스팅 필요
val userId = arguments?.getString("userId") ?: ""
val userName = arguments?.getString("userName") ?: ""
val userAge = arguments?.getInt("userAge", 0) ?: 0
// 잘못된 키 사용시 런타임 에러
}
}
// SafeArgs 사용
// 데이터 전달 (보내는 쪽)
class ListFragment : Fragment() {
private fun moveToDetail(user: User) {
// 컴파일 타임에 타입 체크
val action = ListFragmentDirections.actionListToDetail(
userId = user.id,
userName = user.name,
userAge = user.age
)
findNavController().navigate(action)
}
}
// 데이터 받는 쪽
class DetailFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 자동 생성된 클래스로 안전한 접근
val args = DetailFragmentArgs.fromBundle(requireArguments())
val userId = args.userId // non-null
val userName = args.userName // non-null
val userAge = args.userAge // primitive type
// 컴파일 타임에 오류 체크
}
}
코드를 보면 이전 방식에서는 타입을 지정하면서, 키값에 대한 오류의 위험성, 널 안정성이 부족하고, 타입 안정성도 부족하였습니다. 한번 다시 자세히 차이점을 살펴봅니다.
1. 기본 데이터 전달
// 이전: Bundle 사용
// 보내는 쪽
val bundle = Bundle().apply {
putString("userId", "123") // 문자열 키 사용으로 오타 위험
}
fragment.arguments = bundle
// 받는 쪽
val userId = arguments?.getString("userId") // null 가능성, 키 오타 위험
// 이후: SafeArgs 사용
// nav_graph.xml
<fragment android:id="@+id/detailFragment">
<argument
android:name="userId"
app:argType="string"/>
</fragment>
// 보내는 쪽
val action = ListFragmentDirections.actionListToDetail(userId = "123")
findNavController().navigate(action)
// 받는 쪽
val args = DetailFragmentArgs.fromBundle(requireArguments())
val userId = args.userId // non-null, 컴파일타임 타입 체크
2. 복잡한 객체 전달
// 이전: Parcelable/Serializable
// 보내는 쪽
val bundle = Bundle().apply {
putParcelable("user", user) // 타입 캐스팅 필요
}
// 받는 쪽
val user = arguments?.getParcelable<User>("user") // 타입 캐스팅, null 체크 필요
// 이후: SafeArgs
// nav_graph.xml
<fragment android:id="@+id/detailFragment">
<argument
android:name="user"
app:argType="com.example.User"/>
</fragment>
// 보내는 쪽
val action = ListFragmentDirections.actionListToDetail(user = user)
findNavController().navigate(action)
// 받는 쪽
val args = DetailFragmentArgs.fromBundle(requireArguments())
val user = args.user // 자동 타입 변환
3. 옵셔널 파라미터
// 이전: 수동 null 체크
// 받는 쪽
val searchQuery = arguments?.getString("query") ?: ""
// 이후: SafeArgs
// nav_graph.xml
<argument
android:name="query"
app:argType="string"
app:nullable="true"/>
// 받는 쪽
val query = args.query // nullable 타입으로 자동 처리
4. 기본값 설정
// 이전: 수동 기본값 처리
// 받는 쪽
val page = arguments?.getInt("page", 1) ?: 1
// 이후: SafeArgs
// nav_graph.xml
<argument
android:name="page"
app:argType="integer"
android:defaultValue="1"/>
// 받는 쪽
val page = args.page // 기본값 자동 적용
5. 여러 파라미터 전달
// 이전: 번거로운 Bundle 처리
val bundle = Bundle().apply {
putString("id", id)
putString("name", name)
putInt("age", age)
putBoolean("isAdmin", isAdmin)
}
// 이후: SafeArgs
val action = UserFragmentDirections.actionUserToDetail(
id = id,
name = name,
age = age,
isAdmin = isAdmin
) // 컴파일러가 필수 파라미터 체크
이렇게 이전의 모든 위험요소들이 SafeArgs에서는 컴파일 타임에 체크 되면서 안정적으로 사용할 수 있습니다.
조건부 네비게이션
사용자 로그인 상태 등에 따라 다른 화면으로 이동해야 할 때 조건부 네비게이션을 활용합니다.
val navController = findNavController()
val action = if (userLoggedIn) {
HomeFragmentDirections.actionHomeToProfile()
} else {
HomeFragmentDirections.actionHomeToLogin()
}
navController.navigate(action)
Deep Link 구현
외부 소스(알림, 웹 링크 등)에서 앱 특정 화면으로 바로 이동할 수 있는 딥링크 구현이 Navigation Component에서는 매우 간단합니다.
<fragment
android:id="@+id/productDetailFragment"
android:name="com.example.app.ProductDetailFragment"
android:label="Product Detail">
<argument
android:name="productId"
app:argType="string" />
<deepLink
app:uri="example://products/{productId}" />
</fragment>
AndroidManifest.xml에도 intent-filter를 추가합니다:
<activity android:name=".MainActivity">
<nav-graph android:value="@navigation/nav_graph" />
</activity>
주의 사항
1. 그래프 분리: 대규모 앱의 경우 기능별로 Navigation Graph를 분리하고 중첩 그래프를 활용하세요.
2. 백 스택 관리: popUpTo 및 popUpToInclusive 속성으로 백 스택을 적절히 관리하여 사용자가 예상치 못한 화면으로 돌아가는 것을 방지하세요.
<action
android:id="@+id/action_login_to_home"
app:destination="@id/homeFragment"
app:popUpTo="@id/loginFragment"
app:popUpToInclusive="true" />
3. 공유 요소 전환: Material Design의 공유 요소 전환을 Navigation Component와 함께 사용하여 세련된 UX를 구현하세요.
4. 테스트 작성: Navigation Component 사용 시에도 UI 테스트를 작성하여 화면 흐름 검증이 중요합니다.
@Test
fun testNavigationToDetailScreen() {
val scenario = launchFragmentInContainer<HomeFragment>()
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
scenario.onFragment { fragment ->
navController.setGraph(R.navigation.nav_graph)
Navigation.setViewNavController(fragment.requireView(), navController)
}
onView(withId(R.id.item_card)).perform(click())
assertEquals(navController.currentDestination?.id, R.id.detailFragment)
}
결론
Navigation Component는 안드로이드 앱 개발에서 화면 전환 로직의 복잡성을 크게 줄여주는 강력한 도구입니다. 복잡한 앱일수록 Navigation Component의 체계적인 접근 방식이 더 큰 가치를 발휘합니다. 특히 중첩 그래프와 타입 안전한 인자 전달 기능은 대규모 팀에서 개발할 때 일관성과 안정성을 크게 향상시킵니다.
Navigation Component는 단순히 화면 전환을 위한 도구가 아니라, 앱의 사용자 경험을 설계하는 전략적 요소로 접근해야 합니다. 이 글에서 공유한 패턴과 주의사항이 여러분의 안드로이드 개발 여정에 도움이 되길 바랍니다.
참고자료
https://labs.brandi.co.kr//2021/08/27/leedw.html