
대부분의 안드로이드 앱에서는 UI에 데이터를 출력하기 위해 Flow를 사용합니다.
비동기 환경에서는 코루틴과 친화적인 Flow가 더욱 유연한 처리가 가능하기 때문이죠.
class MyBottariViewModel : ViewModel() {
private val _uiState: MutableStateFlow<MyBottariUiState> = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<MyBottariUiState> = _uiState.asStateFlow()
}
이러한 코드는 대부분의 안드로이드 앱에서 찾아볼 수 있을 것입니다.
그렇다면 화면에서는 이 값을 어떻게 표시해야 할까요?
@Composable
fun MyBottariScreen(
viewModel: MyBottariViewModel,
) {
val uiState = viewModel.uiState.value
Text(uiState.bottariName)
}
단순히 value만 가져오면 될 것 같지만 이는 잘못된 방식입니다.
해당 코드는 처음 컴포저블 함수가 실행될 때만 값을 읽을 뿐, 이후 Flow에 새로운 값을 내보내도 UI에 반영할 수 없습니다.
Compose에서의 UI 갱신은 어떤 값이 바뀌었는지가 아닌 Compose Runtime이 추적하는 State의 값이 바뀌었는가를 기준으로 발생합니다.
반면 Flow는 시간에 따라 값을 발행하는 비동기 스트림일 뿐, 그 자체가 Compose와는 연결되어 있지 않기 때문에 UI 갱신을 일으킬 수 없습니다.
Flow를 UI에 연결하려면 스트림에서 값을 수집하고, 받은 최신 값을 Compose Runtime이 추적할 수 있는 State에 저장하는 역할이 필요합니다.
collectAsState()는 바로 이 역할을 담당합니다.
@Composable
fun MyBottariScreen(
viewModel: MyBottariViewModel,
) {
val uiState by viewModel.uiState.collectAsState()
Text(uiState.bottariName)
}
이제 Flow가 새로운 값을 발행하면 collectAsState()가 반환한 State가 갱신되고,
해당 값을 파라미터로 받고 있던 Text는 다시 그려지게 됩니다.
collectAsState()에 대해 살펴보기 전에, Compose가 State 변경을 감지하고 UI를 다시 그리는 과정을 간단하게 살펴보겠습니다.
Compose에서는 화면을 직접 수정하는 방식보다 현재 상태를 바탕으로 UI를 선언하는 방식을 사용합니다.
@Composable
fun Counter() {
var count by remember { mutableIntStateOf(0) }
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("카운트 증가")
}
}
}
이 코드에서 Text는 count의 현재 값을 읽어 화면에 표시합니다. 버튼을 눌러 count가 바뀌면 Compose는 count를 읽었던 UI가 더 이상 최신 상태가 아니라는 것을 알고, 필요한 부분을 다시 실행합니다. 이를 리컴포지션(recomposition)이라고 합니다.
핵심은 Compose가 모든 변수의 변경을 감지하는 것은 아니라는 것입니다.
@Composable
fun Counter() {
// Compose가 관찰하지 않는 변수
var count = 0
Column {
Text("Count: $count")
Button(onClick = { count++ }) {
Text("카운트 증가")
}
}
}
위 코드의 count는 일반 지역 변수입니다. 값이 변경되어도 Compose는 이를 알 수 없으므로 UI 갱신이 일어나지 않습니다. 게다가 리컴포지션이 발생하면 함수 본문이 다시 실행되므로 count는 다시 0으로 초기화될 수도 있습니다.
반면 mutableStateOf, mutableIntStateOf처럼 Compose의 State API로 만든 값은 변경을 추적합니다.
var count by remember { mutableIntStateOf(0) }
Compose는 UI를 구성하는 동안 count를 읽은 위치를 기록합니다. 이후 count가 변경되면, Compose는 해당 상태를 읽었던(파라미터로 받았던) Composable을 리컴포지션 대상으로 표시합니다.
중요한 점은 상태가 변경될 때마다 화면 전체를 갱신하는 것이 아니라, 변경된 상태를 읽는 범위를 중심으로 갱신합니다.
collectAsState()는 Flow가 발행한 값을 이러한 Compose State에 반영합니다. 따라서 Flow의 값 변경이 Compose가 이해할 수 있는 상태 변경으로 바뀌고, 결과적으로 그 상태를 읽는 UI가 리컴포지션됩니다.
collectAsState()collectAsState()는 androidx.compose.runtime에 작성되어 있는 컴포저블 함수입니다.
@Composable
fun <T> StateFlow<T>.collectAsState(
context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsState(value, context)
@Composable
fun <T : R, R> Flow<T>.collectAsState(
initial: R,
context: CoroutineContext = EmptyCoroutineContext
): State<R> = produceState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) {
collect { value = it }
}
}
UI 상태를 StateFlow로 처리한다면 StateFlow<T>.collectAsState()가 먼저 호출된 다음 Flow<T>.collectAsState()가 호출되게 됩니다.
UI 상태에 값이 존재하지 않는다면 UI가 무엇을 표시해야 할지 알 수 없기 때문에 State 객체 생성 시 초기값을 전달해야 합니다.
StateFlow는 초기값이 존재하는 반면 Flow는 그렇지 않기 떄문에 Flow<T>.collectAsState()의 첫 번째 파라미터에는 초기값이 있는 것을 볼 수 있습니다.
또한 CoroutineContext를 받고 있는데, 코드를 보면 알 수 있듯이 Flow를 수집할 CoroutineContext를 지정할 수 있게끔 구현되어 있습니다.
Flow가 State로 변환되는 핵심 원리는 produceState에 있습니다.
produceState()produceState()는 외부에서 비동기적으로 들어오는 값을 State로 노출하는 컴포저블 함수입니다.
@Composable
fun <T> produceState(
initialValue: T,
vararg keys: Any?,
producer: suspend ProduceStateScope<T>.() -> Unit
): State<T> {
val result = remember { mutableStateOf(initialValue) }
LaunchedEffect(keys) {
ProduceStateScopeImpl(result, coroutineContext).producer()
}
return result
}
3개의 파라미터를 다음과 같이 이해하면 쉽습니다.
initialValue : producer가 값을 갱신하기 전 반환할 초기 상태값keys : LaunchedEffect의 키 값, 즉 producer()를 다시 시작할 기준producer : 상태를 갱신하는 코루틴 블록 (collectAsState()에서는 collect)예를 들어 네트워크 요청 결과를 UI 상태로 만들 때도 사용할 수 있습니다.
@Composable
fun rememberUserState(userId: String): State<UserUiState> =
produceState<UserUiState>(
initialValue = UserUiState.Loading,
key1 = userId
) {
value = repository
.fetchUser(userId)
.fold(
onSuccess = { UserUiState.Success(it) },
onFailure = { UserUiState.Error(it.message) }
)
}
이 함수가 컴포지션 범위에 들어오면 Loading 상태를 즉시 반환하고, producer 코루틴에서 요청이 끝나면 State.value를 성공 또는 실패 상태로 바꿉니다.
그리고 userId가 변경되면 기존 producer 코루틴을 취소한 뒤, 새 userId를 기준으로 다시 실행합니다.
collectAsState()는 바로 이러한 패턴을 Flow 수집에 적용한 함수라고 볼 수 있습니다.
ProduceStateScope위에서 볼 수 있듯이 produceState의 producer 람다 수신자는 ProduceStateScope<T>입니다.
interface ProduceStateScope<T> : MutableState<T>, CoroutineScope {
suspend fun awaitDispose(onDispose: () -> Unit): Nothing
}
private class ProduceStateScopeImpl<T>(
state: MutableState<T>,
override val coroutineContext: CoroutineContext
) : ProduceStateScope<T>, MutableState<T> by state {
override suspend fun awaitDispose(onDispose: () -> Unit): Nothing {
try {
suspendCancellableCoroutine<Nothing> { }
} finally {
onDispose()
}
}
}
ProduceStateScope<T>는 크게 두 역할을 합친 객체로 볼 수 있습니다.
MutableState<T> : 값을 읽고 쓰는 것CoroutineScope : 코루틴 안에서 suspend 함수 실행따라서 producer 블록에서는 별도의 상태 변수를 만들지 않고도 value에 새 값을 대입할 수 있습니다.
produceState(initialValue = "로딩 중") {
val result = loadMessage() // suspend 함수 호출 가능
value = result // Compose State 갱신
}
collectAsState()의 핵심 코드도 같은 원리입니다.
produceState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) {
collect { value = it }
}
}
여기서 State.value에 대입하는 순간, Flow가 방출한 값은 Compose가 관찰할 수 있는 상태 변경이 됩니다.
ProduceStateScope는 awaitDispose()도 제공합니다.
override suspend fun awaitDispose(onDispose: () -> Unit): Nothing {
try {
// 콜백 기반 비동기 API를 suspend 함수로 변경
suspendCancellableCoroutine<Nothing> { }
} finally {
onDispose()
}
}
콜백 기반 API처럼 Flow가 아닌 외부 데이터 소스를 연결할 때, producer가 취소되는 시점에 리스너를 해제하는 데 사용할 수 있습니다.
produceState(initialValue = LocationUiState.Loading) {
val listener = locationClient.addListener { location ->
value = LocationUiState.Success(location)
}
awaitDispose {
locationClient.removeListener(listener)
}
}
즉, produceState는 값을 만들어 내는 코드와 그 값을 정리하는 코드를 composition 수명에 묶어 둘 수 있게 해 줍니다.
이제 다시 collectAsState()로 돌아가보겠습니다.
@Composable
fun <T : R, R> Flow<T>.collectAsState(
initial: R,
context: CoroutineContext = EmptyCoroutineContext
): State<R> = produceState(initial, this, context) {
if (context == EmptyCoroutineContext) {
collect { value = it }
} else withContext(context) {
collect { value = it }
}
}
collect 블록의 value는 ProduceStateScope가 제공하는 State 변수의 값입니다.
Flow에서 값을 새로 방출하면 다음과 같은 일이 일어납니다.
ViewModel의 uiState가 새 값 발행
↓
collectAsState 내부의 collect가 값 수신
↓
ProduceStateScope.value에 새 값 대입
↓
Compose가 관찰 중인 State 변경을 감지
↓
그 State를 읽은 Composable을 리컴포지션 대상으로 표시
따라서 아래 코드의 ProfileContent는 uiState가 바뀔 때 최신 상태를 반영할 수 있습니다.
@Composable
fun MyBottariScreen(
viewModel: MyBottariViewModel,
) {
val uiState by viewModel.uiState.collectAsState()
MyBottariContent(uiState = uiState)
}
모든 값의 방출이 곧바로 화면 전체를 다시 그린다는 뜻은 아닙니다. Compose는 변경된 상태를 실제로 읽은 UI 범위를 리컴포지션 대상으로 잡으며, 같은 값이 다시 대입되면 리컴포지션을 건너뛰게 됩니다.
collect를 직접 하면 안되는 이유collectAsState()의 내부를 보면 결국 Flow를 구독하고, 받은 값을 State에 대입할 뿐입니다.
그렇다면 화면에서도 직접 collect를 호출하면 되지 않을까요?
결론부터 말하면, UI 상태를 표시하기 위해 Composable 본문에서 Flow를 직접 수집하면 안 됩니다. 문제는 collect 자체가 아니라, 수집 코루틴의 시작과 취소를 누가 관리하는가에 있습니다.
collect는 Flow의 방출이 완료되거나 수집 코루틴이 취소될 때까지 반환되지 않는 suspend 함수입니다. 특히 StateFlow처럼 구독자의 유무와 상관없이 계속 값을 발행하는 Hot Flow는 사실상 화면이 존재하는 동안 계속 수집됩니다.
우선 아래 코드를 Composable 함수에서 작성하면 컴파일 오류가 발생합니다.
@Composable
fun MyBottariScreen(
viewModel: MyBottariViewModel,
) {
viewModel.uiState.collect { uiState ->
// 오류: suspend 함수는 코루틴 안에서 호출해야 한다.
}
}
Flow를 구독하려면 코루틴이 필요합니다. 이때 흔히 떠올릴 수 있는 방식이 rememberCoroutineScope()입니다.
@Composable
fun MyBottariScreen(
viewModel: MyBottariViewModel,
) {
val scope = rememberCoroutineScope()
scope.launch {
viewModel.uiState.collect { uiState ->
}
}
}
하지만 이 코드에는 문제가 있습니다.
Composable 함수는 최초 화면 구성 때만 실행되는 함수가 아닙니다. 관찰하는 상태가 바뀌면 리컴포지션을 위해 여러 번 다시 실행됩니다.
따라서 Composable 본문에서 launch를 호출하면, 리컴포지션마다 새로운 Flow 구독자가 생길 수 있습니다.
이후 Flow가 한 번 값을 발행하면 모든 구독자가 그 값을 받습니다. 같은 UI 상태를 중복 처리할 뿐 아니라, 수집 과정에 데이터 요청·로그·상태 변경 같은 작업이 있다면 의도하지 않은 부수 효과도 반복될 수 있습니다.
Composable 함수의 본문은 UI를 선언하는 곳입니다. 코루틴 시작처럼 한 번만 실행되어야 하는 작업을 본문에 직접 두면, 리컴포지션이라는 Compose의 실행 모델과 충돌하게 됩니다.
다만 UI 상태가 아닌 일회성 이벤트를 Channel이나 SharedFlow로 처리할 때는 LaunchedEffect 내부에서 collect를 직접 호출하기도 합니다.
보통 일회성 이벤트가 발생했을 때는 화면을 다시 그리는 것이 아니라, 화면이 그려지는 과정에서 파생되는 UI 외부의 동작(예: 토스트 출력, 화면 이동)을 실행하는 것이기 때문입니다.
collectAsState()의 생명주기위에서 collectAsState()가 Composable 함수가 컴포지션에서 제거될 때 수집 코루틴을 취소한다고 설명했습니다. 여기서 한 가지 구분이 필요합니다.
Composable이 컴포지션에 존재하는지와 안드로이드 화면이 사용자에게 보이는 상태인지는 같은 기준이 아닙니다.
컴포지션은 Compose가 현재 UI 트리를 구성하고 관리하는 범위를 뜻합니다.
@Composable
fun App(isLoggedIn: Boolean) {
if (isLoggedIn) {
HomeScreen()
} else {
LoginScreen()
}
}
위 코드에서 isLoggedIn이 false로 바뀌면 HomeScreen()은 UI 트리에서 제거됩니다. 이때 HomeScreen 안에서 실행 중이던 collectAsState()의 수집 코루틴도 취소됩니다.
HomeScreen이 composition에 존재 → collectAsState가 Flow 수집
HomeScreen이 composition에서 제거 → 수집 코루틴 취소
이것이 collectAsState()가 관리하는 생명주기입니다.
반면 안드로이드의 생명주기는 액티비티, 프래그먼트 등이 STARTED, RESUMED, STOPPED 같은 상태를 오가는 흐름입니다.
예를 들어 사용자가 앱을 백그라운드로 보내면 일반적으로 STOPPED 상태가 됩니다.
그러나 컴포저블 함수가 즉시 컴포지션에서 제거되는 것은 아닙니다.
앱이 다시 포그라운드 상태로 돌아왔을 때 같은 화면을 빠르게 복원하기 위해, 컴포지션은 유지될 수 있습니다.
즉, collectAsState()만 사용하면 화면이 사용자에게 보이지 않는 동안에도 Flow 수집이 계속될 수 있습니다.
예를 들어 Flow가 다음과 같은 작업을 한다면 백그라운드 수집은 불필요한 비용이 될 수 있습니다.
컴포저블 함수가 컴포지션에서 제거되어도 Flow의 구독자가 끊겼을 뿐 Flow 자체는 남아 있기 때문에, 이러한 경우 업스트림을 임의로 제거할 수 있게끔 처리해야 합니다.
val uiState: StateFlow<UiState> = repository.locationUpdates()
.map { ... }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), // 핵심
initialValue = UiState.Loading
)
collectAsStateWithLifecycle()collectAsStateWithLifecycle()은 수집 기준을 컴포지션 범위가 아닌 안드로이드 생명주기에 맞춘 함수입니다.
@Composable
@Suppress("StateFlowValueCalledInComposition") // Initial value for an ongoing collect.
fun <T> StateFlow<T>.collectAsStateWithLifecycle(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext
): State<T> = collectAsStateWithLifecycle(
initialValue = this.value,
lifecycle = lifecycleOwner.lifecycle,
minActiveState = minActiveState,
context = context
)
@Composable
fun <T> Flow<T>.collectAsStateWithLifecycle(
initialValue: T,
lifecycle: Lifecycle,
minActiveState: Lifecycle.State = Lifecycle.State.STARTED,
context: CoroutineContext = EmptyCoroutineContext
): State<T> {
return produceState(initialValue, this, lifecycle, minActiveState, context) {
lifecycle.repeatOnLifecycle(minActiveState) {
if (context == EmptyCoroutineContext) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
} else withContext(context) {
this@collectAsStateWithLifecycle.collect { this@produceState.value = it }
}
}
}
}
코드가 복잡한 것 같지만, produceState 내부에서 Flow 수집을 시작할 기준에 Flow 객체나 CoroutineContext 외에 생명주기를 추가한 것 뿐입니다.
기본적으로는 STARTED, RESUMED일 때만 수집을 하게끔 구현되어 있습니다.
따라서 앱이 백그라운드로 전환되거나 액티비티가 전환되면 불필요한 수집을 멈출 수 있습니다.
collectAsStateWithLifecycle()도 produceState를 사용해 Compose State를 만드는데, 차이는 producer 내부에서 Lifecycle.repeatOnLifecycle()로 Flow 수집을 감싼다는 점입니다.
repeatOnLifecycle은 Lifecycle이 지정한 상태가이 되면 블록을 실행하고, 그 외의 경우에는 블록의 코루틴을 취소합니다. 이후 Lifecycle이 다시 활성 상태가 되면 새 코루틴으로 블록을 다시 실행합니다.
즉, collectAsStateWithLifecycle()은 다음 두 수명을 모두 고려합니다.
Composable이 composition에 존재하는가? → 아닌 경우 전체 producer와 수집을 취소
Lifecycle이 STARTED, RESUMED인가? → 아닌 경우 Flow 수집만 취소 (재개 가능)
따라서 안드로이드 네이티브 앱에서는 collectAsStateWithLifecycle()을 사용하는 것이 더 안정적입니다.
최근 업데이트를 통해 KMP에서도 collectAsStateWithLifecycle()을 사용할 수 있게 되었다고 하는데, 이 부분은 직접 확인해 본 바가 없어서 확실치 않습니다.