본문 바로가기
Android

[Android] Navigation Component 살펴보기

by 중곰 2025. 2. 20.

 

오늘은 Navigation Component를 살펴봅니다.

서론

안드로이드 앱 개발에서 화면 간 이동 로직은 사용자 경험의 핵심 요소입니다. 초기 Intent 기반 화면 전환부터 Fragment Transaction, 그리고 현재의 Navigation Component까지 모든 변화를 지켜봐 왔습니다. 이 글에서는 Google이 Jetpack의 일부로 제공하는 Navigation Component의 실제 사용 경험과 패턴을 공유해보려 합니다.

Navigation


왜 Navigation Component인가?

기존 화면 전환 방식(Intent, FragmentTransaction)은 다음과 같은 여러 문제점을 가지고 있었습니다:

  1. 분산된 네비게이션 로직: 앱 전체에 화면 전환 코드가 흩어져 있어 유지보수가 어려움
  2. 타입 안정성 부재: Intent Extra나 Fragment Arguments 사용 시 런타임 오류 가능성
  3. 딥링크 처리 복잡성: 외부에서 앱 특정 화면으로 진입 시 처리 로직이 복잡
  4. 애니메이션 일관성: 화면 전환 애니메이션을 일관되게 적용하기 어려움
  5. 백 스택 관리: 복잡한 화면 흐름에서 백 스택 관리가 어려움

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

https://developer.android.com/guide/navigation?hl=ko

https://velog.io/@changhee09/안드로이드-Navigation-Component

반응형