C++20 코루틴 기초: co_await·co_yield와 Generator 구현

들어가며: 값을 하나씩 만들어 주는 함수

일시 정지와 재개

한 번에 전부 계산하지 않고 호출할 때마다 다음 값 하나만 만들어 주는 함수가 필요할 때가 있습니다. I/O를 기다리는 동안 스레드를 블로킹하지 않고, 작업이 끝나면 그 자리부터 이어서 실행하고 싶을 때도 있습니다. 코루틴(coroutine, 실행을 중단했다가 나중에 같은 지점부터 다시 이어갈 수 있는 함수)은 이런 “일시 정지/재개”를 언어 차원에서 지원하는 기능입니다. C++20은 이를 위해 co_yield, co_await, co_return 세 키워드를 추가했습니다.

co_yield는 제너레이터(호출할 때마다 다음 값 하나만 산출하는 lazy 시퀀스), co_await는 비동기 대기를 표현할 때 쓰며, 콜백 지옥 없이 순차적인 코드처럼 작성할 수 있습니다. C++20에서는 프레임워크(promise_type, awaitable)를 직접 설계해야 해서 학습 부담이 있지만, 한 번 이해하면 비동기·스트리밍 코드 구조화에 유용합니다.

코루틴은 스레드가 아닙니다. 스레드는 OS가 스케줄하는 실행 단위이고, 코루틴은 어떤 스레드 위에서 “잠깐 멈추었다가 이어서 실행”되는 함수입니다. 모든 코루틴을 한 스레드의 이벤트 루프에서만 재개한다면 한 번에 하나만 실행되므로 락 없이 설계할 수 있습니다. 반대로 스레드 풀에서 재개하면 여러 코루틴이 동시에 실행되므로, 공유 데이터에는 일반 멀티스레드 코드와 같은 동기화가 필요합니다.

예제는 g++ -std=c++20(GCC 11 이상) 또는 clang++ -std=c++20으로 빌드합니다.


콜백 지옥을 코루틴으로 풀기

콜백 지옥(Callback Hell)

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

// ❌ 콜백 지옥: 가독성 저하, 에러 처리 복잡
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);
}

코드는 위에서 아래로 읽히고, 각 단계에서 던진 예외는 Task가 저장했다가 호출자에게 전달하므로 try/catch 하나로 처리할 수 있습니다. 단계가 늘어나도 들여쓰기가 깊어지지 않습니다. 다만 Task와 각 ...Async 함수가 반환하는 awaitable은 표준이 제공하지 않으므로, 직접 만들거나 라이브러리를 써야 합니다.

대용량 파일 스트리밍과 그 밖의 활용처

대용량 파일 스트리밍

수 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;
}

게임 AI 상태 머신

NPC가 “대기 → 경로 탐색 → 이동 → 공격 → 대기” 같은 여러 단계를 거칠 때, 각 단계마다 “다음 프레임까지 대기”, “애니메이션 완료 대기”, “타겟 발견 대기”가 필요합니다. 콜백으로 구현하면 상태 전이 로직이 분산되고, co_await로 “다음 프레임까지”, “이벤트까지” 대기하면 상태 머신이 한 함수 안에 순차적으로 표현됩니다.

// ❌ 콜백: findPath( { moveAlong( { playAttack({...}); }); });
// ✅ 코루틴: co_await findPathAsync; co_await moveAlongAsync; co_await playAttackAsync;

웹소켓 메시지 처리

연결 수립 → 인증 → 메시지 수신 루프에서, 각 단계가 비동기입니다. 콜백 중첩 시 “연결 끊김 시 정리”, “타임아웃 처리”가 각 레벨에 흩어져 유지보수가 어렵습니다. 코루틴으로 작성하면 try/catch 한 곳에서 예외 처리와 정리가 가능합니다.

실시간 센서 데이터 파이프라인

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


일시 정지와 재개가 가능한 함수, 세 키워드

일시 정지/재개

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

  • 일반 함수: 호출 → 반환 → 끝
  • 코루틴: 호출 → (일시 정지) → 재개 → (일시 정지) → … → 반환
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를 넘기고 일시 정지
    }
}

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 다음부터 재개

co_await와 일시 정지

Awaitable

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

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

호출 흐름: co_await expr를 만나면 먼저 await_ready()를 호출합니다. true면 일시 정지 없이 곧바로 await_resume()으로 결과를 받으며, false면 await_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 {}; }  // 본문 시작 전에 멈춤, 첫 resume()부터 실행
std::suspend_never  initial_suspend() { return {}; }  // 호출 즉시 첫 일시 정지 지점까지 실행

