코루틴 — suspend·구조적 동시성·Dispatchers·취소
안드로이드의 메인(UI) 스레드를 막으면 화면이 멈추고 *ANR(Application Not Responding)*이 뜬다. 그래서 네트워크·DB 같은 느린 작업은 백그라운드에서 해야 하는데 — 콜백으로 짜면 *중첩(콜백 지옥)*이 되고 에러·취소 처리가 흩어진다. **코루틴(coroutine)**은 비동기 코드를 동기 코드처럼 위에서 아래로 쓰게 하면서, *스레드를 막지 않고 일시 중단(suspend)*한다. 코틀린·안드로이드 비동기의 표준이다. (출처: Kotlin Docs — Coroutines / Android — Coroutines.)
suspend 함수 — 멈추되 스레드는 안 막는다
suspend 함수는 실행을 잠시 멈췄다(일시 중단) 나중에 이어갈 수 있는 함수다. 핵심은 — 기다리는 동안 스레드를 점유하지 않는다. delay(1000)은 1초를 블로킹 없이 기다리고(스레드는 그동안 다른 일을 함), 끝나면 그 자리부터 이어서 실행된다.
suspend fun loadUser(id: Long): User {
val dto = api.fetchUser(id) // 네트워크 — 중단점, 스레드 안 막음
return dto.toUser() // 응답 오면 여기서 이어짐
}
규칙 하나 — suspend 함수는 코루틴 안이나 다른 suspend 함수에서만 호출된다. 일반 함수에서 바로 못 부른다(중단할 곳이 없으므로).
코루틴 시작 — launch와 async
코루틴은 CoroutineScope 안에서 빌더로 시작한다.
// launch — 결과를 안 받는 작업(fire-and-forget), Job 반환
scope.launch {
val user = loadUser(42) // suspend 호출 OK
updateUi(user)
}
// async — 결과를 받는 작업, Deferred<T> 반환 → await()로 값
scope.launch {
val a = async { loadUser(1) } // 둘이 동시에 시작
val b = async { loadUser(2) }
val both = listOf(a.await(), b.await()) // 둘 다 끝날 때까지 (병렬)
}
launch는 값이 필요 없는 작업, async/await는 값을 받고 특히 여러 작업을 병렬로 돌릴 때 쓴다.
Dispatchers — 어느 스레드에서 도나
코루틴이 어느 스레드 풀에서 실행될지는 Dispatcher가 정한다.
| Dispatcher | 용도 |
|---|---|
Dispatchers.Main | UI 갱신(안드로이드 메인 스레드) |
Dispatchers.IO | 네트워크·디스크·DB (블로킹 I/O) |
Dispatchers.Default | CPU 집약 계산(정렬·파싱) |
**withContext**로 작업 중 스레드를 바꾼다 — 안드로이드의 핵심 패턴은 *"메인에서 시작 → IO로 전환해 무거운 일 → 메인으로 돌아와 UI 갱신"*이다.
scope.launch(Dispatchers.Main) { // 메인에서 시작
val user = withContext(Dispatchers.IO) { // IO로 전환 (스레드 점프)
api.fetchUser(42) // 네트워크
} // 끝나면 자동으로 메인 복귀
nameView.text = user.name // 메인에서 UI 갱신 (안전)
}
라이브러리(Retrofit·Room)는 대개 알아서 적절한 디스패처로 돈다. 그래서 수동
withContext(IO)가 늘 필요하진 않지만, 직접 블로킹 코드를 부를 땐 IO로 감싼다.
구조적 동시성 — 코루틴은 부모를 따른다
코루틴의 가장 중요한 설계가 **구조적 동시성(structured concurrency)**이다 — 모든 코루틴은 부모 스코프에 속하고, 부모는 자식이 다 끝날 때까지 기다리며, 부모가 취소되면 자식도 전부 취소된다. *길 잃은 코루틴(누수)*이 안 생긴다.
coroutineScope { // 이 블록은 자식이 모두 끝나야 반환
launch { task1() }
launch { task2() }
} // task1·task2 둘 다 끝난 뒤 다음 줄로
안드로이드는 이걸 생명주기에 묶어 제공한다 — viewModelScope(ViewModel 소멸 시 취소)·lifecycleScope(화면 소멸 시 취소). 그래서 화면이 사라지면 진행 중이던 네트워크 코루틴이 자동 취소된다(누수·크래시 방지 — [andsys] 코루틴 on Android에서 자세히).
취소 — 협력적이다
코루틴 취소는 **협력적(cooperative)**이다 — 취소 요청이 와도, 코루틴이 중단점에서 확인해야 실제로 멈춘다. delay·withContext 등 suspend 지점은 자동으로 취소를 확인하지만, 긴 CPU 루프는 직접 확인해야 한다.
val job = scope.launch {
while (isActive) { // 취소 여부 확인 — 협력
computeChunk()
}
// 또는 ensureActive() / yield() 로 취소 지점 삽입
}
job.cancel() // 취소 요청
취소는 **CancellationException**으로 전파된다 — 이 예외는 정상 흐름이라 삼키면 안 된다. try/catch (e: Exception)로 뭉뚱그려 잡으면 취소가 안 되니, 재던지거나 CancellationException을 따로 다룬다.
예외 처리 — launch와 async가 다르다
launch— 예외가 즉시 위로 전파된다.try/catch로 코루틴 안에서 잡거나, *CoroutineExceptionHandler*를 스코프에 단다.async— 예외가await()호출 시점에 던져진다.await()를try/catch로 감싼다.SupervisorJob/supervisorScope— 한 자식의 실패가 형제를 취소하지 않게 한다(독립 작업 여럿일 때). 기본 Job은 한 자식이 실패하면 부모·형제 모두 취소.
val handler = CoroutineExceptionHandler { _, e -> Log.e("TAG", "실패", e) }
scope.launch(handler) { mayFail() }
정리 — 동기처럼 쓰는 비동기
코루틴의 핵심 — suspend로 스레드를 막지 않고 멈췄다 잇고, launch/async로 시작, Dispatchers+withContext로 스레드를 옮기며(IO에서 일하고 Main에서 UI), 구조적 동시성으로 부모-자식을 묶어 누수·취소를 자동화한다. 안드로이드에선 *생명주기 스코프(viewModelScope)*가 이를 화면 수명에 연결한다. 취소는 협력적이고 CancellationException은 삼키지 않는다.
한 줄 요약 —
suspend=스레드 안 막고 중단/재개.launch(fire-and-forget)·async/await(병렬 값).Dispatchers.Main/IO/Default+withContext로 스레드 전환(IO 일·Main UI). 구조적 동시성=부모 스코프가 자식 추적·취소 전파(viewModelScope). 취소는 협력적(isActive),CancellationException은 재던짐. 예외는launch(즉시)·async(await 시),SupervisorJob으로 격리.
(출처: Kotlin Coroutines Guide / Android — Kotlin coroutines / Best practices / Kotlin in Action 2e Ch 14·15.)