C++ sleep_for·sleep_until로 주기 작업 만들기: 누적 드리프트와 짧은 sleep의 부정확함

이 글의 핵심

1초마다 실행하려고 sleep_for(1s)를 반복하면 작업 시간만큼 주기가 조금씩 밀립니다. 이 글은 이런 드리프트를 다음 목표 시각 기준 sleep_until로 줄이는 방법, 운영체제 스케줄러 때문에 짧은 sleep이 요청보다 길어지는 이유, 리트라이와 타임아웃을 조합하는 패턴을 코드와 함께 보여줍니다.

타이머 유틸리티란?

C++ 표준 라이브러리에는 “몇 초 뒤에 이 함수를 호출하라” 같은 타이머 객체가 따로 없습니다. 대신 <thread>의 std::this_thread::sleep_for, sleep_until, yield 세 함수와 <chrono>의 시계·기간 타입을 조합해 지연, 주기 실행, 타임아웃을 직접 만듭니다. 세 함수 모두 현재 스레드를 멈추는 동작이라는 점이 핵심입니다. 스레드가 멈춰 있는 동안 그 스레드에서는 아무 일도 일어나지 않으므로, UI 스레드나 네트워크 이벤트 루프에서 호출하면 전체가 멈춥니다. 이 글은 이 함수들의 정확한 의미와 한계, 그리고 한계를 넘어야 할 때 조건 변수나 Asio 타이머로 넘어가는 기준을 다룹니다.

#include <thread>
#include <chrono>
using namespace std::chrono_literals;

// 지연
std::this_thread::sleep_for(1s);

// 특정 시간까지
auto wakeup = std::chrono::system_clock::now() + 5s;
std::this_thread::sleep_until(wakeup);

sleep_for는 “지금부터 최소 이만큼”, sleep_until은 “이 시각이 될 때까지”를 뜻합니다. 두 함수는 비슷해 보이지만 시계를 다루는 방식이 다릅니다. sleep_for는 표준에 따라 단조 시계(steady clock) 기준으로 측정하도록 권장되어 시스템 시간이 바뀌어도 영향을 받지 않습니다. 반면 sleep_until은 넘겨준 time_point의 시계를 따르므로, 위 예제처럼 system_clock을 쓰면 대기 중에 사용자가 시계를 바꾸거나 NTP가 시간을 크게 보정할 때 깨어나는 시각이 앞당겨지거나 늦어집니다. 이 차이는 뒤의 “시계 선택” 절에서 다시 다룹니다.

sleep_for

using namespace std::chrono_literals;

// 밀리초
std::this_thread::sleep_for(100ms);

// 초
std::this_thread::sleep_for(2s);

// 분
std::this_thread::sleep_for(1min);

100ms, 2s, 1min 같은 리터럴은 std::chrono_literals 네임스페이스에 있고, 각각 milliseconds, seconds, minutes 타입의 duration 값을 만듭니다. sleep_for(100)처럼 정수를 직접 넘기면 컴파일 에러가 나는데, 이는 설계상 의도된 것입니다. 옛 C API의 sleep(1)(초)과 usleep(1000)(마이크로초), Windows의 Sleep(1000)(밀리초)처럼 단위가 함수마다 달라 생기던 실수를 타입으로 막기 위해서입니다. 서로 다른 단위의 duration끼리는 정밀도를 잃지 않는 방향이면 자동 변환되므로, 1s + 500ms는 1500ms로 계산됩니다.

실전 예시

예시 1: 주기적 작업

using namespace std::chrono_literals;

void periodicTask() {
    while (true) {
        // 작업 수행
        std::cout << "작업 실행" << std::endl;
        
        // 1초 대기
        std::this_thread::sleep_for(1s);
    }
}

