안드로이드 코틀린 언어 학습 노트 목차

코루틴 — 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.MainUI 갱신(안드로이드 메인 스레드)
Dispatchers.IO네트워크·디스크·DB (블로킹 I/O)
Dispatchers.DefaultCPU 집약 계산(정렬·파싱)

**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·withContextsuspend 지점은 자동으로 취소를 확인하지만, 긴 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.)

컬렉션·시퀀스 — 함수형 연산·지연 평가Flow·StateFlow·SharedFlow — 비동기 스트림