C++ steady_clock으로 경과 시간 재기: system_clock·high_resolution_clock과 차이

이 글의 핵심

경과 시간과 타임아웃은 steady_clock, 달력 시각은 system_clock으로 나눠야 NTP 보정이나 수동 시간 변경에도 측정이 틀어지지 않습니다. 벤치마크·타이머·데드라인 예제와 함께 duration_cast 절삭, count() 단위, 절전 중 시계 동작, high_resolution_clock의 함정을 정리합니다.

steady_clock이란?

std::chrono::steady_clock은 C++11에 도입된 단조 증가(monotonic) 시계입니다. “단조 증가”란 나중에 읽은 값이 먼저 읽은 값보다 절대 작지 않다는 뜻입니다. 벽시계 시각(system_clock)은 NTP 동기화, 사용자의 수동 변경, 가상 머신 복원 등으로 앞뒤로 튈 수 있지만, steady_clock은 그런 조정의 영향을 받지 않고 일정한 속도로만 흐릅니다. 그래서 “얼마나 걸렸나”와 “언제까지 기다릴까”를 계산할 때 쓰는 시계입니다.

#include <chrono>

auto start = std::chrono::steady_clock::now();
// 작업
auto end = std::chrono::steady_clock::now();
auto elapsed = end - start;

now()가 돌려주는 time_point의 절대값에는 의미가 없습니다. 기준점(epoch)이 구현 정의라서 Linux에서는 보통 부팅 시각, 다른 플랫폼에서는 또 다를 수 있습니다. 두 time_point를 빼서 얻은 duration 만이 의미 있는 값이며, steady_clock의 시각을 로그에 “현재 시각”으로 찍거나 다른 프로세스·머신과 비교하는 것은 잘못된 사용입니다. 또 서로 다른 시계의 time_point는 타입이 달라서 steady_clock::now() - system_clock::now() 같은 코드는 컴파일 에러가 납니다. chrono가 타입으로 실수를 막아 주는 부분입니다.

특징

// 단조 증가 보장
// - 시스템 시간 변경 영향 없음
// - 항상 증가
// - 성능 측정에 적합

std::chrono::steady_clock::is_steady;  // true

실전 예시

예시 1: 벤치마크

template<typename Func>
auto benchmark(Func f) {
    auto start = std::chrono::steady_clock::now();
    
    f();
    
    auto end = std::chrono::steady_clock::now();
    return std::chrono::duration_cast<std::chrono::microseconds>(
        end - start
    );
}

int main() {
    auto duration = benchmark([] {
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    });
    
    std::cout << "실행 시간: " << duration.count() << "μs" << std::endl;
}

이 예제를 실행하면 결과는 정확히 100000μs가 아니라 100100μs처럼 조금 더 크게 나옵니다. sleep_for는 “최소 이만큼” 재우는 것만 보장하고, 실제로는 OS 스케줄러의 타이머 해상도와 깨어난 뒤 CPU를 다시 받기까지의 지연이 더해지기 때문입니다. Windows에서는 기본 타이머 해상도가 약 15.6ms였던 역사가 있어 짧은 sleep의 오차가 더 크게 보일 수 있습니다.

duration_cast<microseconds>는 0 방향으로 잘라냅니다. 1999ns를 마이크로초로 바꾸면 1μs가 되고, 반올림이 필요하면 C++17의 std::chrono::round, 내림·올림은 floor/ceil을 씁니다. 표시만 할 거라면 std::chrono::duration<double, std::milli>(end - start).count()처럼 부동소수점 duration으로 바꾸면 소수점 이하까지 볼 수 있고 캐스트도 필요 없습니다.

예시 2: 타이머 클래스

class Timer {
    std::chrono::steady_clock::time_point start;
    
public:
    Timer() : start(std::chrono::steady_clock::now()) {}
    
    void reset() {
        start = std::chrono::steady_clock::now();
    }
    
    auto elapsed() const {
        auto end = std::chrono::steady_clock::now();
        return std::chrono::duration_cast<std::chrono::milliseconds>(
            end - start
        );
    }
};

int main() {
    Timer timer;
    
    // 작업
    std::this_thread::sleep_for(std::chrono::seconds(1));
    
    std::cout << "경과: " << timer.elapsed().count() << "ms" << std::endl;
}

예시 3: 타임아웃

