The Classic Missed-Signal: condition_variable::wait_for의 잊혀진 함정
📺 원본 영상: Lightning Talk: The Classic Missed-Signal! - Gopal Rander - CppCon 2025 🎙️ 채널: CppCon 📅 정리 일자: 2026-05-14 ⚠️ Note: 본 리포트는 영상의 음성을 텍스트로 변환(STT)하여 정리한 것입니다. 화면에 표시된 자료(코드, 슬라이드 등)는 포함되지 않을 수 있으며, 본문의 코드 예시는 강연자의 설명을 바탕으로 재구성된 것입니다.
개요
멀티스레드 C++ 프로그램에서 특정 컴포넌트를 100배 빠르게 최적화했는데 오히려 전체 실행 시간이 1,000배 느려진다면 어떻게 반응할까? CppCon 2025에서 Gopal Rander가 발표한 이 라이트닝 토크는 정확히 그런 상황을 다룬다. 겉보기엔 완벽해 보이는 std::condition_variable 기반 동기화 코드에서 predicate 하나가 빠졌을 때 발생하는 미스 시그널(Missed Signal) 버그를 실제 경험담을 통해 설명하며, C++ 동시성 프로그래밍에서 절대 놓쳐서는 안 되는 핵심 규칙을 전달한다.
문제 설정: Runner와 Waiter 구조
강연자는 간단하지만 현실적인 시나리오를 제시한다. 어떤 작업을 수행하는 **러너 스레드(Runner Thread)**가 있고, 이 러너가 일정 시간(타임아웃) 내에 작업을 완료하지 못하면 강제로 종료시키는 웨이터 스레드(Waiter Thread)—여기서는 메인 스레드—가 있다. 러너는 편의상 일정 시간 동안 sleep하는 방식으로 작업을 모방하며, 완료 후 메인 스레드에 완료 신호를 보낸다.
이 구조를 구현하기 위해 강연자는 세 가지 핵심 요소를 사용했다. 첫째, 두 스레드가 공유하는 bool done 변수, 둘째, 이 변수를 보호하는 std::mutex, 셋째, 러너가 메인 스레드에 완료를 알리기 위한 std::condition_variable이다. 러너 스레드는 작업을 마치면 done을 true로 설정하고 cv.notify_all()을 호출한다. 메인 스레드는 cv.wait_for(lock, wait_kill_seconds)를 호출하여 러너가 끝날 때까지 기다리거나, 타임아웃이 경과하면 러너를 강제 종료하는 방식이다.
아래는 강연자의 설명을 바탕으로 재구성한 버그가 있는 코드이다. 원본 코드와 세부 구현이 다를 수 있으며, 개념 이해를 위한 참고용으로만 활용하길 권장한다:
// ⚠️ 재구성된 코드 - 원본 영상 참조 권장
// https://www.youtube.com/watch?v=8Ign1X3qkgk
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
std::mutex mtx;
std::condition_variable cv;
bool done = false;
// 초기 설정: 러너는 5,000µs 소요
const auto completion_duration = std::chrono::microseconds(5000);
// 타임아웃: 5초
const auto wait_kill_seconds = std::chrono::seconds(5);
// 러너 스레드: 작업 완료 후 시그널 발송
std::thread runner([&]() {
std::this_thread::sleep_for(completion_duration);
{
std::lock_guard<std::mutex> lk(mtx);
done = true;
}
cv.notify_all(); // 웨이터에게 완료를 알림
});
// 웨이터 스레드 (메인 스레드)
std::unique_lock<std::mutex> lk(mtx);
// ❌ Line 26: predicate 없는 wait_for — 버그 발생 지점
cv.wait_for(lk, wait_kill_seconds);
runner.join();
01:44 - 러너 스레드와 웨이터 스레드의 C++ 코드 전체가 표시된 슬라이드
코드만 보면 그럴듯해 보인다. 러너가 완료되면 시그널을 보내고, 메인 스레드는 그 시그널을 받아 깨어난다. 5초 안에 완료되지 않으면 타임아웃이 걸린다. 강연자도 처음에는 이 코드가 올바르게 동작한다고 생각했다고 말한다.

