Proven C BookEnglish GitHub

74 시간 — <time.h>

먼저 알아야 할 것

46장 구조체 · 구조체로 묶인 값
61장 표준 라이브러리의 지형 · 표준이 정한 계약

돌아보기

제12부에서 다룰 이야기를 미리 당겨 온다면, 달력 시간과 경과 시간은 서로 다른 물건이라 했다. 그러면 표준 C에서 “3초가 지났는지”를 정확히 재려면 무엇을 쓰는가?

답. 표준만으로는 정확히 잴 방법이 없다. time은 초 단위이고 시스템이 시각을 맞추면 뒤로 갈 수 있으며, clock은 벽시계가 아니라 프로세스가 쓴 CPU 시간을 잰다(그래서 입출력을 기다리는 동안은 늘지 않는다). C11이 timespec_get을 들여왔지만 단조 시계를 요구하지는 않는다. 정확한 경과 측정은 POSIX의 clock_gettime(CLOCK_MONOTONIC, …)이나 Windows의 QueryPerformanceCounter — 즉 플랫폼 API의 몫이다.

이 장의 필요성과 맥락

표준이 유난히 적게 정해 준 헤더라, 오히려 정독의 후반부에 놓는 것이 맞다. 「표준이 말하지 않는다」는 사실이 함정의 원인이라는 것을 알아보려면, 앞에서 표준이 말해 준 헤더들을 충분히 보아 두어야 하기 때문이다. 대조가 있어야 빈틈이 보인다.

이 장이 끝나면

표준이 유난히 적게 정해 준 헤더다. time_t가 무엇인지도, 시간대를 어떻게 다루는지도 표준은 말하지 않는다. 그 빈틈에서 생기는 함정들 — 1900을 빼는 연도, 0부터 시작하는 달, 정적 버퍼를 돌려주는 함수, 그리고 경과 시간을 잴 단조 시계가 표준에 없다는 사실 — 을 본다.

이 장에서 답할 질문

  1. 시간을 다루는 데 왜 이렇게 많은 타입이 필요한가 — 초 하나로 충분하지 않은가?

74.1 세 가지 시간 표현

타입·함수무엇알아 둘 것
time_t달력 시각(대개 1970년부터의 초)★ 표준은 “산술 타입”이라고만 정한다
struct tm연·월·일·시·분·초로 쪼갠 것필드 규칙이 함정
clock_t프로세스가 쓴 CPU 시간CLOCKS_PER_SEC로 나눈다
struct timespec초 + 나노초(C11)timespec_get

표 74.1

time_t가 무엇인지 표준이 정하지 않았다는 사실이 중요하다. 대부분의 구현에서 1970-01-01 UTC부터의 초이지만, 그것은 관행이지 표준의 보장이 아니다. 그래서 이식성 있는 코드는 time_t의 내부 값을 직접 계산하지 않고 difftime으로 차이를 구한다.

문. 시간을 다루는 데 왜 이렇게 많은 타입이 필요한가 — 초 하나로 충분하지 않은가?

답. 세 가지가 서로 다른 일을 하기 때문이다. time_t한 순간을 나타내는 불투명한 값이고, struct tm은 사람이 읽는 달력 표기(연·월·일·시·분·초)이며, clock_t경과 시간을 재는 눈금이다. 한 순간을 달력으로 바꾸는 일은 시간대와 서머타임과 윤초가 끼어드는 어려운 계산이라, 표준은 그 둘을 다른 타입으로 갈라 두고 localtime·mktime이 사이를 잇게 했다.

실무에서 사고가 나는 자리도 이 경계다 — time_t끼리 빼면 초가 나오지만, struct tm의 필드를 직접 더하면 정규화되지 않은 날짜가 만들어진다. 날짜 산술은 반드시 mktime을 거쳐야 한다.

74.2 struct tm의 함정

examples/ch74/timefns.c

#include <stdio.h>
#include <time.h>
#include <string.h>