bool waitWithTimeout(std::chrono::milliseconds timeout) {
    auto start = std::chrono::steady_clock::now();
    
    while (true) {
        if (isReady()) {
            return true;
        }
        
        auto now = std::chrono::steady_clock::now();
        if (now - start >= timeout) {
            return false;  // 타임아웃
        }
        
        std::this_thread::sleep_for(std::chrono::milliseconds(10));
    }
}

타임아웃에 steady_clock을 써야 하는 이유는 실제 장애 사례로 보면 분명합니다. system_clock으로 “시작 시각 + 30초”를 계산해 두었는데 그 사이 NTP가 시계를 1분 뒤로 돌리면, 타임아웃이 30초가 아니라 90초 뒤에 발생합니다. 반대로 시계가 앞으로 튀면 방금 시작한 요청이 즉시 타임아웃됩니다. 저는 가상 머신을 일시 정지했다가 재개한 직후 이런 증상으로 연결이 한꺼번에 끊기는 문제를 본 적이 있는데, 원인은 타임아웃 계산에 벽시계를 쓴 코드였습니다. 표준 라이브러리의 condition_variable::wait_for, this_thread::sleep_for도 내부적으로 steady_clock 기준으로 동작하도록 규정되어 있습니다.

한 가지 알아 둘 점은 시스템 절전(suspend) 중의 동작입니다. Linux의 steady_clock은 보통 CLOCK_MONOTONIC을 쓰는데, 이 시계는 절전 중에는 멈춥니다. 노트북 덮개를 닫았다 열면 그 시간은 경과 시간에 포함되지 않으므로, “실제로 흐른 시간”이 필요하다면 절전 시간까지 세는 CLOCK_BOOTTIME을 clock_gettime으로 직접 읽어야 합니다.

예시 4: 성능 비교

void comparePerformance() {
    auto duration1 = benchmark(algorithm1);
    auto duration2 = benchmark(algorithm2);
    
    if (duration1 < duration2) {
        std::cout << "algorithm1이 더 빠름" << std::endl;
    }
    
    auto diff = duration2 - duration1;
    std::cout << "차이: " << diff.count() << "μs" << std::endl;
}

이 비교는 한 번씩만 실행하므로 결과를 믿기 어렵습니다. 먼저 실행한 알고리즘이 캐시를 데워 놓아 두 번째가 유리해지거나, 그 순간 다른 프로세스가 CPU를 가져가 한쪽만 느려질 수 있습니다. 차이가 수 % 이내라면 측정 잡음과 구분되지 않으므로, 순서를 바꿔 여러 번 반복하고 중앙값을 비교하는 것이 최소한의 절차입니다. duration 끼리의 비교(duration1 < duration2)는 단위가 달라도 chrono가 공통 단위로 변환해 비교해 주므로 안전합니다.

system_clock vs steady_clock

// system_clock: 시스템 시간 (변경 가능)
auto sys = std::chrono::system_clock::now();

// steady_clock: 단조 증가 (변경 불가)
auto steady = std::chrono::steady_clock::now();

// 성능 측정: steady_clock 사용

자주 발생하는 문제

문제 1: 시계 선택

// ❌ system_clock (시간 변경 영향)
auto start = std::chrono::system_clock::now();
// 시스템 시간 1시간 뒤로
auto end = std::chrono::system_clock::now();
// 음수 duration

// ✅ steady_clock
auto start = std::chrono::steady_clock::now();
auto end = std::chrono::steady_clock::now();
// 항상 양수

문제 2: 정밀도

// steady_clock 정밀도는 플랫폼 의존
using period = std::chrono::steady_clock::period;
std::cout << "정밀도: " << period::num << "/" << period::den << "s" << std::endl;

문제 3: 오버헤드

// 시계 호출 자체도 시간 소요
auto start = std::chrono::steady_clock::now();
auto end = std::chrono::steady_clock::now();

auto overhead = end - start;
// count()의 단위는 steady_clock::period (주요 구현에서 ns이지만 보장되지 않음)
std::cout << "오버헤드: " << overhead.count() << " ticks" << std::endl;

duration::count()는 단위 없는 숫자를 돌려준다는 점이 함정입니다. 원래 코드처럼 "ns"를 붙여 출력하면 libstdc++·MSVC에서는 우연히 맞지만, 다른 구현에서는 틱 단위가 다를 수 있습니다. 단위를 확정하려면 duration_cast<nanoseconds>(overhead).count()처럼 먼저 변환하고 출력하세요. now() 한 번의 비용은 Linux의 vDSO 경로에서 수십 ns 정도로 작지만, 가상 머신에서 TSC를 쓸 수 없는 환경이라면 시스템 호출로 떨어져 훨씬 커질 수 있습니다.

