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

목차

  1. 비동기 코드가 복잡해요
  2. 코루틴이란
  3. co_yield와 제너레이터
  4. co_await와 일시 정지
  5. co_return 완전 정리
  6. 완전한 코루틴 구현
  7. promise_type 상세 설명
  8. 코루틴 핸들과 수명
  9. 자주 발생하는 오류
  10. 모범 사례 (Best Practices)
  11. 성능 비교
  12. 프로덕션 패턴
  13. 실전 주의사항

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

콜백 지옥(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);
}

장점:

  • 가독성: 위에서 아래로 읽히는 순차적 흐름
  • 에러 처리: 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. 코루틴이란

일시 정지/재개

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

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

수명 다이어그램

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

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

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

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

// ❌ 위험: 루프 중 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를 쓰면 컴파일 에러가 납니다.

// ❌ 컴파일 에러: 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++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
  • C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
  • C++20 코루틴과 Asio | 콜백 지옥 탈출 [#6]

이 글에서 다루는 키워드 (관련 검색어)

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]