C++20 Coroutine | co_await·co_yield로 "콜백 지옥" 탈출하기
이 글의 핵심
C++20 Coroutine에 대한 실전 가이드입니다. co_await·co_yield로 등을 예제와 함께 상세히 설명합니다.
들어가며: “값을 하나씩 만들어 주는 함수가 싶어요”
일시 정지와 재개
한 번에 전부 계산하지 않고, 호출할 때마다 다음 값 하나만 만들어 주는 함수가 필요했습니다. 또는 I/O 대기 중에 스레드를 블로킹하지 않고 나중에 다시 이어서 실행하고 싶었습니다. 코루틴(coroutine—실행을 중단했다가 나중에 같은 지점부터 다시 이어갈 수 있는 함수)은 그런 “일시 정지/재개”를 언어가 지원하는 기능입니다.
목표:
- co_yield: 값을 하나 내보내고 일시 정지 (제너레이터)
- co_await: 비동기 연산이 끝날 때까지 일시 정지
- co_return: 코루틴 종료 및 반환값 전달
- 코루틴이 어떤 타입으로 동작하는지 이해
co_yield는 제너레이터(호출할 때마다 다음 값 하나만 산출하는 lazy 시퀀스), co_await는 비동기 대기를 표현할 때 쓰며, 콜백 지옥 없이 순차적인 코드처럼 작성할 수 있습니다. C++20에서는 프레임워크(promise_type, awaitable)를 직접 설계해야 해서 학습 부담이 있지만, 한 번 이해하면 비동기·스트리밍 코드 구조화에 유용합니다.
스레드와의 차이: 코루틴은 스레드가 아닙니다. 스레드는 OS가 스케줄하는 실행 단위이고, 코루틴은 하나의 스레드 안에서 “잠깐 멈추었다가 이어서 실행”하는 흐름입니다. 그래서 코루틴 여러 개가 있어도 동시에 실행되는 건 한 번에 하나이고, 락을 걸 필요가 없는 구조로 설계할 수 있습니다.
컴파일: C++20 코루틴을 쓰므로 g++ -std=c++20(또는 clang++ -std=c++20)으로 빌드하면 됩니다.
이 글을 읽으면:
- 코루틴의 기본 용어(promise, awaiter, handle)를 알 수 있습니다.
co_yield로 값을 하나씩 만드는 흐름을 이해할 수 있습니다.co_await로 “완료될 때까지 대기” 패턴을 이해할 수 있습니다.promise_type과 코루틴 핸들의 수명을 이해할 수 있습니다.- 자주 발생하는 오류와 프로덕션 패턴을 익힐 수 있습니다.
목차
- 비동기 코드가 복잡해요
- 코루틴이란
- co_yield와 제너레이터
- co_await와 일시 정지
- co_return 완전 정리
- 완전한 코루틴 구현
- promise_type 상세 설명
- 코루틴 핸들과 수명
- 자주 발생하는 오류
- 모범 사례 (Best Practices)
- 성능 비교
- 프로덕션 패턴
- 실전 주의사항
1. 비동기 코드가 복잡해요
콜백 지옥(Callback Hell)
네트워크 요청, 파일 I/O, 타이머 같은 비동기 작업을 처리할 때, 전통적인 방식은 콜백을 사용하는 것입니다. 작업이 끝나면 콜백이 호출되고, 그 안에서 다음 작업을 시작하고… 이렇게 중첩되면 코드가 읽기 어려워집니다.
// ❌ 콜백 지옥: 가독성 저하, 에러 처리 복잡
void fetchUserData(const std::string& userId,
std::function<void(User)> onUser,
std::function<void(Error)> onError)
{
api.getUser(userId, [onUser, onError](User user) {
api.getOrders(user.id, [onUser, onError, user](Orders orders) {
api.getDetails(orders[0].id, [onUser, user, orders](Details details) {
// 3단계 중첩... 더 깊어지면?
onUser(mergeUserData(user, orders, details));
}, onError);
}, onError);
}, onError);
}
문제점:
- 가독성: 중첩이 깊어질수록 코드 추적이 어려움
- 에러 처리: 각 단계마다
onError전달 필요 - 제어 흐름: “순차적으로 실행”하고 싶은데 구조가 비순차적
- 취소/타임아웃: 중간에 중단하기 어려움
코루틴으로 해결
co_await를 사용하면 동기 코드처럼 순차적으로 작성하면서, 실제로는 비동기로 동작합니다.
// ✅ 코루틴: 순차적 코드, 에러 처리 단순
Task<UserData> fetchUserData(const std::string& userId) {
User user = co_await api.getUserAsync(userId);
Orders orders = co_await api.getOrdersAsync(user.id);
Details details = co_await api.getDetailsAsync(orders[0].id);
co_return mergeUserData(user, orders, details);
}
장점:
- 가독성: 위에서 아래로 읽히는 순차적 흐름
- 에러 처리:
try/catch로 일괄 처리 가능 - 확장성: 단계가 늘어나도 구조가 단순함
추가 문제 시나리오
시나리오 2: 대용량 파일 스트리밍
수 GB 크기의 로그 파일을 한 번에 메모리에 올리면 OOM(Out of Memory)이 발생합니다. 전통적인 콜백 방식은 “다음 청크 읽기 → 콜백 → 파싱 → 다음 청크 요청”의 중첩 구조가 되어 가독성이 떨어집니다. 제너레이터로 co_yield하면 “한 줄씩 넘겨주는” 순차 코드처럼 작성할 수 있습니다.
// ❌ 콜백: openFile → readChunk → parseLines → readChunk 중첩
void readLogFile(const std::string& path,
std::function<void(std::string)> onLine,
std::function<void(Error)> onError) {
openFile(path, [onLine, onError](File f) {
readChunk(f, [onLine, onError, f](Chunk c) {
for (auto& line : parseLines(c)) onLine(line);
readChunk(f, /* ... 또 중첩 ... */);
}, onError);
}, onError);
}
// ✅ 제너레이터: 순차적 한 줄씩
Generator<std::string> readLogLines(const std::string& path) {
std::ifstream f(path);
std::string line;
while (std::getline(f, line)) co_yield line;
}
시나리오 3: 게임 AI 상태 머신
NPC가 “대기 → 경로 탐색 → 이동 → 공격 → 대기” 같은 여러 단계를 거칠 때, 각 단계마다 “다음 프레임까지 대기”, “애니메이션 완료 대기”, “타겟 발견 대기”가 필요합니다. 콜백으로 구현하면 상태 전이 로직이 분산되고, co_await로 “다음 프레임까지”, “이벤트까지” 대기하면 상태 머신이 한 함수 안에 순차적으로 표현됩니다.
// ❌ 콜백: findPath( { moveAlong( { playAttack({...}); }); });
// ✅ 코루틴: co_await findPathAsync; co_await moveAlongAsync; co_await playAttackAsync;
시나리오 4: 웹소켓 메시지 처리
연결 수립 → 인증 → 메시지 수신 루프에서, 각 단계가 비동기입니다. 콜백 중첩 시 “연결 끊김 시 정리”, “타임아웃 처리”가 각 레벨에 흩어져 유지보수가 어렵습니다. 코루틴으로 작성하면 try/catch 한 곳에서 예외 처리와 정리가 가능합니다.
시나리오 5: 실시간 센서 데이터 파이프라인
센서에서 데이터가 스트리밍으로 들어올 때, 필터링 → 변환 → 집계를 파이프라인으로 연결하고 싶습니다. 제너레이터 체인(filter → map → batch)으로 표현하면 각 단계가 lazy하게 동작해 메모리 효율이 좋습니다.
2. 코루틴이란
일시 정지/재개
코루틴은 실행을 중단했다가 나중에 같은 지점부터 다시 시작할 수 있는 함수입니다.
- 일반 함수: 호출 → 반환 → 끝
- 코루틴: 호출 → (일시 정지) → 재개 → (일시 정지) → … → 반환
flowchart LR
subgraph normal["일반 함수"]
N1[호출] --> N2[실행] --> N3[반환] --> N4[끝]
end
subgraph coro["코루틴"]
C1[호출] --> C2[실행] --> C3[일시정지]
C3 --> C4[재개] --> C5[실행] --> C6[반환]
end
비유하면, 일반 함수는 “한 번 재생되면 끝까지 가는 영화”이고, 코루틴은 “일시 정지했다가 나중에 그 장면부터 다시 재생하는 영화”라고 보면 됩니다. 그래서 “값을 하나씩만 만들고 멈추는” 제너레이터나 “I/O가 끝날 때까지 기다렸다가 이어서 실행”하는 비동기 흐름을 표현하기에 적합합니다.
키워드
- co_yield expr
expr을 “현재 값”으로 내보내고, 호출자에게 제어를 넘긴다. 다음 호출 시 여기서부터 재개. - co_await expr
expr(awaitable)이 “완료”될 때까지 일시 정지. 완료되면 재개. - co_return
코루틴을 종료하고 (선택적으로) 반환값을 넘긴다.
코루틴인 함수
함수 본문에 co_await, co_yield, co_return 중 하나라도 있으면 그 함수는 코루틴이 됩니다.
count(n)은 0부터 n-1까지 값을 하나씩 내보내는 제너레이터입니다. co_yield i가 실행되면 i가 호출자에게 전달되고, 이 함수는 그 자리에서 일시 정지합니다. 호출자가 다음 값을 요청하면 co_yield 다음부터 실행이 이어져 i+1을 내보냅니다. 즉 100만 개를 한 번에 벡터에 채우지 않고, “필요할 때마다 하나씩” 만들어 줄 수 있어 메모리와 초기 계산을 줄일 수 있습니다. Generator<int>는 promise_type을 가진 사용자/라이브러리 타입이며, 다음 글(23-2)에서 구체적인 구현을 다룹니다.
#include <coroutine>
Generator<int> count(int n) {
for (int i = 0; i < n; ++i) {
co_yield i; // i를 넘기고 일시 정지
}
}
3. co_yield와 제너레이터
사용 예 (개념)
count(3)으로 만든 제너레이터 gen에 대해 next()를 호출할 때마다 0, 1, 2가 순서대로 나오고, 그 다음에는 “끝” 상태가 됩니다. 값을 미리 다 만들지 않으므로, count(1000000)처럼 큰 상한을 줘도 메모리는 “현재 값 하나” 정도만 쓰이고, 호출하는 쪽에서 next()를 몇 번만 쓰고 멈출 수 있어 lazy한 사용이 가능합니다. Generator<T>는 사용자/라이브러리가 promise_type을 정의해 구현합니다. 구체적인 Generator 구현은 다음 글(23-2)에서 다룹니다. 표준에는 std::generator(C++23)가 있지만, C++20만 쓰는 환경에서는 직접 간단한 제너레이터 타입을 만들거나 라이브러리(cppcoro 등)를 씁니다.
동작 요약
co_yield value- promise의
yield_value(value)호출 - 현재 값을 저장하고, 호출자에게 “값”을 넘김
suspend_always등을 반환하면 코루틴이 일시 정지
- promise의
co_yield 내부 동작
// co_yield value 는 대략 다음과 같이 변환됩니다:
// promise.yield_value(value);
// → yield_value가 suspend_always를 반환하면 일시 정지
// → 호출자가 handle.resume() 하면 yield_value 다음부터 재개
4. co_await와 일시 정지
Awaitable
co_await expr에서 expr은 awaitable이어야 합니다. 보통 다음 세 메서드를 가집니다.
- await_ready()
이미 완료됐으면true→ 일시 정지 안 함 - await_suspend(coroutine_handle)
일시 정지 시 호출. 여기서 다른 스레드/이벤트 루프에 핸들을 넘겨 “완료 시 재개” 예약 - await_resume()
재개 시 반환값 (결과)
호출 흐름: co_await expr를 만나면 먼저 await_ready()를 호출합니다. true면 일시 정지 없이 곧바로 await_resume()으로 결과를 받고, false면 await_suspend(handle)를 호출해 “나중에 이 handle로 재개해 달라”를 등록한 뒤 현재 코루틴은 일시 정지합니다. 재개될 때 await_resume()이 반환하는 값이 co_await 식의 결과가 됩니다.
sequenceDiagram
participant C as 코루틴
participant A as Awaitable
participant E as 이벤트루프
C->>A: await_ready()
alt 이미 완료
A-->>C: true
C->>A: await_resume()
A-->>C: 결과
else 대기 필요
A-->>C: false
C->>A: await_suspend(handle)
A->>E: handle 등록
Note over C: 일시 정지
E->>C: resume()
C->>A: await_resume()
A-->>C: 결과
end
단순 예 (개념)
struct Task {
struct promise_type { /* ... */ };
// ...
};
Task asyncRead() {
int value = co_await someAsyncOperation(); // 완료될 때까지 일시 정지
co_return value;
}
실제로는 <coroutine>에 정의된 std::suspend_always / std::suspend_never, 또는 라이브러리 제공 Task/Promise 타입을 쓰는 경우가 많습니다. 둘의 차이는 다음과 같습니다.
#include <coroutine>
// suspend_always: await_ready()가 false → 항상 일시 정지, 나중에 재개
// suspend_never: await_ready()가 true → 일시 정지하지 않고 곧바로 재개
// promise의 initial_suspend() 예시
std::suspend_always initial_suspend() { return {}; } // 첫 co_yield/co_await까지 대기
std::suspend_never initial_suspend() { return {}; } // 코루틴 진입 직후 실행
제너레이터의 initial_suspend()에서 suspend_always를 쓰면 “첫 값을 요청할 때까지” 코루틴이 멈춰 있고, suspend_never를 쓰면 진입 직후 실행이 진행됩니다.
5. co_return 완전 정리
co_return의 두 형태
- co_return expr;
promise_type::return_value(expr)호출. 반환값이 있는 코루틴. - co_return;
promise_type::return_void()호출. 반환값이 없는 코루틴.
주의: 한 코루틴에서 return_value와 return_void를 둘 다 제공하면 안 됩니다. 반환 타입에 맞게 하나만 구현합니다.
// 반환값 있는 코루틴
Task<int> compute() {
int result = 42;
co_return result; // return_value(result) 호출
}
// 반환값 없는 코루틴 (제너레이터 등)
Generator<int> count(int n) {
for (int i = 0; i < n; ++i)
co_yield i;
co_return; // return_void() 호출 (또는 생략 가능)
}
co_return과 final_suspend
co_return(또는 범위 끝 도달) 후 final_suspend()가 호출됩니다. suspend_always를 반환하면 코루틴이 “완료된 상태로 일시 정지”하여, 호출자가 handle.done()으로 종료 여부를 확인하거나 최종 결과를 꺼낼 수 있습니다. suspend_never를 반환하면 코루틴 프레임이 즉시 정리됩니다.
6. 완전한 코루틴 구현
제너레이터 (co_yield)
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
template <typename T>
class Generator {
public:
struct promise_type {
T current_value;
std::suspend_always yield_value(T value) {
current_value = std::move(value);
return {};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::rethrow_exception(std::current_exception()); }
Generator get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
};
struct iterator {
std::coroutine_handle<promise_type> handle;
bool done;
iterator(std::coroutine_handle<promise_type> h, bool d) : handle(h), done(d) {}
T operator*() const { return handle.promise().current_value; }
iterator& operator++() {
handle.resume();
done = handle.done();
return *this;
}
bool operator!=(const iterator& other) const {
return done != other.done;
}
};
explicit Generator(std::coroutine_handle<promise_type> h) : handle_(h) {}
~Generator() { if (handle_) handle_.destroy(); }
Generator(Generator const&) = delete;
Generator& operator=(Generator const&) = delete;
Generator(Generator&& other) noexcept : handle_(other.handle_) { other.handle_ = nullptr; }
Generator& operator=(Generator&& other) noexcept {
if (this != &other) {
if (handle_) handle_.destroy();
handle_ = other.handle_;
other.handle_ = nullptr;
}
return *this;
}
iterator begin() {
handle_.resume();
return iterator{handle_, handle_.done()};
}
iterator end() { return iterator{handle_, true}; }
private:
std::coroutine_handle<promise_type> handle_;
};
Generator<int> count(int n) {
for (int i = 0; i < n; ++i) {
co_yield i;
}
}
int main() {
for (int x : count(5)) {
std::cout << x << " "; // 0 1 2 3 4
}
}
비동기 Task (co_await, co_return)
#include <coroutine>
#include <exception>
#include <future>
#include <iostream>
template <typename T>
class Task {
public:
struct promise_type {
T value;
std::exception_ptr exception;
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
void return_value(T v) { value = std::move(v); }
void unhandled_exception() { exception = std::current_exception(); }
};
explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
~Task() { if (handle_) handle_.destroy(); }
T get() {
handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
return std::move(handle_.promise().value);
}
private:
std::coroutine_handle<promise_type> handle_;
};
// 단순 Awaitable 예시 (즉시 완료)
struct ImmediateAwaitable {
int value;
bool await_ready() const { return true; }
void await_suspend(std::coroutine_handle<>) {}
int await_resume() const { return value; }
};
Task<int> asyncCompute() {
int a = co_await ImmediateAwaitable{10};
int b = co_await ImmediateAwaitable{20};
co_return a + b; // 30
}
int main() {
auto task = asyncCompute();
std::cout << task.get() << "\n"; // 30
}
실전 예제: 에러 처리 포함 비동기 Task
실무에서는 예외를 저장하고 호출자가 get() 시 재전파하는 패턴이 자주 쓰입니다.
// 에러 처리 포함 Task - unhandled_exception에서 저장
// #include <optional> 필요
template <typename T>
class Task {
public:
struct promise_type {
std::optional<T> value;
std::exception_ptr exception;
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
void return_value(T v) { value = std::move(v); }
void unhandled_exception() { exception = std::current_exception(); }
};
T get() {
while (!handle_.done()) handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
return std::move(*handle_.promise().value);
}
// ... (생성자, 소멸자, 이동만)
};
실전 예제: 지연 완료 Awaitable (타임아웃 시뮬레이션)
await_ready()가 false를 반환하면 실제로 일시 정지합니다. 이벤트 루프나 스레드 풀에서 나중에 resume을 호출하는 패턴입니다.
// 나중에 완료되는 Awaitable - 이벤트 루프 연동 예시
struct DelayedAwaitable {
int result;
std::coroutine_handle<>* stored_handle = nullptr;
bool await_ready() const { return false; } // 항상 일시 정지
void await_suspend(std::coroutine_handle<> h) {
stored_handle = &h; // 이벤트 루프에 h 등록
}
int await_resume() const { return result; }
};
// 사용: 이벤트 루프가 "완료" 시 stored_handle->resume() 호출
실전 예제: 지연 완료 Awaitable (실행 가능)
아래는 **별도 스레드에서 resume()**을 호출하는 실행 가능한 예제입니다. await_suspend에서 핸들을 값으로 캡처해 수명을 안전하게 관리합니다.
struct DelayedAwaitable {
int result;
bool await_ready() const { return false; }
void await_suspend(std::coroutine_handle<> h) {
std::thread([h]() {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
h.resume();
}).detach();
}
int await_resume() const { return result; }
};
Task<int> asyncAdd() {
int a = co_await DelayedAwaitable{10};
int b = co_await DelayedAwaitable{20};
co_return a + b;
}
빌드: g++ -std=c++20 -pthread -o coro_demo coro_demo.cpp
실전 예제: 파일 라인 제너레이터 (완전한 코드)
#include <coroutine>
#include <fstream>
#include <string>
Generator<std::string> readLines(const std::string& path) {
std::ifstream f(path);
if (!f) throw std::runtime_error("파일 열기 실패: " + path);
std::string line;
while (std::getline(f, line)) {
co_yield line; // 한 줄씩 넘기고 일시 정지
}
}
// 사용
for (const auto& line : readLines("config.txt")) {
if (line.starts_with("#")) continue;
process(line);
}
7. promise_type 상세 설명
코루틴 반환 타입과의 연결
코루틴의 반환 타입이 R이면, 컴파일러는 R::promise_type을 찾습니다. 이 타입이 코루틴의 생명주기와 동작을 제어합니다.
flowchart TD
subgraph lifecycle["코루틴 생명주기"]
A[코루틴 호출] --> B[promise 생성]
B --> C[get_return_object]
C --> D[initial_suspend]
D --> E{일시정지?}
E -->|suspend_always| F[호출자에게 제어 반환]
E -->|suspend_never| G[본문 실행]
F --> H[resume 호출]
H --> G
G --> I[co_yield/co_await/co_return]
I --> J[final_suspend]
J --> K[코루틴 종료]
end
필수 메서드
| 메서드 | 용도 |
|---|---|
| get_return_object() | 호출자에게 돌려줄 “코루틴 핸들/객체” 생성. 보통 coroutine_handle::from_promise(*this)로 핸들 만들어 반환 타입으로 감싼다. |
| initial_suspend() | 코루틴 진입 직후 일시 정지할지 (suspend_always / suspend_never). 제너레이터는 보통 suspend_always로 “첫 next()까지 대기”. |
| final_suspend() | co_return(또는 끝) 직후 일시 정지할지. suspend_always면 handle.done()으로 종료 확인 가능. |
| yield_value(value) | co_yield value 처리. 값을 저장하고 suspend_always 등 반환. |
| return_value(v) | co_return v 처리. 반환값 있는 코루틴용. |
| return_void() | co_return (값 없음) 처리. 반환값 없는 코루틴용. |
| unhandled_exception() | 코루틴 내 예외 발생 시 호출. 보통 std::current_exception() 저장 후 std::rethrow_exception 또는 로깅. |
promise_type 설계 패턴
- 제너레이터:
yield_value+return_void,initial_suspend=suspend_always - 비동기 Task:
return_value+co_await지원,final_suspend에서 결과 전달 - lazy 값:
initial_suspend=suspend_always, 첫resume에서 계산
promise_type 완전한 예제: co_await·co_yield·co_return 통합
아래는 co_await, co_yield, co_return을 모두 사용하는 promise_type의 최소 구현입니다. 컴파일 가능한 완전한 예제로, 각 메서드의 역할을 한눈에 파악할 수 있습니다.
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
// Awaitable: co_await로 대기 가능한 타입
struct SleepAwaitable {
int ms;
bool await_ready() const { return ms <= 0; }
void await_suspend(std::coroutine_handle<>) const {
// 실제로는 타이머/이벤트 루프에 등록
}
void await_resume() const {}
};
// Task: co_await + co_return 지원
template <typename T>
struct Task {
struct promise_type {
std::optional<T> value;
std::exception_ptr exception;
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
void return_value(T v) { value = std::move(v); }
void unhandled_exception() { exception = std::current_exception(); }
};
std::coroutine_handle<promise_type> handle_;
explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
~Task() { if (handle_) handle_.destroy(); }
T get() {
while (!handle_.done()) handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
return std::move(*handle_.promise().value);
}
};
// 사용 예: co_await + co_return
Task<int> computeAsync() {
co_await SleepAwaitable{0}; // await_ready() == true → 즉시 통과
int a = 10, b = 20;
co_return a + b; // return_value(30) 호출
}
int main() {
auto task = computeAsync();
std::cout << task.get() << "\n"; // 30
}
핵심: get_return_object()로 Task 생성, initial_suspend/final_suspend로 일시 정지 시점 제어, return_value로 co_return 처리, unhandled_exception에서 예외 저장 후 get()에서 재전파.
8. 코루틴 핸들과 수명
coroutine_handle이란
std::coroutine_handle<Promise>는 코루틴 프레임을 가리키는 핸들입니다. resume()으로 재개하고, done()으로 종료 여부를 확인하며, destroy()로 프레임을 해제합니다.
std::coroutine_handle<promise_type> handle;
handle.resume(); // 코루틴 재개
bool finished = handle.done(); // 종료 여부
handle.destroy(); // 프레임 해제 (수명 관리)
수명(Lifetime) 규칙
핵심: 코루틴이 일시 정지된 동안 지역 변수는 코루틴 프레임에 살아 있습니다. 핸들이 프레임을 가리키므로, 핸들(또는 이를 감싼 Generator/Task)이 살아 있는 동안만 그 프레임과 지역 변수가 유효합니다.
Generator<int> makeGen() {
int local = 42; // 코루틴 프레임에 저장됨
co_yield local; // 일시 정지 → local은 프레임에 유지
co_yield local + 1; // 재개 시 local 여전히 유효
}
// ✅ OK: gen이 살아 있는 동안 프레임 유효
auto gen = makeGen();
for (int x : gen) { /* ... */ }
// ❌ 위험: gen이 소멸되면 handle.destroy() → 프레임 해제
// 다른 곳에서 handle만 복사해 두고 gen을 버리면? → dangling
수명 다이어그램
flowchart LR
subgraph valid["유효한 수명"]
V1[Generator/Task 객체 생성] --> V2[코루틴 프레임 할당]
V2 --> V3[resume/next 호출]
V3 --> V4[객체 소멸 시 destroy]
end
subgraph invalid["위험"]
I1[핸들만 복사] --> I2[원본 객체 소멸]
I2 --> I3[dangling handle]
end
9. 자주 발생하는 오류
1. Dangling 참조 (죽은 참조)
원인: 코루틴이 일시 정지된 동안 지역 변수나 인자의 참조를 반환/저장하고, 코루틴이 재개되기 전에 그 객체가 소멸하는 경우.
// ❌ 위험: s는 참조인데, 호출자가 전달한 임시 객체일 수 있음
Generator<std::string_view> splitBad(std::string_view s) {
// s가 임시 std::string에서 온 참조면, 호출 반환 후 소멸
co_yield s.substr(0, 1); // UB 가능
}
// ✅ 안전: 값으로 복사
Generator<std::string> splitGood(std::string s) {
co_yield s.substr(0, 1);
}
2. 핸들 수명 관리 실수
원인: coroutine_handle을 복사해 두고, 원본 Generator/Task를 먼저 소멸시켜 프레임이 destroy된 뒤에 복사본으로 resume 호출.
// ❌ 위험: 객체 소멸 후 핸들 사용
// (일부 라이브러리가 handle()을 노출하는 경우)
{
auto gen = count(5);
auto h = gen.handle(); // 핸들만 복사
} // gen 소멸 → destroy() 호출
h.resume(); // UB: 이미 해제된 프레임
// ✅ 안전: Generator 객체가 살아 있는 동안만 사용
auto gen = count(5);
for (int x : gen) { /* ... */ }
3. promise_type 메서드 누락
원인: return_value와 return_void를 둘 다 정의하거나, 필수 메서드를 빠뜨림.
// ❌ 컴파일 에러: return_value와 return_void 둘 다 있으면 안 됨
struct promise_type {
void return_value(int) {}
void return_void() {}
};
// ✅ 반환 타입에 맞게 하나만
struct promise_type {
void return_value(int v) { value = v; } // Task<int>용
// return_void() 없음
};
4. final_suspend에서 suspend_never 사용 시 주의
원인: final_suspend()가 suspend_never를 반환하면, co_return 직후 코루틴 프레임이 자동으로 정리됩니다. 이때 get_return_object()로 만든 객체가 아직 결과를 꺼내지 않았다면, promise에 저장한 값에 접근할 수 없을 수 있습니다. 결과를 꺼내는 패턴이라면 suspend_always를 쓰고, 호출자가 resume() 한 번 더 한 뒤 destroy() 하는 방식을 많이 씁니다.
// final_suspend() = suspend_never
// → co_return 직후 프레임 해제
// → promise.value에 접근 불가
// final_suspend() = suspend_always
// → 호출자가 resume() 후 done() 확인, value 꺼낸 뒤 destroy()
5. 스레드 안전성
원인: await_suspend에서 다른 스레드로 재개할 수 있습니다. 여러 스레드가 같은 코루틴을 resume하면 데이터 레이스가 발생할 수 있습니다.
// ❌ 위험: 여러 스레드에서 동시에 resume
auto task = asyncCompute();
std::thread t1([&]{ task.get(); });
std::thread t2([&]{ task.get(); }); // 데이터 레이스
// ✅ 안전: 한 스레드에서만 resume, 또는 동기화
6. 예외 전파 실수
원인: unhandled_exception()에서 예외를 처리하지 않거나, std::rethrow_exception 없이 넘기면 코루틴이 비정상 종료하거나 예외가 삼켜질 수 있습니다.
// ❌ 위험: 예외를 무시
void unhandled_exception() {
// 아무것도 안 함 → 예외가 사라짐
}
// ✅ 안전: 저장 후 get()에서 재전파
void unhandled_exception() {
exception = std::current_exception();
}
// get()에서: if (exception) std::rethrow_exception(exception);
7. 반복자/핸들 무효화
원인: for (auto x : gen) 루프 안에서 gen을 이동하거나, 다른 제너레이터로 대입하면 반복자가 무효화됩니다.
// ❌ 위험: 루프 중 gen 이동
for (int x : gen) {
auto other = std::move(gen); // gen 무효화 → 다음 ++에서 UB
}
// ✅ 안전: 루프 밖에서 이동
auto other = std::move(gen);
for (int x : other) { /* ... */ }
8. 재귀 코루틴과 스택
원인: 코루틴 A가 co_await B를 하고, B가 다시 A를 재개하면 “재귀”처럼 보이지만, 코루틴은 스택을 공유하지 않습니다. 각 코루틴은 자신의 프레임(힙)을 가지므로 스택 오버플로우 없이 깊은 체인을 만들 수 있습니다. 다만 resume() 호출이 중첩되면 (A resume → B resume → A resume…) 호출 스택은 쌓이므로, 무한 루프에 빠지지 않도록 주의해야 합니다.
9. promise_type/반환 타입 불일치
원인: Task<int>를 반환하는 코루틴에서 promise_type이 return_void만 정의하거나, Generator<T>인데 return_value를 쓰면 컴파일 에러가 납니다.
// ❌ 컴파일 에러: Task<int>는 return_value 필요
Task<int> foo() {
co_return; // return_void 호출 → promise에 return_void만 있으면 에러
}
// ✅ 맞게: return_value 정의
void return_value(int v) { value = v; }
10. 성능 비교
코루틴 vs 콜백 vs 스레드
| 방식 | 메모리 | 컨텍스트 스위치 | 가독성 | 적합한 용도 |
|---|---|---|---|---|
| 콜백 | 낮음 | 없음 | 낮음 | 단순한 비동기 |
| 코루틴 | 중간 (프레임) | 없음 (협력적) | 높음 | I/O 바운드, 제너레이터 |
| 스레드 | 높음 (스택) | 있음 (OS) | 중간 | CPU 바운드 병렬 |
제너레이터 vs 벡터
시나리오: 0부터 1,000,000까지 정수 시퀀스
| 방식 | 메모리 (대략) | 초기 지연 | 적합한 경우 |
|---|---|---|---|
| vector<int> | ~4MB | 전체 할당 | 전체 순회 확정 |
| Generator<int> | ~수백 바이트 (프레임) | 거의 없음 | 앞 몇 개만 필요, lazy |
// 벡터: 100만 개 전부 할당
std::vector<int> vec(1000000);
std::iota(vec.begin(), vec.end(), 0);
for (int i = 0; i < 10; ++i) // 10개만 쓰는데 100만 개 유지
use(vec[i]);
// 제너레이터: 필요한 만큼만
auto gen = count(1000000);
for (int i = 0; i < 10; ++i) // 10개만 계산
use(*gen.next());
코루틴 프레임 오버헤드
코루틴 프레임은 힙에 할당됩니다 (작은 코루틴도 대략 수백 바이트). 매우 짧은 작업을 수백만 번 수행한다면 오버헤드가 부담될 수 있으나, I/O 대기나 제너레이터처럼 “오래 살아 있는” 흐름에서는 일반적으로 무시할 수준입니다.
성능 최적화 팁
1. 불필요한 일시 정지 줄이기
await_ready()에서 이미 완료된 경우 true를 반환하면 일시 정지를 건너뜁니다. 캐시된 결과, 즉시 완료 가능한 작업에 활용하세요.
bool await_ready() const {
return cached_result.has_value(); // 이미 있으면 일시 정지 생략
}
2. 작은 awaitable은 인라인
co_await의 오버헤드는 주로 프레임 할당입니다. awaitable 객체 자체가 작고 자주 사용되면 [[gnu::hot]] 또는 인라인 힌트를 고려할 수 있습니다. (실제 효과는 프로파일링으로 확인)
3. 제너레이터: 필요한 만큼만 소비
100만 개 시퀀스에서 앞 10개만 쓰면, 제너레이터는 10번만 co_yield하고 끝납니다. 벡터는 100만 개를 모두 할당합니다. “전체가 필요한가?”를 먼저 묻고, 부분 소비가 가능하면 제너레이터가 유리합니다.
4. 컴파일러 최적화
-O2/-O3에서 코루틴 프레임 할당이 인라인되거나 최적화되는 경우가 있습니다. 릴리스 빌드에서 벤치마크하세요.
5. 메모리 풀 (고급)
수백 개 이상의 짧은 코루틴을 빠르게 생성/파괴한다면, operator new 오버로드로 코루틴 프레임 전용 풀을 두는 방법이 있습니다. 대부분의 I/O·제너레이터 용도에서는 불필요합니다.
11. 프로덕션 패턴
1. 제너레이터 (Generator)
용도: lazy 시퀀스, 파일 라인 읽기, 무한 수열, 파이프라인.
Generator<std::string> readLines(const std::string& path) {
std::ifstream f(path);
std::string line;
while (std::getline(f, line)) {
co_yield line;
}
}
// 사용
for (const auto& line : readLines("data.txt")) {
process(line);
}
2. 비동기 Task (Async Task)
용도: 네트워크 요청, 파일 I/O, 타이머. Boost.Asio, libuv 등과 연동.
Task<Response> fetchUrl(const std::string& url) {
auto conn = co_await connectAsync(url);
auto data = co_await readAsync(conn);
co_return parseResponse(data);
}
3. 상태 머신 (게임/UI)
용도: 여러 단계를 순차적으로 표현. co_await로 “다음 프레임까지” 또는 “이벤트까지” 대기.
Task<void> gameLoop() {
while (running) {
co_await waitForNextFrame();
update();
render();
}
}
4. 파이프라인
용도: 제너레이터를 체인으로 연결. 다음 글(23-2)의 Generator와 조합.
Generator<int> filter(Generator<int> src, auto pred) {
for (int x : src) {
if (pred(x)) co_yield x;
}
}
auto nums = count(100);
auto evens = filter(std::move(nums), { return x % 2 == 0; });
5. 취소/타임아웃 패턴
용도: 장시간 대기 작업에 타임아웃을 걸거나, 사용자 취소를 지원할 때.
// 취소 토큰을 awaitable과 연동
struct CancellableAwaitable {
std::atomic<bool>* cancelled;
bool await_ready() const { return cancelled->load(); }
void await_suspend(std::coroutine_handle<> h) {
// 이벤트 루프에 h와 cancelled 등록
// cancelled가 true면 h.resume() 호출하지 않음
}
void await_resume() {
if (*cancelled) throw std::runtime_error("취소됨");
}
};
6. 재시도 패턴
용도: 네트워크 요청 실패 시 일정 횟수만큼 재시도.
Task<Response> fetchWithRetry(const std::string& url, int maxRetries = 3) {
for (int i = 0; i < maxRetries; ++i) {
try {
co_return co_await fetchAsync(url);
} catch (const NetworkError&) {
if (i == maxRetries - 1) throw;
co_await sleepAsync(100 * (i + 1)); // 백오프
}
}
__builtin_unreachable();
}
7. 배치 처리 (제너레이터)
용도: 스트리밍 데이터를 N개씩 묶어서 처리.
template <typename T>
Generator<std::vector<T>> batch(Generator<T> src, size_t n) {
std::vector<T> buf;
buf.reserve(n);
for (T x : src) {
buf.push_back(std::move(x));
if (buf.size() >= n) {
co_yield std::move(buf);
buf.clear();
buf.reserve(n);
}
}
if (!buf.empty()) co_yield std::move(buf);
}
프로덕션 체크리스트
-
promise_type에unhandled_exception()구현 (예외 저장 또는 로깅) -
final_suspend()선택: 결과를 꺼내야 하면suspend_always - 참조 대신 값으로 인자 전달 (dangling 방지)
-
coroutine_handle복사 시 수명 관리 확인 - 멀티스레드 사용 시
resume()동시 호출 금지 - 제너레이터/Task 소멸 시
destroy()호출 (RAII로 처리)
라이브러리 선택
- C++23:
std::generator사용 - C++20: cppcoro, Boost.Asio (experimental coroutine), 또는 직접 구현
13. 실전 주의사항
- 수명: 코루틴이 일시 정지된 동안 지역 변수는 그 코루틴 프레임에 살아 있음. 핸들이 프레임을 가리키므로, 코루틴 객체/핸들이 살아 있는 동안만 유효.
- 스레드:
await_suspend에서 다른 스레드로 재개할 수 있음. 동기화는 개발자 책임. - 표준 타입: C++20에는
std::generator가 없어서, 제너레이터는 직접 구현하거나 라이브러리 사용. C++23에서std::generator추가. - 예외:
unhandled_exception()에서 예외를 저장하지 않고 넘기면 코루틴이 비정상 종료할 수 있음. 반드시 처리하거나 재전파.
디버깅 팁
1. 코루틴이 재개되지 않을 때
await_suspend에서 핸들을 어딘가에 등록했는지 확인하세요. 이벤트 루프나 스레드 풀에서 resume()을 호출하지 않으면 코루틴은 영원히 일시 정지 상태로 남습니다.
2. handle.done()이 true인데 값이 비어 있을 때
final_suspend()가 suspend_never를 반환하면, co_return 직후 프레임이 정리되어 promise에 접근할 수 없습니다. 결과를 꺼내야 한다면 suspend_always를 쓰고, 호출자가 마지막 resume() 후 promise.value를 읽은 뒤 destroy()를 호출하는 패턴을 사용하세요.
3. 크래시가 resume() 호출 시 발생할 때
이미 destroy()된 핸들로 resume()을 호출했을 가능성이 높습니다. 핸들의 수명과 소유권을 추적하세요.
4. GDB/LLDB로 코루틴 디버깅
코루틴 프레임은 힙에 있으므로, coroutine_handle의 주소를 확인한 뒤 해당 메모리를 조사할 수 있습니다. promise 객체는 handle.promise()로 접근 가능합니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
이 글에서 다루는 키워드 (관련 검색어)
C++20 코루틴, co_await co_yield co_return, 코루틴 기초, 비동기, promise_type, coroutine_handle 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| co_yield | 값 하나 내보내고 일시 정지 (제너레이터) |
| co_await | awaitable 완료까지 일시 정지 |
| co_return | 코루틴 종료 (반환값 선택) |
| promise_type | 반환 타입에 정의해 동작 제어 |
| coroutine_handle | 프레임 재개·종료 확인·해제 |
| 수명 | 핸들/객체가 살아 있는 동안만 프레임 유효 |
적용 시나리오별 선택 가이드
| 시나리오 | 권장 패턴 | 비고 |
|---|---|---|
| lazy 시퀀스 (파일 라인, 무한 수열) | co_yield + Generator | 메모리 효율, 부분 소비 |
| 비동기 I/O (네트워크, 파일) | co_await + Task | Boost.Asio, libuv 연동 |
| 게임/UI 상태 머신 | co_await (다음 프레임/이벤트) | 순차적 흐름 유지 |
| 파이프라인 (필터·변환·배치) | Generator 체인 | filter, map, batch |
| 타임아웃/취소 필요 | Awaitable + 취소 토큰 | await_ready에서 조기 반환 |
| 재시도 필요 | try/catch + 루프 | 백오프와 함께 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++20 코루틴의 기본 개념, co_await/co_yield/co_return 문법, promise_type, 코루틴 핸들·수명, 그리고 제너레이터·비동기 Task 예제를 다룹니다. 비동기 I/O, 스트리밍, 게임 루프 등에서 콜백 지옥 없이 순차적 코드로 작성할 때 활용합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. cppcoro, Boost.Asio 문서도 도움이 됩니다.
한 줄 요약: co_yield·co_await·co_return으로 일시 정지/재개가 가능한 코루틴을 쓸 수 있습니다. promise_type과 핸들 수명을 이해하면 실전 적용이 수월합니다. 다음으로 Generator(#23-2)를 읽어보면 좋습니다.
참고 자료
- cppreference - Coroutines (C++20)
- lewissbaker/cppcoro — C++20 코루틴 라이브러리
- Boost.Asio - Coroutines — 비동기 I/O와 코루틴 연동
다음 글: [C++ 실전 가이드 #23-2] Generator 구현: co_yield로 lazy 시퀀스 만들기
이전 글: [C++ 실전 가이드 #22-2] 커스텀 Concepts 작성: 도메인에 맞는 제약 조건 정의하기
관련 글
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]