이 코드는 “1초마다”가 아니라 “작업이 끝나고 1초 뒤마다” 실행됩니다. 작업에 50ms가 걸리면 실제 주기는 1.05초가 되고, 여기에 스케줄러가 스레드를 깨우는 지연까지 더해져 한 시간이면 수 분이 밀릴 수 있습니다. 로그 수집이나 헬스 체크처럼 정확한 주기가 중요하지 않다면 이 방식이 가장 단순하고 충분하지만, 초당 샘플 수가 정해진 측정이나 게임 틱처럼 주기가 중요하다면 뒤의 sleep_until 기반 고정 주기 패턴을 써야 합니다. 또 while (true)에는 종료 방법이 없어서 프로그램을 끝낼 때 이 스레드를 join할 수 없습니다. 실제 코드에서는 종료 플래그를 확인하도록 만들어야 합니다.

예시 2: sleep_until

using namespace std::chrono;
using namespace std::chrono_literals;

void scheduledTask() {
    auto nextRun = steady_clock::now() + 5s;  // 시계 선택은 아래 "시계 선택" 절 참고
    
    std::cout << "5초 후 실행 예정" << std::endl;
    std::this_thread::sleep_until(nextRun);
    
    std::cout << "작업 실행" << std::endl;
}

예시 3: 정밀 타이머

#include <chrono>
#include <iostream>
#include <thread>

using namespace std::chrono;
using namespace std::chrono_literals;

class PrecisionTimer {
    steady_clock::time_point start;
    
public:
    PrecisionTimer() : start(steady_clock::now()) {}
    
    void waitFor(milliseconds duration) {
        auto target = start + duration;
        
        while (steady_clock::now() < target) {
            std::this_thread::yield();  // CPU 양보
        }
    }
    
    auto elapsed() const {
        return duration_cast<microseconds>(
            steady_clock::now() - start
        );
    }
};

int main() {
    PrecisionTimer timer;
    timer.waitFor(100ms);
    
    std::cout << "경과: " << timer.elapsed().count() << "μs" << std::endl;
}

이 클래스의 waitFor는 “지금부터 100ms”가 아니라 객체가 생성된 시각부터 100ms가 지날 때까지 기다립니다(start + duration). 생성 후 바로 호출하면 차이가 없지만, 생성과 호출 사이에 다른 작업이 있으면 대기 시간이 그만큼 짧아지거나 아예 기다리지 않습니다. 의도가 “호출 시점부터”라면 steady_clock::now() + duration으로 바꿔야 합니다.

yield 루프는 sleep_for보다 목표 시각에 훨씬 가깝게 깨어난다는 장점이 있지만, 그 대가로 대기 내내 CPU 코어 하나를 거의 100% 사용합니다. yield는 “다른 스레드가 준비되어 있으면 양보하라”는 힌트일 뿐이라, 다른 할 일이 없는 시스템에서는 곧바로 돌아와 루프를 다시 돕니다. 100ms 동안 이렇게 도는 것은 대부분의 경우 낭비입니다. 실무에서 쓰이는 절충안은 목표 시각 1~2ms 전까지는 sleep_until로 자고, 남은 짧은 구간만 yield나 스핀으로 기다리는 하이브리드 방식입니다. 오디오나 게임 프레임 페이싱처럼 서브밀리초 정밀도가 필요한 곳에서 이 방식을 씁니다.

예시 4: 타임아웃

#include <chrono>
#include <iostream>
#include <thread>

using namespace std::chrono;
using namespace std::chrono_literals;

bool waitForCondition(milliseconds timeout) {
    auto deadline = steady_clock::now() + timeout;
    
    while (steady_clock::now() < deadline) {
        if (checkCondition()) {
            return true;
        }
        
        std::this_thread::sleep_for(10ms);
    }
    
    return false;  // 타임아웃
}

int main() {
    if (waitForCondition(5s)) {
        std::cout << "조건 충족" << std::endl;
    } else {
        std::cout << "타임아웃" << std::endl;
    }
}