문제 4: 긴 측정

// 매우 긴 측정 시 오버플로우 주의
auto start = std::chrono::steady_clock::now();

// 며칠 후
auto end = std::chrono::steady_clock::now();

// duration 타입 확인

주요 구현의 steady_clock은 64비트 정수 나노초(std::chrono::nanoseconds)를 쓰므로 표현 가능한 범위는 약 292년입니다. 경과 시간 자체가 넘칠 걱정은 사실상 없습니다. 실제로 오버플로우가 나는 곳은 직접 만든 좁은 duration 타입입니다. 예를 들어 결과를 std::chrono::duration<int, std::nano>(32비트 나노초)에 담으면 약 2.1초에서 넘치고, int로 받은 count()에 1000을 곱해 단위를 바꾸는 코드도 같은 문제를 일으킵니다. chrono의 표준 타입을 그대로 쓰고 필요할 때만 duration_cast하면 이 문제를 피할 수 있습니다.

활용 패턴

// 1. 벤치마크
auto duration = benchmark(func);

// 2. 타임아웃 (1000ms 리터럴은 using namespace std::chrono_literals; 필요)
bool success = waitWithTimeout(1000ms);

// 3. 성능 비교
compareAlgorithms();

// 4. 프로파일링
profileFunction();

steady_clock vs system_clock vs high_resolution_clock

C++11 chrono에는 대표적으로 세 시계가 있습니다. 이름만 보고 “항상 high_resolution이 가장 좋다”고 고르면 오히려 이식성이 나빠질 수 있습니다.

시계단조 증가(steady)의미 있는 절대 시각일반적 용도
steady_clock보장 (is_steady == true)epoch는 구현 정의, “몇 시”로 쓰기 어려움경과 시간, 데드라인, 타임아웃, 벤치마크
system_clock아님 (NTP·수동 조정 영향)wall-clock, to_time_t 등과 연동로그 시각, 파일·DB 타임스탬프, 만료일(캘린더 의미)
high_resolution_clock구현에 따라 다름종종 steady_clock 또는 system_clock의 별칭짧은 구간 측정(단, 이식성 주의)

high_resolution_clock 주의: 표준은 이 타입이 steady_clock이나 system_clock의 typedef일 수 있다고만 규정합니다. 따라서 절대 시각이 필요하면 system_clock, 경과·타임아웃이면 steady_clock을 직접 쓰는 편이 예측 가능합니다. high_resolution_clock은 “이식성보다 로컬 마이크로벤치”에 가깝습니다.

#include <chrono>
#include <iostream>

int main() {
    using namespace std::chrono;
    std::cout << std::boolalpha;
    std::cout << "steady_clock::is_steady: " << steady_clock::is_steady << '\n';
    std::cout << "system_clock::is_steady: " << system_clock::is_steady << '\n';
    std::cout << "high_resolution_clock::is_steady: " << high_resolution_clock::is_steady << '\n';
}

시간 측정 패턴

  1. 한 구간 측정: auto t0 = steady_clock::now(); … 작업 … auto dt = steady_clock::now() - t0;
    내부 연산은 원래 duration 타입을 유지하며, 표시할 때만 duration_cast하는 편이 누적 오차를 줄이는 데 유리합니다.

  2. 여러 구간 누적: 구간마다 duration을 더하거나 time_point 차이를 합산합니다.

  3. 최소 오버헤드 측정: 아주 짧은 코드는 now() 두 번만으로도 수백 나노초가 나올 수 있어, 측정 대비 오버헤드를 빼거나(캘리브레이션), 반복 실행 후 평균·분위수를 쓰는 것이 일반적입니다.

  4. sleep과 혼합: sleep_for는 “최소”만 보장하므로, 실제 경과는 항상 steady_clock으로 재는 것이 맞습니다.

auto t0 = std::chrono::steady_clock::now();
do_work();
auto t1 = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0);

벤치마킹 활용

마이크로벤치에서는 다음을 함께 고려합니다.

  • 워밍업: 캐시·할당기 상태가 첫 실행과 다를 수 있어, 몇 번 버린 뒤 측정합니다.
  • 반복: 한 번보다 N회 평균·중앙값이 안정적입니다. 노이즈가 크면 고정밀 타이머 + 통계를 병행합니다.
  • 최적화 방지: 컴파일러가 결과를 버리지 않게 벤치마크 라이브러리의 DoNotOptimize 패턴을 쓰기도 합니다(실무에서는 Google Benchmark 등 권장).
  • 시계 선택: 경과 시간은 steady_clock. 결과를 “현재 시각”과 함께 로그에 남기려면 로그용으로만 system_clock을 쓰며, 측정 구간은 steady_clock으로 분리합니다.