02:21 - 프로그램 실행 결과 화면 - 러너 완료 시간(5000µs)과 wait_kill_seconds(5초) 출력
버그의 탄생: 성능 개선이 도리어 독이 된 순간
문제는 성능 최적화를 적용한 이후에야 드러났다. 초기에 러너는 완료하는 데 5,000마이크로초(5밀리초) 정도 걸렸고, 타임아웃은 5초로 설정되어 있었기 때문에 모든 것이 정상적으로 동작했다. 이후 강연자는 러너를 100배 빠르게 최적화하는 데 성공했고, 러너는 이제 불과 50마이크로초만에 작업을 끝낼 수 있게 되었다. 당연히 전체 프로그램도 훨씬 빠르게 종료될 것이라 기대했다.
그런데 현실은 전혀 달랐다. 프로그램을 실행해보니 여전히 약 5초가 걸렸다. 러너 자체는 50마이크로초 만에 끝났지만, 전체 실행 시간은 타임아웃에 맞춰 5초 가까이 소요되었다. 강연자의 말을 빌리자면, 러너를 100배 빠르게 만들었더니 오히려 전체 프로그램은 원래보다 약 1,000배 느리게 동작하는 것처럼 보이는 기이한 상황이 벌어진 것이다.

02:50 - 성능 개선 후(50µs) 프로그램이 여전히 ~5초 소요되는 실행 결과 비교 화면
이 증상 자체가 버그의 본질을 드러내는 단서였다. 러너가 빠르면 빠를수록 상황이 나빠지는 현상—이는 전형적인 **경쟁 조건(Race Condition)**의 역설적 형태였다.
근본 원인: wait_for의 두 가지 오버로드
강연자는 문제의 핵심을 명확하게 짚어낸다. std::condition_variable::wait_for는 cppreference에 두 가지 오버로드(overload)가 정의되어 있다:

