비동기에서 suspendCancellableCoroutine까지, 코루틴이 정말 하는 일

조회

오호라 suspendCancellableCoroutine를 사용해주시다니, 알고 사용하신거죠? 그렇지 않으면 꼭 공부를 하시길 추천드려요.

우테코 미션 중, ㅁㄹㅂ의 리뷰 중 해당 내용이 있어서 이번에 코루틴을 수업에서 다루기도 했고 해서 제대로 공부해보고 싶었다. 하루동안 공부한 내용을 아래로 정리한다!


안드로이드에서 비동기 코드를 다루는 길은 콜백에서 코루틴으로 이어진다. 그런데 코루틴을 "비동기 코드를 동기처럼 쓰는 도구" 정도로만 알고 있으면, suspendCancellableCoroutine 같은 코드 앞에서 멈추게 된다. 이 글은 ANR부터 시작해서 suspendCancellableCoroutine까지, 한 줄씩 분해해보면서 "왜 이게 이렇게 생겼나"를 따라간다.

1. 메인 스레드는 막히면 안 된다

안드로이드는 모든 UI 갱신을 메인 스레드 하나에서 처리한다. 메인 스레드가 어떤 일에 묶여 있는 동안에는 화면도 멈춘다. 사용자 입력에 일정 시간(보통 5초) 이상 응답하지 못하면 시스템이 ANR(Application Not Responding) 다이얼로그를 띄운다.

그래서 네트워크 호출, 디스크 I/O처럼 시간이 걸리는 작업을 메인 스레드에서 그대로 실행하면 안 된다. 메인 스레드와 분리된 다른 스레드로 작업을 보내고, 결과만 받아와야 한다.

2. 전통적 해결: 콜백

비동기 작업의 결과를 받는 가장 단순한 방법은 콜백이다. 라이브러리가 내부적으로 자기 스레드풀에서 작업을 돌리고, 끝나면 등록해둔 람다를 호출한다.

fun onClick() {
    api.getUsers { users ->        // 백그라운드에서 끝나면 콜백
        runOnUiThread {
            showUsers(users)       // UI 갱신은 메인 스레드에서
        }
    }
}

문제는 호출이 중첩될 때다. 사용자 정보를 받아서 주문을 받고, 주문으로 상품을 받는 식으로 비동기 작업이 줄줄이 이어지면 들여쓰기가 끝없이 깊어진다.
점점 장풍을 쏘게된다

api.getUser { user ->
    api.getOrders(user.id) { orders ->
        api.getProducts(orders[0].id) { products ->
            showProducts(products)   // 들여쓰기 4단
        }
    }
}

3. 코루틴이 풀어주는 것

같은 코드를 코루틴으로 쓰면 이렇게 된다.

fun onClick() {
    viewModelScope.launch {
        val user = api.getUser()
        val orders = api.getOrders(user.id)
        val products = api.getProducts(orders[0].id)
        showProducts(products)
    }
}

분명 비동기 호출인데 위에서 아래로 잘 읽힌다. 코루틴은 "비동기 코드를 동기처럼 읽히게" 만든다.

그런데 이게 어떻게 가능한지 알려면 몇 가지 용어를 먼저 정리해야 한다.

3.1 용어 정리

구분 의미
동기 함수를 부르면 결과가 나올 때까지 다음 줄로 안 넘어감
비동기 결과를 기다리지 않고 바로 다음 줄로 넘어감, 결과는 나중에 전달됨
블로킹 결과 나올 때까지 현재 스레드 점유. 스레드는 그동안 아무것도 못 함
논블로킹 기다리는 동안 스레드를 놓아줌. 다른 일 할 수 있음

작업의 성격에 따라서도 구분이 필요하다.

CPU-bound 작업 - CPU가 계속 일해야 결과가 나오는 작업.

fun fib(n: Int): Long = if (n < 2) n.toLong() else fib(n-1) + fib(n-2)
fib(45)   // CPU 풀가동으로 몇 초 동안 계산

I/O-bound 작업 - 외부의 응답을 기다리느라 시간이 걸리는 작업.

val response = api.getUsers()   // 서버 응답 대기
val rows = db.query("...")      // 디스크 읽기 대기
val text = file.readText()      // 파일 읽기 대기

I/O 작업에서 비동기가 필요한 이유는 세 가지다.

  1. 본질적 이유: 외부 장치/시스템을 기다리는 동안 CPU가 놀고 있다. 그 시간에 다른 일을 시키는 게 합리적이다.
  2. 자원 이유: 스레드는 비싸다. 동시 I/O가 많아지면 스레드를 그만큼 만들 수 없다. 적은 스레드로 많은 I/O를 처리하려면 비동기가 필수다.
  3. OS 이유: 운영체제와 하드웨어가 이미 비동기로 동작한다. 비동기 API는 OS의 본성을 그대로 살리는 것이다.