제너레이터의 initial_suspend()에서 suspend_always를 쓰면 본문이 한 줄도 실행되지 않은 채 “첫 값을 요청할 때까지” 멈춰 있고, suspend_never를 쓰면 호출하는 순간 첫 co_yield까지 실행됩니다. 지연 실행(lazy)을 원하면 suspend_always가 맞습니다.


co_return의 두 형태와 final_suspend

co_return의 두 형태

  • co_return expr;
    promise_type::return_value(expr) 호출. 반환값이 있는 코루틴.
  • co_return;
    promise_type::return_void() 호출. 반환값이 없는 코루틴.

주의: 한 코루틴에서 return_value와 return_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를 반환하면 코루틴 프레임이 즉시 정리됩니다.


제너레이터와 비동기 Task 직접 구현하기

제너레이터 (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() {
        // 모든 awaitable이 동기적으로 끝나는 경우에만 안전한 단순화입니다.
        // 다른 스레드나 이벤트 루프가 재개를 맡는다면 여기서 resume()을
        // 반복 호출하면 안 되고, 완료 신호를 기다려야 합니다.
        while (!handle_.done()) handle_.resume();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return std::move(*handle_.promise().value);
    }
    // ... (생성자, 소멸자, 이동만)
};

T value 대신 std::optional<T>를 쓰는 이유는 T가 기본 생성 불가능한 타입이어도 promise를 만들 수 있게 하기 위해서입니다.

실전 예제: 나중에 재개되는 Awaitable과 간단한 이벤트 루프

await_ready()가 false를 반환하면 코루틴은 실제로 일시 정지하고, 누군가 나중에 핸들로 resume()을 호출해야 이어서 실행됩니다. 아래는 그 “누군가”를 단일 스레드 이벤트 루프로 만든 실행 가능한 예제입니다.

#include <coroutine>
#include <deque>
#include <exception>
#include <iostream>
#include <optional>

std::deque<std::coroutine_handle<>> ready_queue;  // 아주 단순한 이벤트 루프의 대기열

struct DelayedAwaitable {
    int result;
    bool await_ready() const noexcept { return false; }  // 항상 일시 정지
    void await_suspend(std::coroutine_handle<> h) {
        ready_queue.push_back(h);  // 핸들을 값으로 저장 (핸들은 포인터 크기라 복사가 싸다)
    }
    int await_resume() const noexcept { return result; }
};

template <typename T>
class Task {
public:
    struct promise_type {
        std::optional<T> value;
        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 {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        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(const Task&) = delete;
    Task& operator=(const Task&) = delete;
    ~Task() { if (handle_) handle_.destroy(); }

    void start() { handle_.resume(); }   // 첫 실행만 직접, 이후 재개는 이벤트 루프가 담당
    bool done() const { return handle_.done(); }
    T result() {
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return std::move(*handle_.promise().value);
    }

private:
    std::coroutine_handle<promise_type> handle_;
};

Task<int> asyncAdd() {
    int a = co_await DelayedAwaitable{10};
    int b = co_await DelayedAwaitable{20};
    co_return a + b;
}

int main() {
    auto task = asyncAdd();
    task.start();
    while (!ready_queue.empty()) {      // 이벤트 루프
        auto h = ready_queue.front();
        ready_queue.pop_front();
        h.resume();
    }
    std::cout << task.result() << "\n";  // 30
}

await_suspend에서 매개변수 h의 주소(&h)를 저장하면 안 됩니다. h는 함수가 끝나면 사라지는 지역 변수라서, 나중에 그 포인터로 재개하면 dangling 포인터 접근이 됩니다. 핸들 자체를 값으로 복사해 두면 됩니다.

이벤트 루프 대신 std::thread를 띄워 다른 스레드에서 h.resume()을 호출할 수도 있지만, 그때는 호출자가 resume()을 반복 호출하는 앞의 get() 같은 방식은 쓸 수 없습니다. 이미 다른 스레드가 재개를 맡은 코루틴을 또 재개하면 같은 프레임을 두 스레드가 동시에 실행하는 미정의 동작이 되므로, 호출자는 완료 신호(예: final_suspend에서 세마포어나 조건 변수에 알림)를 기다려야 합니다.

실전 예제: 파일 라인 제너레이터

앞에서 구현한 Generator<T>를 그대로 사용합니다.

#include <coroutine>
#include <fstream>
#include <stdexcept>
#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);
}

initial_suspend가 suspend_always이므로 파일 열기 실패 예외는 readLines()를 호출하는 순간이 아니라 첫 값을 요청하는 begin()에서 던져집니다. 지연 실행 코루틴에서 인자 검증 오류가 늦게 드러나는 것은 흔히 헷갈리는 지점입니다.


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_always면 handle.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_value로 co_return 처리, unhandled_exception에서 예외 저장 후 get()에서 재전파.


코루틴 핸들과 수명

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

수명 다이어그램

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

dangling 참조, 핸들 수명, final_suspend 실수

Dangling 참조 (죽은 참조)

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

// ❌ 위험: 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);
}