데드라인을 루프 밖에서 한 번만 계산하는 것이 이 패턴의 핵심입니다. 반복할 때마다 남은 시간을 timeout -= 10ms처럼 차감하면 checkCondition() 실행 시간과 sleep 오차가 빠져서 실제 대기 시간이 설정보다 길어집니다. 절대 시각을 기준으로 비교하면 루프 안에서 무슨 일이 일어나든 전체 대기 시간은 timeout을 크게 넘지 않습니다.

다만 이 방식은 폴링이라 조건이 충족되어도 최대 10ms 늦게 알아챕니다. 조건을 바꾸는 쪽이 같은 프로그램의 다른 스레드라면 폴링 대신 std::condition_variable::wait_until(lock, deadline, pred)을 쓰는 것이 정답입니다. 조건이 바뀌는 즉시 깨어나고, 기다리는 동안 CPU를 전혀 쓰지 않습니다. 폴링은 파일 생성, 외부 프로세스 상태, 하드웨어 레지스터처럼 알림을 받을 방법이 없는 대상을 기다릴 때 쓰는 방법입니다.

yield

// CPU 양보
std::this_thread::yield();

// 바쁜 대기에서 사용
while (!ready) {
    std::this_thread::yield();
}

여기서 ready는 반드시 std::atomic<bool>이어야 합니다. 일반 bool을 한 스레드가 쓰고 다른 스레드가 읽으면 데이터 경쟁이라 정의되지 않은 동작이고, 실제로 최적화 컴파일러는 루프 안에서 ready가 바뀌지 않는다고 가정해 값을 한 번만 읽고 무한 루프로 만들 수 있습니다. 디버그 빌드에서는 잘 되다가 릴리스 빌드에서만 멈추는 전형적인 증상입니다. volatile은 이 문제의 해결책이 아닙니다. C++에서 volatile은 스레드 간 동기화를 보장하지 않습니다.

자주 발생하는 문제

문제 1: 정확도

using namespace std::chrono_literals;

// sleep은 최소 시간 보장
// 실제로는 더 길 수 있음
auto start = std::chrono::steady_clock::now();
std::this_thread::sleep_for(100ms);
auto end = std::chrono::steady_clock::now();

auto actual = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "실제: " << actual.count() << "ms" << std::endl;
// 출력: 실제: 102ms (예시)

표준은 sleep이 최소한 요청한 시간만큼 멈춘다는 것만 보장하고, 언제 깨어나는지에 대한 상한은 없습니다. 운영체제는 타이머가 만료되면 스레드를 “실행 가능” 상태로 바꿀 뿐이고, 실제로 CPU를 받는 시점은 스케줄러가 정합니다. 그래서 초과 지연은 시스템 부하에 따라 달라집니다. Linux는 기본적으로 타이머 만료를 약간 늦춰 묶어서 처리하는 timer slack(일반 스레드 기본 50µs)이 있고, Windows는 전통적으로 시스템 타이머 해상도가 약 15.6ms라서 sleep_for(1ms)가 15ms 넘게 걸리는 일이 흔했습니다. Windows에서 1ms 단위가 필요하면 timeBeginPeriod(1)로 해상도를 올리거나 고해상도 대기 타이머를 쓰지만, 시스템 전체 전력 소모가 늘어난다는 대가가 있습니다.

문제 2: 시계 선택

// ❌ system_clock (시간 변경 영향)
auto wakeup = std::chrono::system_clock::now() + 5s;
std::this_thread::sleep_until(wakeup);
// 시스템 시간 변경 시 문제

// ✅ steady_clock
auto wakeup = std::chrono::steady_clock::now() + 5s;
std::this_thread::sleep_until(wakeup);