03:50 - 버그 코드 슬라이드 - Line 26의 predicate 없는 wait_for가 강조된 화면
// 오버로드 1: predicate 없는 버전 — 강연자가 처음 사용했던 것
template< class Rep, class Period >
std::cv_status wait_for( std::unique_lock<std::mutex>& lock,
const std::chrono::duration<Rep, Period>& rel_time );
// 오버로드 2: predicate 있는 버전 — 강연자가 권장하는 것
template< class Rep, class Period, class Predicate >
bool wait_for( std::unique_lock<std::mutex>& lock,
const std::chrono::duration<Rep, Period>& rel_time,
Predicate stop_waiting );
04:11 - cppreference의 wait_for 두 오버로드 정의가 표시된 문서 화면
문제는 첫 번째 오버로드의 동작 방식에 있다. notify_all()은 현재 wait_for를 호출하며 대기 중인 스레드를 깨워주는 역할을 한다. 그런데 러너가 매우 빠르게 실행되어, 메인 스레드가 아직 wait_for에 도달하기도 전에 notify_all()을 호출해버리면 어떻게 될까? notify_all()은 대기 중인 스레드가 없으므로 아무것도 하지 않고 지나친다. 이후 메인 스레드가 wait_for에 진입하면, 이미 발송된 시그널은 어디에도 저장되어 있지 않기 때문에 지정된 타임아웃(5초)이 경과할 때까지 아무 이유 없이 블로킹된다.
강연자는 “notify는 그냥 힌트(hint)에 불과하다”고 강조한다. notify를 보낼 때 상대 스레드가 아직 대기하고 있지 않을 수도 있으며, 그 시그널은 보존되거나 보장되지 않는다는 의미다.
반면 두 번째 오버로드, 즉 predicate를 받는 버전은 내부적으로 루프를 돌면서 wait_for에 진입하는 즉시 predicate를 먼저 확인한다. 따라서 wait_for 호출 시점에 이미 done이 true라면—즉, 러너가 이미 끝난 상태라면—대기 없이 즉시 true를 반환하고 함수를 빠져나온다. 이 덕분에 “시그널이 wait보다 먼저 도착한” 상황을 완전히 해결할 수 있다.
수정: 딱 한 줄의 차이
해결책은 놀랍도록 단순하다. 강연자에 따르면, 버그가 있는 코드(Line 26)와 수정된 코드(Line 27)의 차이는 predicate 람다 단 하나뿐이다:
// ❌ 수정 전 (Line 26): predicate 없음 — 미스 시그널 버그
cv.wait_for(lk, wait_kill_seconds);
// ✅ 수정 후 (Line 27): predicate 추가 — 버그 수정
cv.wait_for(lk, wait_kill_seconds, [&]{ return done; });
04:31 - 수정된 코드 슬라이드 - Line 27에 predicate 람다가 추가된 wait_for 전체 코드
predicate 람다 [&]{ return done; }는 러너와 웨이터 사이에서 공유되던 bool done 변수를 그대로 반환한다. 이렇게 하면 wait_for는 대기를 시작하기 전에 항상 done의 상태를 먼저 확인하고, 이미 참이라면 즉시 반환한다. 러너가 아무리 빠르게 끝나도 메인 스레드는 올바르게 인식할 수 있게 된다.
강연자는 predicate 없이 사용하는 첫 번째 오버로드가 왜 표준에 존재하는지 C++ 위원회에 묻고 싶다고 유머러스하게 말했는데, 이는 첫 번째 오버로드가 실수를 유발하기 쉬운 인터페이스라는 점을 꼬집는 발언이기도 하다.
condition_variable과 허위 깨어남(Spurious Wakeup)에 대한 보충 이해
강연자는 본문에서 허위 깨어남(spurious wakeup) 이라는 또 다른 위험 요소도 언급했다. wait_for는 notify_all 없이도 운영체제 수준에서 간헐적으로 스레드를 깨울 수 있다는 것이 C++ 표준이 인정하는 동작이다. 강연자는 이 강연에서 해당 경우는 실제로 발생하지 않았다고 언급했지만, predicate 없는 버전은 허위 깨어남도 처리하지 못한다는 점에서 이중으로 위험하다. 진짜 완료 시그널로 깨어난 것인지, 허위 깨어남인지를 구분할 수단이 없기 때문이다.
predicate가 있는 두 번째 오버로드는 이 두 가지 문제—미스 시그널과 허위 깨어남—를 모두 올바르게 처리한다. predicate가 false를 반환하는 한, 허위 깨어남이 발생하더라도 다시 대기 상태로 돌아가기 때문이다. 이는 단순한 코드 스타일의 문제가 아니라, 정확한 동작을 보장하기 위한 필수적인 방어 코드(defensive programming) 패턴임을 의미한다.
핵심 정리
Gopal Rander는 이 라이트닝 토크를 통해 C++ 동시성 코드를 작성하는 모든 개발자가 기억해야 할 규칙을 명확하게 제시했다. 첫째, 공유 상태(shared state)는 반드시 뮤텍스로 보호해야 한다. 이는 기본 중의 기본이지만, 동시성 버그의 많은 부분이 이 원칙을 소홀히 할 때 발생한다. 둘째이자 이 강연의 핵심 메시지는, condition_variable의 wait_for(혹은 wait)를 사용할 때는 반드시 predicate를 함께 제공해야 한다는 것이다. 셋째, notify는 “힌트”에 불과하다—notify를 보내는 시점에 상대 스레드가 대기 중이라는 보장이 없으며, 이를 당연하게 가정하는 코드는 미묘하고 재현하기 어려운 버그를 낳는다.
단 5분짜리 라이트닝 토크임에도 불구하고, 이 발표는 C++ 실무에서 많은 개발자가 실제로 빠지는 함정을 정확히 짚어냈다는 점에서 가치 있는 내용이다. 성능 최적화를 할수록 오히려 동작이 나빠지는 현상이 단 한 줄—predicate 람다의 부재—에서 비롯될 수 있다는 교훈은 오래 기억할 만하다.
📚 참고 자료
이 섹션은 강연 내용과 별개로, 이해를 돕기 위한 추가 자료이다.
강연자가 직접 언급한 cppreference.com의 std::condition_variable::wait_for 문서는 두 오버로드의 정확한 동작 명세를 확인할 수 있는 가장 신뢰할 수 있는 레퍼런스다. 특히 predicate를 받는 두 번째 오버로드의 설명에서, 이 함수가 “stop_waiting()이 true를 반환하거나 rel_time이 경과할 때까지 대기한다”고 명시하고 있으며, 진입 시점에 predicate가 이미 참이라면 즉시 반환함을 보장한다. 이것이 미스 시그널을 방어하는 핵심 메커니즘이다.
C++ Core Guidelines 역시 동일한 권장 사항을 제시하고 있다. condition_variable을 사용할 때는 항상 루프 안에서 조건을 다시 확인하거나, predicate를 갖는 wait/wait_for 오버로드를 사용하라는 것이다. 이 관례가 단순한 스타일 가이드가 아니라 정확한 동작을 보장하기 위한 필수 패턴임을, 이 강연이 실제 사례를 통해 증명한다.
📚 출처 및 참고
| 구분 | 내용 |
|---|---|
| 원본 영상 | Lightning Talk: The Classic Missed-Signal! - Gopal Rander - CppCon 2025 |
| 채널 | CppCon |
| 리포트 작성 | 2026-05-14 |
| 작성 방식 | YouTube 영상 STT 변환 후 에디팅 |
💬 본 리포트는 영상 내용을 바탕으로 재구성되었습니다. 정확한 내용은 원본 영상을 참조해 주세요.
Related Reports
- C++ Magic Statics Demystified - History, Internals, Pitfalls, and Optimizations - C++Now 2026
- Choose the Right C++ Parallelism Tool | Low-Level vs Async vs Coroutines vs Data Parallel
- Beyond Sequential Consistency - Unlocking Hidden Performance Gains - Christopher Fretz - CppCon 2025
- Beautiful C++ Code - Told Through the Eyes of A Failed AI Prompt - Erich Lohrmann - C++Now 2026