[Android Compose] @Composable 종속성을 StateFlow로 바꿔보자

2025. 8. 9. 00:22·Android/Compose

안녕하세요, Android 개발자 한민재입니다.

최근 Hilingual 프로젝트의 네비게이션 로직을 리팩토링 했어요. 이번 글에서는 @Composable에 의존하던 내비게이션 상태 로직을 UI와 분리된 상태 홀더(State Holder) 패턴과 StateFlow 를 통해 개선한 과정을 상세히 공유해 보려고 합니다. (해당 작업 PR입니다.)

UI와 결합된 로직

리팩토링 이전, MainNavigator 클래스는 앱의 메인 화면 탐색을 관리했어요. 이 클래스는 DroidKnightsApp을 레퍼런스로 사용했던 코드에요. 그러나 최근에 해당 작업자 본인이 말하길 “땜빵코드”라 꼭 고쳐서 쓰라고 조언을 받았습니다 😅

저는 그 얘기를 듣고 한참을 고민했었어요. 좋은 코드같았는데 뭐가 문제였을까? 하면서 고민한 결과 몇가지 문제가 있다고 판단했어요.

 

Before: @Composable에 종속된 MainNavigator

internal class MainNavigator(
    val navController: NavHostController
) {
    // NavDestination을 @Composable 컨텍스트에서만 접근 가능
    private val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // 현재 탭 정보 또한 @Composable에 강하게 의존
    val currentTab: MainTab?
        @Composable get() = MainTab.find { tab ->
            currentDestination?.hasRoute(tab::class) == true
        }

    // UI 표시 여부 로직이 Composable 함수로 구현됨
    @Composable
    fun isBottomBarVisible() = MainTab.contains {
        currentDestination?.hasRoute(it::class) == true
    }
    // ...
}

이 구현의 문제는 책임 분리 원칙에 위배된다고 생각했어요.

  1. UI 레이어와의 강한 결합: MainNavigator는 본질적으로 탐색 로직을 담당하는 일반 클래스인데, @Composable 어노테이션을 사용함으로써 Compose UI 런타임에 직접 종속됨. 이는 상태 관리 로직이 뷰(UI) 레이어 없이는 독립적으로 존재할 수 없게함
  2. 테스트 용이성 저하: 로직을 검증하기 위해 UI 테스트 프레임워크(ComposeTestRule)가 강제됨. 이는 단순한 상태 변화를 테스트하는 데 불필요한 복잡성과 실행 비용이 들고, 순수한 로직을 대상으로 하는 가볍고 빠른 유닛 테스트의 작성이 불가능
  3. 상태 관리의 모호성: currentBackStackEntryAsState()와 같은 API를 상태가 필요한 여러 위치에서 산발적으로 호출하는 것은 데이터 흐름을 예측하기 어렵게 만들 수 있음. 상태 소스의 분산을 야기하고, 불필요한 리컴포지션을 유발하여 성능 저하의 원인이 될 가능성이 있음

상태 홀더 패턴과 단방향 데이터 흐름

이러한 문제를 해결하기 위해, Google의 공식 가이드와 Now in Android 샘플 앱에서 권장하는 상태 홀더(State Holder) 패턴을 도입했어요. 기존 MainNavigator를 MainAppState라는 새로운 상태 관리 클래스로 대체하고, 데이터 흐름을 재정의했어요.

 

After: StateFlow 기반의 독립적인 MainAppState

@Stable
internal class MainAppState(
    val navController: NavHostController,
    coroutineScope: CoroutineScope,
    // ...
) {
    // 1. NavController의 Flow를 관찰하여 현재 Destination을 StateFlow로 변환
    private val currentDestination: StateFlow<NavDestination?> =
        navController.currentBackStackEntryFlow
            .map { it.destination }
            .stateIn(
                scope = coroutineScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = null
            )

    // 2. 파생 상태(Derived State)를 Composable 종속성 없이 StateFlow로 생성
    val currentTab: StateFlow<MainTab?> = currentDestination
        .map { destination ->
            MainTab.find { tab ->
                destination?.hasRoute(tab::class) == true
            }
        }
        .stateIn( /* ... */ )

    // 3. UI 표시 여부 또한 StateFlow<Boolean>으로 명확하게 노출
    val isBottomBarVisible: StateFlow<Boolean> = currentDestination
        .map { destination ->
            MainTab.contains { tab ->
                destination?.hasRoute(tab::class) == true
            }
        }
        .stateIn( /* ... */ )

    // ...
}

