한국에서 만든 예약 서비스가 해외로 확장되자 사용자의 회의 시간이 한 시간씩 밀리고, 월말 정산 배치가 어떤 지역에서는 하루 두 번 실행되며, 생일 알림이 전날 도착한다고 생각해 보겠습니다. 개발 환경에서는 잘 동작했지만 국가와 계절이 바뀌는 순간 시간은 예상하지 못한 방식으로 흔들립니다.
날짜·시간 버그가 어려운 이유는 하나의 값처럼 보이는 “2026년 10월 25일 새벽 1시 30분” 안에 서로 다른 개념이 섞여 있기 때문입니다. 실제 세계의 한 순간인지, 달력의 날짜인지, 특정 지역의 벽시계 시간인지, 반복되는 업무 규칙인지에 따라 저장하고 계산하는 방법이 달라집니다.
흔히 “모두 UTC로 저장하면 된다”고 말합니다. 발생한 사건을 기록하는 데는 좋은 출발점이지만 미래 예약, 생일, 영업일과 반복 일정까지 해결하지는 못합니다. 이번 글에서는 시간 데이터를 유형별로 나누고 API, 데이터베이스, 배치 작업과 테스트에서 어떻게 다뤄야 하는지 살펴보겠습니다.
먼저 어떤 종류의 시간을 다루는지 묻는다
시간 필드에 무조건 같은 timestamp 타입을 쓰기 전에 업무 의미부터 구분해야 합니다.
- Instant: 전 세계에서 하나로 합의되는 실제 순간입니다. 결제 승인 시각, 로그 발생 시각, 메시지 수신 시각이 해당합니다.
- Local date: 시간대와 무관한 달력 날짜입니다. 생일, 휴일, 회계 기준일처럼 “몇 월 며칠” 자체가 의미입니다.
- Local time: 지역이 정해지지 않은 벽시계 시간입니다. 매장 오픈 시각 09:00처럼 날짜와 타임존을 별도로 알아야 실제 순간이 됩니다.
- Zoned date-time: 지역의 시간대 규칙이 결합된 날짜와 시각입니다. 매주 뉴욕 현지 시간 오전 9시에 여는 회의처럼 서머타임 변화까지 반영해야 합니다.
- Duration과 period: 정확히 24시간 뒤와 달력상 하루 뒤는 같지 않을 수 있습니다. 전자는 경과 시간이고 후자는 달력 계산입니다.

