Seastar 기반 서비스에서 실제로 겪은 버그를 바탕으로 정리한, 올바른 비동기 C++를 작성하기 위한 24가지 실전 규칙.
Ranvier- [x]
2026년 3월 29일 • Minds Aspire
비동기 C++는 해제 후 사용 버그를 쓰게 만들 수 있고, 그 버그는 부하가 걸렸을 때, 한 달의 셋째 화요일에만, 버그와 전혀 관계없어 보이는 스택 프레임에서만 드러날 수 있습니다. 컴파일러는 경고하지 않습니다. 테스트는 통과합니다. sanitizer도 어깨를 으쓱할 뿐입니다. 그리고 나서 운영 환경이 당신이 놓친 것을 가르쳐 줍니다.
저는 Seastar 위에 구축된 약 5만 LOC 규모의 C++20 서비스를 유지보수하고 있습니다. 저를 태워버린 모든 종류의 버그를 분류해서, 모든 커밋마다 강제하는 24개의 규칙으로 만들었습니다. 각각은 진단하는 데 최소 하루가 들었습니다. 여기 있습니다.
최악의 비동기 C++ 버그 대부분은 수명 버그입니다. 동기 코드에서는 객체가 존재한다면 그것을 만든 스코프도 여전히 존재합니다. 비동기 코드에서는 그렇지 않습니다. 객체는 살아 있지만 그것을 만든 스코프는 이미 오래전에 끝났습니다.
**규칙 16 — ```plaintext .then()
안의 람다 코루틴은 해제 후 사용입니다.** 이것은 목록에서 가장 무서운 버그인데, 완전히 올바른 코드처럼 보이기 때문입니다. ```plaintext
co_await
를 포함한 람다를 작성하고, 그것을 ```plaintext .then()
에 넘기면, 모든 것이 컴파일됩니다. 실제로 일어나는 일은 이렇습니다. ```plaintext
.then()
이 람다를 내부 저장소로 이동시킵니다. 람다의 ```plaintext operator()
가 호출되고, 힙에 코루틴 프레임이 생성됩니다. 코루틴은 ```plaintext
co_await
에서 일시 중단됩니다. ```plaintext .then()
은 람다에 대한 일을 끝냈다고 보고 그것을 파괴합니다. 코루틴은 해제된 메모리로 재개됩니다.
깨진 버전:
// BROKEN — use-after-free when the coroutine suspends future<> handle(request req) { return async_lookup(req.key()).then([req = std::move(req)](auto val) -> future<> { co_await async_log(req, val); // .then() has already freed this lambda co_return; }); }
수정된 버전:
// FIXED — seastar::coroutine::lambda() keeps the frame alive future<> handle(request req) { return async_lookup(req.key()).then(seastar::coroutine::lambda([req = std::move(req)](auto val) -> future<> { co_await async_log(req, val); co_return; })); }
컴파일러는 이에 대해 절대 경고하지 않습니다. 저는 지속적인 부하에서만 나타나는 힙 손상을 3일 동안 추적한 끝에 이것을 찾아냈습니다.
**규칙 5 — 타이머 콜백에는 gate guard가 필요합니다.** 반복 타이머는 ```plaintext
stop()
가 이미 ```plaintext this
를 파괴하기 시작한 뒤에 발화할 수 있습니다. 콜백은 더 이상 존재하지 않는 멤버 변수를 역참조합니다. 해결책은 ```plaintext
seastar::gate
이지만, gate holder는 단지 try 블록만이 아니라 전체 비동기 작업보다 오래 살아야 합니다.
// BROKEN — gate guard scoped to try body; catch runs outside the gate
void on_timer() {
try {
auto holder = _gate.hold();
co_await do_work();
} catch (...) {
_logger.warn("failed"); // _logger is destroyed during shutdown
}
}
// FIXED — gate guard covers the entire operation including error handling
void on_timer() {
auto holder = _gate.hold();
try {
co_await do_work();
} catch (...) {
_logger.warn("failed");
}
}
종료 중에는 ```plaintext _gate.close()
가 남아 있는 holder를 기다립니다. holder의 스코프가 try 내부에 있으면, catch 경로는 보호 없이 실행되며 ```plaintext
stop()
가 이미 파괴한 멤버를 건드립니다.
규칙 21 — 코루틴 참조 매개변수는 매달립니다. 코루틴 매개변수는 그냥 값으로 받으십시오. 항상. ```plaintext const std::string&
를 받는 코루틴은 올바르게 보이고, 컴파일도 잘 되며, 모든 단위 테스트도 통과하지만, 부하 상황에서 깨집니다. 호출자의 문자열 스코프가 끝나고, 코루틴은 일시 중단되며, 다시 재개될 때 참조는 해제된 메모리를 가리킵니다.
// BROKEN — reference dangles when caller's scope ends before coroutine resumes future<> process(const std::string& key) { co_await db.lookup(key); // key may be freed by now }
// FIXED — take by value, always future<> process(std::string key) { co_await db.lookup(key); }
복사의 비용은 99.9퍼센타일에서만 나타나는 매달린 참조를 디버깅하는 비용에 비하면 아무것도 아닙니다.
**규칙 20 — ```plaintext
do_with
람다에서 ```plaintext &
가 빠지는 경우.** ```plaintext
seastar::do_with
는 객체를 힙에 할당하고 당신의 람다에 참조로 전달합니다. ```plaintext &
하나를 빼먹으면 복사본을 얻게 됩니다.
// BROKEN — buf is captured by value; the copy dies when the lambda returns return seastar::do_with(std::move(buf), [](auto buf) { return async_write(buf); // dangling reference to destroyed copy });
// FIXED — capture by reference; do_with owns the object for the future's lifetime return seastar::do_with(std::move(buf), [](auto& buf) { return async_write(buf); });
문자 하나가 빠졌을 뿐입니다. 복사본은 람다가 반환될 때 파괴되지만, 그것이 생성한 future는 여전히 실행 중이고, 이제 죽어버린 복사본에 대한 참조를 여전히 붙잡고 있습니다. 전혀 관계없는 코드에서, 때로는 몇 분 뒤에야 나타나는 힙 손상입니다.
**규칙 23 — ```plaintext
share()
를 ```plaintext temporary_buffer
에 쓰면 전체 할당을 고정합니다.** 64KB 네트워크 버퍼에서 32바이트 헤더를 가져오려고 ```plaintext
.share()
를 호출합니다. 이제 두 개의 shared view가 같은 기반 할당을 함께 고정합니다. 당신은 헤더를 캐시하지만, “temporary” 버퍼는 영원히 살아 있습니다. 결과는 논리적 데이터 크기와 상관관계가 없는 설명하기 어려운 메모리 증가입니다. 해결책은 필요한 바이트를 새 버퍼로 복사한 뒤, shared view를 해제하는 것입니다.
Seastar는 협력적입니다. 당신을 선점해 줄 커널은 없습니다. 당신이 막는 매 마이크로초마다 그 코어는 요청을 0개 처리합니다.
**규칙 2 — 외부 자원에 대한 무한 루프 안에서 ```plaintext co_await
금지.** ```plaintext
for (auto& item : items) { co_await process(item); }
패턴은 O(n) 지연입니다. 각 항목이 10ms씩 걸리는 항목 100개라면, 그 코어는 1초 전체 동안 다른 아무 일도 하지 않습니다. ```plaintext seastar::parallel_for_each
또는 ```plaintext
seastar::max_concurrent_for_each
를 사용해 동시성을 제한하십시오. 항목은 병렬로 처리하되, 메모리를 고갈시키지 않도록 병렬성을 상한으로 묶으십시오.
**규칙 12 — 코루틴에서 ```plaintext std::ifstream
금지.** 컴파일됩니다. SSD 테스트 환경에서도 동작합니다. 운영에서는 10ms짜리 디스크 정체 한 번이 전체 shard를 얼려버립니다. 그 코어의 모든 연결은 10ms 동안 패킷을 놓칩니다. reactor를 거치고 올바르게 양보하는 Seastar의 파일 I/O를 사용하십시오. 정말로 blocking I/O를 호출해야만 한다면, 그것을 ```plaintext
seastar::thread
안에 격리하고, 매우 크게 문서화하십시오.
규칙 17 — 뜨거운 루프의 선점 지점. 양보 없이 500μs 동안 도는 촘촘한 루프는 그 코어의 다른 모든 것을 굶깁니다. 약 100회 반복마다 ```plaintext co_await seastar::coroutine::maybe_yield()
를 넣으십시오. 비용은 거의 실행되지 않는 분기 하나입니다. _그렇게 하지 않았을 때_의 비용은 로그에 남는 reactor stall 경고와, 부하를 줄이면 사라지는 수수께끼 같은 지연 급등입니다.
## Cross-Shard는 Cross-Universe입니다
Seastar에서 각 코어는 자체 메모리 할당자를 가집니다. 이것은 무시할 수 있는 구현 세부사항이 아닙니다. 시스템을 떠받치는 불변조건이며, 이를 위반하면 할당자 상태를 조용히 손상시킵니다.
**규칙 0 — ```plaintext
std::shared_ptr
는 잘못된 shard에서 파괴됩니다.** 참조 카운트는 atomic이므로 감소는 어떤 코어에서든 “안전”합니다. 하지만 소멸자는 마지막으로 감소시킨 코어에서 실행됩니다. 그 소멸자는 잘못된 코어의 할당자를 통해 메모리를 해제합니다.
// BROKEN — destructor runs on whichever shard releases last
std::shared_ptr<session> s = std::make_shared<session>();
// ... shared across shards via submit_to() ...
// shard 3 drops the last reference; ~session() frees memory
// allocated by shard 0's allocator. Silent corruption.
// FIXED — foreign_ptr ensures destruction on the owning shard
seastar::foreign_ptr<seastar::lw_shared_ptr<session>> s;
한 shard 안에 머무는 객체에는 ```plaintext seastar::lw_shared_ptr
를 사용하십시오. 비원자적 참조 카운트를 가지며 shard-local 전용입니다. shard를 넘는 포인터는 ```plaintext
seastar::foreign_ptr
로 감싸십시오. 이것은 소멸자가 소유 shard에서 실행되도록 보장합니다. 이것은 저를 처음 태운 버그였고, 동시에 제가 가장 마지막에 예상했던 버그였기 때문에 규칙 0입니다.
규칙 14 — shard 간 힙 데이터는 반드시 로컬에서 다시 할당해야 합니다. ```plaintext submit_to()
로 다른 shard에 ```plaintext
std::string
을 넘깁니다. 대상 shard는 원본 shard의 할당자가 할당한 메모리를 읽습니다. 오늘은 동작할 수도 있습니다. 할당자 메타데이터가 바로 옆에 있어 다음 할당에서 손상될 수도 있습니다. 수신 시 복사하십시오. 항상. 낭비처럼 느껴지지만 조용한 손상을 막아줍니다.
규칙 15 — shard 경계를 넘는 FFI는 양방향 재할당이 필요합니다. Seastar가 할당한 메모리를 FFI 경계로 넘기면 Rust나 C 라이브러리 같은 외부 코드가 다른 할당자를 통해 그것을 해제하거나 재할당할 수 있습니다. FFI를 호출하기 전에 표준 ```plaintext malloc
메모리로 다시 할당하십시오. 그리고 reactor로 돌아오기 전에 결과를 다시 Seastar 할당자로 재할당하십시오.
## Future는 예외가 아닙니다
이제 C++에는 사실상 두 가지 오류 전파 시스템이 있습니다. 예외와 future 체인입니다. 둘을 섞는 코드는 오류가 새어 나가는 틈을 만듭니다.
**규칙 18 — 버려진 future는 오류를 조용히 삼킵니다.** ```plaintext
co_await
없이 비동기 함수를 호출하면 반환된 future는 즉시 파괴됩니다. 그 future가 나중에 예외와 함께 완료되면 아무도 그것을 보지 못합니다. Seastar는 런타임에 경고를 로그로 남기지만, 그때는 이미 피해가 발생한 뒤입니다. 완료되지 않은 쓰기, 실행되지 않은 정리 작업. 모든 future는 반드시 ```plaintext co_await
되거나, 반환되거나, 왜 버려도 되는지 설명하는 주석과 함께 명시적으로 폐기되어야 합니다.
**규칙 22 — future를 반환하기 전에 던져지는 예외는 ```plaintext
.finally()
를 우회합니다.** 함수가 future를 반환하기 전에 예외를 동기적으로 던지면, 그것은 일반 C++ 예외로 전파됩니다. 기대했던 반환값에 붙여 둔 어떤 ```plaintext .finally()
도 실행되지 않습니다. 정리는 건너뛰어집니다. 자원이 누수됩니다. 호출을 ```plaintext
seastar::futurize_invoke()
로 감싸십시오. 이것은 동기 예외를 잡아 실패한 future로 바꿔줍니다. 아니면 그냥 코루틴을 사용하십시오. 코루틴은 이를 자연스럽게 처리합니다.
**규칙 19 — 날것의 ```plaintext semaphore::wait()/signal()
는 throw 시 unit을 누수시킵니다.** ```plaintext
wait()
를 호출하고, 일을 하고, ```plaintext .finally()
에서 ```plaintext
signal()
를 호출합니다. 하지만 ```plaintext .finally()
를 붙이기 전에 작업이 동기적으로 예외를 던지면, unit은 절대 반환되지 않습니다. 세마포어의 사용 가능 카운트는 모든 것이 데드락에 빠질 때까지 단조롭게 감소합니다. ```plaintext
seastar::with_semaphore()
를 사용하십시오. 작업이 어떤 방식으로 실패하든 올바르게 수명을 처리해 줍니다.
어떤 규칙은 언어 자체에 관한 것이 아닙니다.
규칙 4 — 커지는 모든 컨테이너에는 MAX_SIZE가 필요합니다. 무한 버퍼는 절대 금지입니다. oversized 메시지를 보내는 악의적인 peer 하나만 있어도, 큐를 제한하는 것이 없다면 프로세스는 OOM에 빠집니다. 모든 ```plaintext std::vector
와 ```plaintext
std::deque
, 모든 ring buffer에 설정 가능한 최대값을 두십시오.
규칙 9 — 모든 catch 블록은 warn 레벨로 로그를 남깁니다. 조용한 ```plaintext catch(...)
는 운영 환경에서 “작동은 하는데 뭔가 이상한” 상태의 가장 큰 원인입니다. 예외를 잡는다면, 예상치 못한 일이 일어난 것입니다. 로그를 남기십시오. 너무 시끄럽다면 증상을 숨기지 말고 근본 원인을 고치십시오.
**규칙 7 — 영속 계층은 저장만 하고 검증은 하지 않습니다.** 이것은 언어 규칙이 아니라 설계 규칙입니다. 영속 계층이 검증까지 하면, 저장소를 띄우지 않고는 비즈니스 로직을 테스트할 수 없습니다. 저장만 하게 두면, 검증을 분리해서 테스트할 수 있고 I/O를 생각하지 않고도 올바름을 추론할 수 있습니다.
## 나머지 규칙들
완전성을 위해, 위에서 자세히 다루지 않은 규칙들을 나열하면 다음과 같습니다.
* **규칙 1** — 메트릭 접근자는 lock-free여야 하며, 질의 메서드 안에 ```plaintext
std::mutex
가 있으면 안 됩니다.
는 빈 컬럼에서 NULL을 반환하며, 이를 역참조하면 정의되지 않은 동작입니다.
* **규칙 6** — ```plaintext
stop()
에서는 메트릭 등록 해제를 먼저 하십시오. Prometheus scrape 람다는 ```plaintext this
를 캡처합니다. ```plaintext
this
가 먼저 파괴되면 다음 scrape는 해제 후 사용이 됩니다.
구조체는 하나만 두고, 흩어진 ```plaintext
thread_local
변수는 두지 마십시오.
는 잘못된 입력에서 예외를 던지며, 요청 파싱에서의 날것 호출은 충돌을 기다리는 코드입니다.
* **규칙 11** — 일회성 전역 초기화에는 ```plaintext
std::call_once
또는 ```plaintext std::atomic
를 사용하고, 지연 초기화되는 맨 bare static은 절대 사용하지 마십시오.
* **규칙 13** — thread-local ```plaintext
new
는 allocator에 등록된 명시적 destroy 함수가 필요합니다. 그렇지 않으면 shard 종료 시 메모리가 누수됩니다.
이 규칙들은 제가 모든 커밋마다 참고하는 참조 문서에 들어 있습니다. 도구가 아니라 규율로 강제됩니다. 어떤 linter도 ```plaintext .then()
안의 람다 코루틴이 해제 후 사용이라고 말해주지 못합니다.
번호를 붙이는 것이 중요합니다. “규칙 16”은 코루틴 프레임 수명 문제를 마주칠 때마다 다시 유도해내는 것보다 훨씬 빠른 축약입니다.
이 목록은 규칙 0에서 시작해 24까지 자랐습니다. 저는 버그가 저를 태웠을 때만 규칙을 추가합니다. 절대 추측으로 만들지 않습니다. 비슷한 것을 만들고 있다면, 당신만의 목록을 시작하십시오. 구체적인 규칙 자체보다 그것을 적어두는 습관이 더 중요합니다.
## 핵심 요점
비동기 C++는 어떤 가비지 컬렉션 언어도 따라올 수 없는 성능을 주지만, 안전망을 가져가 버립니다. 당신이 직접 만들어야 합니다. 규칙을 적어두십시오.
저는 Seastar 위에서 LLM 추론용 Layer 7 로드 밸런서인 [Ranvier](https://github.com/Ranvier-Systems/ranvier-core)를 만들고 있습니다. 이런 종류의 시스템 작업에 관심이 있다면 [source](https://github.com/Ranvier-Systems/ranvier-core)를 확인해 보십시오.
* * *
_Ranvier는 Minds Aspire, LLC의 프로젝트입니다._
[](https://ranvier.systems/2026/03/29/24-hard-rules-for-writing-correct-async-cpp.html)
## Ranvier
* Minds Aspire, LLC
LLM 추론을 위한 Intelligence Layer