3.2 suspendlaunch

코루틴 코드에서 가장 먼저 마주치는 키워드 둘.

  • suspend: 중단 지점을 만들 수 있는 함수. 컴파일러가 Continuation 객체를 만들어서, 함수가 도중에 빠져나갔다가 다시 들어올 수 있게 변환해준다. 단, suspend 함수는 다른 suspend 함수나 코루틴 빌더 안에서만 호출할 수 있다.
  • launch: 코루틴을 새로 만들어서 시작하는 빌더. 코루틴을 시작하는 트리거 역할이다.

4. 스레드 vs 코루틴

둘 다 "동시에 뭔가 한다"는 점은 비슷한데, 관리 주체와 비용이 완전히 다르다. 절대 스레드와 코루틴은 같지 않다. 스레드에서 여러 코루틴이 돌아간다.

스레드

OS가 직접 관리하는 "실행 흐름의 단위".

  • OS가 만들고, OS가 스케줄링한다.
  • 메모리를 1~2MB 차지한다 (스레드마다 스택을 가짐).
  • 만들고 없애는 비용이 크다.
  • 스레드 간 전환(context switch)도 비싼 OS 작업이다.

코루틴

언어 런타임이 관리하는 "중단/재개 가능한 작업".

  • OS는 코루틴의 존재를 모른다.
  • 메모리는 수십~수백 바이트 수준이다.
  • 만들고 없애는 비용이 거의 없다.
  • 코루틴 라이브러리가 "어떤 코루틴을 어떤 스레드에서 실행할지" 결정한다.

핵심은 코루틴은 스레드 위에서 도는 작업이라는 것이다. 코루틴이 중단/재개 가능한 작업 단위이기 때문에, 하나의 스레드 위에서 수많은 코루틴이 번갈아 실행될 수 있다.

[코루틴]
Thread-1: [A시작→suspend] [B시작→suspend] [C시작→suspend] ... [A재개] [B재개] ...
티켓 1000개 → 스레드 1개로도 OK

5. 디스패처와 Main-safety

코루틴이 "어떤 스레드에서 실행될지"는 디스패처가 결정한다. 안드로이드에서는 보통 네 가지를 쓴다.

디스패처 용도
Dispatchers.Main 메인 스레드. UI 작업.
Dispatchers.Main.immediate 이미 메인이면 즉시 실행, 아니면 디스패치.
Dispatchers.IO 네트워크/디스크 같은 블로킹 I/O.
Dispatchers.Default CPU 집약 작업.

Main-safe의 의미

main-safe = 메인 스레드에서 호출해도 메인을 막지 않음.

viewModelScope.launch는 기본적으로 Dispatchers.Main에서 시작한다. 그런데 그 안에서 Retrofit이나 Room의 suspend 함수를 호출해도 괜찮다. 왜? 이미 그들이 main-safe하기 때문이다.

  • Retrofit - OkHttp가 자체 스레드풀(enqueue)에서 요청을 처리한다.
  • Room - 자체 스레드풀에서 쿼리를 처리한다.
  • 그 외 라이브러리도 내부에서 withContext(Dispatchers.IO) 같은 처리로 직접 책임을 진다면 main-safe라고 부를 수 있다.

반대로 OkHttp의 execute()처럼 동기 호출은 main-safe하지 않다. 호출자가 직접 Dispatchers.IO로 옮겨줘야 한다.

launch(Dispatchers.IO)가 왜 안티패턴인가

ViewModel에서 이런식으로 코드를 짤 수는 있다.

// 안티패턴
viewModelScope.launch(Dispatchers.IO) {
    val data = repository.findAll()
    _state.value = data
}

위 코드가 Main-safety 원칙과 충돌하는 이유는 세 가지다.

  1. 캡슐화 위반: ViewModel이 "Repository 안에서 어떤 디스패처가 필요한지"를 알아야 한다. 디스패처 선택은 데이터 소스 쪽 책임이다.
  2. UI 크래시 위험: View를 직접 만지는 코드가 IO 스레드에서 실행되면 크래시 난다. LiveData의 setValue도 메인이 아니면 즉시 크래시.
  3. 반복되는 책임: Repository를 다른 ViewModel에서 호출할 때마다 매번 Dispatchers.IO를 명시해야 한다.

