C++ Generator로 지연 시퀀스 만들기: co_yield, 무한 수열, filter·map·take 파이프라인

들어가며

0부터 100만까지 정수가 필요한데 실제로는 앞 10개만 쓰고 끝내는 경우를 생각해 보겠습니다. std::vector<int>에 전부 채우면 약 4MB가 한 번에 할당되고, 99만 9,990개는 계산만 하고 버려집니다. 큰 로그 파일에서 조건에 맞는 줄 몇 개만 찾거나, 피보나치·소수처럼 끝이 없는 수열을 다룰 때는 이 방식이 아예 성립하지 않습니다.

제너레이터(Generator)는 값을 요청받을 때마다 하나씩 만들어 주는 지연(lazy) 시퀀스입니다. C++20 코루틴의 co_yield로 “값 하나를 내보내고 멈춘다”를 표현하면, 호출자가 다음 값을 요청할 때까지 나머지 계산이 미뤄집니다. 이 글에서는 Generator 타입을 직접 구현하고, 무한 시퀀스와 filter·map·take 조합, 수명(lifetime) 관련 실수, vector와의 성능 차이, 파일 스트리밍 예제를 차례로 다룹니다.

예제는 C++20(g++ -std=c++20 또는 clang++ -std=c++20) 기준입니다. C++23 이상이라면 표준 std::generator(<generator>)를 쓸 수 있습니다.


벡터로 시퀀스를 만들 때의 메모리 문제와 Generator

벡터 방식의 한계

대용량 시퀀스를 만들 때 std::vector를 쓰면 전체를 한 번에 메모리에 올립니다.

// ❌ 100만 개 전부 할당 — 앞 10개만 쓰는데 4MB 사용
std::vector<int> createSequence(int n) {
    std::vector<int> vec;
    vec.reserve(n);
    for (int i = 0; i < n; ++i) {
        vec.push_back(i);
    }
    return vec;
}
int main() {
    auto seq = createSequence(1'000'000);
    for (int i = 0; i < 10; ++i) {  // 10개만 사용
        process(seq[i]);
    }
    // 99만 9,990개는 메모리에 그대로 — 낭비
}

이 방식은 n에 비례하는 메모리를 쓰고, 시퀀스 전체가 만들어질 때까지 첫 값을 쓸 수 없으며, 앞부분만 쓰면 나머지 계산은 모두 낭비가 됩니다.

Generator로 해결

Generator는 호출할 때마다 다음 값 하나만 만들어 줍니다. 필요한 만큼만 계산하며, 나머지는 생성하지 않습니다.

// ✅ Generator: 필요한 만큼만 계산
Generator<int> count(int n) {
    for (int i = 0; i < n; ++i) {
        co_yield i;  // i를 넘기고 일시 정지
    }
}
int main() {
    auto gen = count(1'000'000);
    for (int i = 0; i < 10; ++i) {
        auto opt = gen.next();  // 10번만 계산
        if (opt) process(*opt);
    }
    // 나머지 99만 9,990개는 계산하지 않음
}

Generator는 코루틴 프레임 하나와 현재 값만 유지하므로 메모리가 시퀀스 길이와 무관하고, 첫 값을 바로 꺼낼 수 있으며, 요청하지 않은 값은 계산하지 않습니다.

시각화

flowchart TB
    subgraph vector[벡터 방식]
        V1[createSequence 호출] --> V2[100만 개 전부 할당]
        V2 --> V3[메모리 ~4MB]
        V3 --> V4[10개만 사용]
        V4 --> V5[99만 9,990개 낭비]
    end
    subgraph generator[Generator 방식]
        G1[count 호출] --> G2[첫 co_yield까지 대기]
        G2 --> G3[next 10번 호출]
        G3 --> G4[10개만 계산]
        G4 --> G5[나머지 미계산]
    end

같은 구조는 트리 순회처럼 방문 순서대로 노드를 내보내는 경우나, primes()에서 조건을 만족하는 첫 소수를 찾을 때까지만 계산하는 조기 종료에도 그대로 쓰입니다. 다만 이 글의 Generator는 동기식입니다. 값이 네트워크나 센서에서 비동기로 도착한다면 co_await를 지원하는 비동기 제너레이터가 필요합니다.


Lazy 평가: 필요할 때만 값을 만든다

Lazy vs Eager