system_clock은 벽시계 시간이라 사용자 설정, NTP 동기화, 가상 머신 일시 정지 후 재개 등으로 앞뒤로 점프할 수 있습니다. 대기 중에 시계가 1시간 뒤로 돌아가면 5초 대기가 1시간 5초 대기가 됩니다. steady_clock은 한 방향으로만 일정하게 증가하도록 보장되므로 타임아웃과 주기 작업에는 항상 이쪽을 씁니다. system_clock이 맞는 경우는 “매일 새벽 3시에 실행”처럼 달력상의 시각이 의미를 갖는 경우뿐이고, 그때도 긴 시간을 한 번에 자기보다 짧게 끊어 자면서 현재 시각을 다시 확인하는 편이 안전합니다. 두 예제 모두 같은 스코프에서 wakeup을 두 번 선언하므로 그대로 붙여 넣으면 재정의 에러가 나는 점은 비교를 위한 것이니 감안하고 보면 됩니다.

문제 3: 바쁜 대기

// ❌ CPU 100% 사용
while (!ready) {
    // 바쁜 대기
}

// ⚠️ yield: 다른 스레드에 양보하지만, 할 일이 없으면 여전히 코어를 거의 다 씀
while (!ready) {
    std::this_thread::yield();
}

// ✅ 짧은 sleep: CPU는 거의 쓰지 않지만 최대 1ms(+스케줄링 지연) 늦게 알아챔
while (!ready) {
    std::this_thread::sleep_for(1ms);
}

세 방식 모두 ready는 std::atomic<bool>이어야 합니다. 바쁜 대기와 yield는 반응이 가장 빠르지만 대기 시간 동안 코어 하나를 차지하므로, 마이크로초 단위로 끝나는 대기에만 어울립니다. 그보다 길어질 수 있다면 짧은 sleep으로 폴링하거나, ready를 바꾸는 쪽이 같은 프로그램 안에 있을 때는 조건 변수(C++20이라면 std::atomic<bool>::wait/notify_one)로 블록하는 편이 낫습니다.

문제 4: 짧은 sleep

// 매우 짧은 sleep은 부정확
std::this_thread::sleep_for(std::chrono::microseconds(1));
// 실제로는 더 오래 걸림

// OS 스케줄러 한계

1µs sleep을 요청해도 스레드를 재우고 깨우는 과정 자체에 시스템 호출과 컨텍스트 스위치 두 번이 필요하고, 여기에 앞에서 본 Linux의 timer slack(기본 50µs)이나 Windows의 타이머 해상도가 더해집니다. 그래서 sleep_for(microseconds(100))로 10kHz 주기를 기대하고 루프를 짜면, 실제로는 sleep 한 번의 고정 비용이 요청 시간과 비슷하거나 더 커서 기대보다 한참 낮은 빈도로 돌기 쉽습니다. 이런 구간에서는 sleep 시간을 줄여도 주기가 빨라지지 않습니다. 요구 정밀도가 수십 µs 이하라면 sleep이 아니라 스핀 대기, 실시간 스케줄링(SCHED_FIFO), 하드웨어 타이머 같은 다른 수단을 검토해야 합니다.

시간 측정

using namespace std::chrono;

auto start = steady_clock::now();

// 작업
std::this_thread::sleep_for(100ms);

auto end = steady_clock::now();
auto elapsed = duration_cast<milliseconds>(end - start);

std::cout << "경과: " << elapsed.count() << "ms" << std::endl;

duration_cast는 버림으로 변환합니다. 99.9ms는 99ms가 되므로, 반올림이 필요하면 C++17의 std::chrono::round<milliseconds>()를 쓰고, 소수점까지 보고 싶으면 duration<double, std::milli>로 변환하면 됩니다. 실행 시간 측정에 high_resolution_clock을 쓰는 예제도 많은데, 이 시계는 구현에 따라 system_clock의 별칭일 수 있어서(libstdc++가 그렇습니다) 측정 중 시계 보정의 영향을 받을 수 있습니다. 경과 시간 측정에는 steady_clock을 쓰는 것이 이식성 면에서 안전합니다.

타이머 구현 패턴

