74 시간 — <time.h>
먼저 알아야 할 것
돌아보기
제12부에서 다룰 이야기를 미리 당겨 온다면, 달력 시간과 경과 시간은 서로 다른 물건이라 했다. 그러면 표준 C에서 “3초가 지났는지”를 정확히 재려면 무엇을 쓰는가?
답. 표준만으로는 정확히 잴 방법이 없다. time은 초 단위이고 시스템이 시각을 맞추면 뒤로 갈 수 있으며, clock은 벽시계가 아니라 프로세스가 쓴 CPU 시간을 잰다(그래서 입출력을 기다리는 동안은 늘지 않는다). C11이 timespec_get을 들여왔지만 단조 시계를 요구하지는 않는다. 정확한 경과 측정은 POSIX의 clock_gettime(CLOCK_MONOTONIC, …)이나 Windows의 QueryPerformanceCounter — 즉 플랫폼 API의 몫이다.
이 장의 필요성과 맥락
이 장이 끝나면
time_t가 무엇인지도, 시간대를 어떻게 다루는지도 표준은 말하지 않는다. 그 빈틈에서 생기는 함정들 — 1900을 빼는 연도, 0부터 시작하는 달, 정적 버퍼를 돌려주는 함수, 그리고 경과 시간을 잴 단조 시계가 표준에 없다는 사실 — 을 본다.이 장에서 답할 질문
- 시간을 다루는 데 왜 이렇게 많은 타입이 필요한가 — 초 하나로 충분하지 않은가?
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_year는 1900을 뺀 값이다. 2026년은 126이다.tm_mon은 0부터다. 8월은 7이다.
tm_mday(1부터), tm_wday(일요일 0), tm_yday(0부터)까지 규칙이 제각각이라, 직접 채울 때는 표를 옆에 두는 편이 낫다.
mktime은 두 가지 일을 한다 — struct tm을 time_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:%S | C99 |
%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 = 실패 |
| 차이 | difftime | t2 - t1은 이식성 없음 |
| 경과 측정 | 플랫폼 단조 시계 | time·localtime 금지 |
| 저장·전송 | UTC + ISO 8601 | 현지 시각 저장 금지 |
| 쓰지 말 것 | asctime·ctime | 정적 버퍼·고정 형식·C23 폐기 예정 |
표 74.3
시간을 지났다. 다음 장은 프로그램이 잘못됐을 때 쓰는 도구들 — 오류 번호, 단언, 신호, 그리고 비지역 점프다.