Kotlin: 비동기 스트림을 알고 계신가요?

rivermoon·2025년 5월 1일
post-thumbnail

Introduction

Android 개발을 하면서 Flow, StateFlow, SharedFlow를 "어디선가 쓰라니까" 쓰는 경우, 많지 않나요?
처음 Android를 배우거나, ViewModel 구조를 따라 만들다 보면
"왜 Flow를 써야 하지?", "StateFlow랑 뭐가 다른데?"
이런 의문이 들기 시작합니다.

그래서 이번 아티클에서는 단순한 사용법을 넘어서,
비동기 스트림을 제대로 이해하기 위한 기반부터 심화까지 다루려고 합니다.

  • 왜 비동기 스트림이 필요한지
  • 각각이 어떤 역할을 가지는지
  • 내부적으로 어떤 방식으로 동작하는지
  • 어떤 상황에서 어떤 스트림을 써야 하는지

이 내용들을 하나하나 탄탄하게 정리할 예정입니다.


1. 비동기 스트림이란 무엇일까요?

??? : 비동기가 뭐에요??

  • 여러 작업을 동시에 수행할 수 있도록 하는 프로그램 패러다임
  • Non-Blocking 방식으로 CPU 리소스를 효율적으로 사용 가능
  • 전통적으로는 쓰레드를 사용해 작업을 병렬로 수행했음

즉, 작업이 시작된 후 결과를 기다리지 않고 다른 작업을 동시에 진행할 수 있는 프로그래밍 방식입니다.

  • 동기(Synchronous): 작업이 끝날 때까지 기다린다.
  • 비동기(Asynchronous): 기다리지 않고 다른 일을 한다.

우선 이걸 이해하고 계셔야되요.


Android 개발에서는 왜 비동기가 필요할까요?

  • 네트워크 요청
  • 디스크 읽기/쓰기
  • 위치 정보 수집

이런 시간이 오래 걸리는 작업을 비동기로 처리해야 합니다.

왜요?

만약 이런 작업을 동기 방식으로 처리하면,
앱이 멈추거나 사용자 경험이 크게 망가질 수 있기 때문이죠.


그러면, 스트림(Stream)이란 무엇일까요?

스트림(Stream)은 말 그대로
데이터가 시간에 따라 흘러가는 흐름을 의미합니다.

  • 하나의 데이터가 아니라,
  • 시간이 지남에 따라
  • 여러 데이터가 연속적으로 발생하고 흘러가는 것!

비유하자면...

Flow는 강물처럼 끊임없이 흘러가는 데이터라고 상상하면 됩니다.


예시 케이스

  • 사용자가 입력한 검색어
  • GPS로 지속적으로 업데이트되는 위치 정보
  • 서버로부터 주기적으로 오는 채팅 메시지

이 모든 것이 "데이터 스트림"입니다.


앱 개발과 스트림

현대 앱은 단순히 한 번 데이터를 요청하고 끝나는 게 아니라,
시간에 따라 계속 변하는 데이터를 비동기로 다루는 것이 필수가 되었습니다.

예시

  • 검색어 입력마다 실시간 결과 업데이트
  • 서버 Push 알림을 실시간으로 수신
  • 배터리 잔량이 변할 때마다 UI 갱신

이런 데이터는

  • 결과가 한 번만 오는 게 아니라
  • 여러 번 발생하고
  • 언제 올지 모르는 특성이 있습니다.

→ 이걸 다루려면 비동기 스트림 개념이 필요합니다.


결론: 비동기 스트림이란?

"시간에 따라 변하는 데이터를, 기다리지 않고 다른 작업과 함께 자연스럽게 받아서 처리하는 흐름" 이다.


2. Flow: 데이터가 흐르는 기본 단위

왜 Flow가 필요했을까...?

앞서 이야기한 "비동기 스트림"을 실제로 구현하려고 하면 문제가 발생해요.

예를 들어서,

  • 네트워크 요청 결과를 받으면서
  • 동시에 사용자 입력도 받고
  • 서버 이벤트도 받아야 한다면

단순한 코루틴만으로는 어렵습니다.

코루틴은 "한 번에 하나의 suspend 작업"에는 강하지만,
"시간에 따라 계속 여러 데이터가 발생"하는 흐름을 자연스럽게 처리하기에는 한계가 있었기 때문이죠.

그래서 등장한게 바로 Flow 입니다.

Flow...?

