Unity의 해킹 기법을 보고 깨달은 C++ 코루틴의 진짜 가치
C++20에 코루틴(Coroutines)이 도입된 지 벌써 6년이 지났다. 하지만 솔직히 묻고 싶다. 여러분의 프로덕션 코드베이스에서 코루틴을 적극적으로 사용하고 있는가? 아마 대부분은 고개를 저을 것이다. 나 역시 수많은 레거시와 모던 C++ 프로젝트를 오가며 아키텍처를 설계해 왔지만, 실무에서 C++ 코루틴을 마주치는 일은 손에 꼽을 정도였다.
우리가 C++ 코루틴을 멀리하는 이유는 명확하다. 문법이 기괴해서? 아니, 그보다는 유스케이스 가 와닿지 않기 때문이다. 기술 컨퍼런스나 튜토리얼을 보면 십중팔구 피보나치 수열을 생성하는 제너레이터 예제만 보여준다. 실무에서 피보나치 수열을 무한대로 뽑아낼 일이 도대체 얼마나 있단 말인가?
최근 한 개발자의 블로그 글과 해커뉴스(Hacker News)의 열띤 토론을 보면서, 나 역시 깊이 공감하는 바가 있어 이 주제를 파헤쳐보려 한다. 결론부터 말하자면, C++ 코루틴의 진짜 가치는 비동기 I/O가 아니라 상태 머신(State Machine)의 은닉 에 있다. 그리고 이를 가장 잘 보여주는 사례가 바로 Unity의 C# 코루틴 패턴이다.
상태 머신 지옥에서 벗어나기
게임 개발이나 UI 프로그래밍을 해본 시니어 엔지니어라면, 시간에 흐름에 따라 여러 프레임에 걸쳐 동작하는 로직을 작성하는 것이 얼마나 고통스러운지 알 것이다. 예를 들어 캐릭터가 왼쪽으로 점프한 뒤, 오른쪽으로 4번 이동하고, 다시 제자리에서 회전하는 애니메이션을 코드로 짠다고 해보자.
일반적인 C++ 환경에서 이를 구현하려면 거대한 switch 문과 상태를 저장할 멤버 변수들이 덕지덕지 붙은 클래스를 만들어야 한다.
class TimeWarp {
enum class State { Jump, StepRight, HandsOnHips, DoAgain };
State _state = State::Jump;
int _i = 0;
Transform* _transform;
public:
TimeWarp(Transform& transform) : _transform(&transform) {}
bool operator()() {
switch (_state) {
case State::Jump:
_transform->position.x -= 1.f;
_state = State::StepRight;
break;
case State::StepRight:
_transform->position.x += 0.2f;
if (++_i == 4) {
_state = State::HandsOnHips;
_i = 0;
}
break;
// ... 생략 ...
}
return false;
}
};
이런 코드는 작성하기도 싫고, 코드 리뷰에서 마주치기도 싫다. 상태 전이가 조금만 복잡해져도 유지보수는 지옥이 된다.
Unity의 해킹, 그리고 C++23 제너레이터
Unity는 오래전부터 C#의 IEnumerator와 yield return null을 이용해 이 문제를 우아하게 해결해 왔다. 엄밀히 말해 이는 비동기 대기(await)라기보다는 매 프레임마다 실행을 일시 중지하고 제어권을 메인 루프에 넘기는 제너레이터 패턴을 해킹 한 것에 가깝다.
흥미로운 점은, C++23에 추가된 <generator>를 사용하면 우리도 이와 완벽하게 동일한 패턴을 구현할 수 있다는 것이다.
std::generator<std::monostate> TimeWarp(GameObject& obj) {
// 왼쪽으로 점프
obj.transform.position.x -= 1.f;
co_yield {};
// 오른쪽으로 4번 스텝
for (int i = 0; i < 4; ++i) {
obj.transform.position.x += 0.2f;
co_yield {};
}
// 다시 시간 왜곡!
for (int i = 0; i < 4; ++i) {
obj.transform.Rotate(0.f, 90.f * i, 0.f);
co_yield {};
}
}
이 코드를 보라. 복잡한 상태 머신이 직관적인 절차적 코드로 변모했다. 루프와 지역 변수만으로 상태가 자연스럽게 유지된다. 이것이 코루틴이 제공하는 강력한 제어 흐름의 추상화다.
실행기(Executor)를 만드는 것도 생각보다 간단하다. std::vector에 제너레이터들을 담아두고, 매 프레임마다 순회하며 ++iterator를 호출해 주기만 하면 된다. 완료된 코루틴을 std::remove_if로 정리하는 로직만 추가하면 100줄도 안 되는 코드로 훌륭한 게임 이펙트 스케줄러를 만들 수 있다.
왜 co_await 대신 co_yield인가?
이 대목에서 이런 의문이 들 수 있다. “왜 co_await NextFrame() 같은 우아한 비동기 문법을 쓰지 않고 co_yield를 이용한 꼼수를 쓰는가?”
이유는 단순하다. C++에서 co_await를 제대로 쓰려면 밑바닥부터 너무 많은 것을 직접 만들어야 하기 때문이다.
해커뉴스 스레드에서도 이 부분에 대한 성토가 이어졌다. C++의 코루틴은 극단적으로 Un-opinionated(특정 방식을 강제하지 않는) 철학을 따른다. co_await를 호출했을 때 코루틴이 어느 스레드에서 재개될지, 스케줄러는 어떻게 동작할지 표준 라이브러리는 전혀 관여하지 않는다.
결국 개발자가 직접 Promise 타입, Awaitable 객체, 커스텀 스케줄러를 전부 작성해야 한다. 반면 co_yield 기반의 제너레이터는 제어의 역전(IoC) 없이 호출자가 직접 루프를 돌며 제어권을 쥐기 때문에 훨씬 직관적이고 구현이 쉽다.
해커뉴스의 시선: C++ 코루틴의 복잡성 논란
이번 주제에 대한 해커뉴스 커뮤니티의 반응을 살펴보면, C++ 코루틴의 설계 철학에 대한 시니어 엔지니어들의 엇갈린 시선을 볼 수 있다.
- Tick-Tock 릴리즈 주기: 한 유저의 통찰력 있는 지적에 따르면, C++ 표준은 복잡한 기능에 대해 Tick-Tock 방식을 취한다. C++20(Tick)에서는 컴파일러 개발자와 라이브러리 작성자를 위한 로우레벨 코어 언어 기능을 던져주었고, C++26(Tock)에서 비로소
std::execution같은 하이레벨 프레임워크가 표준 라이브러리에 들어올 예정이다. 즉, 지금 우리가 느끼는 고통은 과도기적 현상이라는 것이다. - Stackful vs Stackless: C++20 코루틴은 Stackless다. 즉, 호출 스택을 통째로 저장하지 않고 중단점의 상태만 저장한다. 일부 시스템 프로그래머들은 ucontext나 어셈블리 레벨의 컨텍스트 스위칭을 활용하는 Stackful 코루틴(예: 미니코로, Boost.Coroutine)을 선호한다고 밝혔다. Stackful 방식은 함수의 색깔(Coloring) 문제에서 자유롭기 때문이다.
- 힙 할당(Heap Allocation) 오해: Stackless 코루틴은 생성 시 기본적으로 힙 할당을 발생시킨다. 이것이 성능에 민감한 개발자들에게 거부감을 주지만, 코루틴의 Promise 타입 내부에
operator new를 오버로딩하면 커스텀 메모리 풀을 사용하거나 힙 할당을 완전히 회피할 수 있다. 컴파일러의 HALO(Heap Allocation Elision Optimization)에만 의존할 필요가 없다는 뜻이다.
Principal Engineer의 시선으로 본 결론
“그래서, 프로덕션에 도입해야 할까?”
내 대답은 조건부 Yes 다. 만약 당신의 팀이 복잡한 상태 머신(NPC AI, 애니메이션 시퀀스, 복잡한 UI 트랜지션 등) 때문에 코드 가독성이 박살 나고 있다면, 본문에서 소개한 std::generator 기반의 스케줄러 해킹은 당장 도입해 볼 만한 훌륭한 패턴이다. 부수 효과(Side-effect)를 없애기 위해 co_yield DrawCommand{...} 형태로 렌더링 명령만 반환하게 만들면 멀티스레딩까지 공짜로 얻을 수 있다.
하지만 일반적인 비동기 I/O 네트워크 서버를 바닥부터 코루틴으로 짠다면 말리고 싶다. Boost.Asio 같은 검증된 라이브러리(또는 독립형 Asio)를 사용하거나, C++26의 std::execution이 성숙해질 때까지 기다리는 것이 현명하다. C++ 위원회는 우리에게 자동차가 아니라 고성능 엔진 부품을 주었다. 부품 조립은 라이브러리 작성자들의 몫이지, 비즈니스 로직을 짜야 하는 애플리케이션 개발자의 몫이 아니다.
코루틴은 마법이 아니다. 그저 상태를 저장해 주는 컴파일러 주도형 State Machine 생성기일 뿐이다. 이 사실을 깨닫고 나면, C++ 코루틴이 비로소 실용적인 도구로 보이기 시작할 것이다.
References:
- Original Article: Looking at Unity made me understand the point of C++ coroutines
- Hacker News Thread: https://news.ycombinator.com/item?id=47472566