해결 방법은, 디스패처는 데이터 소스 안에서 처리하고, ViewModel은 그냥 suspend 함수를 호출한다.

class UserRepository {
    suspend fun findAll(): List<User> = withContext(Dispatchers.IO) {
        api.findAll()
    }
}

// ViewModel은 디스패처를 신경 쓰지 않는다
viewModelScope.launch {
    val data = repository.findAll()
    _state.value = data
}

6. CoroutineScope와 Job: 구조적 동시성

다음 코드는 무엇이 잘못되었을까.

fun onButtonClick() {
    GlobalScope.launch {
        val data = api.findAll()
        updateUi(data)
    }
}

사용자가 버튼을 누르고 화면을 떠나면, 코루틴은 여전히 살아서 API 응답을 기다린다. 응답이 도착하면 이미 사라진 화면의 UI를 갱신하려다 크래시가 나거나 메모리 누수가 발생한다.

이게 일어나지 않게 하는 도구가 CoroutineScope다.

CoroutineScope

  1. 코루틴 작업 단위를 모아두는 그릇.
  2. 모든 코루틴은 어떤 스코프 안에서 태어난다.
  3. 스코프가 죽으면 그 안의 코루틴도 모두 죽는다.
  4. 내부적으로 Job 하나 + CoroutineContext(디스패처 등)를 들고 다닌다.

안드로이드의 viewModelScope, lifecycleScope가 이 패턴을 활용한다. ViewModel이 cleared될 때 viewModelScope가 자동으로 cancel되면서 그 안의 모든 코루틴이 정리된다.

Job, Scope, Coroutine의 관계

val job: Job = scope.launch {
  //   ↑           ↑      ↑
  //   Job        Scope   이 중괄호 코드 블록 전체 = "코루틴"
      delay(1000)
      println("done")
  }
  • Coroutine = 중괄호 안의 코드 블록 자체. 실제로 일하는 주체.
  • Scope = 이 코루틴을 어느 그릇에서 시작할지 정한다. scope.launch는 "이 그릇에 새 일감 등록"이라는 뜻.
  • Job = launch가 리턴해주는 객체. 방금 만든 코루틴의 "상태 카드". 이걸 들고 있으면 코루틴을 조작할 수 있다.
job.cancel()      // 그 코루틴 죽이기
job.isActive      // 그 코루틴 살아있나 확인
job.join()        // 끝날 때까지 기다리기

정리하면, 코루틴은 코드 블록 자체이고, Job은 그 코루틴을 조작하는 핸들이고, Scope는 부모 Job을 들고 있는 그릇이다.

scope.cancel() 호출 시 일어나는 일

val scope = CoroutineScope(Job() + Dispatchers.IO)

scope.launch { delay(1000) }   // 코루틴 A
scope.launch { delay(2000) }   // 코루틴 B

scope.cancel()

호출 직후 순서는 이렇다.

  1. launch로 만들어진 자식 Job 둘이 scope의 부모 Job 아래 등록되어 있다.
  2. scope.cancel() 호출.
  3. 부모 Job이 Cancelling 상태로 바뀐다.
  4. Cancelling 상태가 자식 Job들에게 전파된다.
  5. 각 자식 코루틴에 CancellationException이 던져진다.
    • Suspended 상태(delay 등으로 멈춰있음): 즉시 깨어나서 예외 받고 종료.
    • Running 상태(CPU 코드 실행 중): 다음 suspend 지점에서 종료.
  6. 자식 Job들이 모두 Cancelled 상태로 변경.
  7. 자식이 모두 끝나면 부모 Job도 Cancelled가 된다.

7. 협력적 취소

앞에서 "Running 상태면 다음 suspend 지점에서 종료"라고 했는데, 이게 코루틴 취소의 핵심이다.

코루틴 취소는 OS가 강제하는 게 아니라, 코루틴이 협력해야만 일어난다.

scope.launch {
    println("A")
    delay(1000)        // ← suspend 지점 ①
    println("B")
    heavyMath()        // ← suspend 함수 아님 (CPU 작업)
    delay(500)         // ← suspend 지점 ②
    println("C")
}

협력하는 두 주체는,

  1. cancel을 호출한 쪽 (예: viewModelScope.cancel() 부른 onCleared())
  2. 코루틴 자신 (실제로 일하는 코드 블록)

그래서 "협력적"이다. cancel을 요청해도 코루틴은 자기가 suspend된 시점에서만 요청을 받아들여 종료한다.

상태 모습 cancel 받을 때
Suspended delay(), IO 대기 등으로 멈춤 즉시 깨어나 CancellationException 받고 종료
Running CPU 코드 실행 중 다음 suspend 지점까지 안 죽음

