- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 시간은 왜 이렇게 어려운가
- 대원칙 — UTC로 저장하고 로컬로 표시하라
- epoch vs ISO 8601 — 두 가지 표현
- 오프셋 ≠ 타임존 — 가장 흔한 오해
- IANA tz 데이터베이스 — 세계의 시간 규칙 사전
- 서머타임의 지옥 — 빈 시간과 겹치는 시간
- 실화 — 서머타임 전환 시각에 실행된 배치
- 윤초 — 1분이 61초가 되는 순간
- 클라이언트 시계를 믿지 마라
- NTP와 되감기는 시계 — 시간은 앞으로만 흐르지 않는다
- 날짜 vs 순간 — 생일에는 타임존이 없다
- 실무 체크리스트
- 마치며
- 참고 자료
들어가며 — 시간은 왜 이렇게 어려운가
시간을 다루는 코드는 겉보기에 쉬워 보입니다. 현재 시각을 가져오고, 저장하고, 화면에 보여주면 끝일 것 같습니다. 그러다 어느 날 버그 리포트가 들어옵니다. "예약이 한 시간 어긋나요." "리포트의 날짜가 하루 밀렸어요." "특정 사용자만 로그인 시각이 이상해요." 이런 버그의 대부분은 시간을 순진하게 다룬 대가입니다.
시간이 어려운 이유는 그것이 순수한 물리량이 아니라 인간의 약속이 겹겹이 얹힌 개념이기 때문입니다. 지구의 자전, 표준시의 정치적 경계, 서머타임, 윤초, 역사적 오프셋 변경까지. 우리가 "그냥 시간"이라고 부르는 것 뒤에는 놀랍도록 복잡한 규칙이 있습니다.
다행히 이 복잡성을 다스리는 견고한 원칙들이 있습니다. 이 글은 그 원칙을 하나씩 정리합니다. 핵심 한 줄로 요약하면 이렇습니다. 저장은 UTC로, 표시는 로컬로. 나머지는 이 원칙을 왜, 어떻게 지키는가에 대한 이야기입니다. 개념을 직접 확인하고 싶다면 이 사이트의 유닉스 타임스탬프 변환기, UTC 변환기, 세계 시계를 함께 열어 두고 읽으면 좋습니다.
대원칙 — UTC로 저장하고 로컬로 표시하라
시간을 다루는 가장 중요한 규칙은 저장과 표시를 분리하는 것입니다.
- 저장: 항상 UTC(협정 세계시)로 저장합니다. UTC는 지역이나 서머타임에 흔들리지 않는 단일한 기준선입니다.
- 표시: 사용자에게 보여줄 때만 그 사용자의 타임존으로 변환합니다.
왜 저장을 UTC로 해야 할까요? 만약 "서울 시각 2026-06-14 15:00"처럼 지역 시각을 그대로 저장하면, 그 값은 홀로 서지 못합니다. 서머타임이 있는 지역이라면 같은 벽시계 시각이 1년에 두 번 나타나거나 아예 존재하지 않을 수 있고, 서버와 클라이언트가 다른 지역이면 해석이 갈립니다. UTC로 저장하면 이 모든 모호함이 사라집니다. UTC의 특정 순간은 지구상 어디서든 단 하나의 절대적 순간을 가리킵니다.
표시는 그 반대입니다. 사용자는 UTC를 원하지 않습니다. 서울 사용자는 한국 시각을, 뉴욕 사용자는 동부 시각을 보고 싶어 합니다. 그래서 저장된 UTC를 화면에 그릴 때만 사용자의 타임존으로 변환합니다. 저장 계층은 하나의 진실을, 표시 계층은 여러 개의 지역화된 뷰를 갖는 구조입니다.
epoch vs ISO 8601 — 두 가지 표현
시간을 UTC로 저장한다고 할 때, 실제로 어떤 형식으로 저장할까요? 크게 두 가지 방식이 있습니다.
유닉스 타임스탬프(epoch). 1970년 1월 1일 00:00:00 UTC로부터 흐른 초(또는 밀리초)의 수입니다. 예를 들어 1749884400처럼 하나의 정수입니다.
1749884400 → 2025-06-14T07:00:00Z (UTC)
장점은 명확합니다. 타임존이 아예 없는, 순수한 절대 순간입니다. 비교와 계산이 정수 연산으로 끝나고, 저장 공간도 작습니다. 단점은 사람이 읽을 수 없다는 것입니다.
ISO 8601 문자열. 2025-06-14T07:00:00Z처럼 사람이 읽을 수 있는 표준 형식입니다. 끝의 Z는 "Zulu", 즉 UTC를 뜻합니다. 오프셋을 명시하려면 2025-06-14T16:00:00+09:00처럼 씁니다.
2025-06-14T07:00:00Z ← UTC (Z = +00:00)
2025-06-14T16:00:00+09:00 ← 같은 순간을 서울 오프셋으로 표기
두 문자열은 같은 절대 순간을 가리킵니다. ISO 8601의 장점은 가독성과 오프셋 명시입니다. 로그, API 응답, 사람이 읽을 데이터에는 ISO 8601이 좋고, 내부 계산과 저장 효율이 중요하면 epoch가 편합니다. 중요한 것은 어느 쪽이든 타임존 정보를 잃지 않는 것입니다. 2025-06-14 16:00:00처럼 오프셋도 Z도 없는 "naive" 문자열은 그 자체로 모호하며, 시간 버그의 단골 원인입니다.
오프셋 ≠ 타임존 — 가장 흔한 오해
여기서 시간 다루기의 가장 중요한 개념적 구분이 나옵니다. 오프셋과 타임존은 다릅니다.
- 오프셋(offset): UTC로부터의 시차.
+09:00,-05:00같은 단순한 숫자입니다. 특정 순간의 상태일 뿐입니다. - 타임존(time zone): 어떤 지역이 시간을 어떻게 결정하는가에 대한 규칙의 집합.
Asia/Seoul,America/New_York같은 이름으로 식별됩니다.
왜 이 구분이 중요할까요? 오프셋은 시간에 따라 변하기 때문입니다. America/New_York은 겨울에는 -05:00(EST)이지만 여름에는 서머타임 때문에 -04:00(EDT)입니다. 즉 "뉴욕 시각"을 오프셋 하나로 고정하는 것은 틀렸습니다. 오프셋은 순간마다 달라지고, 그 변화의 규칙을 담고 있는 것이 타임존입니다.
America/New_York 이라는 타임존:
2025-01-15 → 오프셋 -05:00 (EST, 겨울)
2025-07-15 → 오프셋 -04:00 (EDT, 여름, 서머타임)
실무적 함의는 큽니다. 미래의 회의를 저장할 때, 오프셋만 저장하면 안 됩니다. 지금 서울이 +09:00이라고 해서 6개월 뒤 회의를 +09:00으로 굳혀 두면, 그 사이에 만약 나라가 서머타임 정책을 바꾸면 회의 시각이 어긋납니다. 미래 이벤트는 오프셋이 아니라 타임존 이름(Asia/Seoul)과 함께 저장해야, 규칙이 바뀌어도 올바른 벽시계 시각으로 재계산됩니다.
IANA tz 데이터베이스 — 세계의 시간 규칙 사전
타임존이 규칙의 집합이라면, 그 규칙은 어디에 있을까요? IANA 타임존 데이터베이스(tz database, 또는 zoneinfo, Olson database)입니다. 이것은 전 세계 모든 지역의 시간 규칙을 담은, 꾸준히 갱신되는 공개 데이터베이스입니다.
이 데이터베이스가 담는 것은 놀랍도록 방대합니다. 현재의 오프셋과 서머타임 규칙뿐 아니라, 역사적 변경까지 기록합니다. 예를 들어 어떤 나라가 언제 서머타임을 도입했다가 폐지했는지, 언제 표준시 오프셋 자체를 바꿨는지가 들어 있습니다. 그래서 Asia/Seoul로 1988년의 어떤 순간을 계산하면, 그해 서울에 서머타임이 있었다는 역사적 사실까지 반영됩니다.
핵심 교훈은 이것입니다. 타임존 규칙을 직접 하드코딩하지 마라. "한국은 UTC+9"라고 코드에 박아 넣는 것은 지금은 맞지만 미래를 보장하지 못하고, 서머타임이 있는 지역에서는 지금도 틀립니다. 대신 운영체제와 언어 런타임이 제공하는 IANA 데이터베이스 기반 라이브러리를 쓰고, 그 데이터베이스를 최신으로 유지해야 합니다. 나라들은 생각보다 자주 시간 정책을 바꾸며, 그때마다 tz 데이터베이스가 갱신됩니다.
서머타임의 지옥 — 빈 시간과 겹치는 시간
서머타임(DST)은 시간 버그의 가장 비옥한 토양입니다. 문제의 본질은, 서머타임 전환 순간에 벽시계 시각이 연속적이지 않다는 것입니다.
봄 — 사라지는 시간(gap). 서머타임이 시작될 때 시계는 한 시간 앞으로 점프합니다. 예를 들어 새벽 2시가 되는 순간 곧바로 3시가 됩니다. 그 결과 그날 그 지역에는 2시 30분이라는 시각이 존재하지 않습니다.
봄 전환 (서머타임 시작):
01:59:59 → 03:00:00 (2시대가 통째로 사라짐)
"02:30"은 그날 존재하지 않는 시각
만약 사용자가 매일 02:30에 실행되는 알람을 걸어 뒀다면, 서머타임 시작일에 그 알람은 언제 울려야 할까요? 이것은 정답이 없는 모호한 상황이고, 라이브러리와 정책마다 다르게 처리합니다.
가을 — 겹치는 시간(overlap). 서머타임이 끝날 때 시계는 한 시간 뒤로 돌아갑니다. 그 결과 1시 30분이라는 벽시계 시각이 그날 두 번 나타납니다.
가을 전환 (서머타임 종료):
01:59:59 (서머타임) → 01:00:00 (표준시) (1시대가 반복)
"01:30"은 그날 두 번 존재하는 시각
이때 "01:30에 일어난 일"이라는 로그는 둘 중 어느 01:30인지 모호합니다. 오프셋이 없으면 두 순간을 구별할 수 없습니다. 이 두 현상(gap과 overlap)이 바로 지역 시각을 그대로 저장하면 안 되는 결정적 이유입니다. UTC로 저장하면 이런 모호함이 애초에 생기지 않습니다. UTC에는 서머타임이 없기 때문입니다.
실화 — 서머타임 전환 시각에 실행된 배치
한 결제 시스템이 매일 새벽 2시 30분에 정산 배치를 돌렸습니다. 지역 시각 기준이었습니다. 평소엔 아무 문제가 없었지만, 봄 서머타임 전환일에 그 시각이 존재하지 않게 되자 배치가 그날 실행되지 않았습니다. 하루치 정산이 누락되었고, 아무도 에러 로그를 보지 못했습니다. 스케줄러 입장에서는 "그런 시각이 오지 않았을" 뿐이니까요.
교훈은 두 가지입니다. 첫째, 반복 스케줄은 서머타임 전환대(보통 새벽 1~3시)를 피하는 것이 안전합니다. 둘째, 스케줄러가 지역 시각을 쓰는지 UTC를 쓰는지 반드시 확인해야 합니다. 많은 사고가 "지역 시각으로 새벽에 도는 배치"에서 나옵니다.
윤초 — 1분이 61초가 되는 순간
시간의 또 다른 미신은 "1분은 항상 60초"라는 것입니다. 사실이 아닙니다. 지구의 자전은 완벽히 규칙적이지 않아서, 원자시계 기반의 시간과 지구 자전 기반의 시간 사이에 미세한 오차가 쌓입니다. 이를 맞추기 위해 이따금 **윤초(leap second)**를 삽입합니다. 그런 날에는 하루의 마지막 1분이 61초가 됩니다.
윤초 삽입 시:
23:59:58
23:59:59
23:59:60 ← 존재하는 시각! (평소엔 없는 60초)
00:00:00
대부분의 애플리케이션은 윤초를 직접 다룰 일이 없습니다. 유닉스 시간 자체가 윤초를 "무시"하도록 정의되어 있기 때문입니다. 하지만 윤초는 실제로 사고를 일으켜 왔습니다. 과거에 몇몇 대형 시스템이 윤초 삽입 순간에 시계가 꼬여 장애를 겪었습니다. 최근에는 이 문제 때문에 윤초를 시스템에 매끄럽게 "펴 바르는"(leap smearing) 기법이 널리 쓰입니다. 하루에 걸쳐 아주 조금씩 시계를 늦춰 61초짜리 1분을 피하는 것입니다. 요점은, "1초는 항상 1초이고 1분은 항상 60초"라는 가정이 물리적으로 틀릴 수 있음을 아는 것입니다.
클라이언트 시계를 믿지 마라
분산 시스템에서 반드시 새겨야 할 원칙이 있습니다. 클라이언트의 시계를 신뢰하지 마라. 사용자의 기기 시계는 틀려 있을 수 있습니다. 배터리가 방전됐다가 부팅됐거나, 사용자가 일부러 바꿨거나, 그냥 몇 분씩 어긋나 있을 수 있습니다.
이것이 문제가 되는 상황은 많습니다.
- 보안 토큰 만료: 만료 판단을 클라이언트 시계로 하면, 시계를 조작해 만료를 회피할 수 있습니다. 만료는 서버 시각으로 판정해야 합니다.
- 이벤트 순서: 여러 기기가 보낸 이벤트를 클라이언트 타임스탬프로 정렬하면, 시계가 어긋난 기기 때문에 순서가 뒤엉킵니다.
- 선착순 처리: 쿠폰 선착순 같은 로직을 클라이언트 시각으로 판단하면 공정하지 않습니다.
권위 있는 시각은 서버가 정합니다. 서버들끼리도 NTP(Network Time Protocol)로 시계를 맞추지만 완벽하지 않으므로, 정밀한 순서가 필요하면 단조 증가 시퀀스나 논리적 시계(logical clock) 같은 별도 수단을 씁니다. 그리고 서버의 시계조차도 NTP 동기화 때문에 이따금 뒤로 점프할 수 있음을 기억해야 합니다.
NTP와 되감기는 시계 — 시간은 앞으로만 흐르지 않는다
방금 언급한 대로, 시계는 항상 앞으로만 가지 않습니다. 서버는 NTP로 정확한 시각에 맞추는데, 만약 로컬 시계가 실제보다 앞서 있었다면 NTP가 시계를 뒤로 조정합니다. 그 순간, 코드 입장에서는 시간이 거꾸로 흐른 것처럼 보입니다.
이것이 왜 위험할까요? 경과 시간을 재는 순진한 코드를 생각해 봅시다.
start = now() // 벽시계 시각
... 작업 수행 ...
elapsed = now() - start
만약 그 사이에 NTP가 시계를 뒤로 당기면, elapsed가 음수가 될 수 있습니다. 타임아웃 로직이라면 즉시 만료되거나 영원히 만료되지 않는 버그가 생깁니다. 그래서 경과 시간 측정에는 벽시계가 아니라 단조 시계(monotonic clock)를 써야 합니다. 단조 시계는 절대 뒤로 가지 않으며 오직 경과량만 잽니다. 벽시계는 "지금 몇 시인가"에, 단조 시계는 "얼마나 지났는가"에 쓰는 것이 원칙입니다.
날짜 vs 순간 — 생일에는 타임존이 없다
또 하나 중요한 구분은 "날짜"와 "순간"이 다른 종류의 값이라는 것입니다.
- 순간(instant): 시간선 위의 한 점. "2025-06-14T07:00:00Z"처럼 절대적이고 전 세계 공통입니다. 이벤트 발생 시각, 로그 타임스탬프가 여기 속합니다.
- 날짜(date-only / civil date): 타임존과 무관한 달력상의 날. 생일, 공휴일, 계약 만료일 같은 것입니다. "6월 14일"은 서울에서든 뉴욕에서든 같은 6월 14일입니다.
이 둘을 혼동하면 버그가 납니다. 생일을 순간(예: 2000-06-14T00:00:00Z)으로 저장하면, 서머타임이나 타임존 변환 과정에서 하루가 밀려 "생일이 하루 어긋나는" 고전적 버그가 생깁니다. 사용자가 서쪽 타임존에 있으면 UTC 자정이 그 지역에서는 전날이 되기 때문입니다.
원칙은 이렇습니다. 특정 순간을 다루면 UTC 순간으로, 달력상의 날짜를 다루면 타임존 없는 날짜 타입(LocalDate, date 등)으로 저장하세요. "이것은 절대 순간인가, 아니면 달력의 날인가"를 먼저 물어야 올바른 타입을 고를 수 있습니다.
실무 체크리스트
지금까지의 원칙을 실무 규칙으로 압축합니다.
- 저장은 UTC, 표시는 로컬. 데이터베이스와 API 내부는 UTC 순간으로 통일합니다.
- naive datetime을 저장하지 마라. 오프셋도
Z도 없는 시각은 모호합니다. 항상 타임존 정보를 함께 보관합니다. - 미래 이벤트는 타임존 이름과 함께 저장하라. 오프셋만 저장하면 규칙 변경에 취약합니다.
- 타임존 규칙을 하드코딩하지 마라. IANA tz 데이터베이스 기반 라이브러리를 쓰고 최신으로 유지합니다.
- 경과 시간은 단조 시계로 재라. 벽시계는 NTP로 뒤로 갈 수 있습니다.
- 클라이언트 시계를 신뢰하지 마라. 만료·순서·공정성은 서버 시각으로 판정합니다.
- 날짜와 순간을 구분하라. 생일과 공휴일은 타임존 없는 날짜로.
- 반복 스케줄은 서머타임 전환대를 피하라. 새벽 1~3시는 위험 구간입니다.
마치며
시간을 다루는 것은 물리와 인간의 약속이 교차하는 지점을 다루는 일입니다. 그래서 순진한 직관이 자주 배신합니다. 하루가 항상 24시간인 것도, 1분이 항상 60초인 것도, 시계가 앞으로만 가는 것도, 타임존이 고정된 오프셋인 것도 사실이 아니었습니다.
하지만 이 복잡성 앞에서 우리가 붙잡을 원칙은 의외로 단순합니다. 저장은 UTC로, 표시는 로컬로, 미래 이벤트는 타임존 이름과 함께, 경과 측정은 단조 시계로, 그리고 클라이언트 시계는 믿지 않기. 이 원칙들을 지키면 시간 관련 버그의 절대다수를 예방할 수 있습니다. 개념을 직접 만져 보고 싶다면 유닉스 타임스탬프 변환기, UTC 변환기, 세계 시계에서 값이 어떻게 변환되는지 확인해 보세요.
참고 자료
- IANA Time Zone Database: https://www.iana.org/time-zones
- RFC 3339 (Date and Time on the Internet): https://datatracker.ietf.org/doc/html/rfc3339
- ISO 8601 개요: https://en.wikipedia.org/wiki/ISO_8601
- Unix time: https://en.wikipedia.org/wiki/Unix_time
- Leap second: https://en.wikipedia.org/wiki/Leap_second
- The Problem with Time & Timezones (Computerphile): https://www.youtube.com/watch?v=-5wpm-gesOY