int main(void)
{
    /* 고정된 시각을 써서 출력이 매번 같게 한다 (재현 가능한 예제) */
    struct tm t = {0};
    t.tm_year = 2026 - 1900;   /* ★ 1900 을 뺀 값 */
    t.tm_mon  = 8 - 1;         /* ★ 0 부터 시작 */
    t.tm_mday = 5;
    t.tm_hour = 13; t.tm_min = 45; t.tm_sec = 30;
    t.tm_isdst = -1;           /* 서머타임 여부를 모른다고 표시 */

    printf("the trap in the struct fields: tm_year=%d (the year 2026), tm_mon=%d (August)\n",
           t.tm_year, t.tm_mon);

    /* mktime 은 값을 정규화하고 요일을 채워 준다 */
    struct tm norm = t;
    time_t stamp = mktime(&norm);
    static const char *dow[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
    printf("the weekday mktime filled in: %s (tm_wday=%d)\n",
           norm.tm_wday >= 0 && norm.tm_wday < 7 ? dow[norm.tm_wday] : "?", norm.tm_wday);

    /* strftime 은 버퍼 크기를 받고, 넘치면 0 을 돌려준다 */
    char buf[64];
    size_t n = strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &norm);
    printf("strftime: [%s] (%zu characters)\n", buf, n);

    char tiny[8];
    size_t m = strftime(tiny, sizeof tiny, "%Y-%m-%d %H:%M:%S", &norm);
    printf("a small buffer: returned %zu (0 means failure - the contents are unspecified)\n", m);

    /* 날짜 산술은 초 단위 뺄셈이 아니라 mktime 으로 한다 */
    struct tm plus = norm;
    plus.tm_mday += 30;              /* 30일 뒤 — 달을 넘겨도 mktime 이 정리한다 */
    time_t later = mktime(&plus);
    strftime(buf, sizeof buf, "%Y-%m-%d", &plus);
    printf("30 days later: %s\n", buf);
    printf("difftime: %.0f seconds\n", difftime(later, stamp));

    /* 경과 시간 측정에는 clock() 이나 플랫폼의 단조 시계를 쓴다 */
    clock_t c0 = clock();
    volatile long acc = 0;
    for (long i = 0; i < 1000000; i++) acc += i;
    clock_t c1 = clock();
    printf("time measured with clock(): %s\n",
           (double)(c1 - c0) / CLOCKS_PER_SEC >= 0.0 ? "measured (the value differs per machine)" : "?");
    return 0;
}

실행 결과

the trap in the struct fields: tm_year=126 (the year 2026), tm_mon=7 (August)
the weekday mktime filled in: Wed (tm_wday=3)
strftime: [2026-08-05 13:45:30] (19 characters)
a small buffer: returned 0 (0 means failure - the contents are unspecified)
30 days later: 2026-09-04
difftime: 2592000 seconds
time measured with clock(): measured (the value differs per machine)

두 필드가 유명한 함정이다.

tm_mday(1부터), tm_wday(일요일 0), tm_yday(0부터)까지 규칙이 제각각이라, 직접 채울 때는 표를 옆에 두는 편이 낫다.

mktime은 두 가지 일을 한다 — struct tmtime_t로 바꾸고, 구조체를 정규화한다. 예제에서 tm_mday에 30을 더해 범위를 넘겼는데도 9월 4일로 정리된 것이 그 덕이다. 날짜 산술을 직접 하지 말고 이 성질을 쓰는 것이 정석 이다.

tm_isdst도 잊기 쉽다. −1을 넣으면 “모르니 알아서 판단하라”는 뜻이고, 0이나 1을 잘못 넣으면 한 시간이 어긋난다.

반례. localtime의 결과를 들고 다니기

struct tm *a = localtime(&t1);
struct tm *b = localtime(&t2);   /* a 가 가리키던 내용이 덮어써졌다 */
printf("%d %d\n", a->tm_hour, b->tm_hour);   /* 둘 다 t2 의 시각 */

localtime·gmtime·ctime·asctime내부의 정적 버퍼를 돌려준다 (61장에서 이름만 스친 그 함수들이다). 다음 호출이 이전 결과를 덮어쓰고, 여러 갈래로 도는 프로그램에서는 서로의 결과를 망가뜨린다.

