C++ Generator 완벽 가이드 | co_yield로 lazy 시퀀스·무한 수열·파이프라인 만들기
이 글의 핵심
메모리 부족·OOM 해결, Generator 완전 구현(co_yield·promise_type), lazy 평가·무한 시퀀스, filter·map·take 조합, 자주 발생하는 에러(dangling·lifetime)·해결법, 성능 비교, 파일 읽기·데이터 스트리밍 프로덕션 패턴.
들어가며: “시퀀스를 만들다 메모리 부족 에러가 나요”
실제 겪는 문제 시나리오
// ❌ 문제: 대용량 시퀀스가 필요한데...
// - 10GB 로그 파일에서 ERROR 라인만 앞 100개 찾기
// - API 페이지네이션: 100만 건 중 처음 20개만 표시
// - 피보나치·소수 같은 무한 수열 — 벡터로는 표현 불가
// vector에 전부 담으면 → OOM(Out of Memory)
// 전체를 미리 계산하면 → 불필요한 CPU·메모리 낭비
시나리오 1 (로그 분석): 10GB 로그에서 ERROR 라인만 앞 100개 추출. vector<string>에 전체를 올리면 RAM 부족으로 크래시. 필요한 만큼만 스트리밍으로 읽어야 합니다.
시나리오 2 (API 페이지네이션): DB에 100만 건. 처음 20개만 화면에 표시하는데, 전부 fetch하면 네트워크·메모리 낭비. Generator로 “요청할 때마다 다음 N개”만 가져오는 패턴이 적합합니다.
시나리오 3 (무한 수열): 피보나치, 소수, 카운터처럼 끝이 없는 시퀀스. 벡터는 크기를 미리 알아야 하므로 표현 불가. Generator는 co_yield로 “다음 값 하나”만 내보내므로 무한 시퀀스를 자연스럽게 표현합니다.
시나리오 4 (데이터 파이프라인): filter → map → take처럼 여러 단계를 거치는 처리. 중간 결과를 전부 벡터에 담으면 메모리 폭발. Generator 파이프라인은 값이 흐르는 대로 한 개씩 처리합니다.
실제 프로덕션에서 겪는 문제들:
- OOM: 대용량 시퀀스를 vector에 담다 메모리 부족
- 초기 지연: 전체 생성이 끝날 때까지 블로킹
- 불필요한 계산: 앞 10개만 쓰는데 100만 개 전부 계산
- 무한 시퀀스 표현 불가: 벡터는 크기 한계가 있음
해결책: Generator — co_yield로 필요할 때마다 값 하나씩 만들어 주는 lazy 시퀀스. 100만 개를 미리 만들지 않고, next() 호출 시마다 다음 값만 계산합니다.
문제 상황 (요약)
0부터 100만까지 정수를 만들어야 하는데, 앞 10개만 쓰고 끝내는 경우가 있습니다. 전통적인 방식은 std::vector<int>에 100만 개를 전부 채워 넣는 것입니다. 그러면 약 4MB 메모리가 한 번에 할당되고, 99만 9,990개는 결국 버려집니다. 시퀀스가 더 크거나 요소 타입이 더 무거우면 메모리 부족이나 지연이 심해집니다.
제너레이터(Generator)는 필요할 때마다 값 하나씩 만들어 주는 lazy 시퀀스입니다. 100만 개를 미리 만들지 않고, next()를 호출할 때마다 다음 값만 계산합니다. 10개만 쓰고 멈추면 나머지는 계산하지 않으므로 메모리와 CPU를 절약할 수 있습니다.
목표:
- 문제 시나리오: 메모리를 많이 쓰는 시퀀스 생성
- Generator 완전 구현: promise_type, iterator, 범위 기반 for
- 무한 제너레이터: 끝이 없는 시퀀스
- 제너레이터 조합: filter, map, 파이프라인
- 자주 발생하는 오류: lifetime(수명) 이슈
- 성능 비교: generator vs vector
- 프로덕션 패턴: 파일 읽기, 데이터 스트리밍
이 글을 읽으면:
co_yield로 lazy 시퀀스를 만드는 방법을 알 수 있습니다.- Generator 타입을 처음부터 구현할 수 있습니다.
- 무한 시퀀스와 조합 패턴을 활용할 수 있습니다.
- lifetime 관련 실수를 피할 수 있습니다.
- 실무에서 파일·스트리밍 처리에 적용할 수 있습니다.
요구 환경: C++20 (g++ -std=c++20 또는 clang++ -std=c++20). C++23에서는 std::generator를 사용할 수 있습니다.
목차
- 문제 시나리오: 메모리를 많이 쓰는 시퀀스 생성
- Lazy 평가 (지연 평가) 이해하기
- Generator 완전 구현
- 무한 제너레이터
- 제너레이터 조합
- 자주 발생하는 오류 (lifetime 수명)
- 모범 사례 (Best Practices)
- 성능 비교: generator vs vector
- 프로덕션 패턴: 파일 읽기, 데이터 스트리밍
- 정리
1. 문제 시나리오: 메모리를 많이 쓰는 시퀀스 생성
벡터 방식의 한계
대용량 시퀀스를 만들 때 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이 크면 O(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개는 계산하지 않음
}
장점:
- 메모리: O(1) — 현재 값 하나만 유지
- 지연: 첫 값은 즉시, 이후 값은 요청 시마다
- lazy: 필요한 만큼만 계산
시각화
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
추가 활용 사례
- 트리/그래프 DFS: 방문한 노드만
co_yield— 전체 경로 저장 불필요 - 조건부 조기 종료:
primes()에서p > n인 첫 소수 찾을 때까지만 계산 - 임베디드: 1MB RAM 장치에서
vector할당 실패 시 Generator로 스트리밍 - 실시간 스트리밍: 센서·이벤트 로그처럼 끝이 없는 스트림 (비동기는
co_await와 결합)
2. 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) |
| 초기 지연 | 전체 생성 완료까지 | 첫 값만 |
| 조기 종료 | 나머지도 이미 계산됨 | 요청 안 하면 미계산 |
| 무한 시퀀스 | 불가 | 가능 |
3. 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)
};
}
};
// ... (아래 계속)
};
핵심 포인트:
yield_value(T):co_yield값 저장 후suspend_always로 일시 정지initial_suspend() = suspend_always: 첫resume()전까지 대기final_suspend() = suspend_always: 종료 후done()확인 가능
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_.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;
}
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_.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: ... 반복 ...
4. 무한 제너레이터
끝이 없는 시퀀스
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;
}
5. 제너레이터 조합
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), { 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), { 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), { return x % 2 == 0; });
auto squares = map(std::move(evens), { 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) {
size_t count = 0;
for (T x : src) {
if (count++ >= n) break;
co_yield x;
}
}
// 사용
for (int x : take(count(1000), 5)) {
std::cout << x << " "; // 0 1 2 3 4
}
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;
}
}
// 사용
auto nums = count(5);
auto letters = /* Generator<char> 예시 */;
for (auto [n, c] : zip(std::move(nums), std::move(letters))) {
std::cout << "(" << n << "," << c << ") ";
}
enumerate, sliding_window, flatten, distinct
- enumerate:
{idx++, x}로 인덱스와 값 쌍 반환 (Pythonenumerate()와 동일) - sliding_window: 연속 N개 구간 — 이동 평균, 패턴 탐지에 활용
- flatten:
Generator<Generator<T>>→Generator<T>평탄화 - distinct:
std::unordered_set으로 중복 제거
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;
}
6. 자주 발생하는 오류 (lifetime 수명)
1. Dangling 참조 (죽은 참조)
원인: co_yield로 참조를 반환하고, 그 참조가 가리키는 객체가 코루틴 재개 전에 소멸하는 경우.
// ❌ 위험: string_view는 참조 — 임시 string 소멸 후 접근
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); // UB 가능
pos = end == s.size() ? end : end + 1;
}
}
// 호출 시
for (auto part : splitBad(std::string("a b c"))) { // 임시 string
// part가 이미 소멸한 임시를 참조 — 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_view::npos) end = s.size();
co_yield s.substr(pos, end - pos);
pos = end == s.size() ? end : end + 1;
}
}
2. Generator 이동 후 사용
원인: Generator는 이동 전용입니다. filter나 map에 넘긴 뒤 원본을 다시 쓰면 안 됩니다.
auto nums = count(10);
auto evens = filter(std::move(nums), { return x % 2 == 0; });
// nums는 이미 비어 있음 (handle이 이동됨)
for (int x : nums) { } // ❌ UB: 빈 Generator
for (int x : evens) { } // ✅ OK
3. 핸들 수명 관리
원인: coroutine_handle만 복사해 두고, Generator 객체는 소멸시킨 뒤 핸들로 resume 호출.
// ❌ 위험
std::coroutine_handle<> dangerous;
{
auto gen = count(5);
dangerous = gen.handle(); // 핸들만 복사 (실제로는 handle_이 private)
} // gen 소멸 → destroy() 호출
dangerous.resume(); // UB: 이미 해제된 프레임
// ✅ 안전: Generator 객체가 살아 있는 동안만 사용
auto gen = count(5);
for (int x : gen) { /* ... */ }
4. iterator 무효화
원인: begin()으로 얻은 iterator를 사용하는 동안 Generator를 이동하거나 소멸하면 iterator가 무효화됩니다.
auto gen = count(5);
auto it = gen.begin();
gen = count(10); // gen 이동 — it가 가리키는 handle 무효
++it; // ❌ UB
5. 지역 변수 참조 co_yield
원인: 스택에 있는 지역 변수의 주소나 참조를 co_yield하면, 코루틴이 일시 정지된 동안 그 스택은 유지되지만, 호출자에게 반환된 값이 참조 타입이면 호출자가 사용할 때 이미 소멸했을 수 있습니다.
// ❌ 위험
Generator<const std::string&> bad() {
std::string local = "hello";
co_yield local; // local의 참조 반환
} // 코루틴 일시 정지 — local은 프레임에 있음
// 하지만 호출자가 *it를 쓰는 시점에 "const string&"이
// 프레임 내 local을 가리키므로, 이 경우는 유효할 수 있음.
// 문제: Generator가 이동되거나 소멸되면 dangling
// ✅ 안전: 값으로 반환
Generator<std::string> good() {
std::string local = "hello";
co_yield local; // 복사본 반환
}
6. 반복 begin() 호출
원인: begin()을 여러 번 호출하면 코루틴이 여러 번 resume되어 순회가 꼬입니다. 범위 기반 for는 begin()을 한 번만 호출하므로 안전합니다.
// ❌ 위험: begin() 두 번 호출
auto gen = count(5);
auto it1 = gen.begin();
auto it2 = gen.begin(); // it1과 handle 공유 → 순회 꼬임
// ✅ 안전
for (int x : count(5)) { /* ... */ }
7. 재귀 Generator와 스택 오버플로우
원인: dfs(child)처럼 재귀 호출 시 깊은 트리에서 스택 오버플로우. 코루틴 프레임은 힙에 있지만 for 루프는 호출 스택을 사용합니다. 깊이 제한을 두거나 반복+스택으로 전환하세요.
8. take() off-by-one 오류
원인: count > n과 count >= n 혼동 시 yield 개수 오류.
// ❌ count > n → n=5일 때 6개 yield
if (count > n) break;
// ✅ count >= n → 정확히 n개
if (count >= n) break;
7. 모범 사례 (Best Practices)
| 항목 | 권장 | 비권장 |
|---|---|---|
| 타입 | 값 타입 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 과다 연결 |
8. 성능 비교: generator vs vector
시나리오: 0 ~ 1,000,000 정수 시퀀스
| 방식 | 메모리 (대략) | 초기 지연 | 10개만 사용 시 |
|---|---|---|---|
| vector<int> | ~4MB | 전체 할당 완료까지 | 4MB 유지, 99만 9,990개 낭비 |
| Generator<int> | ~수백 바이트 (프레임) | 거의 없음 | 10개만 계산 |
벤치마크 예시
#include <chrono>
#include <numeric>
#include <vector>
// 벡터: 100만 개 전부 할당
void bench_vector() {
auto start = std::chrono::high_resolution_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::high_resolution_clock::now();
// 할당 + 10개 접근
}
// Generator: 10개만 계산
void bench_generator() {
auto start = std::chrono::high_resolution_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::high_resolution_clock::now();
// 10번 co_yield만
}
예상 결과 (환경에 따라 다름):
- vector: 초기 할당 수 ms ~ 수십 ms
- Generator: 수 μs ~ 수십 μs (10개만 계산)
언제 어떤 방식을 쓸까?
| 상황 | 권장 |
|---|---|
| 전체 순회가 확정 | vector (캐시 친화적, 단순) |
| 앞 N개만 필요 | Generator |
| 무한/매우 큰 시퀀스 | Generator |
| 요소 타입이 무거움 | Generator (복사 최소화) |
| 랜덤 접근 필요 | vector |
메모리 사용량 비교 다이어그램
flowchart LR
subgraph vec["vector (100만 int)"]
V1[4MB 힙]
end
subgraph gen["Generator (100만)"]
G1[코루틴 프레임 ~1KB]
end
성능 최적화 팁
1. 작은 타입 사용
co_yield할 때마다 값이 promise의 current_value에 복사됩니다. int, double 같은 작은 타입은 복사 비용이 거의 없습니다. std::string은 이동 의미론을 활용해 co_yield std::move(str)로 넘기면 복사 대신 이동이 일어납니다.
// ✅ 이동으로 복사 비용 절감
Generator<std::string> lines() {
std::string line;
while (std::getline(file, line)) {
co_yield std::move(line); // line은 다음 반복에서 새로 채워짐
}
}
2. 파이프라인 단계 최소화
filter → map → take처럼 여러 Generator를 연결하면, 각 값마다 여러 코루틴이 resume/suspend를 반복합니다. 단계가 많을수록 오버헤드가 커집니다. 가능하면 한 Generator에서 여러 조건을 처리하는 편이 빠릅니다.
// ❌ 3단계: filter → map → take (각 값마다 3번 suspend/resume)
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; }
}
}
3. 전체 순회가 확정이면 vector
앞 N개만 쓰는 게 아니라 전체를 여러 번 순회하거나, 랜덤 접근이 필요하면 vector가 캐시 친화적이고 단순합니다. Generator는 “필요한 만큼만”이 명확할 때 선택하세요.
4. 인라인과 LTO
Generator의 resume()/yield_value() 호출이 인라인되면 분기 비용이 줄어듭니다. -O2 이상과 LTO(Link Time Optimization)를 사용하세요.
g++ -std=c++20 -O3 -flto -o app main.cpp
5. 벤치마크 작성 시 주의
Generator는 “첫 값까지의 지연”과 “값 하나당 비용”을 따로 측정하는 것이 좋습니다. 전체 순회 시간만 보면 컴파일러 최적화에 따라 결과가 달라질 수 있습니다.
// 의미 있는 벤치마크: 10개만 꺼내는 시간
auto start = std::chrono::high_resolution_clock::now();
int sum = 0, n = 0;
for (int x : count(1'000'000)) {
sum += x;
if (++n >= 10) break;
}
auto end = std::chrono::high_resolution_clock::now();
9. 프로덕션 패턴: 파일 읽기, 데이터 스트리밍
파일 라인별 읽기
대용량 파일을 한 줄씩 스트리밍으로 읽을 때 Generator를 쓰면 메모리를 절약할 수 있습니다.
#include <fstream>
#include <string>
Generator<std::string> readLines(const 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;
}
}
// 사용: 10GB 로그 파일도 한 줄씩만 메모리에
for (const auto& line : readLines("huge.log")) {
if (line.find("ERROR") != std::string::npos) {
processError(line);
}
}
CSV 파싱 스트리밍
Generator<std::vector<std::string>> parseCsv(const 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);
}
}
네트워크 청크 스트리밍
// 개념적 예: 청크 단위로 데이터 수신
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),
{
return s.find("ERROR") != std::string::npos;
});
auto parsed = map(std::move(errors), parseLogLine);
int count = 0;
for (auto& entry : parsed) {
handleError(entry);
if (++count >= 100) break;
}
에러 처리·재시도·메트릭
- 예외:
readLines에서throw→ 호출 측try/catch로 처리 - 재시도:
max_retries만큼 파일 열기 재시도 후sleep_for대기 - 백프레셔: Generator는 pull 기반 —
next()호출 시에만 생산되므로 자연스럽게 적용 - 메트릭:
withMetrics(gen, count)로count.fetch_add(1)래핑 - 배치:
sliding_window(lines, 100)으로 100개씩 묶어 처리 - 에러 복구:
tryParse실패 시logWarning후 건너뛰기
체크리스트
- 파일 열기 실패 시 예외 또는
std::optional처리 - Generator가 파일/스트림을 소유할 때 수명 관리 확인
- 대용량 시퀀스는
take로 상한 두기 -
string_view대신string등 값 타입 사용 (lifetime 안전) - 불안정한 I/O는 재시도 로직 추가
- 프로덕션에서는 처리량 메트릭 수집 고려
- 파싱 실패 시 건너뛰기 vs 종료 정책 결정
10. 정리
| 항목 | 내용 |
|---|---|
| 문제 | 벡터는 전체 할당 → 메모리·계산 낭비 |
| Generator | co_yield로 값 하나씩 lazy 생성 |
| 구현 | promise_type (yield_value, initial/final_suspend) + iterator |
| 무한 | break/개수 제한으로 조절 |
| 조합 | filter, map, take, enumerate, sliding_window, flatten, distinct |
| lifetime | 참조 대신 값, 이동 후 원본 사용 금지 |
| 오류 | dangling 참조, 재귀 스택 오버플로우, 반복 begin() 금지 |
| 성능 | 작은 타입·이동 활용, 파이프라인 단계 최소화, LTO |
| 실전 | 파일 라인별 읽기, CSV, 재시도, 메트릭, 배치 처리 |
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++20 Coroutine | co_await·co_yield로 “콜백 지옥” 탈출하기
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++ 캐시 최적화 | 메모리 접근 패턴 바꿔서 성능 10배 향상시키기
이 글에서 다루는 키워드 (관련 검색어)
C++ Generator, co_yield, lazy 시퀀스, 제너레이터, promise_type, 코루틴 제너레이터, 메모리 최적화 등으로 검색하시면 이 글이 도움이 됩니다.
자주 묻는 질문 (FAQ)
Q. C++23 std::generator와 차이는?
A. C++23 std::generator는 표준 라이브러리에 포함된 제너레이터 타입입니다. yield_value로 참조를 반환할 수 있어 복사 비용을 줄일 수 있습니다. C++20 환경에서는 이 글의 구현을 사용하거나 cppcoro를 활용할 수 있습니다.
Q. Generator를 스레드에서 써도 되나요?
A. Generator 객체 자체는 스레드 안전하지 않습니다. 한 스레드에서 begin()/end()로 순회하는 동안 다른 스레드에서 같은 Generator를 사용하면 데이터 레이스가 발생합니다. 스레드마다 별도 Generator를 만들거나, 동기화를 추가해야 합니다.
Q. 예외가 발생하면 어떻게 되나요?
A. unhandled_exception()에서 std::current_exception()을 저장하고, operator++()나 begin()에서 std::rethrow_exception으로 호출자에게 전파합니다. 호출하는 쪽에서 try/catch로 처리할 수 있습니다.
한 줄 요약: co_yield로 lazy 시퀀스를 만들면 메모리와 계산을 절약할 수 있습니다. Generator 구현, 무한 시퀀스, 조합, lifetime 주의사항, 성능 비교, 파일·스트리밍 패턴까지 실전에 바로 쓸 수 있습니다.
이전 글: C++ 코루틴 기초 (#23-1)
다음 글: C++ 비동기 코루틴 (#23-3)
관련 글
- C++20 Coroutine | co_await·co_yield로
- C++ 비동기 작업과 Coroutine | co_await로 콜백 지옥 탈출하기 [#23-3]
- C++20 Concepts | 템플릿 에러 메시지를 읽기 쉽게 만드는 방법
- C++ 커스텀 Concepts 작성 | 도메인에 맞는 제약 조건 정의하기 [#22-2]
- C++20 Modules |