핸들 수명 관리 실수

원인: coroutine_handle을 복사해 두며, 원본 Generator/Task를 먼저 소멸시켜 프레임이 destroy된 뒤에 복사본으로 resume 호출.

// ❌ 위험: 객체 소멸 후 핸들 사용
// (일부 라이브러리가 handle()을 노출하는 경우)
std::coroutine_handle<> h;
{
    auto gen = count(5);
    h = gen.handle();  // 핸들만 복사
}  // gen 소멸 → destroy() 호출
h.resume();  // UB: 이미 해제된 프레임

// ✅ 안전: Generator 객체가 살아 있는 동안만 사용
auto gen = count(5);
for (int x : gen) { /* ... */ }

promise_type 메서드 누락

원인: return_value와 return_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() 없음
};

final_suspend에서 suspend_never 사용 시 주의

final_suspend()가 suspend_never를 반환하면 co_return 직후 코루틴 프레임이 자동으로 해제됩니다. promise도 프레임 안에 있으므로, 반환 객체가 아직 결과를 꺼내지 않았다면 그 값은 이미 사라진 뒤입니다. 게다가 반환 객체의 소멸자가 destroy()를 호출하면 이중 해제가 됩니다. 결과를 꺼내는 패턴이라면 suspend_always를 써서 프레임을 완료 상태로 남겨 두고, 호출자가 done()을 확인한 뒤 값을 꺼내고 destroy()합니다. 최종 일시 정지 지점에 멈춘 코루틴을 다시 resume()하는 것은 미정의 동작입니다.

// final_suspend() = suspend_never
// → co_return 직후 프레임 해제
// → promise.value에 접근 불가, 소멸자에서 destroy()하면 이중 해제

// final_suspend() = suspend_always
// → 호출자가 done() 확인 → value 꺼냄 → destroy() (다시 resume() 금지)

스레드 안전성

원인: await_suspend에서 다른 스레드로 재개할 수 있습니다. 여러 스레드가 같은 코루틴을 resume하면 데이터 레이스가 발생할 수 있습니다.

// ❌ 위험: 여러 스레드에서 동시에 resume
auto task = asyncCompute();
std::thread t1([&]{ task.get(); });
std::thread t2([&]{ task.get(); });  // 데이터 레이스

// ✅ 안전: 한 스레드에서만 resume, 또는 동기화

예외 전파 실수

원인: unhandled_exception()에서 예외를 처리하지 않거나, std::rethrow_exception 없이 넘기면 코루틴이 비정상 종료하거나 예외가 삼켜질 수 있습니다.

// ❌ 위험: 예외를 무시
void unhandled_exception() {
    // 아무것도 안 함 → 예외가 사라짐
}

// ✅ 안전: 저장 후 get()에서 재전파
void unhandled_exception() {
    exception = std::current_exception();
}
// get()에서: if (exception) std::rethrow_exception(exception);

반복자/핸들 무효화

원인: for (auto x : gen) 루프 안에서 gen을 이동하거나, 다른 제너레이터로 대입하면 반복자가 무효화됩니다.

// ❌ 위험: 루프 중 gen 이동
for (int x : gen) {
    auto other = std::move(gen);  // gen 무효화 → 다음 ++에서 UB
}

// ✅ 안전: 루프 밖에서 이동
auto other = std::move(gen);
for (int x : other) { /* ... */ }

재귀 코루틴과 스택

원인: 코루틴 A가 co_await B를 하며, B가 다시 A를 재개하면 “재귀”처럼 보이지만, 코루틴은 스택을 공유하지 않습니다. 각 코루틴은 자신의 프레임(힙)을 가지므로 스택 오버플로우 없이 깊은 체인을 만들 수 있습니다. 다만 await_suspend 안에서 다른 코루틴의 resume()을 직접 호출하면 (A resume → B resume → A resume…) 호출 스택은 쌓이므로, 체인이 길면 스택 오버플로가 날 수 있습니다. C++20은 이를 위해 await_suspend가 다음에 실행할 coroutine_handle을 반환하는 대칭 전송(symmetric transfer)을 제공합니다. 이렇게 하면 컴파일러가 꼬리 호출처럼 처리해 스택이 쌓이지 않습니다.