Flow는 시간에 따라 발생하는 여러 개의 데이터를 비동기적으로 순차 처리할 수 있게 해주는 Kotlin의 표준 스트림 API입니다.

  • 데이터가 한 번에 오지 않고, 순차적으로 발생할 때
  • 각 데이터 사이에 시간 차이가 있을 때
  • 데이터를 받을 때마다 중단(suspend)과 재개(resume) 가 필요할 때

이런 상황을 자연스럽게 다루기 위해 Flow가 만들어졌습니다.

Flow의 핵심 개념 요약

핵심 포인트설명
Cold Stream구독자가 collect할 때 비로소 흐름이 시작된다.
suspend 가능Flow 내부에서도 delay, network 요청 등 비동기 처리가 가능하다.
데이터 흐름 조작 가능map, filter, take, combine 등 다양한 중간 연산자를 통해 데이터 흐름을 자유롭게 가공할 수 있다.
Backpressure 대응생산 속도와 소비 속도의 차이를 자연스럽게 흡수할 수 있다.

Flow를 쉽게 비유하면?

"필요할 때만 흐르는 수도꼭지"

  • 수도꼭지를 틀어야 물이 흐른다 → collect 해야 데이터가 흐른다
  • 물이 계속 나오는 게 아니라, 틀 때마다 시작한다 → Cold Stream
  • 물을 받을 때 잠깐 멈출 수도 있고, 이어서 받을 수도 있다 → suspend

이렇게 이해하면 될 것 같습니다.


Flow의 기본 사용법

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를 수집할 때는 반드시 코루틴 안에서 해야 한다 (runBlocking, launch 등)

✍︎ Flow 요약

  • Flow는 Kotlin 비동기 스트림의 기본 단위이다.
  • 필요할 때만 데이터를 발행하는 Cold Stream이다.
  • Flow를 통해 시간을 기준으로 변하는 데이터를 안전하고 간결하게 다룰 수 있다.

3. StateFlow: 상태를 기억하는 스트림

Flow만으로는 충분하지 않았다?

Flow는 시간에 따라 데이터를 흘려보낼 수 있지만,
"항상 최신 상태를 보관하고 싶을 때" 는 문제가 생깁니다.

예를 들어서,

  • 사용자가 화면을 다시 열었을 때
  • 화면이 회전(재구성)됐을 때
  • 앱이 일시적으로 백그라운드에 갔다가 다시 복귀했을 때

이럴 때마다
Flow는 다시 collect를 시작하지만,
이전까지의 상태를 기억하고 있지는 않습니다.

즉, Flow는 상태(state)를 "저장"하지 않고, 그냥 "흘려보내는" 역할만 한다는 거죠.


그래서 등장한 것이 바로 StateFlow다!

StateFlow는 Kotlin에서 제공하는
"항상 최신 상태를 기억하고 있는" 특별한 Flow입니다.

Flow의 특징을 가지면서, 상태를 보존할 수 있게 확장한 것이라고 이해하면 됩니다.


StateFlow의 핵심 개념 요약

핵심 포인트설명
항상 값이 있다StateFlow는 항상 하나의 최신 값을 들고 있다. (초기값 필요)
Cold → Hot StreamFlow는 Cold Stream이지만, StateFlow는 Hot Stream이다. (항상 살아 있음)
구독 즉시 현재 상태 제공collect를 시작하면 과거 데이터가 아니라 최신 상태를 바로 받는다.
값 변경은 value를 통해stateFlow.value = newValue 형태로 상태를 업데이트한다.

쉽게 비유하면?

"StateFlow는 물이 흐르는 수도관에 물탱크를 추가한 것"

  • 수도관(Flow)은 수도꼭지를 틀어야 물이 흐르지만,
  • StateFlow는 항상 물이 차 있는 탱크처럼,
  • 언제든지 최신 물(데이터)을 바로 받을 수 있다.

이렇게 이해하면 될 것 같습니다.


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 Vs StateFlow

항목FlowStateFlow
데이터 보존❌⭕ (항상 현재 값 들고 있음)
초기값 필요❌⭕
Cold/HotCold StreamHot Stream
구독자 유무에 따른 흐름구독해야 흐름 시작항상 흐르고 있음
사용 예시네트워크 요청, 일회성 스트림UI 상태 관리, 화면 상태 보존

Compose에서 StateFlow를 사용하는 방법

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 요약

  • Flow는 데이터를 흘려보내기만 하지만, 상태를 기억하지 않는다.
  • StateFlow는 항상 최신 상태를 보존한다.
  • StateFlow는 Hot Stream이다 (구독자가 없어도 흐른다).
  • Compose에서는 collectAsState로 자연스럽게 StateFlow를 사용할 수 있다.

4. SharedFlow: 이벤트를 브로드캐스트하는 스트림

