본문으로 건너뛰기
Previous
Next
C++20 Coroutine | co_await·co_yield로 '콜백 지옥' 탈출하기

C++20 Coroutine | co_await·co_yield로 '콜백 지옥' 탈출하기

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과 코루틴 핸들의 수명을 이해할 수 있습니다.
  • 자주 발생하는 오류와 프로덕션 패턴을 익힐 수 있습니다.

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

1. 비동기 코드가 복잡해요

콜백 지옥(Callback Hell)

네트워크 요청, 파일 I/O, 타이머 같은 비동기 작업을 처리할 때, 전통적인 방식은 콜백을 사용하는 것입니다. 작업이 끝나면 콜백이 호출되고, 그 안에서 다음 작업을 시작하고… 이렇게 중첩되면 코드가 읽기 어려워집니다.

fetchUserData 함수의 구현 예제입니다.

// ❌ 콜백 지옥: 가독성 저하, 에러 처리 복잡
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: 실시간 센서 데이터 파이프라인

센서에서 데이터가 스트리밍으로 들어올 때, 필터링 → 변환 → 집계를 파이프라인으로 연결하고 싶습니다. 제너레이터 체인(filtermapbatch)으로 표현하면 각 단계가 lazy하게 동작해 메모리 효율이 좋습니다.


2. 코루틴이란

일시 정지/재개

코루틴실행을 중단했다가 나중에 같은 지점부터 다시 시작할 수 있는 함수입니다.

  • 일반 함수: 호출 → 반환 → 끝
  • 코루틴: 호출 → (일시 정지) → 재개 → (일시 정지) → … → 반환

다음은 mermaid 예제 코드입니다.

// 실행 예제
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 등을 반환하면 코루틴이 일시 정지

co_yield 내부 동작

// co_yield value 는 대략 다음과 같이 변환됩니다:
// promise.yield_value(value);
// → yield_value가 suspend_always를 반환하면 일시 정지
// → 호출자가 handle.resume() 하면 yield_value 다음부터 재개

4. co_await와 일시 정지

Awaitable

co_await expr에서 exprawaitable이어야 합니다. 보통 다음 세 메서드를 가집니다.

  • await_ready()
    이미 완료됐으면 true → 일시 정지 안 함
  • await_suspend(coroutine_handle)
    일시 정지 시 호출. 여기서 다른 스레드/이벤트 루프에 핸들을 넘겨 “완료 시 재개” 예약
  • await_resume()
    재개 시 반환값 (결과)

호출 흐름: co_await expr를 만나면 먼저 await_ready()를 호출합니다. true면 일시 정지 없이 곧바로 await_resume()으로 결과를 받고, falseawait_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_valuereturn_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_alwayshandle.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_valueco_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

수명 다이어그램

다음은 mermaid 예제 코드입니다.

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 참조 (죽은 참조)

원인: 코루틴이 일시 정지된 동안 지역 변수나 인자의 참조를 반환/저장하고, 코루틴이 재개되기 전에 그 객체가 소멸하는 경우.

C/C++ 예제 코드입니다.

// ❌ 위험: 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_valuereturn_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() 하는 방식을 많이 씁니다.

C/C++ 예제 코드입니다.

// 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을 이동하거나, 다른 제너레이터로 대입하면 반복자가 무효화됩니다.

C/C++ 예제 코드입니다.

// ❌ 위험: 루프 중 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_typereturn_void만 정의하거나, Generator<T>인데 return_value를 쓰면 컴파일 에러가 납니다.

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_typeunhandled_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++20 코루틴, co_await co_yield co_return, 코루틴 기초, 비동기, promise_type, coroutine_handle 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목내용
co_yield값 하나 내보내고 일시 정지 (제너레이터)
co_awaitawaitable 완료까지 일시 정지
co_return코루틴 종료 (반환값 선택)
promise_type반환 타입에 정의해 동작 제어
coroutine_handle프레임 재개·종료 확인·해제
수명핸들/객체가 살아 있는 동안만 프레임 유효

적용 시나리오별 선택 가이드

시나리오권장 패턴비고
lazy 시퀀스 (파일 라인, 무한 수열)co_yield + Generator메모리 효율, 부분 소비
비동기 I/O (네트워크, 파일)co_await + TaskBoost.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)를 읽어보면 좋습니다.

참고 자료

다음 글: [C++ 실전 가이드 #23-2] Generator 구현: co_yield로 lazy 시퀀스 만들기

이전 글: [C++ 실전 가이드 #22-2] 커스텀 Concepts 작성: 도메인에 맞는 제약 조건 정의하기


관련 글

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

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++20 Coroutine | co_await·co_yield로 ‘콜백 지옥’ 탈출하기」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++20 Coroutine | co_await·co_yield로 ‘콜백 지옥’ 탈출하기」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.