앞의 예시를 정리하면, 표준 스레드 API로 만드는 타이머는 상대 지연(sleep_for), 절대 시각까지 대기(sleep_until), 짧은 간격의 폴링, 데드라인 기반 루프 네 가지 조합입니다. 이 중 주기 작업에서 가장 차이가 큰 것은 다음 깨어날 시각을 어떻게 계산하느냐입니다. 시계와 duration 타입 자체는 time() 대신 std::chrono: duration·time_point, 클럭 선택, duration_cast, C++20 달력에서 자세히 다룹니다.

using namespace std::chrono_literals;

void fixed_rate_loop() {
    auto next = std::chrono::steady_clock::now();
    const auto interval = 1s;
    for (int i = 0; i < 10; ++i) {
        next += interval;
        do_tick();
        std::this_thread::sleep_until(next);  // 누적 시각 기준으로 드리프트 완화
    }
}

이 패턴이 드리프트를 없애는 원리는 “다음 깨어날 시각”을 이전 목표 시각에 간격을 더해 계산한다는 데 있습니다. 어떤 틱에서 스케줄러 때문에 3ms 늦게 깨어났다면, 다음 sleep_until은 그만큼 짧게 자서 원래 일정으로 돌아옵니다. 오차가 누적되지 않고 틱마다 상쇄되는 것입니다.

주의할 점은 do_tick()이 간격보다 오래 걸리는 경우입니다. 예를 들어 한 번 3초가 걸리면 next는 이미 과거 시각이 되어 sleep_until이 즉시 반환되고, 밀린 틱 두세 개가 연달아 실행됩니다. 측정 데이터를 빠짐없이 모아야 한다면 이 “따라잡기”가 맞는 동작이지만, 화면 갱신처럼 최신 상태만 중요하다면 밀린 틱은 건너뛰는 편이 낫습니다. 그럴 때는 if (next < steady_clock::now()) next = steady_clock::now(); 같은 보정을 넣어 과거로 밀린 목표 시각을 현재로 당깁니다.

Asio(Boost.Asio) 타이머와의 연계

비동기 I/O를 쓰는 서버와 클라이언트에서는 스레드를 재우는 대신 asio::steady_timer나 asio::system_timer로 이벤트 루프에 타이머를 등록합니다. steady_timer는 steady_clock 기준이라 타임아웃, 재전송 간격, 하트비트에 맞고, system_timer는 벽시계 기준이라 달력상의 특정 시각에 실행해야 할 때 씁니다.