Eager(즉시 평가): 시퀀스를 한 번에 전부 계산합니다. vector에 push_back으로 채우는 방식이 여기에 해당합니다. Lazy(지연 평가): 값이 필요할 때만 계산합니다. co_yield는 “이 값을 넘기고 일시 정지”하므로, 호출자가 다음 값을 요청할 때까지 다음 계산이 미뤄집니다.

// Eager: 100만 개 전부 계산 후 반환
std::vector<int> eager = createSequence(1'000'000);  // 즉시 100만 개 생성
// Lazy: 10개만 요청하면 10개만 계산
auto gen = count(1'000'000);
for (int i = 0; i < 10; ++i) {
    auto v = gen.next();  // i번째 요청 시에만 i번째 값 계산
}

Lazy 평가 흐름

sequenceDiagram
    participant C as 호출자
    participant G as Generator
    C->>G: next() 1회
    G->>G: co_yield 0
    G-->>C: 0 반환, 일시 정지
    C->>C: 0 사용
    C->>G: next() 2회
    G->>G: co_yield 1
    G-->>C: 1 반환
    Note over G: 2, 3, 4... 는 아직 계산 안 함

count(1'000'000)을 호출해도 100만 개를 만들지 않습니다. next()를 10번 호출하면 10개만 계산하며, 나머지는 생성되지 않습니다.

Lazy의 장점

항목Eager (vector)Lazy (Generator)
메모리O(n)O(1)
초기 지연전체 생성 완료까지첫 값만
조기 종료나머지도 이미 계산됨요청 안 하면 미계산
무한 시퀀스불가가능

promise_type과 iterator로 Generator 구현하기

promise_type 설계

Generator의 동작은 promise_type으로 제어합니다. 이전 글(#23-1)에서 다룬 코루틴 기초를 바탕으로, co_yield에 맞는 promise를 정의합니다.

#include <coroutine>
#include <exception>
#include <optional>
template <typename T>
class Generator {
public:
    struct promise_type {
        T current_value;
        std::exception_ptr exception;
        // co_yield value 호출 시: 값을 저장하고 일시 정지
        std::suspend_always yield_value(T value) {
            current_value = std::move(value);
            return {};
        }
        // 코루틴 진입 직후 일시 정지 — 첫 next()에서 resume
        std::suspend_always initial_suspend() { return {}; }
        // co_return 후 일시 정지 — 호출자가 done() 확인 가능
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { exception = std::current_exception(); }
        Generator get_return_object() {
            return Generator{
                std::coroutine_handle<promise_type>::from_promise(*this)
            };
        }
    };
    // ... (아래 계속)
};

co_yield v는 컴파일러가 co_await promise.yield_value(v)로 바꿔 부르므로, yield_value가 값을 저장하고 suspend_always를 돌려주면 그 자리에서 멈춥니다. initial_suspend가 suspend_always이면 코루틴은 만들어지자마자 멈춰 있다가 첫 resume()에서야 본문을 실행합니다. final_suspend도 멈추게 해 두어야 끝난 뒤에도 프레임이 남아 done()을 확인하고 소멸자에서 destroy()할 수 있습니다. 이 구현은 current_value를 멤버로 들고 있으므로 T가 기본 생성 가능한 값 타입이어야 합니다.

iterator와 범위 기반 for

begin()/end()를 제공하면 범위 기반 for로 사용할 수 있습니다.

    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();
            if (handle.promise().exception)
                std::rethrow_exception(handle.promise().exception);
            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(const Generator&) = delete;
    Generator& operator=(const Generator&) = 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();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return iterator{handle_, handle_.done()};
    }
    iterator end() { return iterator{handle_, true}; }
private:
    std::coroutine_handle<promise_type> handle_;
};

next() 인터페이스 (선택)

next()로 값을 하나씩 꺼내는 인터페이스도 추가할 수 있습니다.

    std::optional<T> next() {
        if (!handle_ || handle_.done()) return std::nullopt;
        handle_.resume();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        if (handle_.done()) return std::nullopt;
        return handle_.promise().current_value;
    }

완전한 예제

