C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
콜백 지옥과 코루틴
네트워크 요청을 보내고, 응답이 오면 파싱하고, 그 결과로 다른 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](error_code ec, User user) {
if (ec) { /* 에러 처리 */ return; }
dbSave(user, [userId](error_code ec) {
if (ec) { /* 에러 처리 */ return; }
notifyUser(userId);
});
});
});
}
이 구조에서는 에러 처리가 단계마다 반복되고, 다음 단계에서 쓸 값을 람다 캡처로 계속 넘겨야 하며, 캡처한 객체의 수명을 직접 따져야 합니다. 디버거로 멈춰 보면 콜 스택에는 마지막 콜백만 남아 있어서 어떤 경로로 여기까지 왔는지 추적하기도 어렵습니다.
같은 흐름을 코루틴으로 쓰면 다음과 같습니다.
Task<void> fetchUserData(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에서는 그 코루틴만 중단되고 스레드는 호출자(결국 이벤트 루프)에게 돌아가 다른 핸들러를 실행합니다. 작업이 끝나면 완료 콜백이 코루틴을 재개하므로, 논블로킹 이벤트 루프 모델은 그대로 유지됩니다. 매개변수를 const std::string&가 아니라 값으로 받은 이유는 뒤의 수명 관리 절에서 설명합니다.
flowchart LR
subgraph callback[콜백 방식]
C1[요청] --> C2[콜백1]
C2 --> C3[콜백2]
C3 --> C4[콜백3]
end
subgraph coroutine[코루틴 방식]
R1[요청] --> R2[co_await]
R2 --> R3[co_await]
R3 --> R4[완료]
end
Awaitable의 세 가지 메서드와 co_await 흐름
co_await expr에서 expr의 타입(또는 그 타입의 operator co_await 결과)은 다음 세 메서드를 제공해야 합니다.
struct MyAwaitable {
// 1. true를 반환하면 중단하지 않고 바로 await_resume으로 넘어갑니다.
bool await_ready() const { return false; }
// 2. 중단 직후 호출됩니다. h는 현재 코루틴의 핸들입니다.
// 비동기 작업을 시작하고, 완료 콜백에서 h.resume()을 호출합니다.
void await_suspend(std::coroutine_handle<> h) {
startAsyncWork([h] { h.resume(); });
}
// 3. 재개될 때 호출되며, 반환값이 co_await 식의 결과가 됩니다.
int await_resume() { return 42; }
};
await_suspend의 반환 타입은 세 가지 중 하나를 고를 수 있습니다. void는 항상 중단, bool은 false를 반환하면 중단을 취소하고 바로 재개, std::coroutine_handle<>을 반환하면 그 핸들의 코루틴으로 곧바로 제어를 넘깁니다(대칭 전송, symmetric transfer). 세 번째 형태는 아래 Task 구현에서 핵심 역할을 합니다.
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
표준 라이브러리는 가장 단순한 두 Awaitable을 제공합니다. std::suspend_always는 await_ready가 항상 false라서 항상 중단하고, std::suspend_never는 항상 true라서 절대 중단하지 않습니다. 주로 initial_suspend와 final_suspend의 반환 타입으로 쓰입니다.
Task 타입 구현
값을 반환하는 Task
아래 Task<T>는 지연 시작(lazy) 방식입니다. 코루틴을 호출하면 프레임만 만들어지고 initial_suspend에서 바로 멈추며, 누군가 co_await할 때 비로소 실행됩니다.
#include <coroutine>
#include <exception>
#include <optional>
#include <utility>
template <typename T>
class [[nodiscard]] Task {
public:
struct promise_type {
std::optional<T> value;
std::coroutine_handle<> continuation;
std::exception_ptr exception;
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
// 끝나면 자신을 기다리던 코루틴으로 제어를 넘깁니다.
struct FinalAwaiter {
bool await_ready() const noexcept { return false; }
std::coroutine_handle<> await_suspend(
std::coroutine_handle<promise_type> h) noexcept {
if (auto c = h.promise().continuation) return c;
return std::noop_coroutine();
}
void await_resume() const noexcept {}
};
FinalAwaiter final_suspend() noexcept { 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 noexcept { return false; }
std::coroutine_handle<> await_suspend(std::coroutine_handle<> h) noexcept {
handle.promise().continuation = h;
return handle; // 대칭 전송: 자식 코루틴을 시작합니다.
}
T await_resume() {
if (handle.promise().exception)
std::rethrow_exception(handle.promise().exception);
return std::move(*handle.promise().value);
}
};
Awaiter operator co_await() noexcept { return Awaiter{handle_}; }
Task(Task&& o) noexcept : handle_(std::exchange(o.handle_, {})) {}
Task& operator=(Task&& o) noexcept {
if (this != &o) {
if (handle_) handle_.destroy();
handle_ = std::exchange(o.handle_, {});
}
return *this;
}
~Task() { if (handle_) handle_.destroy(); }
private:
explicit Task(std::coroutine_handle<promise_type> h) : handle_(h) {}
std::coroutine_handle<promise_type> handle_;
};
이 구현에서 짚어 둘 설계 결정이 몇 가지 있습니다.
final_suspend에서 continuation을 직접resume()하지 않고FinalAwaiter::await_suspend가 핸들을 반환하게 했습니다.final_suspend()함수 본문에서 재개하면 자식 코루틴이 아직 최종 중단 지점에 도달하지 않은 상태로 부모가 실행되고, 부모가 그 사이Task를 소멸시키면 실행 중인 프레임을 파괴하게 됩니다. 핸들 반환 방식은 자식이 완전히 중단된 다음 제어를 넘기며, 중첩이 깊어도 스택이 쌓이지 않습니다.Awaiter::await_suspend가 자식 핸들을 반환하므로co_await task가 곧 자식을 시작하는 동작이 됩니다.initial_suspend가suspend_always인데 아무도 자식을 재개하지 않으면 코루틴은 영원히 실행되지 않습니다.T value대신std::optional<T>를 써서 기본 생성자가 없는 타입도 반환할 수 있게 했습니다.- 이동된
Task는 핸들이 비어 있으므로 소멸자와 이동 대입에서 널 검사를 합니다. 이동 생성자를 선언했기 때문에 복사 생성자는 암시적으로 삭제됩니다.
Task<void>는 promise_type에서 value와 return_value 대신 void return_void() {}를 두고, Awaiter::await_resume이 예외만 다시 던지도록 바꾸면 됩니다. 나머지 구조는 같습니다.
최상위에서 Task를 실행하는 문제
이 Task는 co_await로만 시작되므로, 코루틴이 아닌 main에서 첫 Task를 시작하고 완료를 기다리는 장치가 따로 필요합니다. 흔히 while (!h.done()) h.resume(); 같은 get()을 붙이곤 하는데, 이는 내부의 모든 co_await가 동기적으로 끝날 때만 맞습니다. 실제 비동기 작업을 기다리는 중인 코루틴을 다른 곳에서 resume()하면 정의되지 않은 동작입니다. cppcoro의 sync_wait처럼 완료를 이벤트로 통지받아 기다리는 함수를 만들거나, 실전에서는 아래에서 다룰 Asio의 co_spawn처럼 이벤트 루프가 최상위 코루틴을 소유하게 합니다. 직접 만든 Task를 프로덕션에서 쓰기보다 cppcoro, libunifex, Asio 같은 라이브러리의 타입을 쓰는 이유가 이런 주변 장치 때문입니다.
Task와 커스텀 Awaitable 조합
// 별도 스레드에서 잠든 뒤 코루틴을 재개하는 Awaitable
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() {}
};
Task<int> fetchParseAndSave(std::string url) {
co_await SleepAwaitable{std::chrono::milliseconds(100)}; // 요청 흉내
co_await SleepAwaitable{std::chrono::milliseconds(50)}; // 파싱 흉내
co_return 42;
}
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 SleepAwaitable 이후의 코드가 원래 스레드가 아니라 sleep 스레드에서 실행된다는 것입니다. 어느 스레드에서 재개되는지는 전적으로 Awaitable이 결정하며, 이 점이 뒤에서 다룰 스레드 안전성 문제의 출발점입니다.
co_await 연산자 오버로딩
co_await expr에서 expr이 operator co_await를 제공하면 그 결과가 Awaitable로 쓰입니다. 값 타입은 단순하게 두고, 중단·재개 로직은 별도의 Awaiter에 둘 때 유용합니다.
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) {
scheduleTimer(delay, [h] { h.resume(); }); // delay 후 재개
}
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;
}
같은 방식으로 기존 Awaiter를 감싸 중단·재개 시점에 로그를 남기는 래퍼도 만들 수 있습니다. 디버깅할 때 어떤 코루틴이 어디서 멈췄는지 확인하는 데 쓸 만합니다.
template <typename Inner>
struct LoggingAwaiter {
Inner inner;
bool await_ready() { 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();
}
};
boost::asio::awaitable과 strand 연동
Boost.Asio는 1.70부터 boost::asio::awaitable과 use_awaitable 완료 토큰을 제공합니다. 앞에서 직접 만든 Task의 역할을 awaitable이, 최상위 실행 문제를 co_spawn과 io_context가 맡습니다.
#include <boost/asio.hpp>
#include <array>
#include <iostream>
namespace asio = boost::asio;
using asio::ip::tcp;
asio::awaitable<void> echoSession(tcp::socket socket) {
std::array<char, 1024> buf;
try {
for (;;) {
std::size_t n = co_await socket.async_read_some(
asio::buffer(buf), asio::use_awaitable);
co_await asio::async_write(socket,
asio::buffer(buf.data(), n), asio::use_awaitable);
}
} catch (const boost::system::system_error& e) {
if (e.code() != asio::error::eof)
std::cerr << "session error: " << e.what() << "\n";
}
}
asio::awaitable<void> listener(tcp::acceptor& acceptor) {
for (;;) {
auto socket = co_await acceptor.async_accept(asio::use_awaitable);
auto ex = socket.get_executor();
asio::co_spawn(ex, echoSession(std::move(socket)), asio::detached);
}
}
int main() {
asio::io_context io;
tcp::acceptor acceptor(io, {tcp::v4(), 8080});
asio::co_spawn(io, listener(acceptor), asio::detached);
std::cout << "Echo server listening on port 8080\n";
io.run();
}
클라이언트가 보낸 데이터를 그대로 돌려보내는 Echo 서버입니다. use_awaitable은 비동기 연산을 co_await할 수 있게 만드는 완료 토큰이고, co_spawn은 코루틴을 executor 위에서 시작하며, detached는 그 코루틴의 완료를 기다리지 않겠다는 뜻입니다. 상대가 연결을 닫으면 async_read_some이 eof 에러로 예외를 던지므로 그 경우는 정상 종료로 취급합니다.
# GCC 10은 -fcoroutines 플래그가 필요하고, GCC 11 이상은 -std=c++20만으로 됩니다.
g++ -std=c++20 -o echo_server echo_server.cpp -pthread
./echo_server
# 다른 터미널에서
echo "hello" | nc localhost 8080
Boost.System은 1.69부터 헤더 전용이라 -lboost_system은 보통 필요하지 않습니다.
Strand가 필요한 경우
auto ex = asio::make_strand(io.get_executor());
asio::co_spawn(ex, echoSession(std::move(socket)), asio::detached);
이렇게 시작하면 그 코루틴의 모든 재개가 같은 strand에서 실행됩니다. 다만 코루틴 하나는 그 자체로 이미 순차적입니다. co_await에서 멈췄다 재개될 뿐 자기 자신과 동시에 실행되지는 않으므로, 세션 상태를 그 코루틴 하나만 건드린다면 io.run()을 여러 스레드에서 돌려도 strand 없이 데이터 레이스가 생기지 않습니다. strand가 필요한 것은 한 소켓에 읽기 코루틴과 쓰기 코루틴을 따로 두고 같은 큐를 공유하거나, 타이머 핸들러가 세션을 닫는 경우처럼 같은 상태를 여러 실행 흐름이 건드릴 때이고, 그 흐름들을 같은 strand 위에서 co_spawn해야 겹치지 않습니다.
반대로 코루틴에서 std::mutex를 쓸 때는 락을 잡은 구간 안에 co_await를 두면 안 됩니다. 코루틴이 멈춰 있는 동안 락이 계속 잡혀 있어 같은 락을 원하는 핸들러가 워커 스레드를 붙잡고 기다리고, 워커가 모두 막히면 멈춘 코루틴을 재개할 스레드가 없어 교착 상태가 됩니다. 게다가 코루틴이 다른 스레드에서 재개되면 lock_guard 소멸자가 락을 잡지 않은 스레드에서 unlock을 호출하게 되는데, std::mutex에서 이는 정의되지 않은 동작입니다.
detached 완료 토큰은 코루틴의 결과를 버리기 때문에, 코루틴 밖으로 빠져나온 예외도 그대로 무시됩니다. 세션 코루틴이 예외로 끝나도 로그 한 줄 없이 연결만 사라지는 증상이 여기서 나옵니다. 완료 토큰으로 [](std::exception_ptr e) { if (e) std::rethrow_exception(e); } 같은 람다를 넘겨 다시 던지거나 로그를 남기고, 세션 단위로 처리할 예외는 코루틴 안에서 try/catch로 잡는 것이 좋습니다.
에러 처리: 예외, unhandled_exception, error_code
코루틴 본문에서 잡히지 않은 예외가 나오면 컴파일러가 생성한 코드가 promise_type::unhandled_exception()을 호출합니다. 앞의 Task 구현은 여기서 std::current_exception()을 저장해 두었다가 await_resume에서 std::rethrow_exception으로 다시 던지므로, 호출자 코루틴 입장에서는 일반 함수 호출처럼 예외가 전파됩니다. Asio의 use_awaitable도 같은 방식으로 에러를 boost::system::system_error 예외로 던집니다.
예외 대신 에러 코드로 받고 싶으면 redirect_error로 토큰을 감쌉니다. 예외를 끈 빌드이거나, eof처럼 자주 일어나는 정상 흐름을 예외로 처리하고 싶지 않을 때 씁니다.
boost::system::error_code ec;
std::size_t n = co_await socket.async_read_some(asio::buffer(buf),
asio::redirect_error(asio::use_awaitable, ec));
if (ec == asio::error::eof) { /* 정상 종료 */ }
else if (ec) { /* 에러 처리 */ }
catch 블록 안에서는 co_await를 쓸 수 없습니다
재시도 로직을 짤 때 가장 먼저 부딪히는 제약입니다. C++20은 예외 핸들러(catch 블록) 안의 co_await를 금지하므로, catch 안에서 대기 타이머를 co_await하는 코드는 컴파일되지 않습니다. 예외는 catch에서 기록만 하고, 대기는 블록 밖에서 해야 합니다.
asio::awaitable<std::string> fetchWithBackoff(std::string url, int maxRetries = 5) {
auto ex = co_await asio::this_coro::executor;
std::chrono::milliseconds delay(100);
for (int i = 0;; ++i) {
try {
co_return co_await httpGet(url);
} catch (const std::exception& e) {
if (i + 1 == maxRetries) throw;
std::cerr << "retry " << (i + 1) << "/" << maxRetries
<< " after " << delay.count() << "ms: " << e.what() << "\n";
}
// catch 블록 밖에서 대기합니다.
asio::steady_timer timer(ex, delay);
co_await timer.async_wait(asio::use_awaitable);
delay *= 2; // 100ms → 200ms → 400ms → ...
}
}
co_return co_await ...는 try 블록 안이므로 허용됩니다. 마지막 시도에서 실패하면 throw;로 원래 예외를 다시 던져 호출자가 실패 원인을 볼 수 있게 했습니다.
수명과 스레드에서 생기는 실수
Dangling 참조
// 위험: 첫 co_await에서 호출자로 돌아간 사이 s가 가리키는 객체가 사라질 수 있습니다.
Task<void> bad(const std::string& s) {
co_await someAsyncOp();
std::cout << s << "\n"; // UB 가능
}
// 안전: 값으로 받으면 코루틴 프레임에 복사본이 저장됩니다.
Task<void> good(std::string s) {
co_await someAsyncOp();
std::cout << s << "\n";
}
코루틴 매개변수는 프레임으로 복사되지만, 참조 매개변수는 참조 자체만 복사됩니다. bad(std::string("temp"))처럼 임시 객체를 넘기면 임시 객체는 그 전체 식이 끝날 때 사라지는데, 지연 시작 Task라면 그 시점에 코루틴은 아직 시작도 하지 않았습니다. 그래서 중단 지점을 넘어 쓰는 인자는 값으로 받고, 여러 코루틴이 공유해야 하는 큰 객체는 std::shared_ptr로 넘깁니다. 람다 코루틴의 캡처도 같은 문제를 가집니다. 캡처는 프레임이 아니라 람다 객체에 들어 있으므로, 람다 객체가 먼저 사라지면 캡처한 값도 함께 사라집니다.
Task를 버리는 경우
void startWork() {
computeAsync(); // 반환된 Task를 버림
}
지연 시작 Task를 받지 않고 버리면 임시 객체의 소멸자가 프레임을 파괴하므로 메모리는 새지 않지만, 코루틴 본문은 한 줄도 실행되지 않습니다. 에러도 나지 않고 작업만 조용히 빠지는 버그가 됩니다. 앞의 구현에서 클래스에 [[nodiscard]]를 붙인 이유가 이것입니다. 더 나쁜 경우는 이미 시작되어 비동기 작업을 기다리는 코루틴의 Task를 소멸시키는 것입니다. 프레임은 파괴됐는데 나중에 완료 콜백이 그 핸들로 resume()을 호출하면 해제된 메모리를 실행하게 됩니다. Task는 반드시 co_await하거나, 수명을 맡아 줄 실행기(co_spawn 등)에 넘겨야 합니다.
재개 스레드와 데이터 레이스
Task<void> unsafe() {
static int counter = 0;
co_await asyncOnOtherThread();
++counter; // 다른 스레드에서 재개되면 데이터 레이스
}
co_await 앞뒤가 서로 다른 스레드에서 실행될 수 있다는 점을 놓치기 쉽습니다. 여러 코루틴이 공유하는 상태는 같은 strand에서 실행되게 하거나, co_await를 포함하지 않는 짧은 구간에서만 락을 잡아 보호합니다.
Asio에서 io_context를 직접 넘기는 패턴
// io_context에 묶인 버전
asio::awaitable<void> waitA(asio::io_context& io) {
asio::steady_timer timer(io);
timer.expires_after(std::chrono::seconds(1));
co_await timer.async_wait(asio::use_awaitable);
}
// 현재 코루틴의 executor를 쓰는 버전
asio::awaitable<void> waitB() {
auto ex = co_await asio::this_coro::executor;
asio::steady_timer timer(ex, std::chrono::seconds(1));
co_await timer.async_wait(asio::use_awaitable);
}
use_awaitable로 기다리는 경우 재개는 코루틴의 executor에서 일어나므로 첫 번째 버전이 곧바로 잘못된 스레드에서 재개되는 것은 아닙니다. 그래도 두 번째 버전을 권하는 이유는, I/O 객체가 코루틴과 같은 executor(예를 들어 strand)에 묶여 있어야 그 객체를 다른 핸들러와 공유할 때 직렬화가 보장되고, 함수가 특정 io_context에 의존하지 않아 재사용하기 쉽기 때문입니다.
co_return 없이 끝나는 경로
return_value만 있고 return_void가 없는 promise에서 본문 끝까지 흘러가 버리면 정의되지 않은 동작입니다. if/else의 한 경로에서만 co_return하는 실수가 대표적이며, 컴파일러에 따라 경고조차 나지 않을 수 있으므로 모든 경로가 co_return이나 throw로 끝나는지 확인해야 합니다.
콜백과 코루틴의 비용
코루틴의 주된 비용은 프레임 할당입니다. 코루틴을 호출할 때마다 중단 지점을 넘어 살아남아야 하는 지역 변수와 매개변수, promise를 담은 프레임이 보통 힙에 할당됩니다. 컴파일러가 프레임 수명이 호출자 안에 갇혀 있다고 증명하면 할당을 생략할 수 있지만(HALO), 이 최적화가 적용된다고 기대하고 설계하기는 어렵습니다. 콜백 방식도 캡처가 큰 람다를 std::function 같은 타입 소거 컨테이너에 담으면 힙 할당이 생기므로, 실제 차이는 구현에 따라 다릅니다.
I/O 바운드 코드에서는 네트워크 왕복 시간이 할당이나 재개 비용보다 몇 자릿수 크기 때문에 이 차이가 병목이 되는 경우는 드뭅니다. 할당 비용이 문제가 되는 핫 경로라면 프로파일러로 확인한 다음 아래 방법을 고려합니다.
중단 지점을 넘는 큰 지역 변수는 프레임 크기를 그대로 키웁니다.
// 1MB 배열이 코루틴 프레임 안에 들어갑니다.
Task<void> bad(tcp::socket& socket) {
std::array<char, 1024 * 1024> buffer;
co_await read(socket, buffer);
}
// 프레임에는 포인터만 남고, 버퍼는 따로 할당됩니다.
Task<void> better(tcp::socket& socket) {
auto buffer = std::make_unique<std::array<char, 1024 * 1024>>();
co_await read(socket, *buffer);
}
할당 횟수 자체는 같지만, 큰 프레임은 프레임 할당자를 커스터마이즈하거나 풀을 쓸 때 다루기 어렵고 스택에 둘 수도 없으므로 버퍼를 분리하는 편이 낫습니다. 또 결과를 그대로 전달만 하는 얇은 래퍼 코루틴은 호출마다 프레임을 하나 더 만듭니다. 래퍼가 하는 일이 없다면 코루틴이 아닌 일반 함수로 만들어 내부 Task나 Awaitable을 그대로 반환하는 편이 프레임 하나를 아낍니다.
타임아웃, 병렬 요청, 취소, 종료
간단한 HTTP 클라이언트
asio::awaitable<std::string> httpGet(std::string host, std::string path) {
auto ex = co_await asio::this_coro::executor;
tcp::resolver resolver(ex);
auto endpoints = co_await resolver.async_resolve(host, "80", asio::use_awaitable);
tcp::socket socket(ex);
co_await asio::async_connect(socket, endpoints, asio::use_awaitable);
// HTTP/1.0을 쓰면 chunked 인코딩 없이 서버가 응답 후 연결을 닫습니다.
std::string request = "GET " + path + " HTTP/1.0\r\nHost: " + host + "\r\n\r\n";
co_await asio::async_write(socket, asio::buffer(request), asio::use_awaitable);
std::string response;
boost::system::error_code ec;
co_await asio::async_read(socket, asio::dynamic_buffer(response),
asio::redirect_error(asio::use_awaitable, ec));
if (ec && ec != asio::error::eof) throw boost::system::system_error(ec);
auto pos = response.find("\r\n\r\n");
co_return pos == std::string::npos ? std::string{} : response.substr(pos + 4);
}
연결이 닫힐 때까지 읽으므로 마지막에는 항상 eof가 오고, 이를 에러가 아닌 정상 종료로 처리합니다. 상태 코드 확인, 리다이렉트, TLS는 다루지 않는 학습용 코드이며, 실제로는 Boost.Beast 같은 HTTP 라이브러리를 씁니다.
타임아웃
experimental::awaitable_operators의 ||로 메인 연산과 타이머를 동시에 시작해 먼저 끝나는 쪽을 받을 수 있습니다. ||는 먼저 완료된 쪽의 결과를 std::variant로 돌려주고 늦은 쪽에는 취소 신호를 보내므로, result.index()로 어느 쪽이 먼저 끝났는지 구분합니다(&&는 둘 다 끝날 때까지 기다리는 연산자입니다). 이를 직접 구현하면 “타이머 핸들러가 소켓을 닫고 읽기는 operation_aborted로 끝나는” 흐름을 두 실행 흐름으로 나눠 짜야 하고, 두 흐름이 같은 소켓을 건드리므로 strand가 필요해집니다. experimental 네임스페이스라 Boost 버전 사이에 API가 바뀔 수 있다는 점은 감안해야 합니다.
#include <boost/asio/experimental/awaitable_operators.hpp>
asio::awaitable<bool> withTimeout(asio::awaitable<void> op,
std::chrono::seconds timeout) {
using namespace asio::experimental::awaitable_operators;
asio::steady_timer timer(co_await asio::this_coro::executor);
timer.expires_after(timeout);
auto result = co_await (std::move(op) || timer.async_wait(asio::use_awaitable));
co_return result.index() == 0; // true: op가 먼저 끝남, false: 타임아웃
}
병렬 요청
여러 요청을 동시에 시작하고 모두 끝날 때까지 기다리려면 같은 헤더의 &&를 씁니다. 결과는 void가 아닌 것들의 std::tuple로 돌아옵니다. 한쪽이 예외로 끝나면 나머지에 취소 신호를 보내고 예외를 전파합니다.
asio::awaitable<Dashboard> loadDashboard(std::string userId) {
using namespace asio::experimental::awaitable_operators;
auto [user, orders, notifications] = co_await (
api.getUser(userId) && api.getOrders(userId) && api.getNotifications(userId));
co_return Dashboard{std::move(user), std::move(orders), std::move(notifications)};
}
awaitable이 아니라 일반 비동기 연산 여러 개를 묶을 때는 experimental::make_parallel_group을 씁니다. 각 연산을 deferred 토큰으로 만들어 넘기면, 완료 순서와 각 연산의 결과가 펼쳐진 형태로 돌아옵니다.
auto [order, ec1, n1, ec2, n2] = co_await
asio::experimental::make_parallel_group(
socket1.async_read_some(asio::buffer(buf1), asio::deferred),
socket2.async_read_some(asio::buffer(buf2), asio::deferred)
).async_wait(asio::experimental::wait_for_all(), asio::use_awaitable);
awaitable_operators와 parallel_group은 비교적 최근 Boost에서 추가된 기능이므로 사용하는 Boost 버전의 문서를 확인하십시오. 자세한 사용법은 Boost.Asio Parallel Group 문서에 있습니다.
취소
co_spawn의 완료 토큰에 bind_cancellation_slot으로 취소 슬롯을 연결하면, 시그널을 보냈을 때 그 코루틴이 현재 기다리는 연산이 취소됩니다. 코루틴은 기본적으로 terminal 취소만 받아들이므로 cancellation_type::terminal로 보냅니다.
asio::awaitable<void> longRunningTask() {
asio::steady_timer timer(co_await asio::this_coro::executor);
timer.expires_after(std::chrono::seconds(10));
boost::system::error_code ec;
co_await timer.async_wait(asio::redirect_error(asio::use_awaitable, ec));
if (ec == asio::error::operation_aborted) {
co_return; // 취소됨: 정리 후 종료
}
// 정상 경로
}
asio::cancellation_signal cancelSignal;
asio::co_spawn(io, longRunningTask(),
asio::bind_cancellation_slot(cancelSignal.slot(), asio::detached));
// 나중에 취소할 때 (같은 executor에서 호출해야 합니다)
cancelSignal.emit(asio::cancellation_type::terminal);
cancellation_signal은 스레드 안전하지 않으므로 코루틴이 실행되는 executor 위에서 emit해야 합니다.
우아한 종료
새 연결 수락을 멈추려면 acceptor를 닫는 것이 가장 단순합니다. 대기 중인 async_accept가 operation_aborted로 끝나므로 루프를 빠져나올 수 있습니다.
asio::awaitable<void> listener(tcp::acceptor& acceptor) {
for (;;) {
boost::system::error_code ec;
auto socket = co_await acceptor.async_accept(
asio::redirect_error(asio::use_awaitable, ec));
if (ec == asio::error::operation_aborted) co_return; // acceptor가 닫힘
if (ec) continue;
asio::co_spawn(socket.get_executor(),
echoSession(std::move(socket)), asio::detached);
}
}
int main() {
asio::io_context io;
tcp::acceptor acceptor(io, {tcp::v4(), 8080});
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&](auto, int) { acceptor.close(); });
asio::co_spawn(io, listener(acceptor), asio::detached);
io.run(); // 남은 세션이 모두 끝나면 반환됩니다.
}
시그널 핸들러에서 io.stop()을 바로 부르면 진행 중인 세션이 중간에 끊깁니다. acceptor만 닫으면 기존 세션은 끝날 때까지 처리되고, 할 일이 없어지면 io.run()이 반환됩니다. 오래 열려 있는 연결까지 정리하려면 세션들에도 위의 취소 시그널을 연결해 두어야 합니다.