C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]

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와 연동하는 실전 패턴을 알 수 있습니다.
  • 수명·에러·성능 등 실전 주의사항을 파악할 수 있습니다.

목차

  1. Awaitable이란
  2. Task 타입 완전 구현
  3. co_await 연산자 오버로딩
  4. Asio 연동 예제
  5. 코루틴 에러 처리
  6. 자주 발생하는 실수
  7. 성능 비교: 콜백 vs 코루틴
  8. 프로덕션 패턴

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에서 exproperator 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+는 awaitableuse_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_groupwait_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 처리)
  • 프로파일링으로 성능 확인

참고 자료


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • 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 코루틴 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
Awaitableawait_ready, await_suspend(handle), await_resume
Taskpromise_type + operator co_await로 다른 코루틴이 co_await 가능
재개완료 시 handle.resume() 호출
Asiouse_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]