예를 들어 생일을 UTC 자정으로 바꿔 저장하면 다른 시간대에서 날짜가 전날이나 다음 날로 보일 수 있습니다. 생일에는 시각도 타임존도 필요하지 않으므로 DATE가 더 정확한 모델입니다. 반대로 결제 시각을 지역 문자열만으로 저장하면 두 사건의 선후관계를 비교할 수 없습니다.
UTC가 해결하는 것과 해결하지 못하는 것
이미 발생한 사건은 UTC 기반 Instant로 정규화해 저장하면 정렬, 기간 계산, 시스템 간 교환이 단순해집니다. 서버의 로컬 시간대가 달라도 같은 순간을 가리키고, 사용자에게 보여줄 때만 원하는 지역 시간으로 변환할 수 있습니다.
그러나 “내년 3월부터 매주 월요일 파리 시간 오전 9시”라는 일정은 현재 UTC 값 하나로만 보존하면 안 됩니다. 파리의 UTC 오프셋은 계절에 따라 달라지고, 정부가 미래 시간대 규칙을 변경할 수도 있습니다. 반복 일정의 의도는 현지 오전 9시이므로 현지 날짜·시간, IANA 타임존 ID, 반복 규칙을 함께 저장하고 각 실행 시점을 계산해야 합니다.
일회성 미래 예약도 요구사항에 따라 다릅니다. 국제선 출발처럼 실제 순간이 확정되어 있다면 Instant와 표시용 지역 정보를 저장할 수 있습니다. “서울 지점에서 12월 1일 오전 10시 상담”처럼 지역 벽시계가 계약의 핵심이라면 현지 시간과 지역 ID를 원본 의도로 남기는 편이 규칙 변경에 대응하기 쉽습니다.
UTC 오프셋과 타임존은 같은 것이 아니다
+09:00은 특정 순간에 UTC보다 아홉 시간 빠르다는 오프셋입니다. Asia/Seoul은 과거와 미래에 어떤 오프셋을 적용할지를 담은 시간대 규칙의 식별자입니다. 현재 서울은 둘이 단순하게 대응하지만 모든 지역이 그렇지는 않습니다.
-05:00만으로는 뉴욕인지, 리마인지, 특정 계절의 다른 지역인지 알 수 없습니다. 뉴욕은 계절에 따라 -05:00과 -04:00 사이를 오가지만 리마는 같은 방식으로 바뀌지 않습니다. 미래의 현지 시각을 유지해야 한다면 고정 오프셋이 아니라 America/New_York 같은 IANA 타임존 ID가 필요합니다.
EST, CST 같은 약어는 지역에 따라 의미가 모호하므로 데이터 교환 규격으로 피하는 편이 좋습니다. UI에서는 사용자에게 익숙한 도시 이름을 보여줄 수 있지만 내부 값은 표준 지역 ID를 사용해야 합니다.
서머타임에는 존재하지 않는 시간과 두 번 존재하는 시간이 있다
서머타임을 시작하는 봄에는 시계가 한 시간 앞으로 뛰면서 어떤 현지 시각 구간이 존재하지 않습니다. 반대로 가을에는 시계가 뒤로 돌아가 같은 현지 시각이 두 번 나타납니다. “새벽 2시 30분”이라는 문자열만으로는 실제 순간을 하나로 결정할 수 없거나 아예 유효하지 않을 수 있습니다.

