C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]

이 글의 핵심

코루틴은 콜백 체인을 순차 코드처럼 읽히게 해 주지만, co_spawn에 detached를 넘기면 코루틴 밖으로 나온 예외가 조용히 버려져 연결만 사라지는 증상이 생깁니다. 코루틴이 상태 머신으로 바뀌는 원리와 awaitable 구현 개요를 짚고, 콜백 방식과의 성능 비교 기준과 디버깅 팁을 함께 정리합니다.

들어가며: 콜백에서 코루틴으로

비동기 코드의 가독성 한계

Asio의 async_read / async_write를 콜백으로 이어 붙이면 “읽기 완료 → 처리 → 쓰기 시작 → 쓰기 완료 → 다시 읽기 시작 → …”이 중첩 람다로 깊어집니다. 에러 처리와 타이밍을 넣을수록 콜백 지옥이 됩니다. C++20 코루틴과 Asio의 awaitable을 쓰면, 같은 비동기 흐름을 동기 코드처럼 한 줄씩 co_await로 쓸 수 있습니다. “읽기 완료될 때까지 대기 → 처리 → 쓰기 완료될 때까지 대기”가 그대로 읽히므로, 유지보수와 디버깅이 훨씬 수월해집니다. 처음 코루틴을 접할 때 “co_await 한 번에 스레드가 블로킹되나?”라고 생각할 수 있습니다. 블로킹되지 않습니다. co_await 시 그 코루틴만 일시 정지하며, io_context는 다른 핸들러를 계속 실행합니다. 완료되면 해당 코루틴이 재개되므로, 논블로킹 이벤트 루프 모델은 그대로 유지됩니다. C++20 코루틴과 Asio를 함께 쓰려면 컴파일러가 C++20을 지원해야 하며, MSVC/GCC/Clang 최신 버전을 사용하면 됩니다. 목표:

  • boost::asio::awaitable과 co_await 기본 사용
  • async_read, async_write를 awaitable로 감싸서 사용
  • 에러 처리와 실행 맥락(executor) 지정
  • 실전: Echo 서버를 코루틴 스타일로 작성

awaitable이란

Asio의 awaitable 타입

boost::asio::awaitable (또는 asio::awaitable)는 co_await 가능한 Asio 비동기 연산의 결과 타입입니다. 비동기 연산이 “완료되면 T를 반환”하는 형태를 표현합니다.

  • async_read를 awaitable 버전으로 호출하면, co_await한 순간 그 코루틴은 일시 정지하며, 읽기가 완료되면 재개되며 결과(바이트 수 등)를 받습니다.
  • 코루틴은 스택이 아닌 힙/프레임에 상태가 저장되므로, “대기 중”에 다른 핸들러가 실행될 수 있어 논블로킹이 유지됩니다.

기본 형태

#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;
    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);
    }
}
  • use_awaitable은 “이 비동기 연산을 awaitable로 바꿔 달라”는 토큰입니다.
  • co_await 시 해당 연산이 완료될 때까지 코루틴이 일시 정지하며, 완료 후 결과를 받아 다음 줄로 진행합니다.

이 코드에서 buf가 코루틴의 지역 변수라는 점이 콜백 방식과 비교해 가장 큰 차이입니다. 콜백 방식에서는 읽기 버퍼가 비동기 연산이 끝날 때까지 살아 있어야 하므로 세션 객체의 멤버로 두고 shared_from_this()로 수명을 연장해야 했습니다. 코루틴에서는 지역 변수가 코루틴 프레임에 저장되어 코루틴이 끝날 때까지 유지되므로, 수명 관리 코드가 통째로 사라집니다. socket을 값으로 받은 것도 같은 이유입니다. 코루틴 매개변수는 프레임으로 복사·이동되므로 값으로 받으면 안전하지만, 참조로 받은 매개변수는 호출자의 객체를 가리킬 뿐이라 호출자가 먼저 사라지면 댕글링이 됩니다. co_spawn(io, session(sock_ref), detached)처럼 참조를 넘기고 호출한 함수가 반환해 버리는 것이 코루틴 입문 시 가장 흔한 크래시 원인입니다.


co_await로 비동기 대기

async_read_some / async_write

  • async_read_some(…, use_awaitable) → 완료 시 읽은 바이트 수를 반환.
  • async_write(…, use_awaitable) → 완료 시 쓴 바이트 수를 반환 (에러면 예외). 에러가 나면 use_awaitable은 기본적으로 boost::system::system_error를 던지므로, try/catch로 처리할 수 있습니다.
try {
    std::size_t n = co_await socket.async_read_some(
        boost::asio::buffer(buf), boost::asio::use_awaitable);
    // ...
} catch (const boost::system::system_error& e) {
    if (e.code() != boost::asio::error::eof)
        std::cerr << "read error: " << e.what() << "\n";
    co_return;
}