StateFlow만으로 충분할까?

우리는 StateFlow를 통해 "현재 상태"를 안전하게 관리할 수 있었죠?.
하지만, 상태(state)와는 다른 개념이 하나 있답니다.

바로 "일회성 이벤트(event)" 입니다.

예를 들어서,

  • 토스트(Toast) 띄우기
  • 화면 이동(Navigation)
  • 다이얼로그 열기

이런 동작은
상태(state) 처럼 오래 유지하는 것이 아니라,
"한 번만 발생하고 끝나야 하는" 행동이죠.


그런데 StateFlow로 이벤트를 처리하면?

문제가 생깁니다..

  • 화면이 회전하거나
  • 재구성(recomposition)될 때

StateFlow는 항상 최신 상태를 들고 있기 때문에,
옛날에 발생했던 이벤트가 다시 발생할 수도 있습니다.

→ 이미 본 Toast가 한 번 더 뜬다거나,
→ 이미 이동한 화면으로 다시 이동하려고 한다거나

→ 이런 버그를 막기 위해
"일회성 이벤트"를 위한 스트림이 따로 필요하다는 거죠.


그래서 등장한 것이 바로 SharedFlow

SharedFlow는 Kotlin에서 제공하는
"여러 수신자에게 동시에 이벤트를 전달하는" Hot Stream입니다.

쉽게 말해, "한 번 발생하면 필요한 모든 구독자가 동시에 받을 수 있는" 스트림


SharedFlow의 핵심 개념 요약

핵심 포인트설명
Hot Stream구독자 유무와 관계없이 항상 살아있다.
여러 수신자 지원여러 구독자가 collect할 수 있다.
replay 설정 가능최근 N개의 이벤트를 새 구독자에게 재전송할 수 있다.
버퍼 설정 가능extraBufferCapacity로 이벤트를 버퍼링할 수 있다.
State가 아닌 Event상태 저장이 아니라 이벤트 발생에 적합하다.

쉽게 비유하자면

"SharedFlow는 학교 방송 스피커"

  • 방송실(Flow)에서 이벤트를 한번 발사하면,
  • 복도에 있는 모든 학생(구독자)이 동시에 듣게 됩니다.

(한 명만 듣는 게 아니라, 원하면 모두 들을 수 있다.)


기본 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

실행 순서

  1. First Collector가 바로 collect 시작
  2. 0.1초 뒤에 emit(1), emit(2) → First Collector가 바로 받음
  3. 0.5초 지남 → Second Collector 시작
  4. 근데 이때 이미 모든 이벤트는 다 소비되고 없음
  5. Second Collector는 새롭게 들어왔지만, replay가 0이라 아무것도 안 받고 기다리는 중

그래서 Second Collector를 받고 싶다면 replay를 1로 설정하는 방법이 있습니다.
1로 한다면 아래와 같은 결과가 나오겠죠?

First Collector: 1
First Collector: 2
Second Collector: 2

replay, extraBufferCapacity란?

replay: 과거 이벤트를 새로 들어온 구독자에게 몇 개까지 재전송할지 설정
extraBufferCapacity: 현재 발생 중인 이벤트를 일시적으로 버퍼링할 수 있는 크기

일반적으로 "단발성 이벤트 처리"라면
replay = 0, extraBufferCapacity = 1 이렇게 설정합니다.


Flow vs StateFlow vs SharedFlow

항목FlowStateFlowSharedFlow
목적데이터 스트림상태 관리이벤트 브로드캐스트
구독 방식collect 시 시작항상 최신값 제공이벤트 발생 시 모두 알림
Cold/HotCold StreamHot StreamHot Stream
기억 여부❌⭕ (현재 상태)⭕ (replay 설정)
예시네트워크 응답화면 상태 (로딩, 성공, 에러)Toast, Navigation

Compose에서 SharedFlow를 사용하는 방법

Compose에서 SharedFlow를 사용하는 방법

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해서 바로 처리하면 됩니다.


실행결과는 다음과 같습니다.


✍ SharedFlow 요약

  • SharedFlow는 여러 구독자에게 동시에 이벤트를 뿌릴 수 있다.
  • replay와 버퍼(extraBufferCapacity)를 설정할 수 있다.
  • Compose에서는 LaunchedEffect 안에서 collect하면 된다.
  • StateFlow는 "상태(state)"를 다루고, SharedFlow는 "이벤트(event)"를 다룬다.

5. Channel: 1:1 통신 파이프라인

왜 Channel이 필요했을까?