핵심 개선 사항은 다음과 같은데요

  • @Composable 속성을 완전히 제거하고, navController.currentBackStackEntryFlow라는 Flow를 상태의 원천으로 사용했어요. 이 Flow는 내비게이션 스택의 변화를 반응형 스트림으로 제공해요.
  • map 연산자로 Flow를 가공하여 UI가 필요로 하는 상태(현재 탭, 하단 바 표시 여부 등)로 변환한 뒤, .stateIn() 확장 함수를 사용해 StateFlow로 변환했어요. StateFlow는 수명 주기를 인식하고 마지막 상태를 캐시하는 효율적인 상태 홀더예요.

이러한 접근 방식을 통해 MainAppState는 UI와 직접적인 종속성이 제거된, 테스트 가능하고 재사용 가능한 클래스로 개선 되었어요.

개선 결과 및 아키텍처적 이점

이번 리팩토링은 단순한 코드 수정을 넘어, 애플리케이션의 아키텍처를 다시한번 생각하게 되었는데요

  • MainAppState는 '어떤 상태를 보여줄 것인가'를 결정하는 역할을, Composable 함수는 그 상태를 '어떻게 그릴 것인가'를 담당하는 역할로 명확히 분리되었어요. 이는 코드의 가독성과 유지보수성을 크게 향상시켜요.
    • 하지만 하나의 클래스가 책임이 크다고도 생각이 들었어요. 물론 ‘MainState를 관리한다’라는 책임은 맞지만 객체 지향면에서는 과연?? 이 부분에 대해서는 좀더 고민해보기로 했어요.
  • MainAppState는 더 이상 UI 프레임워크에 의존하지 않아서, 일반적인 JVM 환경에서 TestCoroutineScope와 Mock NavController를 주입하여 상태 로직의 정확성을 검증할 수 있어요.
  • stateIn의 SharingStarted.WhileSubscribed(5_000) 를 UI(구독자)가 활성 상태일 때만 업스트림 Flow를 활성화하여 리소스를 효율적으로 사용하도록 했어요. 특히 5초의 유예 시간은 화면 회전과 같은 Configuration Change에도 스트림 구독이 잠시 끊겼다 재개될 때 불필요한 데이터 재요청을 방지해요. 모든 상태 변화가 StateFlow를 통해 단방향으로 UI에 전달되므로, UDF를 만족해요.

결론

안정적이고 확장 가능한 Compose 애플리케이션을 구축하기 위해서는 UI 로직과 상태 관리 로직을 체계적으로 분리하는 것이 무엇보다 중요한 것 같아요. 그리고 검증된 코드라고 생각이 들어도 한번 다시 생각해보자 라고…다시한번 배웠어요 :)

이번 리팩토링의 공유가 여러분의 Compose 프로젝트에서 아키텍처를 고민할 때 도움이 되기를 바랍니다. 긴 글 읽어주셔서 감사합니다. 본 주제에 대한 더 나은 접근 방식이나 의견이 있으시다면 언제든지 공유해주세요 :)

참고 자료 (References)

  • Android Developers: UI 레이어 - 상태 홀더 및 UI 상태
  • 참고 아키텍처: Now in Android - NiaAppState.kt

'Android > Compose' 카테고리의 다른 글

[Android Compose] 골치덩어리 EdgeToEdge를 잘 써보자.  (1) 2025.09.23
[Android Compose] AboutLibraries로 오픈소스 라이선스 화면을 만들기  (0) 2025.09.23
[Android Compose] flowWithLifecycle은 언제 쓰면 좋을까?  (0) 2025.08.08
[Android Compose] Effect Handlers 딥다이브  (0) 2025.06.20
[Android Compose] 상태 읽기 지연(Defer State Reads)으로 리컴포지션 최적화하기  (0) 2025.06.18
'Android/Compose' 카테고리의 다른 글
  • [Android Compose] 골치덩어리 EdgeToEdge를 잘 써보자.
  • [Android Compose] AboutLibraries로 오픈소스 라이선스 화면을 만들기
  • [Android Compose] flowWithLifecycle은 언제 쓰면 좋을까?
  • [Android Compose] Effect Handlers 딥다이브
한민돌
한민돌
Android 개발자가 되기까지.
  • 한민돌
    미래 가젯 연구소
    한민돌
  • 전체
    오늘
    어제
    • 분류 전체보기 (23) N
      • Android (6) N
        • Compose (10)
        • Jetpack (2)
      • Kotlin (2)
      • 외부 활동 (3)
        • 우아한테크코스 8기 (3)
  • 블로그 메뉴

    • 홈
    • 안드로이드
    • 태그
  • 링크

    • GitHub
  • 공지사항

  • 인기 글

  • 태그

    우테코
    coroutine
    class
    LaunchedEffect
    compose
    runcatching
    jetpack
    Android
    Retrofit
    sideeffect
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
한민돌
[Android Compose] @Composable 종속성을 StateFlow로 바꿔보자
상단으로

티스토리툴바