에러 처리와 executor

executor 바인딩

코루틴이 어느 executor(예: strand) 에서 실행될지는, 해당 코루틴을 어디서 시작하느냐에 달려 있습니다. co_spawn(io, session(std::move(socket)), boost::asio::detached) 처럼 co_spawn에 executor를 넘기면, 그 executor에서 코루틴이 실행됩니다.

auto ex = boost::asio::make_strand(io.get_executor());
boost::asio::co_spawn(ex, session(std::move(socket)), boost::asio::detached);
  • session의 co_await들은 모두 ex(strand) 위에서 재개되므로, Strand의 순서 보장을 그대로 받을 수 있습니다.

여기서 짚어 둘 점은 코루틴 하나는 그 자체로 이미 순차적이라는 것입니다. 한 코루틴은 co_await에서 멈췄다가 재개될 뿐 자기 자신과 동시에 실행될 수 없으므로, 세션 상태를 그 코루틴 하나만 건드린다면 strand가 없어도 데이터 레이스가 생기지 않습니다. Strand가 필요해지는 것은 같은 상태를 여러 실행 흐름이 건드릴 때입니다. 예를 들어 한 소켓에 읽기 코루틴과 쓰기 코루틴을 따로 두고 둘이 같은 큐를 공유하거나, 타이머 핸들러가 세션을 닫는 경우입니다. 이런 흐름들을 같은 strand 위에서 co_spawn해야 둘이 겹치지 않습니다.

코루틴 프레임 할당

코루틴 프레임은 기본적으로 힙에 할당됩니다. Boost.Asio는 awaitable 프레임을 스레드별 재활용 캐시에서 가져오도록 구현되어 있어, 짧은 코루틴을 반복해서 만들어도 매번 malloc을 호출하지는 않습니다. 비동기 연산의 핸들러 메모리를 직접 제어하는 방법은 이전 글의 핸들러 메모리 최적화에서 다룬 연관 할당자(associated allocator)를 참고하면 됩니다.


실전: 코루틴 Echo 서버

accept 루프와 세션

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();
}
  • async_accept(use_awaitable) 로 새 연결을 co_await로 받으며,
  • 각 연결마다 session 코루틴을 co_spawn으로 띄웁니다.
  • session 안에서는 async_read_some → async_write를 co_await로 반복하면, 콜백 중첩 없이 Echo 로직이 직선으로 읽힙니다.

정리

  • awaitable과 co_await로 Asio 비동기 연산을 “동기처럼” 한 줄씩 작성할 수 있음.
  • use_awaitable을 비동기 연산에 넘기면 해당 연산이 완료될 때까지 코루틴이 일시 정지하며, 완료 후 재개됩니다.
  • co_spawn으로 코루틴을 시작하며, executor를 지정하면 Strand 등과 결합 가능.
  • 콜백 지옥을 피하고 가독성과 유지보수성을 크게 높일 수 있는 최신 기법입니다.

보강: 실전 코드 예제 확장

에코 루프에 상한·로깅을 넣은 형태입니다. co_await 사이에 동기 코드가 있어도, 실행 스레드는 완료마다 달라질 수 있으므로 세션 상태는 Strand 안에서만 건드리는 것이 안전합니다.

