C#·C++ 모던

C++ 23 std::generator로 게임 웨이브와 절차적 콘텐츠를 lazy 시퀀스로 뽑아내기

kr-gamedev 2026. 6. 16. 09:02

웨이브 스포너를 구현해 본 사람이라면 누구나 한 번쯤 비슷한 코드를 짜 봤을 것이다. 현재 웨이브 번호를 멤버 변수로 들고, 매 웨이브가 끝날 때마다 ++currentWave 하고, 다음에 스폰할 적의 종류와 수를 그때그때 switchif 덩어리로 계산한다. 절차적 던전도 마찬가지다. 방 목록을 미리 std::vector 에 전부 채워 넣거나, 인덱스를 손으로 굴리면서 "다음 방"을 꺼내는 상태 머신을 직접 만든다.

문제는 이 두 가지가 본질적으로 같은 패턴인데도 매번 손으로 상태 관리 코드를 다시 쓴다는 점이다. "다음 값을 요청하면 그때 계산해서 돌려준다"는 lazy 시퀀스 개념이 게임 로직 곳곳에 있는데, C++ 에는 오랫동안 이걸 깔끔하게 표현할 표준 도구가 없었다.

C++ 23 의 std::generator 가 바로 이 자리를 채운다. <generator> 헤더 하나만 추가하면 co_yield 로 값을 하나씩 흘려보내는 코루틴 함수를 만들 수 있고, 그 결과는 곧바로 range 가 되어 range 기반 for 문과 ranges 파이프라인에 그대로 꽂힌다. 상태 변수도, 인덱스 관리도, 콜백도 필요 없다.

기본형: yield 하나로 끝나는 무한 시퀀스

가장 단순한 형태부터 보자. 웨이브가 진행될수록 적 수가 늘어나는 무한 시퀀스다.

#include <generator>
#include <print>

std::generator<int> wave_sizes() {
    int base = 5;
    int wave = 0;
    while (true) {
        co_yield base + wave * 2;   // 호출자가 값을 요청할 때까지 여기서 멈춰 있음
        ++wave;
    }
}

while (true) 인데도 무한 루프에 빠지지 않는다. co_yield 가 실행되는 순간 함수는 그 자리에서 정지하고 제어권을 호출자에게 넘긴다. 다음 값을 요청해야 비로소 ++wave 부터 재개된다. 즉 값이 필요한 만큼만 계산된다.

실전: 웨이브 스포너를 시퀀스로 표현하기

실제 게임에서는 적 종류, 수, 스폰 간격을 묶은 구조체를 흘려보내고 싶을 것이다. 보스 웨이브 규칙까지 한 함수 안에 자연스럽게 담을 수 있다.

#include <generator>
#include <algorithm>

struct SpawnPlan {
    const char* type;
    int   count;
    float interval;
};

std::generator<SpawnPlan> make_spawn_plan(int startWave) {
    for (int wave = startWave; ; ++wave) {
        if (wave % 5 == 0) {
            co_yield SpawnPlan{ "Boss", 1, 0.0f };
        } else {
            int   grunts   = 4 + wave;
            float interval = std::max(0.3f, 1.5f - wave * 0.05f);
            co_yield SpawnPlan{ "Grunt", grunts, interval };
        }
    }
}

웨이브 진행 규칙이 전부 한 함수 안에 선형으로 읽힌다. 호출 쪽은 상태를 전혀 모른다.

auto plan = make_spawn_plan(1);
auto it   = plan.begin();
for (int i = 0; i < 8; ++i, ++it) {
    const SpawnPlan& s = *it;
    std::print("{} x{} @ {:.1f}s\n", s.type, s.count, s.interval);
}

스폰 규칙을 바꾸고 싶으면 generator 함수 본문만 고치면 된다. 소비하는 쪽 코드는 손댈 필요가 없다.

ranges 와 결합해서 무한을 유한하게 자르기

std::generator 는 그 자체로 view 라서 ranges 어댑터와 그대로 결합된다. 무한 시퀀스를 take 로 잘라내거나 filter 로 거르는 게 한 줄이다.

#include <ranges>

// 앞 5개 웨이브만, 그중 보스 웨이브만 추리기
auto bosses = make_spawn_plan(1)
            | std::views::take(20)
            | std::views::filter([](const SpawnPlan& s){
                  return s.count == 1;
              });

for (const auto& b : bosses) {
    std::print("boss wave plan ready\n");
}

생성 로직(generator)과 가공 로직(ranges)이 깔끔하게 분리된다. 웨이브 설계는 generator 가 책임지고, "몇 개까지" "어떤 조건만"은 소비 시점에 결정한다.

재귀 위임으로 절차적 던전 흘려보내기

std::generator 의 진짜 강점은 다른 generator 를 통째로 위임할 수 있다는 점이다. std::ranges::elements_of 로 감싸면 자식 generator 의 값들이 부모 시퀀스에 그대로 이어진다. 트리 구조 던전을 재귀적으로 표현하기에 딱 맞는다.

#include <generator>

struct Room { int depth; const char* kind; };

std::generator<Room> dungeon(int depth) {
    co_yield Room{ depth, "Corridor" };
    if (depth > 0) {
        // 자식 던전 시퀀스를 그대로 이어 붙임
        co_yield std::ranges::elements_of(dungeon(depth - 1));
    }
    co_yield Room{ depth, "Treasure" };
}

직접 재귀 함수를 짜면 결과를 담을 벡터를 인자로 넘기며 누적해야 하지만, 여기서는 그런 보일러플레이트가 사라진다. 각 깊이의 방이 호출자가 요청하는 순간에만 생성되므로, 던전이 아무리 깊어도 메모리에는 지금 소비 중인 방 하나 분량만 살아 있다.

도입 전 알아둘 것

std::generator 는 GCC 14, MSVC 2022 17.8 이상에서 표준 라이브러리로 제공되고 Clang/libc++ 조합은 비교적 최근 버전부터 지원된다. 프로젝트 컴파일러 버전을 먼저 확인하자. 언리얼 엔진처럼 자체 빌드 환경과 코루틴 체계를 가진 곳에서는 게임 런타임에 바로 넣기보다, 맵 생성기나 데이터 파이프라인 같은 오프라인 툴 쪽이 도입 부담이 적다.

성능 측면에서는 코루틴 프레임이 힙에 할당될 수 있다는 점을 기억해야 한다. 컴파일러가 최적화로 제거해 주기도 하지만 보장되지는 않는다. 따라서 매 프레임 새 generator 를 통째로 만들어 버리는 핫패스는 피하고, 레벨 로드나 웨이브 설계처럼 시퀀스를 한 번 만들어 두고 천천히 소비하는 자리에 쓰는 것이 안전하다.

정리

std::generator 는 "다음 값을 요청하면 그때 계산한다"는 lazy 시퀀스를 표준 문법으로 끌어올린다. 웨이브 스포너는 무한 generator 로, 절차적 던전은 재귀 위임으로, 무한을 유한하게 자르는 일은 ranges 결합으로 풀린다. 공통점은 상태 변수와 인덱스 관리 코드가 통째로 사라지고, 생성 규칙이 한 함수 안에 선형으로 읽힌다는 것이다. 컴파일러 버전과 힙 할당 특성만 챙기면, 손으로 굴리던 상태 머신 상당수를 짧고 읽기 좋은 코루틴으로 바꿔 놓을 수 있다.