|

collectAsStateWithLifecycle 사용 기준

collectAsStateWithLifecycle 글 대표 이미지
collectAsStateWithLifecycle을 언제 써야 하는지 정리합니다. Compose에서 StateFlow를 lifecycle-aware하게 수집하는 이유와 ViewModel UI state 설계를 예제로 설명합니다.

collectAsStateWithLifecycle은 Compose에서 Flow나 StateFlow를 화면 상태로 읽을 때 생명주기를 함께 고려하기 위한 API입니다. 단순히 값을 가져오는 편의 함수가 아니라, 화면이 실제로 활성 상태일 때 수집하도록 돕는 장치입니다.

핵심은 ViewModel의 StateFlow를 Compose State로 바꾸되, 화면 lifecycle에 맞게 수집한다는 점입니다. 이 기준을 알면 collectAsState와 언제 구분해야 하는지도 자연스럽게 보입니다.

collectAsStateWithLifecycle 요약 카드
ViewModel StateFlow를 Compose State로 안전하게 수집하는 흐름을 보여주는 카드

collectAsStateWithLifecycle이 필요한 이유

Compose 화면은 상태를 읽고, 그 상태가 바뀌면 다시 그립니다. ViewModel에서 StateFlow로 UI state를 노출하고 화면에서 이를 구독하는 구조는 매우 자연스럽습니다.

문제는 화면이 항상 보이는 상태가 아니라는 점입니다. 사용자가 다른 화면으로 이동하거나 앱이 background로 가면, 화면에 필요 없는 수집을 계속하지 않는 편이 좋습니다. lifecycle-aware 수집은 이런 상황을 다루기 위한 기본 습관입니다.

기본 사용 예시

@Composable
fun ProfileRoute(
    viewModel: ProfileViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    ProfileScreen(
        uiState = uiState,
        onRefresh = viewModel::refresh
    )
}

이 코드는 ViewModel의 uiState를 Compose State로 변환합니다. uiState가 바뀌면 ProfileRoute는 새 상태를 읽고 필요한 부분을 다시 그립니다.

ViewModel에서는 StateFlow로 화면 상태를 노출한다

ViewModel은 화면이 알아야 할 상태를 하나의 UI state로 모아 노출하는 편이 좋습니다. loading, data, error를 따로 흩뿌리면 화면에서 조합하기 어려워집니다.

data class ProfileUiState(
    val loading: Boolean = false,
    val name: String = "",
    val errorMessage: String? = null
)

class ProfileViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(ProfileUiState())
    val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
}

collectAsState와 무엇이 다를까

collectAsState도 Flow를 Compose State로 바꿉니다. 하지만 Android 화면의 lifecycle과 연결된 수집이 필요하다면 collectAsStateWithLifecycle 쪽이 더 직접적입니다.

  • collectAsState: Compose runtime 관점에서 Flow를 State로 수집한다
  • collectAsStateWithLifecycle: Android lifecycle을 고려해 Flow를 State로 수집한다
  • 화면 UI state 수집: collectAsStateWithLifecycle을 기본값으로 두기 좋다
  • 플랫폼 독립 Compose 코드: collectAsState가 더 자연스러울 수 있다

Lifecycle STARTED 기준을 이해하기

Android의 lifecycle-aware 수집은 보통 화면이 STARTED 이상일 때 값을 수집하고, 그 아래 상태로 내려가면 수집을 멈추는 흐름으로 이해하면 됩니다. 이 동작은 화면이 보이지 않을 때 불필요한 작업을 줄이는 데 도움이 됩니다.

다만 이 API가 모든 비용 문제를 자동으로 해결해 주는 것은 아닙니다. upstream Flow가 어떻게 만들어졌는지, stateIn의 SharingStarted 정책이 무엇인지도 함께 봐야 합니다.

이벤트와 상태를 섞지 않는다

StateFlow는 현재 화면 상태를 표현하기 좋습니다. 하지만 토스트, 일회성 navigation, snackbar 같은 이벤트를 영구 상태처럼 넣으면 회전이나 재수집 때 같은 이벤트가 다시 처리될 수 있습니다.

  • 화면에 계속 남아야 하는 값은 UI state에 둔다
  • 한 번만 처리할 이벤트는 별도 event 흐름을 검토한다
  • 상태는 idempotent하게 다시 그려져도 문제가 없어야 한다
  • 이벤트 처리 여부를 상태에 섞을 때는 재실행을 조심한다

자주 하는 실수

  1. Composable 안에서 collect를 직접 호출하고 coroutine scope 관리가 흐려진다
  2. 여러 Flow를 화면에서 따로 수집해 recomposition 원인을 추적하기 어려워진다
  3. ViewModel이 UI state 대신 domain model을 그대로 노출한다
  4. event를 StateFlow로만 처리해 화면 복귀 때 같은 이벤트가 반복된다
  5. lifecycle-aware 수집만 믿고 upstream 비용을 확인하지 않는다

