Android 개발을 하면서 Flow, StateFlow, SharedFlow를 "어디선가 쓰라니까" 쓰는 경우, 많지 않나요?
처음 Android를 배우거나, ViewModel 구조를 따라 만들다 보면
"왜 Flow를 써야 하지?", "StateFlow랑 뭐가 다른데?"
이런 의문이 들기 시작합니다.
그래서 이번 아티클에서는 단순한 사용법을 넘어서,
비동기 스트림을 제대로 이해하기 위한 기반부터 심화까지 다루려고 합니다.
- 왜 비동기 스트림이 필요한지
- 각각이 어떤 역할을 가지는지
- 내부적으로 어떤 방식으로 동작하는지
- 어떤 상황에서 어떤 스트림을 써야 하는지
이 내용들을 하나하나 탄탄하게 정리할 예정입니다.
즉, 작업이 시작된 후 결과를 기다리지 않고 다른 작업을 동시에 진행할 수 있는 프로그래밍 방식입니다.
우선 이걸 이해하고 계셔야되요.
이런 시간이 오래 걸리는 작업을 비동기로 처리해야 합니다.
만약 이런 작업을 동기 방식으로 처리하면,
앱이 멈추거나 사용자 경험이 크게 망가질 수 있기 때문이죠.
스트림(Stream)은 말 그대로
데이터가 시간에 따라 흘러가는 흐름을 의미합니다.
Flow는 강물처럼 끊임없이 흘러가는 데이터라고 상상하면 됩니다.
이 모든 것이 "데이터 스트림"입니다.
현대 앱은 단순히 한 번 데이터를 요청하고 끝나는 게 아니라,
시간에 따라 계속 변하는 데이터를 비동기로 다루는 것이 필수가 되었습니다.
예시
이런 데이터는
→ 이걸 다루려면 비동기 스트림 개념이 필요합니다.
"시간에 따라 변하는 데이터를, 기다리지 않고 다른 작업과 함께 자연스럽게 받아서 처리하는 흐름" 이다.
앞서 이야기한 "비동기 스트림"을 실제로 구현하려고 하면 문제가 발생해요.
예를 들어서,
단순한 코루틴만으로는 어렵습니다.
코루틴은 "한 번에 하나의 suspend 작업"에는 강하지만,
"시간에 따라 계속 여러 데이터가 발생"하는 흐름을 자연스럽게 처리하기에는 한계가 있었기 때문이죠.
그래서 등장한게 바로 Flow 입니다.
Flow는 시간에 따라 발생하는 여러 개의 데이터를 비동기적으로 순차 처리할 수 있게 해주는 Kotlin의 표준 스트림 API입니다.
이런 상황을 자연스럽게 다루기 위해 Flow가 만들어졌습니다.
| 핵심 포인트 | 설명 |
|---|---|
| Cold Stream | 구독자가 collect할 때 비로소 흐름이 시작된다. |
| suspend 가능 | Flow 내부에서도 delay, network 요청 등 비동기 처리가 가능하다. |
| 데이터 흐름 조작 가능 | map, filter, take, combine 등 다양한 중간 연산자를 통해 데이터 흐름을 자유롭게 가공할 수 있다. |
| Backpressure 대응 | 생산 속도와 소비 속도의 차이를 자연스럽게 흡수할 수 있다. |
"필요할 때만 흐르는 수도꼭지"
이렇게 이해하면 될 것 같습니다.
Flow는 크게 3단계로 사용합니다.
1. 생성: flow {} 블록 안에서 데이터를 emit(발행)한다.
2. 변환: map, filter 같은 연산자를 사용해 데이터 흐름을 가공한다.
3. 수집: collect {} 블록 안에서 데이터를 최종 소비한다.
확 와닿지 않죠? 그래서 예시 코드와 함께 보면 이해가 쉽습니다.
// Int 값을 "순차적으로 방출"하는 Flow
fun simpleFlow(): Flow<Int> = flow {
emit(1) // 1을 방출
delay(1000) // 1초 동안 대기
emit(2) // 2를 방출
delay(1000) // 1초 동안 대기
emit(3) // 3을 방출
}
fun main() = runBlocking {
// 코루틴을 실행하고, simpleFlow가 방출하는 값을 하나씩 수집
simpleFlow().collect { value ->
println("Received: $value") // 방출된 값 출력
}
}
출력결과
Received: 1
Received: 2
Received: 3
flow {} 안의 코드는 suspend 가능하다.delay() 같은 비동기 작업을 쓸 수 있다.collect {}도 suspend 함수다.Flow는 시간에 따라 데이터를 흘려보낼 수 있지만,
"항상 최신 상태를 보관하고 싶을 때" 는 문제가 생깁니다.
예를 들어서,
이럴 때마다
Flow는 다시 collect를 시작하지만,
이전까지의 상태를 기억하고 있지는 않습니다.
즉, Flow는 상태(state)를 "저장"하지 않고, 그냥 "흘려보내는" 역할만 한다는 거죠.
StateFlow는 Kotlin에서 제공하는
"항상 최신 상태를 기억하고 있는" 특별한 Flow입니다.
Flow의 특징을 가지면서, 상태를 보존할 수 있게 확장한 것이라고 이해하면 됩니다.
| 핵심 포인트 | 설명 |
|---|---|
| 항상 값이 있다 | StateFlow는 항상 하나의 최신 값을 들고 있다. (초기값 필요) |
| Cold → Hot Stream | Flow는 Cold Stream이지만, StateFlow는 Hot Stream이다. (항상 살아 있음) |
| 구독 즉시 현재 상태 제공 | collect를 시작하면 과거 데이터가 아니라 최신 상태를 바로 받는다. |
| 값 변경은 value를 통해 | stateFlow.value = newValue 형태로 상태를 업데이트한다. |
"StateFlow는 물이 흐르는 수도관에 물탱크를 추가한 것"
이렇게 이해하면 될 것 같습니다.
fun main(): Unit = runBlocking {
// MutableStateFlow를 0으로 초기화 (현재 상태를 저장하고 변경할 수 있음)
val stateFlow = MutableStateFlow(0)
// 첫 번째 launch: 값을 변경하는 코루틴
launch {
delay(500) // 0.5초 대기
stateFlow.value = 1 // 값을 1로 변경
delay(500) // 또 0.5초 대기
stateFlow.value = 2 // 값을 2로 변경
}
// 두 번째 launch: 값을 수집(collect)하는 코루틴
launch {
stateFlow.collect { value -> // 항상 최신 value를 관찰
println("Received: $value") // 값이 변경될 때마다 출력
}
}
}
출력 결과
Received: 0
Received: 1
Received: 2
collect을 시작할 때 현재 상태(0) 를 바로 받고,
이후 값이 업데이트 될 때마다 새로운 상태를 수신하는거죠.
| 항목 | Flow | StateFlow |
|---|---|---|
| 데이터 보존 | ❌ | ⭕ (항상 현재 값 들고 있음) |
| 초기값 필요 | ❌ | ⭕ |
| Cold/Hot | Cold Stream | Hot Stream |
| 구독자 유무에 따른 흐름 | 구독해야 흐름 시작 | 항상 흐르고 있음 |
| 사용 예시 | 네트워크 요청, 일회성 스트림 | UI 상태 관리, 화면 상태 보존 |
class TestViewModel : ViewModel() {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count
init {
startCounter()
}
private fun startCounter() {
viewModelScope.launch {
while (true) {
delay(1000)
_count.value++
}
}
}
}
@Composable
fun TestScreen(viewModel: TestViewModel) {
val count by viewModel.count.collectAsState()
Text(text = "Count: $count", modifier = Modifier.padding(60.dp))
}
ViewModel에서는 MutableStateFlow를 통해 상태를 관리하고,
Compose 화면에서는 collectAsState()를 통해 값을 구독하는 방식으로 구현을 합니다.(collectAsStateWithLifeCycle은 추후 아티클에서 다루겠습니다)
StateFlow의 값이 변경될 때마다, Compose UI가 자동으로 리컴포지션되어 최신 상태를 반영합니다.
실행 결과는 다음과 같습니다.

