Kotlin Coroutines & Flows
Android 및 Kotlin Multiplatform 프로젝트에서 구조적 동시성, Flow 기반 반응형 스트림, 코루틴 테스트를 위한 패턴입니다.
활성화 시점
- Kotlin 코루틴으로 비동기 코드를 작성할 때
- 반응형 데이터에 Flow, StateFlow, SharedFlow를 사용할 때
- 동시 작업(병렬 로딩, 디바운스, 재시도)을 처리할 때
- 코루틴과 Flow를 테스트할 때
- 코루틴 scope와 취소를 관리할 때
구조적 동시성
Scope 계층 구조
Application
└── viewModelScope (ViewModel)
└── coroutineScope { } (구조적 자식)
├── async { } (동시 작업)
└── async { } (동시 작업)
항상 구조적 동시성을 사용하세요. 절대 GlobalScope를 사용하지 마세요:
// 나쁜 예
GlobalScope.launch { fetchData() }
// 좋은 예 — ViewModel 수명 주기로 범위 지정
viewModelScope.launch { fetchData() }
// 좋은 예 — Composable 수명 주기로 범위 지정
LaunchedEffect(key) { fetchData() }
병렬 분해
병렬 작업에는 coroutineScope + async를 사용하세요:
suspend fun loadDashboard(): Dashboard = coroutineScope {
val items = async { itemRepository.getRecent() }
val stats = async { statsRepository.getToday() }
val profile = async { userRepository.getCurrent() }
Dashboard(
items = items.await(),
stats = stats.await(),
profile = profile.await()
)
}
SupervisorScope
자식 실패가 다른 자식을 취소하지 않아야 할 때 supervisorScope를 사용하세요:
suspend fun syncAll() = supervisorScope {
launch { syncItems() } // 여기서 실패해도 syncStats는 취소되지 않음
launch { syncStats() }
launch { syncSettings() }
}
Flow 패턴
Cold Flow — 일회성에서 스트림으로 변환
fun observeItems(): Flow<List<Item>> = flow {
// 데이터베이스가 변경될 때마다 다시 방출
itemDao.observeAll()
.map { entities -> entities.map { it.toDomain() } }
.collect { emit(it) }
}
UI 상태를 위한 StateFlow
class DashboardViewModel(
observeProgress: ObserveUserProgressUseCase
) : ViewModel() {
val progress: StateFlow<UserProgress> = observeProgress()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UserProgress.EMPTY
)
}
WhileSubscribed(5_000)은 마지막 구독자가 사라진 후에도 5초 동안 업스트림을 활성 상태로 유지합니다 — 설정 변경 시 재시작 없이 생존합니다.
여러 Flow 결합
val uiState: StateFlow<HomeState> = combine(
itemRepository.observeItems(),
settingsRepository.observeTheme(),
userRepository.observeProfile()
) { items, theme, profile ->
HomeState(items = items, theme = theme, profile = profile)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeState())
Flow 연산자
// 검색 입력 디바운스
searchQuery
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { query -> repository.search(query) }
.catch { emit(emptyList()) }
.collect { results -> _state.update { it.copy(results = results) } }
// 지수 백오프로 재시도
fun fetchWithRetry(): Flow<Data> = flow { emit(api.fetch()) }
.retryWhen { cause, attempt ->
if (cause is IOException && attempt < 3) {
delay(1000L * (1 shl attempt.toInt()))
true
} else {
false
}
}
일회성 이벤트를 위한 SharedFlow
class ItemListViewModel : ViewModel() {
private val _effects = MutableSharedFlow<Effect>()
val effects: SharedFlow<Effect> = _effects.asSharedFlow()
sealed interface Effect {
data class ShowSnackbar(val message: String) : Effect
data class NavigateTo(val route: String) : Effect
}
private fun deleteItem(id: String) {
viewModelScope.launch {
repository.delete(id)
_effects.emit(Effect.ShowSnackbar("Item deleted"))
}
}
}
// Composable에서 수집
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
when (effect) {
is Effect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.message)
is Effect.NavigateTo -> navController.navigate(effect.route)
}
}
}
Dispatchers
// CPU 집약적 작업
withContext(Dispatchers.Default) { parseJson(largePayload) }
// I/O 바운드 작업
withContext(Dispatchers.IO) { database.query() }
// 메인 스레드 (UI) — viewModelScope에서 기본값
withContext(Dispatchers.Main) { updateUi() }
KMP에서는 Dispatchers.Default와 Dispatchers.Main을 사용하세요(모든 플랫폼에서 사용 가능). Dispatchers.IO는 JVM/Android 전용입니다 — 다른 플랫폼에서는 Dispatchers.Default를 사용하거나 DI로 제공하세요.
취소
협력적 취소
장기 실행 루프는 취소를 확인해야 합니다:
suspend fun processItems(items: List<Item>) = coroutineScope {
for (item in items) {
ensureActive() // 취소 시 CancellationException 발생
process(item)
}
}
try/finally로 정리
viewModelScope.launch {
try {
_state.update { it.copy(isLoading = true) }
val data = repository.fetch()
_state.update { it.copy(data = data) }
} finally {
_state.update { it.copy(isLoading = false) } // 취소되어도 항상 실행됨
}
}
테스트
Turbine으로 StateFlow 테스트
@Test
fun `search updates item list`() = runTest {
val fakeRepository = FakeItemRepository().apply { emit(testItems) }
val viewModel = ItemListViewModel(GetItemsUseCase(fakeRepository))
viewModel.state.test {
assertEquals(ItemListState(), awaitItem()) // 초기 상태
viewModel.onSearch("query")
val loading = awaitItem()
assertTrue(loading.isLoading)
val loaded = awaitItem()
assertFalse(loaded.isLoading)
assertEquals(1, loaded.items.size)
}
}
TestDispatcher로 테스트
@Test
fun `parallel load completes correctly`() = runTest {
val viewModel = DashboardViewModel(
itemRepo = FakeItemRepo(),
statsRepo = FakeStatsRepo()
)
viewModel.load()
advanceUntilIdle()
val state = viewModel.state.value
assertNotNull(state.items)
assertNotNull(state.stats)
}
Flow 페이크 만들기
class FakeItemRepository : ItemRepository {
private val _items = MutableStateFlow<List<Item>>(emptyList())
override fun observeItems(): Flow<List<Item>> = _items
fun emit(items: List<Item>) { _items.value = items }
override suspend fun getItemsByCategory(category: String): Result<List<Item>> {
return Result.success(_items.value.filter { it.category == category })
}
}
피해야 할 안티 패턴
GlobalScope사용 — 코루틴 누수, 구조적 취소 없음- scope 없이
init {}블록에서 Flow 수집 —viewModelScope.launch사용 - 변경 가능한 컬렉션과 함께
MutableStateFlow사용 — 항상 불변 복사본 사용:_state.update { it.copy(list = it.list + newItem) } CancellationException캐치 — 적절한 취소를 위해 전파해야 함- 수집을 위해
flowOn(Dispatchers.Main)사용 — 수집 디스패처는 호출자의 디스패처임 @Composable에서remember없이Flow생성 — 모든 재구성마다 Flow를 다시 생성함
참고
Flow의 UI 소비는 스킬 compose-multiplatform-patterns를 참조하세요.
코루틴이 계층에 어떻게 맞는지는 스킬 android-clean-architecture를 참조하세요.