핵심 기준

Compose 화면에서 ViewModel의 StateFlow를 읽는다면 collectAsStateWithLifecycle을 기본값으로 두는 편이 좋습니다. 화면이 활성 상태일 때만 상태를 수집하도록 lifecycle을 함께 보장하기 때문입니다.

이 API의 목적은 코드를 짧게 만드는 것이 아닙니다. 화면 생명주기와 상태 수집 타이밍을 맞춰 불필요한 작업과 애매한 버그를 줄이는 데 있습니다.

collectAsStateWithLifecycle 상태 수집 흐름 카드
ViewModel StateFlow가 Compose State로 변환되는 흐름을 보여주는 카드

화면 상태는 하나로 묶는다

loading, data, error를 각각 따로 수집하면 화면에서 조합해야 할 상태가 늘어납니다. 작은 화면에서는 괜찮아 보여도 조건이 늘어나면 어떤 상태가 우선인지 헷갈립니다.

data class ArticleUiState(
    val loading: Boolean = false,
    val articles: List<Article> = emptyList(),
    val error: String? = null
)

class ArticleViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(ArticleUiState())
    val uiState: StateFlow<ArticleUiState> = _uiState.asStateFlow()
}

Composable은 상태를 읽고 이벤트를 올린다

Composable 안에서 비즈니스 로직을 직접 처리하기보다, 상태는 아래로 내려주고 이벤트는 위로 올리는 구조가 유지보수에 좋습니다.

@Composable
fun ArticleRoute(viewModel: ArticleViewModel = hiltViewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    ArticleScreen(
        uiState = uiState,
        onRetry = viewModel::load
    )
}

collectAsState와 비교하는 기준

  • Android 화면에서 lifecycle-aware 수집이 필요하면 collectAsStateWithLifecycle
  • Compose Multiplatform처럼 Android lifecycle이 없는 코드면 collectAsState
  • 일회성 effect는 LaunchedEffect와 이벤트 흐름을 별도 검토
  • 데이터 비용이 큰 Flow는 upstream의 sharing 정책까지 확인

상태와 이벤트를 섞지 않는다

StateFlow는 현재 화면을 다시 그리는 데 필요한 값에 적합합니다. 반대로 snackbar 표시, navigation 이동, toast 같은 이벤트는 한 번만 처리되어야 합니다.

Compose 상태와 이벤트 분리 카드
Compose에서 오래 남는 상태와 한 번 처리할 이벤트를 분리하는 기준 카드

이벤트를 상태처럼 보관하면 화면 회전이나 뒤로 갔다 돌아오는 상황에서 같은 이벤트가 다시 실행될 수 있습니다. 이 문제는 실제 앱에서 자주 보입니다.

테스트하기 쉬운 구조

UI state를 하나의 data class로 묶으면 테스트도 쉬워집니다. ViewModel 테스트에서는 특정 액션 뒤 uiState가 loading에서 success 또는 error로 바뀌는지 확인하면 됩니다.

  1. 초기 상태를 확인한다
  2. load 액션을 호출한다
  3. loading 상태가 되는지 본다
  4. repository 응답 뒤 data 또는 error 상태가 되는지 본다
  5. Composable은 상태별 화면 표시만 테스트한다

성능을 볼 때의 기준

collectAsStateWithLifecycle을 쓴다고 recomposition 문제가 자동으로 사라지지는 않습니다. 너무 큰 UI state를 자주 바꾸거나, 불필요하게 새 객체를 계속 만들면 화면 갱신 비용이 커질 수 있습니다.

  • 상태를 너무 큰 객체 하나로만 만들지 않는다
  • 리스트 item key를 안정적으로 준다
  • derivedStateOf가 필요한 계산인지 확인한다
  • Flow emit 빈도가 너무 높은지 확인한다

정리

collectAsStateWithLifecycle은 Compose와 Android lifecycle 사이를 잇는 실무 기본 도구입니다. ViewModel은 StateFlow로 화면 상태를 노출하고, Composable은 이를 lifecycle-aware하게 State로 읽으면 구조가 단순해집니다.

관련해서 내부 글은 Compose 상태 호이스팅은 언제 해야 할까, collectLatest와 collect는 무엇이 다를까, remember와 rememberSaveable 차이와 함께 보면 좋습니다.

외부 기준은 Android Developers – State and Jetpack Compose, Android Developers – Kotlin coroutines on Android, Kotlin API – StateFlow를 기준으로 확인했습니다.

함께보면 좋은 글