// g++ -std=c++20 -o generator_basic generator_basic.cpp && ./generator_basic
#include <coroutine>
#include <exception>
#include <iostream>
#include <optional>
template <typename T>
class Generator {
public:
    struct promise_type {
        T current_value;
        std::exception_ptr exception;
        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() { 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();
            if (handle.promise().exception)
                std::rethrow_exception(handle.promise().exception);
            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(const Generator&) = delete;
    Generator& operator=(const Generator&) = 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();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        return iterator{handle_, handle_.done()};
    }
    iterator end() { return iterator{handle_, true}; }
    std::optional<T> next() {
        if (!handle_ || handle_.done()) return std::nullopt;
        handle_.resume();
        if (handle_.promise().exception)
            std::rethrow_exception(handle_.promise().exception);
        if (handle_.done()) return std::nullopt;
        return handle_.promise().current_value;
    }
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
    }
    std::cout << "\n";
}

실행 결과: 0 1 2 3 4

co_yield 흐름 다이어그램

sequenceDiagram
    participant C as 호출자
    participant G as Generator
    participant P as promise_type
    participant Co as 코루틴
    C->>G: begin()
    G->>Co: handle_.resume()
    Co->>P: yield_value(0)
    P-->>Co: suspend_always
    Co-->>G: 일시 정지
    G-->>C: iterator(0 반환)
    C->>C: *it → 0
    C->>G: ++it
    G->>Co: handle_.resume()
    Co->>P: yield_value(1)
    Note over Co: ... 반복 ...

무한 카운터와 소수 생성기

끝이 없는 시퀀스

Generator는 끝이 없는 시퀀스도 표현할 수 있습니다. co_return이 없으면 이론적으로 무한히 co_yield할 수 있으며, 호출하는 쪽에서 필요한 만큼만 꺼내 쓰면 됩니다.

// 무한 피보나치 수열
Generator<long long> fibonacci() {
    long long a = 0, b = 1;
    while (true) {
        co_yield a;
        auto next = a + b;
        a = b;
        b = next;
    }
}
int main() {
    int count = 0;
    for (long long x : fibonacci()) {
        std::cout << x << " ";
        if (++count >= 15) break;  // 15개만 출력
    }
    // 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377
}

무한 카운터

Generator<int> counter(int start = 0) {
    int i = start;
    while (true) {
        co_yield i++;
    }
}
// 사용: zip 등에 활용
auto gen = counter(10);
auto it = gen.begin();
for (int i = 0; i < 5; ++i) {
    std::cout << *it << " ";
    ++it;
}
// 10 11 12 13 14

소수 생성기

Generator<int> primes() {
    co_yield 2;
    for (int n = 3; ; n += 2) {
        bool is_prime = true;
        for (int d = 3; d * d <= n; d += 2) {
            if (n % d == 0) { is_prime = false; break; }
        }
        if (is_prime) co_yield n;
    }
}
// 처음 10개 소수
int n = 0;
for (int p : primes()) {
    std::cout << p << " ";
    if (++n >= 10) break;
}
// 2 3 5 7 11 13 17 19 23 29

주의사항

무한 Generator를 범위 기반 for에 그대로 넣으면 무한 루프가 됩니다. 반드시 break나 개수 제한을 두어야 합니다.

// ❌ 위험: 무한 루프
for (auto x : fibonacci()) {
    process(x);  // 절대 끝나지 않음
}
// ✅ 안전: 개수 제한
int n = 0;
for (auto x : fibonacci()) {
    process(x);
    if (++n >= 100) break;
}

filter, map, take, zip으로 제너레이터 조합

filter: 조건에 맞는 값만

다른 Generator를 받아 조건을 만족하는 값만 co_yield하는 Generator를 만들 수 있습니다.

template <typename T, typename Pred>
Generator<T> filter(Generator<T> src, Pred pred) {
    for (T x : src) {
        if (pred(x)) co_yield x;
    }
}
// 사용: 짝수만
auto nums = count(20);
auto evens = filter(std::move(nums), [](int x) { return x % 2 == 0; });
for (int x : evens) {
    std::cout << x << " ";  // 0 2 4 6 8 10 12 14 16 18
}

map: 값 변환

template <typename T, typename F>
Generator<std::invoke_result_t<F, T>> map(Generator<T> src, F f) {
    for (T x : src) {
        co_yield f(x);
    }
}
// 사용: 제곱
auto nums = count(5);
auto squares = map(std::move(nums), [](int x) { return x * x; });
for (int x : squares) {
    std::cout << x << " ";  // 0 1 4 9 16
}