async_wait에 완료 핸들러를 넘기면 만료 시 핸들러가 호출되고, timer.cancel()로 취소하면 핸들러가 operation_aborted 에러 코드와 함께 호출됩니다. 실행 큐와 스트랜드는 C++ Asio post, dispatch, defer | 실행 큐 정밀 제어 [#4]에서 함께 다룹니다.

// 개념 스케치 (실제 코드는 io_context, executor 설정 필요)
// asio::steady_timer t(io, std::chrono::steady_clock::now() + 5s);
// t.async_wait([](const std::error_code& ec) { if (!ec) { /* 타임아웃 처리 */ } });
// (Boost.Asio에서는 boost::system::error_code)

스레드 sleep과의 가장 큰 차이는 Asio 타이머가 스레드를 블록하지 않는다는 점입니다. 단일 스레드 io_context에서도 타이머 수천 개가 대기하는 동안 다른 연결을 계속 처리할 수 있고, 타이머 하나당 스레드 하나를 쓰지 않아도 됩니다.

주기적 작업 (심화)

오래 도는 주기 작업에는 종료 방법이 필요합니다. 흔히 std::atomic<bool> 종료 플래그나 C++20 std::stop_token을 루프 조건으로 확인하는데, 여기서 놓치기 쉬운 점은 sleep_for가 중간에 깨울 수 없다는 것입니다. 종료 플래그를 세워도 스레드가 1분짜리 sleep에 들어가 있으면 1분 뒤에야 플래그를 확인하고, 그동안 프로그램 종료가 멈춘 것처럼 보입니다. 즉시 깨울 수 있어야 한다면 sleep 대신 condition_variable::wait_for(lock, interval, [&]{ return stop; })를 쓰고, 종료할 때 notify_all()을 호출합니다. C++20에서는 std::condition_variable_any의 wait_for가 stop_token을 직접 받으므로, std::jthread가 소멸될 때 요청되는 정지로 대기가 바로 풀립니다. 자세한 내용은 C++20 std::jthread: 자동 join과 stop_token으로 스레드 협조적 중단하기와 C++20 stop_token·stop_source·stop_callback: jthread와 협력적 취소 구현에서 다룹니다.

sleep_for와 sleep_until로 20ms 주기 작업을 50회 돌린 실제 소요 시간

그림: 실제 실행 결과 — sleep_for(20ms)는 작업 시간만큼 매번 밀려 50회 동안 오차가 쌓인다. 다음 시각을 누적 계산해 sleep_until을 쓰면 전체 주기가 목표에 거의 맞는다. (환경: Ubuntu 24.04 · g++ 13.3)

실전: 리트라이와 타임아웃

재시도 로직은 전체 데드라인 하나를 두고 시도 사이마다 짧게 기다리는 구조가 읽기 쉽습니다.

#include <chrono>
#include <thread>

template <class Fn>
bool retry_with_deadline(Fn&& fn, std::chrono::steady_clock::time_point deadline,
                         std::chrono::milliseconds backoff) {
    while (std::chrono::steady_clock::now() < deadline) {
        if (fn()) return true;
        std::this_thread::sleep_for(backoff);
    }
    return false;
}

이 함수는 한 가지를 개선할 여지가 있습니다. 마지막 시도 직전의 sleep_for(backoff)가 데드라인을 넘어서까지 잘 수 있다는 점입니다. 데드라인이 100ms 남았는데 백오프가 500ms라면 400ms를 더 기다린 뒤에야 false를 반환합니다. sleep_until(std::min(now + backoff, deadline))처럼 대기 시각을 데드라인으로 잘라 주면 전체 제한 시간이 정확히 지켜집니다. 백오프를 시도마다 두 배로 늘리고 무작위 지터를 더하면, 서버 장애 뒤 모든 클라이언트가 같은 순간에 재시도해 다시 서버를 쓰러뜨리는 현상을 줄일 수 있습니다.

HTTP나 gRPC 클라이언트에 이 패턴을 적용할 때는 연결 타임아웃과 읽기 타임아웃을 따로 두고, 재시도는 같은 요청을 여러 번 보내도 결과가 같은 멱등 요청에만 적용하는 것이 안전합니다. 결제 요청처럼 멱등하지 않은 요청을 타임아웃 후 재시도하면, 첫 요청이 실제로는 서버에서 처리되었을 때 중복 실행이 됩니다.

성능 고려사항

폴링 간격은 요구 정밀도에 맞춰 정해야 합니다. 간격이 짧을수록 반응은 빨라지지만 스레드가 자주 깨어나 CPU를 쓰고, 노트북이나 모바일 기기에서는 CPU가 저전력 상태로 들어가지 못해 전력 소모가 늘어납니다. 사람이 체감하는 반응이 목적이라면 수십 ms 간격으로도 충분한 경우가 많습니다.

스레드 수도 고려해야 합니다. 타이머마다 스레드를 하나씩 만들어 각자 sleep하게 하면 타이머가 수천 개로 늘었을 때 스레드 스택 메모리와 스케줄러 부담이 커집니다. 이럴 때는 앞의 Asio 타이머처럼 이벤트 루프 하나와 만료 시각 순으로 정렬된 타이머 큐 하나로 합치는 구조가 맞습니다.

참고 자료



같이 보면 좋은 글