위 파일의 benchmark 템플릿은 개념 설명용이며, 제품 코드에서는 외부 벤치마크 프레임워크와 프로파일러를 함께 쓰는 것이 좋습니다.

실전: 타임아웃 구현 (심화)

폴링 루프에서 타임아웃을 줄 때는 시작 시각 + 한도보다 deadline 시각을 두는 편이 읽기 쉽습니다.

#include <chrono>
#include <thread>

bool wait_until(std::chrono::steady_clock::time_point deadline,
                auto pred, std::chrono::milliseconds poll) {
    while (std::chrono::steady_clock::now() < deadline) {
        if (pred()) return true;
        std::this_thread::sleep_for(poll);
    }
    return false;
}
  • 조건 변수·future: 스레드 동기화가 있으면 wait_for / wait_until이 CPU를 덜 쓰고 깨우기도 정확합니다. 폴링은 구현이 단순할 때만 사용합니다.
  • 남은 시간: deadline - steady_clock::now()가 음수면 이미 만료입니다. duration_cast 전에 부호를 확인하거나 0으로 클램프합니다.

플랫폼별 차이

  • Linux: steady_clock은 보통 CLOCK_MONOTONIC 계열로, 시스템 시간 조정과 무관합니다. 해상도는 steady_clock::period로 확인합니다.
  • Windows: 구현은 MSVC/MinGW에 따라 다르지만, 표준 의미(단조 증가)는 동일하게 기대합니다. 고해상도 카운터를 쓰는 경우가 많습니다.
  • macOS/iOS: steady_clock은 일반적으로 단조 증가를 만족합니다. high_resolution_clock이 내부적으로 동일 시계를 가리키는 경우가 많습니다.
  • 임베디드/RTOS: 틱 수가 낮으면 짧은 구간 측정에서 양자화 오차가 커질 수 있습니다. period::num/den과 실제 측정 오차를 한 번 확인하는 것이 좋습니다.
  • 긴 가동 시간: 극단적으로 긴 업타임에서 카운터 비트 폭이 이론적 한계에 가까우면(드묾) 오버플로를 염두에 두되, 일반 데스크톱/서버에서는 duration 타입이 넉넉한 경우가 많습니다.

한 줄 요약: 절대 시각은 system_clock, 지속 시간과 타임아웃은 steady_clock, 이름만 보고 고르지 말고 high_resolution_clock의 is_steady와 구현 관계를 확인하세요.

FAQ

Q1: steady_clock의 시각을 로그에 찍어도 되나요?

A: 안 됩니다. steady_clock의 기준점은 구현 정의(보통 부팅 시각)라 “몇 시 몇 분”으로 해석할 수 없고, 재부팅하면 값이 처음부터 다시 시작합니다. 로그의 타임스탬프는 system_clock으로 찍고, 같은 로그 줄에 소요 시간이 필요하면 steady_clock으로 잰 duration을 따로 남기세요.

Q2: 여러 머신에서 잰 시간을 비교할 수 있나요?

A: duration(경과 시간)끼리는 비교할 수 있지만 time_point는 불가능합니다. 머신마다 기준점이 다르기 때문입니다. 분산 시스템에서 “어느 이벤트가 먼저였나”를 정해야 한다면 벽시계를 NTP/PTP로 동기화하거나, 논리 시계(Lamport clock 등)를 써야 합니다.

Q3: 정밀도(period)가 나노초면 나노초까지 정확한가요?

A: period는 값의 단위일 뿐 실제 해상도나 정확도를 보장하지 않습니다. 단위가 1ns여도 하드웨어 카운터 갱신 주기에 따라 값이 수십 ns 단위로 뛸 수 있습니다. 실제 해상도는 now()를 연속으로 호출해 0이 아닌 최소 차이를 관찰하는 방식으로 확인합니다.

Q4: 짧은 함수의 성능을 정확히 재려면?

A: now() 두 번으로 한 번 호출을 재지 말고, 수천~수백만 번 반복한 총 시간을 반복 횟수로 나누세요. 컴파일러가 결과를 쓰지 않는 코드를 통째로 없애지 않도록 결과를 volatile 변수에 쓰거나 Google Benchmark의 benchmark::DoNotOptimize를 사용하는 것이 안전합니다.


같이 보면 좋은 글