파이프라인

filter와 map을 연결해 파이프라인을 구성할 수 있습니다.

// count(100) → 짝수만 → 제곱 → 10개만
auto nums = count(100);
auto evens = filter(std::move(nums), [](int x) { return x % 2 == 0; });
auto squares = map(std::move(evens), [](int x) { return x * x; });
int n = 0;
for (int x : squares) {
    std::cout << x << " ";
    if (++n >= 10) break;
}
// 0 4 16 36 64 100 144 196 256 324

take: 상위 N개만

template <typename T>
Generator<T> take(Generator<T> src, size_t n) {
    if (n == 0) co_return;
    size_t count = 0;
    for (T x : src) {
        co_yield x;
        if (++count >= n) break;  // n번째 값을 내보낸 직후 멈춤
    }
}
// 사용
for (int x : take(count(1000), 5)) {
    std::cout << x << " ";  // 0 1 2 3 4
}

검사를 co_yield 앞에 두고 if (count++ >= n) break;로 쓰면 출력 개수는 맞지만, 멈추기 위해 원본에서 n+1번째 값을 한 번 더 꺼냅니다. 원본이 파일 읽기나 비싼 계산이면 불필요한 일이고, 무한 원본에서는 문제가 없더라도 의도와 다르게 동작하는 코드입니다. 위처럼 값을 내보낸 직후에 개수를 확인하면 정확히 n번만 원본을 재개합니다.

zip: 두 시퀀스 병합

template <typename T, typename U>
Generator<std::pair<T, U>> zip(Generator<T> a, Generator<U> b) {
    auto it_a = a.begin();
    auto it_b = b.begin();
    auto end_a = a.end();
    auto end_b = b.end();
    while (it_a != end_a && it_b != end_b) {
        co_yield {*it_a, *it_b};
        ++it_a;
        ++it_b;
    }
}
Generator<char> letters(char from, char to) {
    for (char c = from; c <= to; ++c) co_yield c;
}
// 사용: 짧은 쪽에서 멈춤
for (auto [n, c] : zip(count(5), letters('a', 'c'))) {
    std::cout << "(" << n << "," << c << ") ";  // (0,a) (1,b) (2,c)
}

enumerate, distinct

enumerate는 Python의 enumerate()처럼 인덱스와 값을 쌍으로 내보내고, distinct는 이미 본 값을 std::unordered_set(<unordered_set>)에 기록해 중복을 거릅니다. distinct는 지연 평가이긴 하지만 서로 다른 값의 개수만큼 메모리를 쓰므로, 무한 시퀀스에 걸면 메모리가 계속 늘어납니다.

template <typename T>
Generator<std::pair<size_t, T>> enumerate(Generator<T> src) {
    size_t idx = 0;
    for (T x : src) co_yield {idx++, x};
}
template <typename T>
Generator<T> distinct(Generator<T> src) {
    std::unordered_set<T> seen;
    for (T x : src)
        if (seen.insert(x).second) co_yield x;
}

dangling 참조, 이동 후 사용, 핸들 수명

Dangling 참조 (죽은 참조)

코루틴은 호출되면 프레임을 만들고 initial_suspend에서 바로 멈춥니다. 매개변수는 프레임에 복사되지만, 매개변수 자체가 참조나 string_view이면 가리키는 대상까지 복사되지는 않습니다. 대상이 임시 객체라면 호출식이 끝날 때 소멸하고, 나중에 본문이 실행될 때는 이미 사라진 메모리를 읽게 됩니다.

// ❌ 위험: s는 임시 string을 가리키는 view
Generator<std::string_view> splitBad(std::string_view s) {
    size_t pos = 0;
    while (pos < s.size()) {
        auto end = s.find(' ', pos);
        if (end == std::string_view::npos) end = s.size();
        co_yield s.substr(pos, end - pos);
        pos = end == s.size() ? end : end + 1;
    }
}
auto parts = splitBad(std::string("a b c"));  // 임시 string은 이 줄 끝에서 소멸
for (auto part : parts) {
    // 본문이 처음 실행되는 시점에 s는 이미 dangling — UB
}
// ✅ 안전: 문자열을 값으로 받아 프레임이 소유
Generator<std::string> splitGood(std::string s) {
    size_t pos = 0;
    while (pos < s.size()) {
        auto end = s.find(' ', pos);
        if (end == std::string::npos) end = s.size();
        co_yield s.substr(pos, end - pos);
        pos = end == s.size() ? end : end + 1;
    }
}