boost::asio::awaitable<void> session(boost::asio::ip::tcp::socket socket) {
    std::array<char, 4096> buf{};
    std::size_t total = 0;
    const std::size_t max_echo = 1 << 20;
    try {
        for (;;) {
            std::size_t n = co_await socket.async_read_some(
                boost::asio::buffer(buf), boost::asio::use_awaitable);
            total += n;
            if (total > max_echo) co_return;
            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 << e.what() << '\n';
    }
}

보강: co_await 동작 원리 (요약)

  1. co_await expr를 만나면 컴파일러는 awaitable에 대해 await_ready → 필요 시 코루틴 프레임에 상태 저장 후 일시 정지 → I/O 완료 시 await_resume로 결과를 받는 흐름을 생성합니다.
  2. Asio의 async_*(..., use_awaitable)는 완료 시 현재 코루틴을 executor에 다시 스케줄해, 논블로킹으로 다음 줄을 실행합니다.
  3. 따라서 스레드가 소켓에서 막히지 않고, io_context는 다른 핸들러·다른 코루틴을 계속 진행합니다. 디버깅 시 유의: “한 함수 안의 다음 줄”은 같은 OS 스레드라는 뜻이 아니라, 같은 논리 흐름이 이어진다는 뜻에 가깝습니다(executor 정책에 따름).

심화: 코루틴 상태 머신 (개념도)

co_await를 만나면 컴파일러는 코루틴 프레임(힙 또는 커스텀 할당)에 지역 변수·일시 값·재개 지점을 저장합니다. Asio는 완료 시 executor에 “이 코루틴을 재개하는 클로저”를 넣습니다.

stateDiagram-v2
    [*] --> Running
    Running --> Suspended: co_await (I/O 대기)
    Suspended --> Scheduled: 완료 콜백이 executor에 enqueue
    Scheduled --> Running: 재개 (다음 줄)
    Running --> Done: co_return 또는 예외
    Done --> [*]

중요: Suspended 동안 스택 프레임이 유지되는 것이 아니라, 프레임이 힙(또는 커스텀 할당)에 있습니다. 따라서 재개 시 스레드가 바뀔 수 있으며, 이것이 “콜백과 동일하게 공유 상태에 주의”라는 이유입니다.


심화: awaitable 구현 원리 (요약)

표준 코루틴에서 co_await는 대기 대상이 await_ready / await_suspend / await_resume를 제공하는지 확인합니다. Boost.Asio의 async_*(..., use_awaitable)는 내부적으로 비동기 연산의 완료 시점에 현재 코루틴 핸들을 다시 스케줄하도록 연결합니다.

  • await_ready: 이미 완료면 동기적으로 진행.
  • await_suspend: 핸들러에 코루틴 핸들을 넘겨, 완료 시 resume되게 함.
  • await_resume: 결과(error_code, 전송 바이트 등)를 반환하거나 예외를 던짐. 실무에서의 함의: 코루틴 함수는 일반 함수처럼 스택만 보면 안 되고, co_await마다 끊길 수 있는 지점으로 봐야 합니다. 뮤텍스를 잡은 채 co_await하면 데드락·레이스로 이어지기 쉽습니다.

뮤텍스 문제는 단순한 성능 문제가 아니라 정확성 문제입니다. std::lock_guard를 잡고 co_await하면, 코루틴이 멈춰 있는 동안 락이 계속 잡혀 있어 같은 락을 원하는 다른 핸들러가 워커 스레드를 붙잡은 채 기다립니다. 워커 스레드가 모두 이렇게 막히면 멈춘 코루틴을 재개할 스레드가 없어 교착 상태가 됩니다. 게다가 코루틴이 다른 스레드에서 재개되면 lock_guard의 소멸자가 락을 잡은 스레드가 아닌 스레드에서 unlock을 호출하게 되는데, std::mutex에서 이는 정의되지 않은 동작입니다. 코루틴에서는 “락을 잡은 구간 안에 co_await를 두지 않는다”를 규칙으로 삼고, 공유 상태 보호는 strand로 처리하는 것이 원칙입니다.


심화: 실전 에러 처리 패턴

예외 기반 (use_awaitable)

boost::asio::awaitable<void> session(boost::asio::ip::tcp::socket s) {
    std::array<std::byte, 2048> buf{};
    try {
        for (;;) {
            std::size_t n = co_await s.async_read_some(
                boost::asio::buffer(buf), boost::asio::use_awaitable);
            co_await boost::asio::async_write(s,
                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';
    }
    co_return;
}

error_code 기반 (프로젝트 규칙이 “예외 금지”일 때)

완료 토큰을 바꾸면 예외 대신 error_code로 결과를 받을 수 있습니다.

boost::system::error_code ec;
std::size_t n = co_await s.async_read_some(
    boost::asio::buffer(buf),
    boost::asio::redirect_error(boost::asio::use_awaitable, ec));
if (ec == boost::asio::error::eof) co_return;   // 정상 종료
if (ec) { log(ec.message()); co_return; }

// Boost 1.78+ : as_tuple로 에러 코드와 결과를 함께 받기
auto [ec2, n2] = co_await s.async_read_some(
    boost::asio::buffer(buf),
    boost::asio::as_tuple(boost::asio::use_awaitable));

redirect_error는 에러를 예외로 던지는 대신 넘겨준 ec 변수에 담아 두고 정상 반환하므로, 매 호출 뒤에 ec를 확인하는 습관이 필요합니다. 확인을 빠뜨리면 에러가 난 뒤에도 n(0)을 가지고 다음 줄로 진행해 빈 쓰기나 무한 루프를 만들 수 있습니다. 예외 방식과 에러 코드 방식은 성능보다 코드 규약의 문제에 가깝습니다. 연결 종료(eof)처럼 자주 일어나는 정상 흐름까지 예외로 처리하면 예외 비용이 무시할 수 없는 수준이 될 수 있어, 읽기 루프는 에러 코드로, 드문 실패는 예외로 나누는 혼합 방식도 흔합니다.

취소·타임아웃

steady_timer를 같은 executor에서 co_await하며, 소켓 닫기·작업 취소를 한 경로로 모읍니다. 타임아웃과 정상 완료가 경쟁하면 연결 상태 머신(열림/닫힘/드레인)을 명시하는 편이 안전합니다.

Boost.Asio는 이 경쟁을 간단히 표현할 수 있도록 실험적인 awaitable 연산자를 제공합니다.

#include <boost/asio/experimental/awaitable_operators.hpp>
using namespace boost::asio::experimental::awaitable_operators;

boost::asio::steady_timer timer(co_await boost::asio::this_coro::executor);
timer.expires_after(std::chrono::seconds(30));
// 둘 중 먼저 끝나는 쪽의 결과를 받고, 나머지 연산은 취소됨
auto result = co_await (
    s.async_read_some(boost::asio::buffer(buf), boost::asio::use_awaitable) ||
    timer.async_wait(boost::asio::use_awaitable));
if (result.index() == 1) { /* 타임아웃: 연결 정리 */ co_return; }
std::size_t n = std::get<0>(result);

|| 연산자는 두 연산을 동시에 시작하고, 먼저 완료된 쪽의 결과를 std::variant로 돌려주며, 늦은 쪽에는 취소 신호를 보냅니다. 직접 구현하면 “타이머 핸들러가 소켓을 닫고, 읽기는 operation_aborted로 끝나는” 흐름을 두 코루틴이나 핸들러로 나눠 짜야 하는데, 이 경우 두 흐름이 같은 소켓을 건드리므로 앞에서 말한 strand가 필요해집니다. experimental 네임스페이스라 버전 간 API가 바뀔 수 있다는 점은 감안해야 합니다.


심화: 성능 비교 (콜백 vs 코루틴, 체크리스트)

항목콜백코루틴
코드 크기/분기상태를 수동으로 유지컴파일러가 프레임에 저장
할당핸들러 객체만핸들러 + 코루틴 프레임
인라인 가능성작은 람다에 유리할 수 있음컴파일러·Asio 버전에 의존
디버깅콜백 체인 추적이 번거로움단일 함수에 브레이크포인트 가능

측정 제안: 동일 RPS에서 instructions per request와 p99를 함께 보며, 차이가 크면 프로파일에서 coroutine/await 관련 심볼과 할당 비중을 확인합니다. 종종 가독성을 위해 코루틴을 쓰고, 핫패스만 콜백으로 내리는 하이브리드가 현실적인 타협입니다.


보강: awaitable vs 콜백 비교

항목콜백awaitable + co_await
제어 흐름중첩·상태 플래그로 분기위에서 아래로 읽히는 순차 코드
에러 처리각 콜백마다 ec 전파try/catch로 한 블록에 모을 수 있음
스택콜백 깊이만큼 논리적 복잡도코루틴 프레임(힙) — 깊이에 덜 민감
오버헤드핸들러 객체만코루틴 프레임 + 핸들러(최적화 여부는 컴파일러/Asio 버전 의존)
디버깅브레이크포인트가 여러 콜백에 분산한 코루틴 함수에 브레이크포인트 가능

선택: 지연에 극도로 민감한 핫패스는 프로파일 후 결정하며, 대부분의 네트워크 서비스 로직은 가독성·정확성 때문에 코루틴이 유리한 경우가 많습니다.


보강: 디버깅 팁

  • 단일 스레드 io.run()으로 먼저 동작을 검증한 뒤, 멀티 스레드·Strand를 올리면 원인 분리가 쉽습니다.
  • TSan 빌드로 세션 공유 데이터가 코루틴과 콜백 경로로 새어 나갔는지 확인합니다.

보강: 성능 측정 방법

  • 동일 부하로 콜백 구현 vs 코루틴 구현의 RPS·p99를 비교합니다. 차이가 크면 프로파일러로 코루틴 프레임 할당·await 준비 비용을 확인합니다.

보강: 흔한 실수와 해결책

실수해결
co_await 없이 코루틴 함수만 호출비동기 연산이 시작되지 않음 → 반드시 co_spawn 등으로 실행.
코루틴 안에서 블로킹 read/sleep이벤트 루프를 막음 → Asio 비동기 API만 사용.
여러 연결이 같은 코루틴 로컬 상태를 공유데이터 레이스 → 연결별 객체·Strand로 분리.

자주 묻는 질문 (FAQ)

Q. co_spawn에 detached를 넘기면 코루틴 안에서 잡지 않은 예외는 어떻게 되나요?

A. detached 완료 토큰은 코루틴의 결과를 버리므로, 코루틴 밖으로 빠져나온 예외도 std::exception_ptr로 전달된 뒤 그대로 무시됩니다. 그래서 세션 코루틴이 예외로 끝나도 로그 한 줄 없이 연결만 조용히 사라지는 증상이 생깁니다. 완료 토큰으로 [](std::exception_ptr e) { if (e) std::rethrow_exception(e); } 같은 람다를 넘겨 예외를 다시 던지거나 로그를 남기고, 세션 단위로 처리할 예외는 코루틴 안에서 try/catch로 잡는 것이 좋습니다.


같이 보면 좋은 글