코루틴이란 무엇인가 — 스레드와 무엇이 다른가
코루틴을 언어에 종속되지 않은 공통 개념으로 정리한다. 일반 함수와의 차이(suspend/resume), 스레드와의 차이(cooperative vs preemptive), 구현 방식의 갈림길(stackful vs stackless)을 짚고, Go·Rust·C++·Python·Kotlin이 이 개념을 각각 어떻게 구현했는지 한눈에 대응시킨다.
난이도 중급 · 선행 없음 (스레드를 들어봤으면 충분)
한 줄 요약
코루틴은 실행 도중 멈췄다가(suspend) 나중에 그 지점부터 다시 이어서 실행(resume)할 수 있는 함수다. Go의 goroutine, Rust·C++·Python·Kotlin의 async가 전부 이 하나의 개념을 각자 구현한 것이다.
흔한 오해부터 — 코루틴은 특정 언어 것이 아니다
“코루틴은 Kotlin 개념 아니야?”라는 오해가 흔하다. Kotlin이 이름을 전면에 내세워 마케팅했기 때문인데, 사실 코루틴은 1963년 Melvin Conway가 명명한, 현존하는 거의 모든 언어보다 먼저 나온 개념이다.
Kotlin은 오래된 개념의 구현체 하나일 뿐이다. “람다는 Java 8 개념 아니야?”라는 오해와 똑같다 — 람다도 1930년대 람다 대수에서 왔고 Java는 한참 뒤에 받아들였다. 그래서 이 글은 특정 언어가 아니라 개념 자체를 정리하고, 언어별 구현은 뒤에서 대응만 시킨다.
1. 일반 함수 vs 코루틴 — suspend / resume
일반 함수는 호출되면 끝까지 실행되고 반환하면 끝이다. 반환하는 순간 그 함수의 지역 변수와 실행 위치는 전부 사라진다.
1
2
일반 함수: 호출 → 실행 ─────────────▶ return (끝, 상태 소멸)
코루틴: 호출 → 실행 → suspend(상태 보존) ⇢ resume → 실행 → ... → return
코루틴은 중간에 “잠깐 멈추고 제어권을 넘긴 뒤, 다시 불리면 멈춘 그 줄부터” 재개한다. 즉 함수의 실행 상태(지역 변수 + 실행 위치)가 호출 사이에 보존된다.
1
2
3
4
5
6
7
8
9
10
11
# Python generator — 코루틴의 가장 익숙한 형태
def numbers():
n = 0
while True:
yield n # ★ 여기서 멈추고 값을 반환. 다음 호출 때 이 줄부터 재개
n += 1
gen = numbers()
next(gen) # 0
next(gen) # 1 ← n이 살아있다. 함수를 처음부터 다시 도는 게 아니다
next(gen) # 2
yield에서 함수가 얼어붙었다가, 다음 next()에서 그 지점부터 깨어난다. n이 매번 유지되는 게 핵심이다. 일반 함수라면 매 호출마다 n = 0부터 다시 시작했을 것이다.
2. 스레드 vs 코루틴 — cooperative vs preemptive
“둘 다 여러 작업을 동시에 돌리는 거 아니야?”에서 갈린다. 결정적 차이는 누가 언제 전환을 결정하느냐다.
| 스레드 | 코루틴 | |
|---|---|---|
| 전환 방식 | 선점형(preemptive) — OS가 임의 시점에 강제 전환 | 협력형(cooperative) — 코루틴이 yield/await에서 자발적으로 양보 |
| 전환 비용 | 비쌈 (커널 개입, 컨텍스트 스위치) | 쌈 (커널 없이 함수 호출 수준) |
| 개수 | 수백~수천이 한계 | 수십만 개도 가능 |
| 데이터 레이스 | 언제든 끊길 수 있어 락 필요 | 양보 지점 사이는 원자적 → 단일 스레드면 락 불필요 |
| 함정 | 락·데드락 | 하나가 양보 안 하면 전부 멈춤 |
핵심은 양보 지점이 코드에 명시적으로 보인다는 것이다. 스레드는 어느 줄에서든 끊길 수 있어 공유 데이터에 락을 둘러야 하지만, 코루틴은 yield/await이 아닌 구간은 통째로 원자적이다. 대신 어떤 코루틴이 CPU를 오래 붙잡고 양보하지 않으면 같은 스레드의 다른 코루틴이 전부 굶는다.
스레드와 코루틴은 대립하지 않는다. 실전에서는 소수의 스레드 위에 수많은 코루틴을 얹는(M:N) 구조가 흔하다. Go 런타임이 정확히 이렇게 동작한다.
3. 구현의 갈림길 — stackful vs stackless
같은 코루틴이라도 “멈춘 상태를 어떻게 저장하느냐”에서 두 갈래로 나뉜다. 언어별 특성과 제약이 대부분 여기서 갈리므로 이해해 둘 가치가 있다.
Stackful — 자기 스택을 통째로 가진다
각 코루틴이 독립된 스택을 하나씩 갖는다. 그래서 아무리 깊이 중첩된 함수 호출 안에서도 멈출 수 있다.
1
coroutine A의 스택: main() → parse() → readToken() ← 여기서 suspend 가능
- 장점: 어디서든 멈출 수 있어 직관적. 일반 함수와 코루틴을 문법적으로 구분할 필요가 거의 없다.
- 단점: 코루틴마다 스택 메모리가 필요해 상대적으로 무겁다.
- 예: Lua 코루틴, Go goroutine, Boost.Coroutine
Stackless — 상태 기계로 변환된다
자기 스택이 없다. 대신 컴파일러가 코루틴 함수를 상태 기계(state machine)로 변환해, 멈출 때 필요한 지역 변수만 힙의 작은 객체에 담아둔다.
- 장점: 메모리가 매우 작다. 수백만 개를 띄워도 부담이 적다.
- 단점: 코루틴 함수 자신의 최상위에서만 멈출 수 있다. 중첩 호출된 일반 함수 안에서는 못 멈춘다. 그래서
async가 호출 사슬을 타고 전파되는 “함수 색칠(function coloring)” 문제가 생긴다 — async 함수를 부르려면 나도 async여야 한다. - 예: Rust
async, C++20 코루틴, Pythonasync, Kotlinsuspend, JavaScriptasync
| Stackful | Stackless | |
|---|---|---|
| 멈출 수 있는 위치 | 중첩된 어디서든 | 코루틴 함수 최상위만 |
| 메모리 | 스택만큼 (상대적으로 큼) | 필요한 변수만 (작음) |
| async 전파(색칠) | 없음 | 있음 (async가 번짐) |
| 대표 | Go, Lua | Rust, C++20, Python, Kotlin, JS |
4. 언어별 구현 한눈에
같은 개념이 언어마다 어떻게 나타나는지 대응시킨 표다. 세부는 각 언어 로드맵에서 다룬다.
| 언어 | 문법 | 방식 | 특징 |
|---|---|---|---|
| Go | go f() + channel | stackful (M:N 스케줄) | 런타임이 스케줄링. 가장 가볍게 시작 |
| Rust | async / .await | stackless | 실행기(tokio 등)가 필요. .await가 suspend 지점 |
| C++20 | co_await / co_yield / co_return | stackless | 컴파일러가 상태 기계로 변환. 저수준·복잡 |
| Python | yield, async / await | stackless | generator가 입문용으로 가장 쉬움 |
| Kotlin | suspend fun | stackless | continuation-passing으로 변환. Android 표준 |
| Lua | coroutine.create/resume/yield | stackful | 코루틴을 언어 핵심으로 삼은 고전 |
Go의 goroutine은 정확히 코루틴인가?
엄밀히는 코루틴 아이디어 + 런타임 스케줄러라서 “그린 스레드(green thread)”에 더 가깝다. 순수 코루틴은 개발자가 resume을 직접 호출해 제어권을 넘기지만, goroutine은 Go 런타임이 알아서 스케줄링하고(오래 도는 goroutine은 런타임이 선점하기도 한다) 서로 channel로 통신한다. 그래도 “경량 실행 단위를 스레드보다 값싸게 수만 개 돌린다”는 목적은 코루틴의 것과 같다.
Go 상세: Go 학습 로드맵 — ⑤ 동시성
코루틴 ≠ 클로저 (자주 같이 나오는 이유)
둘 다 “호출이 끝나도 상태가 살아남는다”는 공통점 때문에 헷갈리지만, 무엇을 보존하는지가 다르다.
| 클로저 | 코루틴 | |
|---|---|---|
| 보존하는 것 | 캡처한 변수 | 실행 위치 + 지역 상태 전부 |
| 재개(resume) 개념 | 없음 (매번 처음부터 실행) | 있음 (멈춘 지점부터 재개) |
클로저로 “다음 값을 뱉는 함수”를 만들면 generator처럼 보이지만, 함수를 매번 처음부터 다시 도는 것이라 중간에서 멈췄다 재개하는 진짜 코루틴이 아니다. → 클로저란 무엇인가
스스로 점검
1. 일반 함수와 코루틴의 결정적 차이 한 가지는?
답
코루틴은 suspend/resume가 된다. 실행 도중 멈췄다가 그 지점부터 재개할 수 있고, 그 사이 지역 변수와 실행 위치가 보존된다. 일반 함수는 반환하면 상태가 사라져 매 호출이 처음부터 시작한다.
2. 코루틴이 스레드보다 데이터 레이스에 강한 이유는?
답
전환이 협력형(cooperative)이라 yield/await이 아닌 구간은 통째로 원자적이다. OS가 임의 시점에 끊는 스레드와 달리, 양보 지점이 코드에 명시적으로 보이므로 단일 스레드에서는 그 사이에 락이 필요 없다. 단, 하나가 양보하지 않으면 나머지가 전부 굶는다.
3. Rust async가 중첩 함수 안에서는 못 멈추고, Go goroutine은 어디서든 멈출 수 있는 이유는?
답
Rust는 stackless라 코루틴 함수를 상태 기계로 변환하고, 자기 스택이 없어 최상위의 .await에서만 멈춘다(그래서 async가 호출 사슬을 타고 번진다 — function coloring). Go goroutine은 stackful이라 자기 스택을 통째로 가지므로 깊이 중첩된 호출 안에서도 멈출 수 있다.