범위 기반 for의 초기화식 안에서 바로 splitBad(std::string("a b c"))를 쓰는 경우는 표준 버전에 따라 결과가 다릅니다. C++20까지는 초기화식의 임시 객체 중 범위 객체(여기서는 Generator)만 수명이 연장되어 임시 string은 루프 전에 소멸하고, C++23(P2718)부터는 초기화식의 모든 임시 객체가 루프 끝까지 연장됩니다. 어느 쪽이든 매개변수를 값으로 받으면 고민할 필요가 없습니다.

Generator 이동 후 사용

Generator는 이동 전용입니다. filter나 map에 넘긴 뒤 원본을 다시 쓰면 안 됩니다.

auto nums = count(10);
auto evens = filter(std::move(nums), [](int x) { return x % 2 == 0; });
// nums의 handle_은 nullptr
for (int x : nums) { }   // ❌ UB: begin()이 빈 핸들을 resume
for (int x : evens) { }  // ✅ OK

핸들 수명 관리

coroutine_handle은 프레임을 가리키는 포인터일 뿐 소유권이 없습니다. 핸들을 따로 복사해 두고 Generator 객체가 소멸한 뒤 resume하면, 소멸자가 이미 destroy()한 프레임에 접근하게 됩니다.

// ❌ 위험 (설명을 위해 handle()이라는 접근자가 있다고 가정)
std::coroutine_handle<> dangerous;
{
    auto gen = count(5);
    dangerous = gen.handle();
}  // gen 소멸 → destroy() 호출
dangerous.resume();  // UB: 이미 해제된 프레임
// ✅ 안전: Generator 객체가 살아 있는 동안만 사용
auto gen = count(5);
for (int x : gen) { /* ... */ }

iterator 무효화

iterator는 핸들을 복사해 들고 있으므로, 순회 도중 Generator에 다른 값을 이동 대입하면 기존 프레임이 파괴되고 iterator가 무효화됩니다.

auto gen = count(5);
auto it = gen.begin();
gen = count(10);  // 이동 대입 — 이전 프레임 destroy, it의 handle은 dangling
++it;  // ❌ UB

참조 타입을 내보내고 싶을 때

이 글의 구현은 T current_value;를 멤버로 들고 있어서 Generator<const std::string&>처럼 참조 타입을 넣으면 컴파일되지 않습니다. 복사를 피하고 싶다면 C++23 std::generator를 쓰는 편이 낫습니다. std::generator<const std::string&>는 co_yield local;로 프레임 안 지역 변수의 참조를 그대로 내보내는데, 이 참조는 다음 재개 전까지만 유효합니다. 따라서 호출자가 참조를 저장해 두었다가 다음 값을 꺼낸 뒤 쓰면 덮어쓰였거나 소멸한 값을 보게 됩니다. 값을 오래 보관할 필요가 있으면 받는 쪽에서 복사해야 합니다.

반복 begin() 호출

begin()은 호출할 때마다 코루틴을 한 번 재개합니다. 두 번 부르면 첫 값을 건너뛰고, 두 iterator가 같은 프레임을 공유하므로 순회가 꼬입니다. 범위 기반 for는 begin()을 한 번만 부르므로 안전합니다.

// ❌ 위험: begin() 두 번 호출
auto gen = count(5);
auto it1 = gen.begin();  // 0까지 실행
auto it2 = gen.begin();  // 다시 재개되어 1까지 실행, it1도 같은 프레임을 봄
// ✅ 안전
for (int x : count(5)) { /* ... */ }

재귀 Generator의 비용

트리 DFS를 for (auto x : dfs(child)) co_yield x;처럼 재귀로 쓰면 깊이 d마다 Generator가 하나씩 중첩됩니다. 프레임은 힙에 있지만, 값 하나를 꺼낼 때마다 가장 바깥 Generator부터 가장 안쪽까지 resume 호출이 연쇄되므로 호출 스택이 깊이만큼 쌓이고 값당 비용도 O(d)가 됩니다. 깊은 트리에서는 명시적 스택을 쓰는 반복 버전이 안전합니다. C++23 std::generator는 co_yield std::ranges::elements_of(dfs(child));로 중첩 제너레이터를 직접 이어 붙이는 기능을 제공해 이 연쇄 비용을 줄여 줍니다.