우리는 StateFlow를 통해 "현재 상태"를 안전하게 관리할 수 있었죠?.
하지만, 상태(state)와는 다른 개념이 하나 있답니다.
바로 "일회성 이벤트(event)" 입니다.
예를 들어서,
이런 동작은
상태(state) 처럼 오래 유지하는 것이 아니라,
"한 번만 발생하고 끝나야 하는" 행동이죠.
문제가 생깁니다..
StateFlow는 항상 최신 상태를 들고 있기 때문에,
옛날에 발생했던 이벤트가 다시 발생할 수도 있습니다.
→ 이미 본 Toast가 한 번 더 뜬다거나,
→ 이미 이동한 화면으로 다시 이동하려고 한다거나
→ 이런 버그를 막기 위해
"일회성 이벤트"를 위한 스트림이 따로 필요하다는 거죠.
SharedFlow는 Kotlin에서 제공하는
"여러 수신자에게 동시에 이벤트를 전달하는" Hot Stream입니다.
쉽게 말해, "한 번 발생하면 필요한 모든 구독자가 동시에 받을 수 있는" 스트림
| 핵심 포인트 | 설명 |
|---|---|
| Hot Stream | 구독자 유무와 관계없이 항상 살아있다. |
| 여러 수신자 지원 | 여러 구독자가 collect할 수 있다. |
| replay 설정 가능 | 최근 N개의 이벤트를 새 구독자에게 재전송할 수 있다. |
| 버퍼 설정 가능 | extraBufferCapacity로 이벤트를 버퍼링할 수 있다. |
| State가 아닌 Event | 상태 저장이 아니라 이벤트 발생에 적합하다. |
"SharedFlow는 학교 방송 스피커"
(한 명만 듣는 게 아니라, 원하면 모두 들을 수 있다.)
fun main() = runBlocking {
val sharedFlow = MutableSharedFlow<Int>(
replay = 0, // 과거 이벤트 기억 안 함
extraBufferCapacity = 1 // 버퍼 1개 추가
)
launch {
sharedFlow.collect { value ->
println("First Collector: $value")
}
}
launch {
delay(500) // 0.5초 뒤에 두 번째 수집자 시작
sharedFlow.collect { value ->
println("Second Collector: $value")
}
}
delay(100) // 0.1초 대기 후
sharedFlow.emit(1)
sharedFlow.emit(2)
}
First Collector: 1
First Collector: 2
그래서 Second Collector를 받고 싶다면 replay를 1로 설정하는 방법이 있습니다.
1로 한다면 아래와 같은 결과가 나오겠죠?
First Collector: 1
First Collector: 2
Second Collector: 2
replay: 과거 이벤트를 새로 들어온 구독자에게 몇 개까지 재전송할지 설정
extraBufferCapacity: 현재 발생 중인 이벤트를 일시적으로 버퍼링할 수 있는 크기
일반적으로 "단발성 이벤트 처리"라면
replay = 0, extraBufferCapacity = 1 이렇게 설정합니다.
| 항목 | Flow | StateFlow | SharedFlow |
|---|---|---|---|
| 목적 | 데이터 스트림 | 상태 관리 | 이벤트 브로드캐스트 |
| 구독 방식 | collect 시 시작 | 항상 최신값 제공 | 이벤트 발생 시 모두 알림 |
| Cold/Hot | Cold Stream | Hot Stream | Hot Stream |
| 기억 여부 | ❌ | ⭕ (현재 상태) | ⭕ (replay 설정) |
| 예시 | 네트워크 응답 | 화면 상태 (로딩, 성공, 에러) | Toast, Navigation |
Compose에서는 일반 Flow처럼 LaunchedEffect 안에서 collect해서 SharedFlow를 사용할 수 있습니다.
아래 예제는 버튼을 클릭했을 때 Toast를 띄우는 간단한 예시입니다.
@Composable
fun TestScreen(viewModel: TestViewModel) {
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.eventFlow.collect { event ->
when (event) {
is Event.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
}
Button(
onClick = { viewModel.onButtonClick() },
modifier = Modifier.padding(16.dp)
) {
Text(text = "Click Me", fontSize = 20.sp)
}
}
sealed class Event {
data class ShowToast(val message: String) : Event()
}
class TestViewModel: ViewModel() {
private val _eventFlow = MutableSharedFlow<Event>(
replay = 0,
extraBufferCapacity = 1
)
val eventFlow = _eventFlow
fun onButtonClick() {
viewModelScope.launch {
_eventFlow.emit(Event.ShowToast("버튼 클릭"))
}
}
}
ViewModel이 SharedFlow로 이벤트를 발사하고
화면(Composable)에서는 collect해서 바로 처리하면 됩니다.
실행결과는 다음과 같습니다.

Flow, StateFlow, SharedFlow는 "데이터 스트림"을 처리하기 위한 좋은 도구입니다.
하지만 애내들은 기본적으로
"발생하는 데이터를 흘려보내고 수집하는" 패턴에 최적화되어 있습니다.
그런데 가끔은 이런 게 필요할 때가 있습니다.
예를 들어,
이런 구조를 다루려면 Flow만으로는 깔끔하지 않습니다.
Channel은 Kotlin 코루틴에서 제공하는
"1:1 통신"을 위한 특별한 데이터 파이프라인입니다.
"하나의 Coroutine이 send()하고, 다른 Coroutine이 receive()하는 구조" 를 안전하게 만들 수 있습니다.
| 특징 | 설명 |
|---|---|
| 1:1 통신 | 한 번 보낸 데이터는 하나의 소비자만 받을 수 있다. |
| Hot Stream | 구독자 유무와 관계없이 항상 살아있다. |
| suspend 가능 | send/receive 시 상대가 준비 안 되어 있으면 일시 중단된다. |
| 버퍼 설정 가능 | 필요에 따라 버퍼 크기를 조정할 수 있다. |
"Channel은 택배 상자"
fun main() = runBlocking {
val channel = Channel<Int>()
launch {
channel.send(1)
channel.send(2)
channel.close() // 더 이상 보낼 데이터가 없음을 명시
}
launch {
for (value in channel) {
println("Received: $value")
}
println("Channel closed.")
}
}
출력 결과
Received: 1
Received: 2
Channel closed.
Channel은 기본적으로 데이터를 하나씩 주고받는 구조이지만,
필요에 따라 버퍼를 설정해 일시적인 데이터 저장이 가능합니다.
그래서 send()가 즉시 처리되지 않아도 데이터를 잠시 보관할 수 있습니다.
| 설정 | 설명 |
|---|---|
Channel() (기본) | 버퍼 없음. send()와 receive() 타이밍이 정확히 맞아야 함. |
Channel.UNLIMITED | 무제한 버퍼. 어떤 상황에서도 send()가 suspend되지 않지만, 메모리 사용에 주의 요구. |
Channel.CONFLATED | 마지막 데이터만 유지. 새로운 데이터가 오면 이전 데이터를 덮어씌움. |
Channel(capacity = N) | 최대 N개의 데이터를 버퍼링할 수 있음. 적절한 크기를 지정해 유연하게 사용 가능 |
예시:
val bufferedChannel = Channel<Int>(capacity = 3)
결론부터 말씀드리면,
가능합니다.
Channel은 단발성 이벤트를 한 번 보내고,
다른 쪽에서 한 번만 수신하는 구조에 딱 맞습니다.
예를 들어서,
ViewModel에서 Channel.send()로 이벤트를 보내고,
화면에서 receiveAsFlow().collect()로 받아서 처리하면
Toast, 화면 이동(Navigation) 같은 단발성 UI 이벤트를 처리할 수 있습니다.
그러나, 단점도 있습니다.
Channel은 1:1 통신 기반이기 때문에
이벤트를 한 번 수신하면 그걸로 끝입니다.
화면이 회전되거나 재구성되면,
→ 이미 소비된 이벤트는 다시 받을 수 없습니다.
이런 구조에서는 수신 타이밍을 놓치면
이벤트가 영영 사라져버릴 수 있는 리스크가 있습니다.
| 항목 | Flow | StateFlow | SharedFlow | Channel |
|---|---|---|---|---|
| Cold/Hot | ❄️ Cold | 🔥 Hot | 🔥 Hot | 🔥 Hot |
| 초기값 필요 | ❌ | ✅ | ❌ | ❌ |
| 값 저장 | ❌ (과거 기억 없음) | ✅ (항상 최신 상태 유지) | 🔄 선택적 (replay 설정 가능) | ❌ (한 번 소비되면 끝) |
| 여러 구독자 | ❌ (1명만) | ✅ | ✅ | ❌ (1:1 전송) |
| 수집 타이밍 의존 | ✅ (구독 시부터 시작) | ❌ (항상 최신값) | ❌ (emit 시마다 전파) | ✅ (수신자가 없으면 suspend 가능) |
| 단발성 이벤트 처리 | ⚠️ 제한적 | ❌ 부적합 | ✅ 강력 | ✅ 가능 (주의 필요) |
| 화면 재구성 대응 | ⚠️ 직접 구현 필요 | ✅ 자연스러움 | ✅ 자연스러움 | ❌ 수신자가 놓치면 손실 |
| 주요 사용처 | 네트워크 요청 등 단발성 데이터 흐름 | UI 상태 | Toast, Navigation 등 UI 이벤트 | 작업 큐, 내부 1:1 통신 |
| 상황 | 추천 스트림 |
|---|---|
| 반복적으로 변하는 UI 상태 관리 | StateFlow |
| Toast / 다이얼로그 / 화면 이동 등 단발성 UI 이벤트 | SharedFlow |
| 1회성 비동기 작업 처리 (네트워크 응답 등) | Flow |
| 코루틴 간 1:1 통신, 백그라운드 작업 큐 | Channel |
| 콜백 기반 API를 코루틴으로 감싸고 싶다 | CallbackFlow (추가 도구) |
Kotlin의 비동기 스트림은 처음 접할 땐 다소 복잡해 보이지만,
그 개념과 쓰임새를 이해하고 나면 오히려 더 깔끔하고 안전한 코드를 만드는 데 큰 도움이 됩니다.
이 글을 통해
명확하게 정리되었기를 바랍니다.
처음엔 익숙하지 않더라도,
이 아티클을 읽있다면, 어느 정도는 개념을 알아갈 수 있다고 생각합니다.
천천히, 그리고 단단하게 우리 모두 강력해집시다.