예약 입력이 Gap에 들어오면 다음 유효 시각으로 이동할지, 입력을 거절하고 사용자가 다시 선택하게 할지 제품 정책이 필요합니다. Overlap에서는 앞쪽 오프셋과 뒤쪽 오프셋 중 어느 것을 선택할지 명시하거나 사용자에게 두 선택지를 보여줘야 합니다. 라이브러리의 기본 동작에 우연히 맡기면 언어와 버전이 바뀔 때 결과도 달라질 수 있습니다.
한국 서비스만 운영해도 해외 사용자의 브라우저에서 날짜를 변환하거나 해외 클라우드 리전에 배치 작업을 실행하면 이 문제가 들어옵니다. 현재 서머타임을 사용하지 않는 지역도 과거 규칙은 달랐을 수 있으므로 과거 데이터 변환에서도 시간대 데이터베이스가 필요합니다.
데이터베이스 타입의 이름만 믿지 않는다
데이터베이스 제품마다 timestamp의 의미가 다릅니다. PostgreSQL의 timestamp with time zone, 즉 timestamptz는 입력을 실제 순간으로 정규화하지만 원래 입력한 타임존 ID를 보존하지 않습니다. 조회 시 세션 시간대에 맞춰 표시되므로 같은 값도 클라이언트 설정에 따라 다른 문자열로 보일 수 있습니다.
PostgreSQL의 timestamp without time zone은 타임존 정보가 없는 현지 날짜·시간에 적합하지만, 실제 순간을 저장하면서 “UTC라고 약속”만 해두면 애플리케이션 실수로 지역 시간이 섞이기 쉽습니다. 필드 이름, 타입, 연결 세션과 드라이버 변환 규칙을 함께 확인해야 합니다.
MySQL의 TIMESTAMP와 DATETIME도 변환 동작과 지원 범위가 다릅니다. SQL Server에는 datetimeoffset이 있지만 이것 역시 지역의 전체 시간대 규칙을 보존하는 것은 아닙니다. ORM 타입이 해당 DB 컬럼과 언어의 시간 객체를 어떻게 매핑하는지도 테스트해야 합니다.
실제 사건에는 UTC Instant 컬럼을 사용하고, 미래의 현지 의도를 보존해야 하면 local date-time과 zone ID를 별도 컬럼에 저장하는 방식이 명확합니다. 컬럼 이름도 created_at, scheduled_local_at, scheduled_zone_id처럼 의미가 드러나야 합니다.
API에서는 모호한 문자열을 받지 않는다
2026-08-31 09:00:00은 어느 지역의 시간인지 알 수 없습니다. 실제 순간을 주고받는 API라면 ISO 8601/RFC 3339 형태로 UTC의 Z나 명시적 오프셋을 포함해야 합니다.
2026-08-31T00:00:00Z
2026-08-31T09:00:00+09:00
두 값은 같은 순간을 나타냅니다. 서버는 파싱한 뒤 Instant로 비교하고, 응답 규격에서 항상 UTC로 보낼지 요청 오프셋을 유지할지 일관되게 정합니다. 초 단위 정밀도와 소수 초 자리수도 시스템마다 잘리지 않도록 계약에 포함해야 합니다.
예약 API라면 localDateTime과 timeZone을 분리해 받을 수 있습니다. 서버는 해당 지역 규칙으로 유효성을 검사하고 계산된 Instant를 함께 반환합니다. 사용자가 타임존을 바꿀 때 “같은 실제 순간 유지”와 “같은 벽시계 시각 유지” 중 무엇을 원하는지도 UI에서 구분해야 합니다.
날짜 범위 조회의 끝 경계가 버그를 만든다
“8월 31일 주문”을 찾으면서 23:59:59까지 조건을 만들면 밀리초나 마이크로초가 있는 데이터를 놓칠 수 있습니다. 지역 날짜의 시작을 UTC Instant로 변환하고 다음 날 시작 미만으로 조회하는 반개방 구간이 안전합니다.
occurred_at >= start_of_day_instant
AND occurred_at < next_day_start_instant
이때 하루가 항상 24시간이라는 가정도 피해야 합니다. 서머타임 전환일은 지역 기준으로 23시간 또는 25시간일 수 있습니다. 먼저 지역 달력에서 다음 날짜를 구한 뒤 각각을 Instant로 변환해야 합니다.
월별 정산도 서버의 타임존이 아니라 업무가 정의한 기준 지역과 마감 시각을 사용해야 합니다. 한국 사업일이 자정이 아니라 오전 4시에 끝난다면 그 규칙을 명시적인 비즈니스 캘린더로 모델링해야 합니다.
반복 작업은 이전 실행 시각에 24시간을 더하지 않는다
“매일 현지 시간 오전 9시” 작업의 다음 실행을 이전 Instant에 24시간 더해 계산하면 서머타임 전환 뒤 오전 8시나 10시에 실행될 수 있습니다. 반복 규칙은 현지 달력에서 다음 날짜와 시각을 구한 뒤 해당 타임존 규칙으로 새 Instant를 계산해야 합니다.
실행기가 재시작되거나 한동안 중단됐을 때 누락된 회차를 모두 실행할지, 가장 최근 회차만 실행할지, 다음 회차부터 재개할지도 정책입니다. 작업의 예약 시각과 실제 시작 시각을 따로 기록하고, 동일 회차를 식별하는 키를 두면 재시도에서 중복 실행을 막을 수 있습니다.
타임존 규칙 데이터인 tz database도 운영체제와 런타임 업데이트를 통해 바뀝니다. 정부가 시간대 정책을 갑자기 변경하면 미래 예약을 다시 계산해야 할 수 있습니다. 사용한 규칙 버전을 기록하거나 변경 후 영향을 받는 예약을 재검증할 수 있는 절차가 필요합니다.
경과 시간 측정에는 벽시계를 쓰지 않는다
API 요청이 몇 밀리초 걸렸는지 측정할 때 시스템 현재 시각 두 개를 빼면 NTP 보정이나 수동 시간 변경 때문에 음수나 비정상 값이 나올 수 있습니다. 경과 시간과 타임아웃은 뒤로 가지 않는 monotonic clock을 사용해야 합니다. 반면 로그와 감사 기록에는 실제 세계 시각을 나타내는 wall clock이 필요합니다.
분산 시스템의 여러 서버 시계는 완전히 같지 않습니다. 서로 다른 서버에서 만든 timestamp만으로 사건의 인과 순서를 단정하면 안 됩니다. 순서가 중요한 데이터에는 데이터베이스 시퀀스, 메시지 오프셋, 논리 버전처럼 별도의 순서 기준을 사용해야 합니다.
현재 시간을 코드에서 직접 부르지 않는다
코드 곳곳에서 now()를 직접 호출하면 월말, 자정, 서머타임 전환과 만료 직전 상황을 재현하기 어렵습니다. Clock 인터페이스를 주입하고 운영에서는 시스템 Clock, 테스트에서는 고정하거나 전진시킬 수 있는 Clock을 사용하면 시간 의존 로직을 결정적으로 검증할 수 있습니다.
다음 시나리오는 테스트 가치가 높습니다.
- 월말과 연말, 윤년의 2월 29일 전후
- 서머타임 시작의 존재하지 않는 현지 시각
- 서머타임 종료의 두 번 존재하는 현지 시각
- 서로 날짜가 다른 UTC-12와 UTC+14 사용자
- 자정 직전 생성하고 자정 직후 조회하는 데이터
- 시간대 규칙 업데이트 뒤의 미래 반복 예약
- 서버 시계가 앞뒤로 보정되는 동안의 타임아웃
- 소수 초 정밀도가 다른 API와 데이터베이스 왕복
기존 시스템을 안전하게 정리하는 방법
이미 모호한 timestamp가 쌓여 있다면 무조건 UTC라고 가정해 일괄 변환하면 안 됩니다. 데이터를 작성한 서버의 과거 설정, 사용자의 지역, 당시 애플리케이션 코드와 샘플 로그를 조사해 가능한 의미를 분류해야 합니다. 확신할 수 없는 레코드는 별도 표시하고 업무 담당자의 검토 규칙을 마련합니다.
새 컬럼을 추가해 원본 문자열과 해석된 Instant, 적용한 zone ID를 함께 보존한 뒤 점진적으로 읽기 경로를 전환하는 편이 안전합니다. 변환 전후 건수, 날짜별 분포, 사건 순서 위반을 검증하고 되돌릴 수 있는 상태에서 배포합니다.
시간 설계 체크리스트
- 이 값은 실제 순간, 달력 날짜, 현지 시각, 반복 규칙 중 무엇인가?
- 실제 사건은 오프셋이 명확한 Instant로 저장하고 교환하는가?
- 미래의 현지 의도에는 IANA 타임존 ID를 보존하는가?
- 생일과 휴일을 임의의 UTC 자정으로 바꾸지 않는가?
- 날짜 범위는 다음 구간 시작 미만의 반개방 조건을 사용하는가?
- 서머타임 Gap과 Overlap의 제품 정책이 정의되어 있는가?
- 반복 일정은 현지 달력 기준으로 다음 실행을 계산하는가?
- 경과 시간은 monotonic clock으로 측정하는가?
- 테스트에서 현재 시간을 고정하고 경계 상황을 재현할 수 있는가?
- DB, ORM, API 직렬화가 같은 의미와 정밀도를 유지하는가?
시간은 문자열이 아니라 업무 규칙이다
시간 버그는 포맷 하나를 통일한다고 사라지지 않습니다. 중요한 것은 값이 표현하는 의미를 먼저 결정하는 것입니다. 발생한 순간은 Instant로, 날짜 자체가 중요한 값은 Local date로, 현지 시각을 지켜야 하는 예약은 zone ID와 업무 규칙으로 보존해야 합니다.
핵심을 요약하면 이렇습니다. 저장할 때 의미를 잃지 말고, 변환할 때 지역 규칙을 명시하며, 계산할 때 달력 시간과 경과 시간을 구분하라. 이 원칙을 데이터 모델과 API 계약에 반영하면 국가가 늘고 계절이 바뀌어도 예측 가능한 시간 시스템을 만들 수 있습니다.