한 줄 정의
Kotlin 컴파일러가 suspend 함수를 CPS(Continuation-Passing Style)로 변환하여 구현하는 경량 동시성 메커니즘. 스레드를 블로킹하지 않고 실행을 중단/재개할 수 있다.
쉽게 말하면
일반 함수는 “전화 통화”와 같습니다. 한번 시작하면 통화가 끝날 때까지 전화기(스레드)를 계속 붙잡고 있어야 하죠.
반면 Coroutine의 suspend 함수는 “카카오톡 대화”에 가깝습니다. 메시지를 보내놓고(I/O 요청) 답이 올 때까지 폰(스레드)을 내려놓은 채 다른 일을 하다가, 답이 도착하면 다시 대화를 이어가는 방식입니다. 폰은 하나뿐이지만 카톡방은 수만 개를 동시에 열어둘 수 있는 것처럼, 스레드 하나로도 수많은 코루틴을 동시에 처리할 수 있습니다.
왜 이걸 알아야 하는가?
- Kotlin 서버 개발의 표준 동시성 모델: Spring WebFlux + Kotlin, Ktor 등 Kotlin 기반 서버에서 Coroutine은 사실상 기본이기 때문에, suspend 함수를 이해하지 못하면 코드를 읽는 것 자체가 어렵습니다.
- Structured Concurrency로 리소스 누수 방지: 스레드를 spawn하고 잊어버리는 fire-and-forget 패턴은 리소스 누수의 대표적인 원인입니다. Coroutine의 CoroutineScope/Job 계층 구조를 이해하면 “부모가 취소되면 자식도 자동 취소”라는 보장을 활용하여 안전한 동시성 코드를 작성할 수 있습니다.
- Dispatcher 선택이 성능을 결정:
CPU-bound 작업을
Dispatchers.IO에서 돌리거나, 블로킹 JDBC를Dispatchers.Default에서 호출하면 성능이 크게 떨어집니다. 따라서 각 Dispatcher의 스레드 풀 구조와 용도를 알아야 올바른 선택이 가능합니다.
왜 이렇게 설계했는가?
- 해결하는 문제: 비동기 코드를 동기 코드처럼 읽기 쉽게 작성하고 싶다는 것이 핵심 동기이며, 콜백 지옥(Callback Hell)과 리액티브 체인의 가독성 문제를 해결합니다.
- 이게 없다면:
- 콜백 방식: 중첩이 깊어지고 에러 핸들링이 산발적이라 코드 흐름을 따라가기 어렵습니다.
- 리액티브 (RxJava, Reactor): 체이닝 덕분에 콜백보다는 낫지만,
.flatMap,.switchMap등 연산자를 외워야 하고 스택 트레이스도 끊깁니다. - Virtual Thread: 블로킹 코드 스타일로 작성할 수 있지만, Structured Concurrency가 아직 미성숙(JEP 462 프리뷰)하고 Java에서만 사용 가능합니다.
- Coroutine의 접근: 컴파일러가 코드를 변환하여 “동기처럼 보이지만 비동기로 동작”하게 만들고, 언어 레벨에서 Structured Concurrency를 강제함으로써 생명주기 관리까지 자동화합니다.
어떻게 동작하는가?
컴파일러 변환: CPS (Continuation-Passing Style)
suspend 키워드가 붙은 함수를 Kotlin 컴파일러가 **상태 머신(state machine)**으로 변환하는 것이 Coroutine의 핵심입니다.
변환 전 (개발자가 작성하는 코드):
suspend fun fetchUserData(userId: String): UserData {
val user = userApi.getUser(userId) // suspend point 1
val orders = orderApi.getOrders(user.id) // suspend point 2
return UserData(user, orders)
}변환 후 (컴파일러가 생성하는 바이트코드의 논리적 구조):
// 실제로는 바이트코드 레벨에서 생성됨. 개념적 표현.
fun fetchUserData(userId: String, continuation: Continuation<UserData>): Any? {
val sm = continuation as? FetchUserDataSM ?: FetchUserDataSM(continuation)
when (sm.label) {
0 -> {
sm.label = 1
val result = userApi.getUser(userId, sm) // Continuation 전달
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
1 -> {
sm.user = sm.result as User
sm.label = 2
val result = orderApi.getOrders(sm.user.id, sm)
if (result == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
}
2 -> {
sm.orders = sm.result as List<Order>
return UserData(sm.user, sm.orders)
}
}
}핵심 포인트:
- 각 suspend point가
when의 분기(label)가 되며, 함수 하나가 여러 번 호출될 수 있는 상태 머신으로 변환됩니다. - 중간 상태(지역변수 값)는 Continuation 객체의 필드에 저장되므로, Heap에 저장되어 스레드 스택이 필요 없습니다.
COROUTINE_SUSPENDED를 리턴하면 호출 스택이 풀리면서 스레드가 해방되고, 이후 I/O가 완료되면 Continuation의resumeWith()가 호출되어 다음 label부터 이어서 실행됩니다.
CoroutineDispatcher — 어디서 실행할 것인가
Coroutine은 어떤 스레드에서 실행될지를 Dispatcher가 결정합니다.
| Dispatcher | 스레드 풀 | 용도 | 사이즈 |
|---|---|---|---|
Dispatchers.Default | 공유 스레드 풀 | CPU-bound 연산 | 코어 수 (최소 2) |
Dispatchers.IO | 별도 스레드 풀 | 블로킹 I/O | 기본 64, 필요시 확장 |
Dispatchers.Main | 메인 스레드 | Android UI 업데이트 | 1 |
Dispatchers.Unconfined | 호출자 스레드 | 테스트, 특수 용도 | 없음 |
Dispatchers.Default vs IO의 분리가 중요한 이유: Default는 코어 수만큼의 스레드만 갖고 있어서, 여기서 블로킹 I/O를 수행하면 코어를 점유하여 CPU-bound 작업이 밀리게 됩니다. 반대로 IO Dispatcher에서 CPU-bound 작업을 돌리면 불필요하게 많은 스레드가 생성됩니다. 따라서 작업 특성에 맞는 Dispatcher를 선택하는 것이 중요합니다.
withContext로 Dispatcher 전환:
suspend fun processImage(url: String): Bitmap {
val data = withContext(Dispatchers.IO) { // I/O 스레드에서 다운로드
httpClient.download(url)
}
return withContext(Dispatchers.Default) { // CPU 스레드에서 디코딩
decodeImage(data)
}
}Structured Concurrency — Coroutine의 핵심 차별점
Structured Concurrency는 “모든 Coroutine은 부모 CoroutineScope 안에서 실행되어야 한다”는 원칙으로, Coroutine을 단순한 경량 스레드가 아닌 구조화된 동시성 프레임워크로 만들어주는 핵심 차별점입니다.
원칙:
- 생명주기 바운딩: 자식 Coroutine은 부모 Scope가 끝나기 전에 반드시 완료되어야 하므로, “실행하고 잊어버리는(fire-and-forget)” 패턴이 원천적으로 불가능합니다.
- 취소 전파: 부모가 취소되면 모든 자식도 자동으로 취소되며, 자식 하나가 예외를 던지면 형제들도 함께 취소됩니다.
- 에러 전파: 자식의 예외가 부모로 전파되기 때문에, try-catch로 일관되게 처리할 수 있습니다.
// 구조화된 동시성 예시
suspend fun loadDashboard(): Dashboard = coroutineScope {
val userDeferred = async { userApi.getUser() } // 병렬 실행 1
val ordersDeferred = async { orderApi.getOrders() } // 병렬 실행 2
val statsDeferred = async { statsApi.getStats() } // 병렬 실행 3
// 하나라도 실패하면 나머지도 자동 취소
Dashboard(
user = userDeferred.await(),
orders = ordersDeferred.await(),
stats = statsDeferred.await()
)
}
// coroutineScope 블록이 끝나면 3개의 자식이 모두 완료된 것이 보장됨Structured Concurrency가 없다면:
스레드 3개를 spawn한 뒤 하나가 실패했을 때 나머지 2개를 수동으로 interrupt/cancel해야 하는데, 이를 빼먹으면 곧바로 리소스 누수로 이어집니다.
실제로 Java의 ExecutorService에서 이런 실수가 매우 흔합니다.
취소 메커니즘 — CancellationException
Coroutine의 취소는 **협력적(cooperative)**입니다. cancel()을 호출하더라도 Coroutine이 즉시 멈추는 것이 아니라, 다음 suspend point에서 CancellationException이 발생하면서 비로소 중단됩니다.
isActive 체크가 필요한 경우: CPU-bound 루프에서는 suspend point가 없으므로 취소가 전달되지 않습니다. 이런 경우 명시적인 체크가 필요합니다.
suspend fun heavyComputation(data: List<Int>) = coroutineScope {
for (item in data) {
ensureActive() // 취소 확인. 없으면 취소 불가능한 좀비 Coroutine이 됨
process(item)
}
}이것은 Virtual Thread의 비선점 스케줄링과 같은 맥락입니다 — 둘 다 자발적 양보 지점이 있어야 동작합니다.
Job 계층 구조
모든 Coroutine은 Job 객체를 가지며, 이 Job들이 부모-자식 트리를 형성합니다.
graph TD Parent["Parent Job (CoroutineScope)"] Parent --> Child1["Child Job 1 (async)"] Parent --> Child2["Child Job 2 (async)"] Child2 --> GrandChild1["GrandChild Job (launch)"] Parent -->|"취소 시"| Child1 Parent -->|"취소 시"| Child2 Child2 -->|"취소 시"| GrandChild1
- 부모 Job이 취소되면 모든 자식이 재귀적으로 취소됩니다.
- 자식 Job이 실패하면 부모에게 전파되고, 형제도 함께 취소됩니다 (SupervisorJob 제외).
SupervisorJob은 자식의 실패가 형제에게 전파되지 않으므로, 독립적인 작업들을 병렬로 돌릴 때 사용합니다.
시각화
Coroutine suspend/resume 흐름
sequenceDiagram participant C as Coroutine participant D as Dispatcher participant T as 스레드 풀 participant IO as I/O D->>T: Coroutine을 스레드에 배정 T->>C: 코드 실행 C->>IO: suspend point (API 호출) C-->>T: COROUTINE_SUSPENDED 리턴 → 스레드 반환 Note over T: 다른 Coroutine 실행 가능 IO-->>D: I/O 완료 D->>T: Coroutine을 아무 스레드에 재배정 T->>C: resume → 다음 label부터 이어서 실행
CPS 변환 상태 머신
stateDiagram-v2 [*] --> Label0: 함수 최초 호출 Label0 --> Suspended1: suspend point 1 (API 호출) Suspended1 --> Label1: resume (결과 전달) Label1 --> Suspended2: suspend point 2 (API 호출) Suspended2 --> Label2: resume (결과 전달) Label2 --> [*]: 최종 결과 리턴
정리
| 기준 | Kotlin Coroutine | Java Virtual Thread | 리액티브 (Reactor/RxJava) |
|---|---|---|---|
| 구현 레벨 | 컴파일러 (CPS 변환) | JVM (Continuation) | 라이브러리 (연산자 체인) |
| 코드 스타일 | suspend 함수 (순차적) | 블로킹 (순차적) | 체이닝 (.flatMap 등) |
| 스레드 모델 | Dispatcher 기반 배정 | 캐리어 스레드 M:N | 이벤트 루프 (소수 스레드) |
| Structured Concurrency | 성숙 (CoroutineScope, Job) | 프리뷰 (StructuredTaskScope) | 없음 (Disposable 수동 관리) |
| 취소 메커니즘 | CancellationException 전파 | interrupt() | Disposable.dispose() |
| 기존 블로킹 코드 호환 | withContext(IO) 필요 | 대부분 자동 호환 | 별도 래퍼 필요 |
| 백프레셔 | Flow (cold stream) | 없음 (별도 구현 필요) | 내장 (Flux, Observable) |
| 스택 트레이스 | 일부 손실 가능 (설정으로 개선) | 완전 유지 | 거의 완전 손실 |
| 학습 곡선 | 중간 (suspend 개념) | 낮음 (기존 코드 유지) | 높음 (연산자, 스케줄러) |
실무에서 만난 사례
- Spring WebFlux + Kotlin Coroutine:
WebFlux의
Mono/Flux대신 suspend 함수로 핸들러를 작성할 수 있으며, 내부적으로mono { }등의 브릿지 함수가 Coroutine ↔ Reactor 간 변환을 담당합니다. 이를 통해 리액티브의 성능과 Coroutine의 가독성을 동시에 얻을 수 있습니다. - Ktor: 처음부터 Coroutine 기반으로 설계된 서버 프레임워크로, 모든 핸들러가 suspend 함수입니다. Structured Concurrency 덕분에 요청 스코프 안에서 자식 Coroutine의 생명주기가 자동으로 관리됩니다.
- Android:
viewModelScope,lifecycleScope를 통해 UI 생명주기와 Coroutine 생명주기를 바인딩할 수 있습니다. Activity가 종료되면 진행 중인 네트워크 요청도 자동으로 취소되는데, 이것이 Structured Concurrency의 대표적인 실용 사례입니다. - 블로킹 라이브러리 호출 주의:
JDBC는 블로킹 API이므로 반드시
withContext(Dispatchers.IO)안에서 호출해야 합니다. Default Dispatcher에서 JDBC를 호출하면 CPU 스레드가 I/O 대기에 묶여 다른 Coroutine이 실행되지 못하는 문제가 발생합니다.
관련 개념
출처
- Roman Elizarov, “Structured Concurrency” (KotlinConf 2019)
- Kotlin 공식 문서: Coroutines Guide
- KotlinConf 2023: “Coroutines Under the Hood”
- JetBrains Blog: “How Kotlin Coroutine suspend functions work”