그래서 suspend 지점이 없는 무한 루프는 cancel해도 안 죽는다.

scope.launch {
    while (true) {
        someHeavyComputation()   // suspend 지점 없음
    }
}
job.cancel()   // 효과 없음, 무한 루프 그대로

이런 경우엔 직접 협력해야 한다.

scope.launch {
    while (isActive) {           // 상태 직접 체크
        someHeavyComputation()
        yield()                  // 또는 suspend 함수 호출
    }
}

isActive, yield(), ensureActive() 같은 도구로 코루틴이 자기 상태를 직접 확인할 수 있다.

8. Continuation: 코루틴의 책갈피

이제 더 깊이 들어가보자.

delay(1000)에서 코루틴이 "자고 있다"고 한다. 자고 있다는 게 정확히 뭘까? 1초 후엔 누가 깨워줄까? 그 코루틴은 누가 들고 있는가?

이 질문의 답이 Continuation이다.

Continuation = "코루틴이 멈춘 지점부터 끝까지 남은 일"을 통째로 들고 있는 객체.

코루틴이 suspend될 때 컴파일러는 자동으로 책갈피(Continuation)를 만든다. 누군가 이 책갈피를 손에 들고 있다가 resume()을 호출하면, 코루틴이 멈춘 곳부터 다시 실행된다.

컴파일러가 하는 일

다음과 같은 코루틴이 있다고 하자.

viewModelScope.launch {
    println("A")
    delay(1000)
    println("B")
    delay(500)
    println("C")
}

컴파일러는 이 코드를 상태 머신으로 변환한다. 진짜 변환은 더 복잡하지만, 아이디어는 이렇다.

// 의사 코드
val continuation = object : Continuation<Unit> {
    var state = 0

    fun resumeWith(result: Any) {
        when (state) {
            0 -> {
                println("A")
                state = 1
                delay(1000, this)    // ← this = 자기 자신을 넘김
                return                // ← 함수 빠져나감 (= "멈춤")
            }
            1 -> {
                println("B")
                state = 2
                delay(500, this)
                return
            }
            2 -> {
                println("C")
            }
        }
    }
}
continuation.resumeWith(...)  // 시작
  • state 변수가 "지금 어디까지 했는지"를 기억한다.
  • delay를 호출하면서 자기 자신(this)을 넘긴다. 그래야 나중에 누군가 깨워줄 수 있다.
  • 함수에서 return으로 빠져나가는 게 곧 "멈춤"이다.

그래서 다음 질문의 답이 자연스럽게 나온다.

Q1. 자고 있다는 게 정확히 뭔가?

→ 코루틴 함수가 Continuation 객체를 만들어 외부에 넘기고 return해버린 상태. 함수는 실제로 끝났고, 다시 실행될 수 있는 "책갈피"만 남아있다.

Q2. 1초 후엔 누가 깨워주는가?

현재 디스패처에 딸린 스케줄러가 깨운다. delay(1000)는 자기가 시간을 재지 않고, 현재 코루틴 컨텍스트에서 디스패처를 꺼내 "1초 뒤에 이 Continuation을 깨워줘"라고 등록만 한다. 시간이 되면 디스패처가 자기 스레드풀에 continuation.resume(Unit) 작업을 던진다.

디스패처 깨우는 방법
Dispatchers.Main Android Handler의 postDelayed (Looper 이용)
Dispatchers.IO / Default 풀 내부의 시간 스케줄러

9. suspendCancellableCoroutine: 콜백을 코루틴으로 다리 놓기

이제 본론이다. Retrofit이 코루틴을 지원하지 않던 시절, 또는 Firebase 같은 콜백 기반 라이브러리를 코루틴 함수로 감싸야 할 때 쓰는 빌더가 suspendCancellableCoroutine이다.

suspend fun findAll(): ProductResponse = suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation { call.cancel() }
    call.enqueue(object : Callback<ProductResponse> {
        override fun onResponse(call: Call<ProductResponse>, response: Response<ProductResponse>) {
            continuation.resume(response.body()!!)
        }
        override fun onFailure(call: Call<ProductResponse>, t: Throwable) {
            continuation.resumeWithException(t)
        }
    })
}

한 줄 정의는 이렇다.

suspendCancellableCoroutine = 콜백 기반 비동기 호출을 suspend 함수로 감싸는 빌더. 람다 인자로 CancellableContinuation을 받아서, 콜백이 호출될 때 resume / resumeWithException으로 코루틴을 깨운다. suspendCoroutine과 달리 invokeOnCancellation을 등록할 수 있어, 코루틴 취소를 외부 콜백 세계로 전달할 수 있다.