promise_type/반환 타입 불일치

원인: Task<int>를 반환하는 코루틴에서 promise_type이 return_void만 정의하거나, Generator<T>인데 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; }

코루틴, 콜백, 스레드의 비용 비교

코루틴 vs 콜백 vs 스레드

방식메모리컨텍스트 스위치가독성적합한 용도
콜백낮음없음낮음단순한 비동기
코루틴중간 (프레임)없음 (협력적)높음I/O 바운드, 제너레이터
스레드높음 (스택)있음 (OS)중간CPU 바운드 병렬

제너레이터 vs 벡터

시나리오: 0부터 1,000,000까지 정수 시퀀스

방식메모리 (대략)초기 지연적합한 경우
vector<int>~4MB전체 할당전체 순회 확정
Generator<int>프레임 하나 (보통 수십~수백 바이트)거의 없음앞 몇 개만 필요, lazy
// 벡터: 100만 개 전부 할당 (int 4바이트 × 100만 ≈ 4MB)
std::vector<int> vec(1000000);
std::iota(vec.begin(), vec.end(), 0);
for (int i = 0; i < 10; ++i)  // 10개만 쓰는데 100만 개 유지
    use(vec[i]);

// 제너레이터: 필요한 만큼만
int taken = 0;
for (int x : count(1000000)) {  // 10개만 계산하고 멈춤
    use(x);
    if (++taken == 10) break;
}

루프를 break로 빠져나오면 임시 제너레이터가 소멸하면서 destroy()가 호출되고, 아직 실행되지 않은 나머지 99만여 개는 계산되지 않습니다.

코루틴 프레임 오버헤드

코루틴 프레임은 기본적으로 operator new로 힙에 할당되며, 크기는 지역 변수와 promise에 따라 달라집니다. 할당은 co_await마다가 아니라 코루틴을 호출할 때 한 번 일어나고, 일시 정지·재개 자체는 간접 함수 호출 정도의 비용입니다. 매우 짧은 코루틴을 수백만 번 호출한다면 할당 비용이 보일 수 있으나, I/O 대기나 제너레이터처럼 오래 살아 있는 흐름에서는 대개 무시할 수준입니다.

성능 최적화 팁

  1. 불필요한 일시 정지 줄이기
    await_ready()에서 이미 완료된 경우 true를 반환하면 일시 정지를 건너뜁니다. 캐시된 결과처럼 즉시 끝나는 작업에 활용합니다.
bool await_ready() const {
    return cached_result.has_value();  // 이미 있으면 일시 정지 생략
}
  1. 할당 생략(HALO)에 기대는 조건 이해하기
    코루틴의 수명이 호출자 안에 완전히 갇혀 있고 컴파일러가 이를 증명할 수 있으면, Clang 등은 최적화 빌드에서 프레임을 호출자의 스택에 두어 힙 할당을 생략합니다. 핸들을 다른 곳에 저장하거나 다른 스레드로 넘기면 이 최적화는 적용되지 않으므로, 성능 비교는 반드시 -O2 이상의 릴리스 빌드에서 해야 합니다.

  2. 프레임 전용 할당자 (고급)
    짧은 코루틴을 대량으로 생성·파괴한다면 promise_type에 operator new/operator delete를 정의해 프레임을 풀에서 할당할 수 있습니다. 컴파일러는 프레임을 할당할 때 promise_type의 operator new를 먼저 찾습니다. 대부분의 I/O·제너레이터 용도에서는 필요하지 않습니다.


제너레이터, Task, 상태 머신, 취소와 재시도 패턴

제너레이터 (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);
}

비동기 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);
}

상태 머신 (게임/UI)

용도: 여러 단계를 순차적으로 표현. co_await로 “다음 프레임까지” 또는 “이벤트까지” 대기.

Task<void> gameLoop() {
    while (running) {
        co_await waitForNextFrame();
        update();
        render();
    }
}

파이프라인

용도: 제너레이터를 체인으로 연결. 다음 글(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), [](int x) { return x % 2 == 0; });

취소/타임아웃 패턴

용도: 장시간 대기 작업에 타임아웃을 걸거나, 사용자 취소를 지원할 때.

// 취소 토큰을 awaitable과 연동
struct CancellableAwaitable {
    std::atomic<bool>* cancelled;