Generator를 설계할 때 지킬 원칙

항목권장비권장
타입값 타입 Generator<std::string>참조 Generator<std::string_view> (lifetime 위험)
무한 시퀀스take() 또는 루프 내 break상한 없이 순회
이동filter(std::move(nums), pred) 후 nums 미사용이동 후 원본 재사용
예외호출 측 try/catch예외 무시
성능파이프라인 단계 최소화, co_yield std::move(x)filter→map→take 과다 연결

성능 비교: generator vs vector

시나리오: 0 ~ 1,000,000 정수 시퀀스

방식메모리초기 지연10개만 사용 시
vector<int>약 4MB (100만 × 4바이트)전체 생성 완료까지100만 개 모두 계산, 4MB 유지
Generator<int>코루틴 프레임 하나 (크기는 컴파일러가 결정, 이 예제는 작음)거의 없음10개만 계산

벤치마크 예시

#include <chrono>
#include <numeric>
#include <vector>
// 벡터: 100만 개 전부 할당
void bench_vector() {
    auto start = std::chrono::steady_clock::now();
    std::vector<int> vec(1'000'000);
    std::iota(vec.begin(), vec.end(), 0);
    int sum = 0;
    for (int i = 0; i < 10; ++i) sum += vec[i];
    auto end = std::chrono::steady_clock::now();
    // 할당 + 100만 번 쓰기 + 10개 접근
}
// Generator: 10개만 계산
void bench_generator() {
    auto start = std::chrono::steady_clock::now();
    auto gen = count(1'000'000);
    int sum = 0;
    int n = 0;
    for (int x : gen) {
        sum += x;
        if (++n >= 10) break;
    }
    auto end = std::chrono::steady_clock::now();
    // 프레임 할당 한 번 + resume 10번
}

절대값은 환경에 따라 다르지만, 연산 횟수로 보면 차이는 분명합니다. vector는 4MB 할당과 100만 번의 쓰기(iota)가 끝나야 첫 원소를 읽을 수 있으므로 시간이 전체 원소 수에 비례합니다. Generator는 프레임 할당 한 번과 재개 10번만 일어나므로 시퀀스 길이와 거의 무관합니다. 대신 원소 하나를 꺼낼 때마다 재개 비용(간접 호출과 상태 저장)이 있어, 전체를 순회한다면 vector가 더 빠를 수 있습니다. 직접 잴 때는 결과(sum)를 출력하는 등으로 사용하지 않으면 최적화로 루프 자체가 사라질 수 있으니 주의해야 합니다.

언제 어떤 방식을 쓸까?

상황권장
전체 순회가 확정, 여러 번 순회vector (캐시 친화적, 단순)
앞 N개만 필요Generator
무한/메모리에 다 올릴 수 없는 시퀀스Generator
요소를 하나씩 처리하고 버릴 수 있음Generator
랜덤 접근 필요vector

성능을 위해 고려할 점

co_yield할 때마다 값이 promise의 current_value로 옮겨집니다. int, double 같은 작은 타입은 비용이 거의 없고, std::string처럼 큰 타입은 co_yield std::move(str)로 넘기면 복사 대신 이동이 일어납니다.

Generator<std::string> lines(std::istream& in) {
    std::string line;
    while (std::getline(in, line)) {
        co_yield std::move(line);  // 이동 후 line은 getline이 다시 채움
    }
}

filter → map → take처럼 여러 Generator를 연결하면, 값 하나마다 단계 수만큼 코루틴이 resume/suspend를 반복합니다. 단계가 많고 값이 작다면 한 Generator에서 조건을 함께 처리하는 편이 빠릅니다.

// 3단계: 값마다 filter, map, take가 각각 재개됨
auto r = take(map(filter(count(100), pred), f), 10);
// 1단계: 한 Generator에서 처리
Generator<int> combined() {
    int n = 0;
    for (int i = 0; i < 100 && n < 10; ++i) {
        if (pred(i)) { co_yield f(i); ++n; }
    }
}

