C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
이 글의 핵심
C++20 코루틴으로 비동기 작업을 co_await하고, Task 타입을 설계하며, Asio와 연동하는 기본 패턴을 다룹니다. 콜백 지옥 해결, 에러 처리, 수명 관리, 성능 비교, 베스트 프랙티스, 프로덕션 패턴까지 실전 가이드.
들어가며: “비동기 코드가 콜백 지옥이에요”
콜백 지옥이란?
네트워크 요청을 보낸 뒤, 응답이 오면 파싱하고, 그 결과로 다른 API를 호출하고, 그 결과로 DB에 저장하는 식의 비동기 연쇄를 작성할 때, 전통적인 콜백 방식은 코드가 깊어지고 읽기 어려워집니다.
// ❌ 콜백 지옥: HTTP 요청 → 파싱 → DB 저장
void fetchUserData(const std::string& userId) {
httpGet("/api/user/" + userId, [userId](error_code ec, std::string body) {
if (ec) { /* 에러 처리 */ return; }
parseJson(body, [userId, body](error_code ec, User user) {
if (ec) { /* 에러 처리 */ return; }
dbSave(user, [userId](error_code ec) {
if (ec) { /* 에러 처리 */ return; }
notifyUser(userId); // 4단계 중첩!
});
});
});
}
문제점:
- 중첩 람다가 깊어질수록 들여쓰기와 스코프가 복잡해짐
- 에러 처리가 각 단계마다 반복되고, early return 패턴이 산재함
- 변수 캡처가
[userId, body]처럼 늘어나고, 수명 관리가 어려움 - 디버깅 시 콜 스택이 끊겨서 흐름 추적이 힘듦
코루틴으로 해결:
// ✅ co_await: 동기 코드처럼 읽는 순서대로 실행
Task<void> fetchUserData(const std::string& userId) {
std::string body = co_await httpGet("/api/user/" + userId);
User user = co_await parseJson(body);
co_await dbSave(user);
notifyUser(userId);
}
코루틴의 장점:
- 한 줄씩 위에서 아래로 읽히는 흐름
- 에러 처리와 수명 관리가 단순해짐
- co_await 시 해당 코루틴만 일시 정지하고, 스레드는 블로킹되지 않음
처음 코루틴을 접할 때 “co_await 한 번에 스레드가 블로킹되나?”라고 생각할 수 있습니다. 블로킹되지 않습니다. co_await 시 그 코루틴만 일시 정지하고, 이벤트 루프(또는 io_context)는 다른 핸들러를 계속 실행합니다. 완료되면 해당 코루틴이 재개되므로, 논블로킹 이벤트 루프 모델은 그대로 유지됩니다.
flowchart LR
subgraph callback["콜백 방식"]
C1[요청] --> C2[콜백1]
C2 --> C3[콜백2]
C3 --> C4[콜백3]
C4 --> C5[콜백4]
end
subgraph coroutine["코루틴 방식"]
R1[요청] --> R2[co_await]
R2 --> R3[co_await]
R3 --> R4[co_await]
R4 --> R5[완료]
end
이 글을 읽으면:
- co_await가 동작하는 방식을 이해할 수 있습니다.
- 간단한 Task 타입을 설계할 수 있습니다.
- Asio와 연동하는 실전 패턴을 알 수 있습니다.
- 수명·에러·성능 등 실전 주의사항을 파악할 수 있습니다.
목차
- Awaitable이란
- Task 타입 완전 구현
- co_await 연산자 오버로딩
- Asio 연동 예제
- 코루틴 에러 처리
- 자주 발생하는 실수
- 성능 비교: 콜백 vs 코루틴
- 프로덕션 패턴
1. Awaitable이란
세 가지 메서드
co_await expr에서 expr의 타입(또는 operator co_await 결과)이 제공해야 하는 것:
struct MyAwaitable {
// 1. 이미 완료되었으면 true → 일시 정지 없이 바로 await_resume
bool await_ready() const {
return false; // true면 일시 정지 안 함
}
// 2. 일시 정지 시 호출. h: 현재 코루틴 핸들
// 완료 시 h.resume() 호출하면 재개
void await_suspend(std::coroutine_handle<> h) {
// 비동기 작업 시작, 완료 콜백에서 h.resume() 호출
}
// 3. 재개 시 호출. co_await 식의 결과값
int await_resume() {
return 42;
}
};
co_await 동작 흐름
sequenceDiagram
participant C as 코루틴
participant A as Awaitable
participant S as 스케줄러
C->>A: await_ready()
alt 이미 완료
A-->>C: true
C->>A: await_resume()
else 미완료
A-->>C: false
C->>A: await_suspend(handle)
A->>S: 비동기 작업 등록
Note over C: 일시 정지
S->>A: 완료 콜백
A->>C: handle.resume()
C->>A: await_resume()
end
suspend_always / suspend_never
표준 라이브러리에서 제공하는 유틸리티:
struct suspend_always {
bool await_ready() const noexcept { return false; }
void await_suspend(std::coroutine_handle<>) const noexcept {}
void await_resume() const noexcept {}
};
struct suspend_never {
bool await_ready() const noexcept { return true; }
void await_suspend(std::coroutine_handle<>) const noexcept {}
void await_resume() const noexcept {}
};
- suspend_always: 항상 일시 정지
- suspend_never: 절대 일시 정지하지 않음 (초기화 완료 시점 등)
2. Task 타입 완전 구현
반환값 있는 Task (동작 가능한 구현)
#include <coroutine>
#include <exception>
#include <utility>
template <typename T>
class Task {
public:
struct promise_type {
T value;
std::coroutine_handle<> continuation = nullptr;
std::exception_ptr exception;
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept {
if (continuation) continuation.resume();
return {};
}
void return_value(T v) { value = std::move(v); }
void unhandled_exception() { exception = std::current_exception(); }
};
struct Awaiter {
std::coroutine_handle<promise_type> handle;
bool await_ready() const { return handle.done(); }
void await_suspend(std::coroutine_handle<> h) {
handle.promise().continuation = h;
if (!handle.done()) return;
h.resume();
}
T await_resume() {
if (handle.promise().exception)
std::rethrow_exception(handle.promise().exception);
return std::move(handle.promise().value);
}
};
Awaiter operator co_await() { return Awaiter{handle_}; }
T get() {
while (!handle_.done()) handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
return std::move(handle_.promise().value);
}
~Task() { handle_.destroy(); }
Task(Task&& o) noexcept : handle_(o.handle_) { o.handle_ = nullptr; }
Task& operator=(Task&& o) noexcept {
if (this != &o) {
handle_.destroy();
handle_ = o.handle_;
o.handle_ = nullptr;
}
return *this;
}
private:
explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
std::coroutine_handle<promise_type> handle_;
};
void 반환 Task
template <>
class Task<void> {
public:
struct promise_type {
std::coroutine_handle<> continuation = nullptr;
std::exception_ptr exception;
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept {
if (continuation) continuation.resume();
return {};
}
void return_void() {}
void unhandled_exception() { exception = std::current_exception(); }
};
struct Awaiter {
std::coroutine_handle<promise_type> handle;
bool await_ready() const { return handle.done(); }
void await_suspend(std::coroutine_handle<> h) {
handle.promise().continuation = h;
if (!handle.done()) return;
h.resume();
}
void await_resume() {
if (handle.promise().exception)
std::rethrow_exception(handle.promise().exception);
}
};
Awaiter operator co_await() { return Awaiter{handle_}; }
void get() {
while (!handle_.done()) handle_.resume();
if (handle_.promise().exception)
std::rethrow_exception(handle_.promise().exception);
}
~Task() { handle_.destroy(); }
Task(Task&&) = default;
Task& operator=(Task&&) = default;
private:
explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
std::coroutine_handle<promise_type> handle_;
};
사용 예시
Task<int> fetchAndParse() {
std::string raw = co_await httpGet("https://example.com");
int value = co_await parseAsync(raw);
co_return value;
}
Task<void> mainFlow() {
int x = co_await fetchAndParse();
std::cout << "result: " << x << "\n";
}
실제 프로덕션에서는 cppcoro, libunifex 등 라이브러리 Task를 쓰거나, 스케줄러·executor 연동을 추가하는 것이 일반적입니다.
완전한 co_await 연쇄 예제 (Task + Awaitable)
Task(위 섹션 구현)와 커스텀 Awaitable을 조합한 비동기 연쇄 예제입니다.
// sleep Awaitable: await_suspend에서 별도 스레드로 sleep 후 h.resume() 호출
struct SleepAwaitable {
std::chrono::milliseconds duration;
bool await_ready() const { return duration.count() <= 0; }
void await_suspend(std::coroutine_handle<> h) {
std::thread([h, d = duration]() {
std::this_thread::sleep_for(d);
h.resume();
}).detach();
}
void await_resume() {}
};
// 비동기 연쇄: fetch → parse → save (한 줄씩 순차 실행)
Task<int> fetchParseAndSave(const std::string& url) {
co_await SleepAwaitable{std::chrono::milliseconds(100)};
std::string body = "simulated_response";
co_await SleepAwaitable{std::chrono::milliseconds(50)};
int value = 42;
co_await SleepAwaitable{std::chrono::milliseconds(50)};
co_return value;
}
Task<void> mainFlow() {
int result = co_await fetchParseAndSave("https://api.example.com/data");
std::cout << "result: " << result << "\n";
}
핵심: Task<T>의 operator co_await로 다른 코루틴이 co_await fetchParseAndSave(...) 가능. 연쇄가 한 줄씩 읽히며, 각 co_await 시점에 해당 코루틴만 일시 정지합니다.
3. co_await 연산자 오버로딩
operator co_await
co_await expr에서 expr이 operator co_await를 제공하면, 그 결과가 Awaitable로 사용됩니다.
struct DelayedValue {
int value;
std::chrono::milliseconds delay;
struct Awaiter {
int value;
std::chrono::milliseconds delay;
bool await_ready() const { return delay.count() == 0; }
void await_suspend(std::coroutine_handle<> h) {
// 타이머 스케줄: delay 후 h.resume() 호출
scheduleTimer(delay, [h]() { h.resume(); });
}
int await_resume() { return value; }
};
Awaiter operator co_await() { return Awaiter{value, delay}; }
};
Task<int> example() {
auto v = co_await DelayedValue{42, std::chrono::milliseconds(100)};
co_return v;
}
사용자 정의 Awaitable
// 로그를 남기는 Awaitable 래퍼
template <typename T>
struct LoggingAwaitable {
T inner;
struct Awaiter {
T inner;
bool await_ready() const { return inner.await_ready(); }
template <typename Promise>
auto await_suspend(std::coroutine_handle<Promise> h) {
std::cout << "suspending...\n";
return inner.await_suspend(h);
}
auto await_resume() {
std::cout << "resuming...\n";
return inner.await_resume();
}
};
Awaiter operator co_await() { return Awaiter{std::move(inner)}; }
};
4. Asio 연동 예제
boost::asio::awaitable
Boost.Asio 1.70+는 awaitable과 use_awaitable 토큰을 제공합니다.
#include <boost/asio.hpp>
#include <boost/asio/use_awaitable.hpp>
boost::asio::awaitable<void> session(boost::asio::ip::tcp::socket socket) {
std::array<char, 1024> buf;
try {
for (;;) {
std::size_t n = co_await socket.async_read_some(
boost::asio::buffer(buf),
boost::asio::use_awaitable);
co_await boost::asio::async_write(socket,
boost::asio::buffer(buf.data(), n),
boost::asio::use_awaitable);
}
} catch (const boost::system::system_error& e) {
if (e.code() != boost::asio::error::eof)
std::cerr << "session error: " << e.what() << "\n";
}
}
boost::asio::awaitable<void> listener(boost::asio::ip::tcp::acceptor& acceptor) {
for (;;) {
auto socket = co_await acceptor.async_accept(boost::asio::use_awaitable);
auto ex = socket.get_executor();
boost::asio::co_spawn(ex, session(std::move(socket)), boost::asio::detached);
}
}
int main() {
boost::asio::io_context io;
boost::asio::ip::tcp::acceptor acceptor(io,
{boost::asio::ip::tcp::v4(), 8080});
boost::asio::co_spawn(io, listener(acceptor), boost::asio::detached);
io.run();
}
- use_awaitable: 비동기 연산을
co_await가능하게 만드는 토큰 - co_spawn: 코루틴을 executor에서 실행
- detached: 코루틴 완료를 기다리지 않음
Strand와 연동
auto ex = boost::asio::make_strand(io.get_executor());
boost::asio::co_spawn(ex, session(std::move(socket)), boost::asio::detached);
session의 모든co_await재개가 같은 strand에서 실행됩니다.- 스레드 안전성을 보장하면서 락 없이 직렬화할 수 있습니다.
완전한 비동기 코루틴 예제: Echo 서버 (컴파일 가능)
아래는 Boost.Asio와 C++20 코루틴으로 작성한 Echo 서버 전체 코드입니다. 클라이언트가 보낸 데이터를 그대로 돌려보냅니다.
환경 요구사항
- C++20 (
g++ -std=c++20또는clang++ -std=c++20) - Boost 1.70+ (
apt install libboost-all-dev또는 vcpkg)
전체 소스 코드
#include <boost/asio.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <iostream>
// Echo 세션: 클라이언트가 보낸 데이터를 그대로 돌려보냄
boost::asio::awaitable<void> echoSession(
boost::asio::ip::tcp::socket socket)
{
std::array<char, 1024> buf;
try {
for (;;) {
std::size_t n = co_await socket.async_read_some(
boost::asio::buffer(buf),
boost::asio::use_awaitable);
if (n == 0) break;
co_await boost::asio::async_write(socket,
boost::asio::buffer(buf.data(), n),
boost::asio::use_awaitable);
}
} catch (const boost::system::system_error& e) {
if (e.code() != boost::asio::error::eof) {
std::cerr << "session error: " << e.what() << "\n";
}
}
}
// 리스너: 연결 수락 후 각 세션을 별도 코루틴으로 실행
boost::asio::awaitable<void> listener(
boost::asio::ip::tcp::acceptor& acceptor)
{
for (;;) {
auto socket = co_await acceptor.async_accept(boost::asio::use_awaitable);
auto ex = socket.get_executor();
boost::asio::co_spawn(ex, echoSession(std::move(socket)),
boost::asio::detached);
}
}
int main() {
boost::asio::io_context io;
boost::asio::ip::tcp::acceptor acceptor(io,
{boost::asio::ip::tcp::v4(), 8080});
boost::asio::co_spawn(io, listener(acceptor), boost::asio::detached);
std::cout << "Echo server listening on port 8080\n";
io.run();
return 0;
}
빌드 및 실행
# g++ (Boost 설치 필요)
g++ -std=c++20 -o echo_server echo_server.cpp -lboost_system -lpthread
# 실행
./echo_server
# 다른 터미널에서 테스트
echo "hello" | nc localhost 8080
# 출력: hello
5. 코루틴 에러 처리
try/catch
Task<std::string> fetchWithRetry(const std::string& url) {
for (int i = 0; i < 3; ++i) {
try {
std::string result = co_await httpGet(url);
co_return result;
} catch (const std::exception& e) {
std::cerr << "attempt " << (i + 1) << " failed: " << e.what() << "\n";
if (i == 2) throw;
co_await sleep(std::chrono::seconds(1));
}
}
co_return ""; // unreachable
}
promise_type::unhandled_exception
void unhandled_exception() {
exception = std::current_exception();
}
- 코루틴 내부에서 예외가 던져지면
unhandled_exception이 호출됩니다. exception을 저장해 두고,await_resume또는get에서std::rethrow_exception으로 전파합니다.
error_code vs 예외
// Asio: use_awaitable은 기본적으로 예외를 던짐
try {
co_await socket.async_read_some(buf, boost::asio::use_awaitable);
} catch (const boost::system::system_error& e) {
if (e.code() == boost::asio::error::eof) { /* 정상 종료 */ }
}
// error_code로 받고 싶다면
co_await socket.async_read_some(buf,
boost::asio::redirect_error(boost::asio::use_awaitable, ec));
if (ec) { /* 처리 */ }
6. 자주 발생하는 실수
6.1 Dangling 참조
// ❌ 위험: s는 참조인데, 호출하는 쪽의 지역 변수가 스코프를 벗어나면
// 코루틴이 재개될 때 이미 파괴된 객체를 참조
Task<void> bad(const std::string& s) {
co_await someAsyncOp();
std::cout << s << "\n"; // UB: s가 이미 파괴됐을 수 있음
}
// ✅ 올바름: 값으로 복사
Task<void> good(std::string s) {
co_await someAsyncOp();
std::cout << s << "\n";
}
원인: co_await 이후 재개 시점에, 참조 인자로 받은 객체의 수명이 끝났을 수 없음.
해결: 비동기 함수에서는 값으로 복사하거나, shared_ptr로 수명을 확장합니다.
6.2 코루틴 핸들 수명
// ❌ 위험: Task가 파괴되면 handle_도 파괴됨
// 아직 suspend된 코루틴이면 메모리 누수 또는 UB
Task<int> getTask() {
return computeAsync(); // Task 반환
}
// 호출자가 getTask()의 반환값을 받지 않고 버리면?
// ✅ 올바름: Task를 반드시 소유하거나 co_await
void main() {
auto t = getTask(); // Task 소유
int x = t.get(); // 또는 co_await
}
원인: coroutine_handle이 파괴되면 코루틴 프레임도 함께 파괴되어야 합니다. Task를 버리면 handle_.destroy()가 호출되지 않을 수 있습니다.
6.3 스레드 안전성
// ❌ 위험: 재개가 다른 스레드에서 일어나면, 공유 데이터 접근 시 data race
Task<void> unsafe() {
static int counter = 0;
co_await asyncOnOtherThread();
++counter; // data race!
}
// ✅ 올바름: strand로 직렬화하거나, 락 사용
boost::asio::co_spawn(strand, unsafe(), boost::asio::detached);
6.4 반환된 객체의 이동
// ❌ 위험: Task는 이동 전용. 복사하면 안 됨
Task<int> t = compute();
Task<int> t2 = t; // 에러 (복사 생성자 삭제)
// ✅ 올바름
Task<int> t2 = std::move(t);
6.5 co_await 결과 무시, 루프 내 반복 생성, executor 누락
- co_await 결과 무시: 연결 실패 등 예외를 try/catch로 처리하지 않으면 호출자까지 전파됩니다.
- 루프 내 반복 생성:
processOne이 Task를 반환하면 매 반복마다 새 프레임이 할당됩니다. Awaitable만 반환하도록 하거나, 단일 코루틴 안에서 루프를 돌리세요. - executor 누락:
boost::asio::steady_timer timer(io)대신co_await this_coro::executor로 현재 executor를 얻어 타이머를 생성하세요.
6.6 요약 표
| 실수 | 원인 | 해결 |
|---|---|---|
| Dangling 참조 | co_await 후 재개 시 참조 대상 파괴 | 값 복사, shared_ptr |
| 핸들 수명 | Task 버림 → destroy 미호출 | Task 소유 또는 co_await |
| Data race | 재개가 다른 스레드에서 | strand, 락 |
| 복사 시도 | Task 이동 전용 | std::move |
| co_await 결과 무시 | 에러 감지 불가 | try/catch, error_code |
| 루프 내 반복 생성 | 불필요한 프레임 할당 | 단일 코루틴 + 루프 |
| executor 누락 | 잘못된 스레드에서 재개 | this_coro::executor |
6.7 promise_type·executor·co_return 경로
| 실수 | 원인 | 해결 |
|---|---|---|
| promise_type 누락 | get_return_object, initial_suspend, final_suspend, return_value/return_void, unhandled_exception 중 하나라도 없으면 컴파일 에러 | 필수 메서드 모두 구현 |
| executor 누락 | boost::asio::steady_timer timer(io)처럼 io_context 직접 사용 시 재개 스레드 불일치 | co_await this_coro::executor로 현재 executor 사용 |
| co_return 경로 누락 | if/else에서 한 경로에만 co_return → UB | 모든 경로에서 co_return 또는 throw |
// ❌ executor 잘못 사용
boost::asio::awaitable<void> bad(boost::asio::io_context& io) {
boost::asio::steady_timer timer(io); // io의 executor
co_await timer.async_wait(boost::asio::use_awaitable);
}
// ✅ 현재 코루틴의 executor 사용
boost::asio::awaitable<void> good() {
auto ex = co_await boost::asio::this_coro::executor;
boost::asio::steady_timer timer(ex);
co_await timer.async_wait(boost::asio::use_awaitable);
}
7. 베스트 프랙티스
7.1 인자 전달: 값 vs 참조
| 상황 | 권장 |
|---|---|
| 작은 타입 (int, 포인터) | 값으로 전달 |
| std::string, vector | 값 또는 shared_ptr (참조 시 dangling 위험) |
| 읽기 전용 큰 객체 | shared_ptr로 전달 |
boost::asio::awaitable<void> process(std::string id, std::shared_ptr<Config> c) {
co_await asyncOp();
use(id, *c);
}
7.2 에러 전파 전략
- 예외: Asio
use_awaitable기본. try/catch로 처리. - error_code:
redirect_error(use_awaitable, ec)로 예외 대신 ec에 저장.
// 예외 방식
try {
auto data = co_await fetch(url);
co_return process(data);
} catch (const boost::system::system_error& e) {
log_error(e.code().message());
throw;
}
// error_code 방식 (예외 비활성화 환경)
boost::system::error_code ec;
co_await socket.async_read_some(buf,
boost::asio::redirect_error(boost::asio::use_awaitable, ec));
if (ec) { handle_error(ec); co_return; }
7.3 코루틴 수명 관리 체크리스트
- Task/awaitable을 반환하는 함수는 반환값을 반드시 소유하거나 co_await
-
co_spawn(..., detached)사용 시 코루틴이 자기 수명을 책임지도록 설계 (내부에서 shared_ptr로 공유 상태 유지) - 취소가 필요하면
cancellation_signal또는 플래그로 주기적으로 확인
7.4 스레드 안전성
- 단일 스레드 io_context: 추가 동기화 불필요
- 멀티 스레드 io_context: 공유 데이터 접근 시
strand또는mutex사용
auto strand = boost::asio::make_strand(io.get_executor());
boost::asio::co_spawn(strand, session(socket), boost::asio::detached);
7.5 로깅·디버깅
- 재개 시점 로깅:
await_suspend진입,await_resume직전에 로그 - 코루틴 ID: 디버깅 시 각 코루틴에 고유 ID 부여해 흐름 추적
- 프로파일링:
await_ready로 불필요한 일시 정지 제거
8. 성능 비교: 콜백 vs 코루틴
오버헤드
| 항목 | 콜백 | 코루틴 |
|---|---|---|
| 힙 할당 | 람다마다 (캡처 많을 때) | 코루틴 프레임 1회 |
| 스택 | 호출마다 새 스택 프레임 | 프레임은 힙에 저장 |
| 재개 비용 | 함수 포인터 호출 | handle.resume() |
| 코드 크기 | 작음 | promise_type 등으로 증가 |
일반적인 결과:
- 단순 연쇄: 코루틴이 약간의 오버헤드(프레임 할당) 있으나, 가독성·유지보수성 이득이 큼
- 깊은 중첩: 코루틴이 메모리 사용이 더 예측 가능 (프레임 한 번)
- 고성능 핫경로: 콜백이 미세하게 유리할 수 있으나, 대부분의 경우 차이 무시 가능
요약
| 상황 | 권장 |
|---|---|
| 새 비동기 코드 | 코루틴 (가독성·에러 처리) |
| 기존 콜백 코드 | 점진적 마이그레이션 |
| 극한 성능 | 프로파일링 후 결정 |
성능 최적화 팁
1. await_ready로 불필요한 일시 정지 제거
struct MyAwaitable {
bool resultReady = false;
int cachedResult;
bool await_ready() const {
return resultReady; // 이미 완료됐으면 일시 정지 생략
}
void await_suspend(std::coroutine_handle<> h) {
if (resultReady) return;
startAsync([this, h]() {
cachedResult = compute();
resultReady = true;
h.resume();
});
}
int await_resume() { return cachedResult; }
};
효과: 이미 완료된 작업에 대해 await_suspend 호출을 피하고, 스케줄링 오버헤드를 줄입니다.
2. 코루틴 프레임 크기 최소화
// ❌ 비효율: 큰 지역 변수 → 프레임에 저장됨
Task<void> bad() {
std::array<char, 1024 * 1024> hugeBuffer; // 1MB가 프레임에!
co_await read(socket, hugeBuffer);
}
// ✅ 효율: 필요할 때만 힙에 할당
Task<void> good() {
auto buf = std::make_unique<std::array<char, 1024>(); // 작은 버퍼
co_await read(socket, *buf);
}
원리: co_await 이후 재개 시 지역 변수는 코루틴 프레임(힙)에 저장됩니다. 큰 변수는 프레임 크기를 키워 할당/해제 비용을 늘립니다.
3. Allocation 빈도 줄이기
// ❌ 매 호출마다 새 Task 생성
Task<int> process(int id) {
co_return co_await fetch(id); // fetch가 Task 반환 → 내부적으로 또 할당
}
// ✅ Awaitable만 반환하는 fetch 사용
Task<int> process(int id) {
co_return co_await fetchAsAwaitable(id); // 추가 할당 없음
}
| 연산 | 대략적 비용 |
|---|---|
| 코루틴 프레임 할당 | ~100–500ns |
| handle.resume() | ~50–100ns |
결론: I/O 바운드에서는 네트워크 지연(ms)이 지배적이므로 코루틴 오버헤드(ns)는 무시 가능합니다.
9. 프로덕션 패턴
9.1 HTTP 클라이언트
boost::asio::awaitable<std::string> httpGet(
boost::asio::io_context& io,
const std::string& host,
const std::string& path)
{
boost::asio::ip::tcp::resolver resolver(io);
auto endpoints = co_await resolver.async_resolve(
host, "80", boost::asio::use_awaitable);
boost::asio::ip::tcp::socket socket(io);
co_await boost::asio::async_connect(socket, endpoints,
boost::asio::use_awaitable);
std::string request = "GET " + path + " HTTP/1.1\r\n"
"Host: " + host + "\r\n\r\n";
co_await boost::asio::async_write(socket, boost::asio::buffer(request),
boost::asio::use_awaitable);
boost::asio::streambuf response;
co_await boost::asio::async_read_until(socket, response, "\r\n\r\n",
boost::asio::use_awaitable);
std::istream is(&response);
std::string header, body;
std::getline(is, header);
std::getline(is, body, '\0');
co_return body;
}
9.2 DB 쿼리 (개념)
// DB 커넥션 풀 + 비동기 쿼리
boost::asio::awaitable<User> fetchUser(const std::string& id) {
auto conn = co_await pool.acquire();
auto result = co_await conn->async_query(
"SELECT * FROM users WHERE id = ?", id, boost::asio::use_awaitable);
User u = parseUser(result);
co_return u;
}
9.3 타임아웃 래퍼
awaitable_operators의 &&로 메인 연산과 타이머를 동시에 기다릴 수 있습니다.
#include <boost/asio/awaitable.hpp>
#include <boost/asio/use_awaitable.hpp>
#include <boost/asio/experimental/awaitable_operators.hpp>
boost::asio::awaitable<void> withTimeout(
boost::asio::awaitable<void> op,
std::chrono::seconds timeout)
{
using namespace boost::asio::experimental::awaitable_operators;
boost::asio::steady_timer timer(co_await boost::asio::this_coro::executor);
timer.expires_after(timeout);
// op와 타이머 중 먼저 완료되는 쪽으로 진행
co_await (std::move(op) || timer.async_wait(boost::asio::use_awaitable));
}
9.4 병렬 co_await (개념)
여러 비동기 작업을 동시에 시작하고 모두 완료를 기다리려면, Asio의 experimental::make_parallel_group과 wait_for_all을 사용합니다.
// 두 소켓 읽기를 병렬로 수행
auto [order, ec1, n1, ec2, n2] = co_await
boost::asio::experimental::make_parallel_group(
socket1.async_read_some(boost::asio::buffer(buf1), boost::asio::deferred),
socket2.async_read_some(boost::asio::buffer(buf2), boost::asio::deferred)
).async_wait(
boost::asio::experimental::wait_for_all(),
boost::asio::use_awaitable);
자세한 사용법은 Boost.Asio Parallel Group 문서를 참고하세요.
9.5 재시도 + 지수 백오프 (Exponential Backoff)
boost::asio::awaitable<std::string> fetchWithBackoff(
const std::string& url,
int maxRetries = 5)
{
auto ex = co_await boost::asio::this_coro::executor;
std::chrono::milliseconds delay(100);
for (int i = 0; i < maxRetries; ++i) {
try {
co_return co_await httpGet(url);
} catch (const std::exception& e) {
if (i == maxRetries - 1) throw;
std::cerr << "retry " << (i + 1) << "/" << maxRetries
<< " after " << delay.count() << "ms\n";
boost::asio::steady_timer timer(ex, delay);
co_await timer.async_wait(boost::asio::use_awaitable);
delay *= 2; // 100ms → 200ms → 400ms → ...
}
}
co_return "";
}
9.6 Graceful Shutdown (우아한 종료)
// 전역 플래그로 새 연결 수락 중단
std::atomic<bool> g_running{true};
boost::asio::awaitable<void> listener(
boost::asio::ip::tcp::acceptor& acceptor)
{
while (g_running) {
boost::asio::steady_timer timer(
co_await boost::asio::this_coro::executor,
std::chrono::milliseconds(100));
co_await timer.async_wait(boost::asio::use_awaitable);
if (!g_running) break;
boost::system::error_code ec;
auto socket = co_await acceptor.async_accept(
boost::asio::redirect_error(boost::asio::use_awaitable, ec));
if (!ec)
boost::asio::co_spawn(socket.get_executor(),
echoSession(std::move(socket)), boost::asio::detached);
}
}
// SIGINT 핸들러에서 g_running = false 설정 후 io.stop()
9.7 병렬 요청 후 결과 수집
// 3개 API를 동시에 호출하고 모두 완료될 때까지 대기
boost::asio::awaitable<AggregatedResult> loadDashboard(const std::string& userId) {
auto [r1, r2, r3] = co_await boost::asio::experimental::make_parallel_group(
apiClient.getUser(userId),
apiClient.getOrders(userId),
apiClient.getNotifications(userId)
).async_wait(
boost::asio::experimental::wait_for_all(),
boost::asio::use_awaitable);
co_return AggregatedResult{std::get<0>(r1), std::get<0>(r2), std::get<0>(r3)};
}
9.8 취소(Cancellation) 패턴
Asio의 cancellation_signal을 사용해 장시간 작업을 중단할 수 있습니다.
boost::asio::awaitable<void> longRunningTask(
boost::asio::cancellation_signal& cancelSignal)
{
boost::asio::steady_timer timer(
co_await boost::asio::this_coro::executor);
timer.expires_after(std::chrono::seconds(10));
// 타이머 또는 취소 시그널 중 먼저 발생하는 쪽으로
boost::system::error_code ec;
co_await timer.async_wait(
boost::asio::redirect_error(boost::asio::use_awaitable, ec));
if (cancelSignal.slot().is_connected()) {
// 취소 요청이 들어왔으면 조기 종료
}
}
// 호출 측: cancelSignal.emit(boost::asio::cancellation_type::total);
9.9 로깅·모니터링 패턴
boost::asio::awaitable<void> monitoredSession(
std::string sessionId, boost::asio::ip::tcp::socket socket)
{
log_info("session started", sessionId);
std::array<char, 1024> buf;
try {
for (;;) {
auto n = co_await socket.async_read_some(
boost::asio::buffer(buf), boost::asio::use_awaitable);
metrics().record_bytes_read(sessionId, n);
co_await boost::asio::async_write(socket,
boost::asio::buffer(buf.data(), n), boost::asio::use_awaitable);
}
} catch (const std::exception& e) {
log_error("session error", sessionId, e.what());
metrics().record_session_error(sessionId);
throw;
}
log_info("session ended", sessionId);
}
프로덕션 권장: 세션 시작/종료/에러 시점 로그, 메트릭(바이트 수, 에러 수) 수집.
9.10 구현 체크리스트
- Awaitable/코루틴에 값 전달 또는 shared_ptr 사용 (dangling 방지)
- Task/핸들 수명 관리 (파괴 시 destroy)
- 재개 스레드 확인 (strand 또는 락)
- 예외 처리 (unhandled_exception, try/catch)
- 타임아웃·취소 정책 수립
- 재시도 + 백오프 (일시적 장애 대응)
- Graceful shutdown (SIGINT/SIGTERM 처리)
- 프로파일링으로 성능 확인
참고 자료
- cppreference: Coroutines (C++20)
- Boost.Asio: Coroutines
- 고성능 네트워크 가이드 #6: 코루틴과 Asio
- Asio 입문 (#29-1)
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++20 Coroutine | co_await·co_yield로 “콜백 지옥” 탈출하기
- C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
- C++ Boost.Asio 입문 | io_context·async_read
이 글에서 다루는 키워드 (관련 검색어)
C++ 비동기 코루틴, co_await, async 코루틴, awaitable, 콜백 지옥, promise_type, Boost.Asio 코루틴 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| Awaitable | await_ready, await_suspend(handle), await_resume |
| Task | promise_type + operator co_await로 다른 코루틴이 co_await 가능 |
| 재개 | 완료 시 handle.resume() 호출 |
| Asio | use_awaitable, co_spawn으로 연동 |
| 에러 | try/catch, unhandled_exception |
| 수명 | 값 복사, shared_ptr, 핸들 관리 |
| 실전 | 라이브러리 사용 또는 스케줄러와 연동해 구현 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. C++20 코루틴으로 비동기 작업을 co_await하고, Task 타입을 설계하며, 이벤트 루프와 연동하는 기본 패턴을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: co_await와 Awaitable·Task 설계로 논블로킹 비동기 흐름을 표현할 수 있습니다. 콜백 지옥을 피하고, Asio와 연동해 실전 프로젝트에 적용할 수 있습니다. 다음으로 Modules 기초(#24-1)를 읽어보면 좋습니다.
이전 글: C++ 실전 가이드 #23-2: generator
다음 글: [C++ 실전 가이드 #24-1] C++20 Modules 기초: import로 컴파일 속도 높이기
관련 글
- C++20 Coroutine | co_await·co_yield로
- C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
- C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]
- C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
- C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]