동작 흐름

  1. findAll() 호출자(코루틴)는 멈춰서 Continuation을 보관한다.
  2. call.enqueue(...)로 OkHttp가 자기 스레드풀에서 네트워크 요청을 시작한다.
  3. 응답이 오면 → continuation.resume(response.body()!!) → 멈췄던 곳에서 값을 받아 계속 실행.
  4. 에러가 나면 → continuation.resumeWithException(t) → 멈췄던 곳에서 예외가 throw됨.

invokeOnCancellation이 진짜 핵심

suspendCoroutine이 아니라 suspendCancellableCoroutine을 쓰는 이유는 단 하나, 취소를 외부 콜백 세계로 전달할 수 있어서다.

생각해보자. enqueue로 시작한 네트워크 요청은 OkHttp의 스레드풀에서 돌고 있다. 호출자 코루틴이 cancel되어도, OkHttp는 그 사실을 알 방법이 없다. 사용자가 화면을 떠나서 ViewModel이 cleared되어도, 백그라운드에선 여전히 네트워크 요청이 계속된다. 응답이 오면 continuation.resume(...)을 호출하려고 하지만 이미 코루틴은 죽어있다.

그래서 다리가 필요하다.

continuation.invokeOnCancellation { call.cancel() }

이 한 줄이 "코루틴이 cancel되면 OkHttp의 call 객체도 같이 cancel해라"라는 신호를 등록한다. 흐름은 이렇다.

코루틴 cancel
    ↓
invokeOnCancellation 람다 발동
    ↓
call.cancel() 호출
    ↓
OkHttp가 "이 요청 취소됐다" 인식
    ↓
진행 중이던 HTTP 요청 끊고 자원 정리

결과적으로, 사용자가 화면을 떠나는 순간 진행 중이던 네트워크 요청도 정리된다. 네트워크, 대기, 응답 처리에 들어갈 자원을 낭비하지 않는다.

비교: execute는 왜 이런 처리가 필요 없을까

suspend fun findById(id: Int): ProductResponse = withContext(Dispatchers.IO) {
    productService.findById(id).execute().body()!!
}

execute동기 호출이라 호출한 스레드를 직접 점유한다. 코루틴이 cancel되면 그 스레드의 코드 실행 자체가 다음 suspend 지점에서 멈춘다(여기서는 withContext 경계). 다만 이미 진행 중이던 네트워크 요청은 끊을 수 없어서 응답이 와도 그 결과만 버려진다. runInterruptible을 쓰면 진행 중인 작업까지 인터럽트할 수 있다.

suspend fun findById(id: Int): ProductResponse = withContext(Dispatchers.IO) {
    runInterruptible {
        productService.findById(id).execute().body()!!
    }
}

10. 정리

  • ANR 방지를 위해 비동기 작업은 메인 스레드와 분리해야 한다.
  • 콜백은 가장 단순한 답이지만 중첩에 약하다.
  • 코루틴은 비동기 코드를 동기처럼 쓰게 해준다. 스레드 위에서 도는 가벼운 작업 단위다.
  • 디스패처가 코루틴이 어느 스레드에서 실행될지 결정한다. Main-safety는 호출자가 메인 스레드여도 막히지 않는다는 원칙이고, 그 책임은 보통 데이터 소스 쪽이 진다.
  • CoroutineScope는 코루틴들을 묶어두는 그릇이고, Job은 코루틴을 조작하는 핸들이다. Scope가 죽으면 자식 Job들도 모두 cancel된다.
  • 협력적 취소 - 취소는 코루틴이 suspend 지점을 만나야 일어난다. CPU 작업만 있는 코루틴은 isActive / yield()로 직접 협력해야 한다.
  • Continuation은 코루틴이 멈춘 지점부터 끝까지를 들고 있는 책갈피다. 컴파일러가 만든 상태 머신의 상태가 여기 들어있다. 디스패처에 딸린 스케줄러가 적절한 시점에 이 책갈피로 코루틴을 깨운다.
  • suspendCancellableCoroutine은 콜백 세계를 코루틴 세계로 잇는 빌더다. 핵심은 invokeOnCancellation으로 코루틴 취소를 외부 자원 정리로 전달할 수 있다는 점이다.

미션 PR 리뷰에서 suspendCancellableCoroutine를 알고 썼냐는 질문에 여기까지 작성해버렸다... 덕분에 코루틴에 대해서 많이 알게 되었다!!!

댓글

홈으로 돌아가기

검색 결과

"search" 검색 결과입니다.