코루틴 프레임은 기본적으로 힙에 할당됩니다. 컴파일러는 Generator의 수명이 호출자 범위 안에 있고 코루틴이 인라인될 수 있으면 이 힙 할당을 생략할 수 있는데(HALO, heap allocation elision), 최적화 수준과 컴파일러에 따라 적용 여부가 다르고 Clang이 비교적 적극적으로 수행합니다. 최소한 -O2 이상으로 빌드하고, 핫 루프라면 실제로 할당이 사라졌는지 프로파일러로 확인하는 것이 좋습니다.

벤치마크를 작성할 때는 “첫 값까지의 지연”과 “값 하나당 비용”을 따로 재는 것이 좋습니다. 전체 순회 시간 하나만 보면 두 효과가 섞여 어느 쪽이 유리한지 판단하기 어렵습니다.


파일 라인, CSV, 네트워크 청크 스트리밍

파일 라인별 읽기

대용량 파일을 한 줄씩 읽을 때 Generator를 쓰면 메모리에 한 줄만 유지하면서 범위 기반 for로 처리할 수 있습니다.

#include <fstream>
#include <stdexcept>
#include <string>
// path를 값으로 받습니다. const std::string&로 받으면 readLines("huge.log")에서
// 만들어진 임시 string이 본문 실행 전에 소멸해 dangling이 됩니다.
Generator<std::string> readLines(std::string path) {
    std::ifstream f(path);
    if (!f) {
        throw std::runtime_error("Cannot open: " + path);
    }
    std::string line;
    while (std::getline(f, line)) {
        co_yield line;
    }
}
// 사용: 큰 로그 파일도 한 줄씩만 메모리에
for (const auto& line : readLines("huge.log")) {
    if (line.find("ERROR") != std::string::npos) {
        processError(line);
    }
}

initial_suspend가 suspend_always이므로 throw는 readLines()를 호출할 때가 아니라 첫 begin()에서 본문이 실행될 때 일어나고, unhandled_exception이 저장한 예외가 begin()에서 다시 던져집니다. 파일 열기 실패를 호출 시점에 알고 싶다면 파일을 바깥에서 열어 스트림을 넘기는 방법도 있습니다.

CSV 파싱 스트리밍

Generator<std::vector<std::string>> parseCsv(std::string path) {
    std::ifstream f(path);
    std::string line;
    while (std::getline(f, line)) {
        std::vector<std::string> row;
        // 간단한 CSV 파싱 (쉼표 구분, 따옴표 처리 없음)
        size_t pos = 0;
        while (pos < line.size()) {
            auto end = line.find(',', pos);
            if (end == std::string::npos) end = line.size();
            row.push_back(line.substr(pos, end - pos));
            pos = end == line.size() ? end : end + 1;
        }
        co_yield std::move(row);
    }
}

네트워크 청크 스트리밍

// 개념적 예: Socket은 호출자가 소유하고, Generator보다 오래 살아 있어야 합니다
Generator<std::vector<std::byte>> receiveChunks(Socket& sock, size_t chunk_size) {
    std::vector<std::byte> buffer(chunk_size);
    while (true) {
        size_t n = sock.read(buffer.data(), chunk_size);
        if (n == 0) break;
        buffer.resize(n);
        co_yield std::move(buffer);
        buffer.resize(chunk_size);  // 이동 후 상태와 무관하게 다시 크기를 맞춤
    }
}

배치 처리 파이프라인

// 로그 파일 → 필터(ERROR만) → 파싱 → 상위 100개
auto lines = readLines("app.log");
auto errors = filter(std::move(lines),
    [](const std::string& s) {
        return s.find("ERROR") != std::string::npos;
    });
auto parsed = map(std::move(errors), parseLogLine);
int count = 0;
for (const auto& entry : parsed) {  // operator*가 값을 반환하므로 auto&는 컴파일되지 않음
    handleError(entry);
    if (++count >= 100) break;
}

Generator는 호출자가 값을 요청할 때만 다음 값을 만드는 pull 방식이라, 소비가 느리면 생산도 자연히 느려집니다. 별도의 백프레셔 장치 없이도 읽기 속도가 처리 속도에 맞춰지는 것은 이 구조의 장점입니다. 반대로 파싱 실패 줄을 건너뛸지 전체를 중단할지는 parseLogLine이 예외를 던지는지, std::optional을 돌려주는지로 정해야 하는 설계 결정입니다.

참고 자료


같이 보면 좋은 글