Flow, StateFlow, SharedFlow는 "데이터 스트림"을 처리하기 위한 좋은 도구입니다.
하지만 애내들은 기본적으로

"발생하는 데이터를 흘려보내고 수집하는" 패턴에 최적화되어 있습니다.

그런데 가끔은 이런 게 필요할 때가 있습니다.

  • 하나의 코루틴이 데이터를 보내고
  • 다른 코루틴이 그 데이터를 하나씩 직접 받아서
  • 1:1로 정확하게 주고받는 통신

예를 들어,

  • 백그라운드 작업 큐
  • 실시간 데이터 소비 처리
  • 생산자(Producer) ↔ 소비자(Consumer) 패턴

이런 구조를 다루려면 Flow만으로는 깔끔하지 않습니다.


그래서 등장한 것이 Channel 이다.

Channel은 Kotlin 코루틴에서 제공하는
"1:1 통신"을 위한 특별한 데이터 파이프라인입니다.

"하나의 Coroutine이 send()하고, 다른 Coroutine이 receive()하는 구조" 를 안전하게 만들 수 있습니다.


Channel의 핵심 개념 요약

특징설명
1:1 통신한 번 보낸 데이터는 하나의 소비자만 받을 수 있다.
Hot Stream구독자 유무와 관계없이 항상 살아있다.
suspend 가능send/receive 시 상대가 준비 안 되어 있으면 일시 중단된다.
버퍼 설정 가능필요에 따라 버퍼 크기를 조정할 수 있다.

쉽게 비유하면

"Channel은 택배 상자"

  • 생산자(Producer)가 택배 상자에 물건을 담아 보낸다 → send()
  • 소비자(Consumer)가 상자를 열고 물건을 꺼낸다 → receive()
  • 서로 타이밍이 안 맞으면 잠깐 기다려야 한다 (suspend)

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의 버퍼링 옵션?

Channel은 기본적으로 데이터를 하나씩 주고받는 구조이지만,
필요에 따라 버퍼를 설정해 일시적인 데이터 저장이 가능합니다.
그래서 send()가 즉시 처리되지 않아도 데이터를 잠시 보관할 수 있습니다.

설정설명
Channel() (기본)버퍼 없음. send()와 receive() 타이밍이 정확히 맞아야 함.
Channel.UNLIMITED무제한 버퍼. 어떤 상황에서도 send()가 suspend되지 않지만, 메모리 사용에 주의 요구.
Channel.CONFLATED마지막 데이터만 유지. 새로운 데이터가 오면 이전 데이터를 덮어씌움.
Channel(capacity = N)최대 N개의 데이터를 버퍼링할 수 있음. 적절한 크기를 지정해 유연하게 사용 가능

예시:

val bufferedChannel = Channel<Int>(capacity = 3)

Channel로 단발성 이벤트를 처리할 수 있을까?

결론부터 말씀드리면,
가능합니다.

Channel은 단발성 이벤트를 한 번 보내고,
다른 쪽에서 한 번만 수신하는 구조에 딱 맞습니다.

예를 들어서,
ViewModel에서 Channel.send()로 이벤트를 보내고,
화면에서 receiveAsFlow().collect()로 받아서 처리하면
Toast, 화면 이동(Navigation) 같은 단발성 UI 이벤트를 처리할 수 있습니다.

그러나, 단점도 있습니다.
Channel은 1:1 통신 기반이기 때문에
이벤트를 한 번 수신하면 그걸로 끝입니다.

화면이 회전되거나 재구성되면,
→ 이미 소비된 이벤트는 다시 받을 수 없습니다.

이런 구조에서는 수신 타이밍을 놓치면
이벤트가 영영 사라져버릴 수 있는 리스크가 있습니다.


비동기 스트림 총정리: 언제 어떤 것을 써야 할까?

📊 비동기 스트림 비교 요약표

항목FlowStateFlowSharedFlowChannel
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의 비동기 스트림은 처음 접할 땐 다소 복잡해 보이지만,
그 개념과 쓰임새를 이해하고 나면 오히려 더 깔끔하고 안전한 코드를 만드는 데 큰 도움이 됩니다.

이 글을 통해

  • 어떤 상황에서 어떤 스트림을 써야 할지
  • 각각의 도구가 왜 만들어졌고
  • 실무에서는 어떻게 활용하는지

명확하게 정리되었기를 바랍니다.

처음엔 익숙하지 않더라도,
이 아티클을 읽있다면, 어느 정도는 개념을 알아갈 수 있다고 생각합니다.

천천히, 그리고 단단하게 우리 모두 강력해집시다.


profile
Android Developer

0개의 댓글