처방은 둘이다. 받은 즉시 복사해 두거나, 호출자가 버퍼를 주는 판 (localtime_r·gmtime_r은 POSIX, localtime_s는 부속서 K와 MSVC)을 쓰는 것이다. 표준만으로 이식성을 지키려면 복사가 정답이다.

74.3 문자열로 찍기 — strftime

printf와 달리 strftime은 버퍼 크기를 받고, 들어가지 않으면 0을 돌려준다. 예제의 작은 버퍼가 그 경우다. 반환값이 0이면 버퍼의 내용은 정해지지 않으므로 쓰면 안 된다.

asctime·ctime은 쓰지 않는 것이 좋다. 정적 버퍼를 돌려주는 데다 형식이 고정이고("Wed Aug 5 13:45:30 2026\n"), 연도가 네 자리를 넘으면 넘칠 수 있어 C23에서 폐기 예정으로 표시됐다.

지정자메모
%Y·%m·%d연·월·일ISO 날짜는 %Y-%m-%d
%H·%M·%S시·분·초24시간
%F·%T%Y-%m-%d·%H:%M:%SC99
%z·%Z시간대 오프셋·이름로케일·플랫폼 의존
%s에폭 초★ 표준이 아니다(POSIX 확장)
%c·%x·%X로케일 표기사람용. 기계용으로 쓰지 않는다

표 74.2

74.4 시간대와 서머타임 — 표준이 다루지 않는 것

표준 C가 아는 시간대는 “현지”와 “UTC” 둘뿐이고, 현지 시간대를 바꾸는 방법조차 표준에 없다(POSIX의 TZ 환경 변수가 관행이다). 임의의 시간대를 다루려면 라이브러리가 필요하다.

서머타임은 더 까다롭다. 전환일에는 존재하지 않는 시각(봄에 건너뛴 한 시간)과 두 번 존재하는 시각(가을에 되풀이되는 한 시간)이 생긴다. mktime이 그런 입력을 받으면 무엇을 돌려줄지는 구현이 정한다.

실제 사례. 시간이 부른 실제 사고들

시간은 조용한 사고의 단골이다. 윤초가 들어간 2012년과 2015년에는 여러 서버 소프트웨어가 CPU를 100%로 태우거나 멎었다 — 커널과 애플리케이션이 “1초가 두 번 오는” 상황을 가정하지 않았기 때문이다.

32비트 time_t의 한계는 2038년 1월 19일에 넘친다(27장의 오버플로가 전 세계 규모로 나타나는 날이다). 임베디드와 옛 시스템에서는 지금도 현재 진행형 과제이고, 그래서 64비트 time_t로의 전환이 오래 진행돼 왔다.

현지 시각으로 경과를 재는 코드는 서머타임 전환일마다 한 시간을 잃거나 얻는다. 로그의 타임스탬프가 뒤로 가고, 타임아웃이 한 시간이 되고, 스케줄러가 같은 작업을 두 번 돌린다. 처방은 언제나 같다 — 경과에는 단조 시계, 기록에는 UTC, 사람에게 보여 줄 때만 현지 시각.

복습 정리

시간 요약.

하고 싶은 일쓸 것조심할 것
현재 시각time(NULL)초 단위. 뒤로 갈 수 있다
쪼개 보기localtime/gmtime + 즉시 복사정적 버퍼
날짜 산술필드 더하고 mktime직접 초 계산 금지
문자열로strftime반환 0 = 실패
차이difftimet2 - t1은 이식성 없음
경과 측정플랫폼 단조 시계time·localtime 금지
저장·전송UTC + ISO 8601현지 시각 저장 금지
쓰지 말 것asctime·ctime정적 버퍼·고정 형식·C23 폐기 예정

표 74.3

시간을 지났다. 다음 장은 프로그램이 잘못됐을 때 쓰는 도구들 — 오류 번호, 단언, 신호, 그리고 비지역 점프다.