    bool await_ready() const { return cancelled->load(); }
    void await_suspend(std::coroutine_handle<> h) {
        // 이벤트 루프에 h 등록. 작업이 끝나거나 취소되면 어느 쪽이든 h.resume()을 호출해야
        // await_resume()에서 취소 예외가 던져지고 코루틴이 정리됨
        // (재개하지 않고 버리면 코루틴이 영원히 매달린 채 프레임이 누수됨)
    }
    void await_resume() {
        if (*cancelled) throw std::runtime_error("취소됨");
    }
};

재시도 패턴

용도: 네트워크 요청 실패 시 일정 횟수만큼 재시도.

Task<Response> fetchWithRetry(std::string url, int maxRetries = 3) {
    for (int i = 0; ; ++i) {
        try {
            co_return co_await fetchAsync(url);
        } catch (const NetworkError&) {
            if (i == maxRetries - 1) throw;
            // catch 블록 안에서는 co_await를 쓸 수 없으므로 여기서는 기록만 함
        }
        co_await sleepAsync(100 * (i + 1));  // 백오프는 catch 밖에서
    }
}

C++20은 예외 처리기(catch 블록) 안에서 co_await를 금지합니다. 그래서 백오프 대기는 catch를 빠져나온 뒤에 둡니다. 또 url을 참조가 아닌 값으로 받은 이유는, 코루틴이 일시 정지한 동안 호출자가 넘긴 문자열이 사라질 수 있기 때문입니다.

배치 처리 (제너레이터)

용도: 스트리밍 데이터를 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);
}

라이브러리 선택

C++23 이상을 쓸 수 있다면 제너레이터는 표준 std::generator를 쓰는 것이 가장 간단합니다. C++20에서는 cppcoro(현재는 유지보수가 거의 멈춰 포크를 쓰는 경우가 많음), 네트워크 I/O라면 Boost.Asio의 awaitable과 co_spawn을 쓰거나, 이 글처럼 필요한 만큼만 직접 구현합니다.


디버깅 팁

  1. 코루틴이 재개되지 않을 때
    await_suspend에서 핸들을 어딘가에 등록했는지 확인하세요. 이벤트 루프나 스레드 풀에서 resume()을 호출하지 않으면 코루틴은 영원히 일시 정지 상태로 남습니다.

  2. 결과를 읽으려는데 값이 비어 있거나 크래시가 날 때
    final_suspend()가 suspend_never를 반환하면 co_return 직후 프레임이 해제되어 promise에 접근할 수 없습니다. 결과를 꺼내야 한다면 suspend_always를 쓰고, done()이 true가 된 뒤 promise().value를 읽고 destroy()를 호출합니다.

  3. resume() 호출 시 크래시가 날 때
    이미 destroy()된 핸들, 최종 일시 정지 지점에 있는 핸들, 또는 다른 스레드가 이미 재개 중인 핸들로 resume()을 호출했을 가능성이 높습니다. AddressSanitizer로 빌드하면 해제된 프레임 접근은 heap-use-after-free로 바로 보고됩니다.

  4. GDB/LLDB로 코루틴 디버깅
    코루틴 프레임은 힙에 있으므로, coroutine_handle이 가리키는 주소를 확인한 뒤 해당 메모리를 조사할 수 있습니다. 일시 정지된 코루틴은 호출 스택에 나타나지 않으므로, 백트레이스만으로는 “누가 이 코루틴을 기다리고 있는지”를 알 수 없다는 점을 염두에 두어야 합니다.

참고 자료


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

co_await를 만나면 항상 일시 정지하나요?

아닙니다. awaitable의 await_ready()가 true를 돌려주면(예: 결과가 이미 준비된 경우) 정지 없이 바로 await_resume()으로 넘어갑니다. 또 await_suspend()가 false를 반환하면 정지를 취소하고 계속 실행합니다. 캐시 히트처럼 대부분 즉시 끝나는 작업은 이 경로로 정지·재개 비용을 피할 수 있습니다.

컴파일러 옵션은 무엇이 필요한가요?

표준 C++20 모드면 됩니다. GCC 10은 -std=c++20 -fcoroutines, GCC 11 이상은 -std=c++20만으로 켜지고, Clang은 14 이상에서 -std=c++20, MSVC는 /std:c++20(VS 2019 16.8 이상)입니다. 예전 자료에 나오는 MSVC /await는 표준 이전 Coroutines TS용 옵션이라 새 코드에는 쓰지 않습니다. 제너레이터가 필요하면 C++23 std::generator(GCC 14, MSVC 17.x 이후 지원)를 먼저 확인하세요.