업무 앱의 날짜/시간과 시간대 ── DateTime의 함정부터 UTC 저장 원칙, 테스트 설계까지
· 小村 豪 · C#, .NET, .NET Framework, Windows, 시간대, 날짜/시간 처리, 테스트, 운영, 기술 상담
「서버를 클라우드 VM으로 이전했더니 보고서의 시각이 전부 9시간 어긋났다」「해외 지사에 배포한 단말에서만 일보의 날짜가 하루 전날이 된다」「야간 집계 배치가 어느 날만 두 번 실행된 흔적이 있다」. 날짜/시간과 시간대에 관한 상담은 이런 식으로 「환경이 바뀐 순간」에 찾아옵니다. 코드는 한 줄도 바꾸지 않았는데 말입니다.
「우리는 일본 국내 전용 앱이니까 시간대는 관계없다」고 생각하기 쉽지만, 실제 상담의 대부분은 바로 그 국내 전용 앱에서 일어납니다. 클라우드 VM이나 컨테이너는 UTC로 설정되어 제공되는 경우가 많고, 해외산 라이브러리나 SaaS의 Web API는 UTC나 오프셋이 붙은 타임스탬프를 돌려줍니다. 앱 자신은 일본 시간으로만 살고 있다고 생각해도, 경계 너머는 이미 UTC의 세계입니다. 「이 시각이 어느 기준인지」를 명시하지 않은 채 값을 주고받다 보면, 서버 이전이나 클라우드화하는 날에 9시간 어긋남으로 한꺼번에 드러납니다.
이 글에서는 .NET 업무 앱을 전제로, DateTime의 Kind와 암묵적 변환의 함정, DateTimeOffset과의 구분 사용, 「저장과 통신은 UTC 또는 오프셋 포함, 표시만 로컬」이라는 원칙, TimeZoneInfo와 일광절약시간, DB와의 경계, 그리고 TimeProvider를 이용한 테스트 설계까지 한 번에 정리합니다. 설계 리뷰에서 매번 확인하는 항목을, 체크리스트 형태로 정리하는 것까지 진행합니다.
1. 먼저 결론
- 날짜/시간 사고의 근본 원인은 거의 하나입니다. 「이 시각이 어느 기준인지」에 대한 정보를 갖지 않은 값이 경계(DB·API·파일)를 넘어가는 것입니다. 코드가 아니라 환경이 바뀐 날에 발병합니다.
DateTime은 Kind(Utc / Local / Unspecified)라는 속성을 가지며, 기본값은 Unspecified입니다.ToLocalTime은 Unspecified를 UTC로 간주하고,ToUniversalTime은 로컬로 간주하는 비대칭적인 암묵적 해석이 있으며, 이것이 「9시간 어긋남」의 전형적인 발생 원인입니다.1- 신규 코드의 기본값은
DateTimeOffset으로 합니다. 공식 가이드에서도 「애플리케이션 개발의 기본 날짜/시간 타입으로 검토하라」고 명시하고 있습니다.2 - 원칙은 「저장과 통신은 UTC 또는 오프셋 포함, 표시만 로컬」입니다. 문자열로 만들 때는 ISO 8601을 따르는 라운드트립 서식 “o”로 기록하고,
DateTimeStyles.RoundtripKind로 다시 읽어들입니다.3 - 시간대 변환은
TimeZoneInfo로 수행합니다. .NET 6 이후에는 IANA ID(Asia/Tokyo)와 Windows ID(Tokyo Standard Time) 둘 다 사용할 수 있고, 상호 변환 API도 있습니다.4 다만 Windows에서의 IANA 해석은 ICU에 의존하므로, 오래된 Windows Server나 불변 전역화(invariant globalization) 구성에서는 실패합니다(4장).5 - 일본에 일광절약시간이 없더라도, 해외 지사의 단말·해외 SaaS와의 연동·UTC로 설정된 서버를 경유하는 순간 DST의 「존재하지 않는 시각·모호한 시각」을 밟게 됩니다.6
DateTime.Now를 코드에 직접 쓰는 것을 그만두고,TimeProvider(.NET 8 표준. 이전 환경은 Microsoft.Bcl.TimeProvider)를 주입해서, 시각을 테스트에서 자유롭게 움직일 수 있는 설계로 만듭니다.7
2. DateTime의 Kind ── 3가지 값의 의미와 암묵적 변환 사고
DateTime은 날짜/시간 값(Ticks)에 더해 Kind라는 속성을 하나 가지고 있습니다. 값은 3가지이며, 기본값은 Unspecified입니다.1
| Kind | 의미 | 주요 발생 경로 |
|---|---|---|
| Utc | UTC 기준의 시각 | DateTime.UtcNow, ToUniversalTime()의 결과 |
| Local | 실행 머신의 로컬 시간대 기준 | DateTime.Now, ToLocalTime()의 결과 |
| Unspecified | 어느 기준인지 불명(기본값) | new DateTime(...), DateTime.Parse(대부분의 경우), DB에서 읽어온 값 |
중요한 것은, 평범하게 코드를 작성하면 Unspecified로 가득 채워진다는 점입니다. 생성자로 만든 값도, 문자열에서 파싱한 값도, DB에서 읽어온 값도, 기본적으로는 「어느 기준인지 불명」합니다. 그 자체는 문제가 아닙니다. 문제는 이 Unspecified 값에 대해 변환 메서드가 몰래 기준을 가정한다는 점입니다.1
| 호출 | Kind=Utc | Kind=Local | Kind=Unspecified |
|---|---|---|---|
ToUniversalTime() |
그대로 반환 | UTC로 변환 | 로컬로 간주하여 UTC로 변환 |
ToLocalTime() |
로컬로 변환 | 그대로 반환 | UTC로 간주하여 로컬로 변환 |
같은 Unspecified가 호출하는 메서드에 따라 「로컬」로도 「UTC」로도 해석된다는 비대칭성이 핵심입니다. 코드로 보면 다음과 같습니다.
// DB에서 읽어온 값. 대부분의 경로에서 Kind = Unspecified가 된다
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);
// 실행 머신이 JST(UTC+9)인 경우:
Console.WriteLine(fromDb.ToLocalTime()); // 18:00 ── UTC로 간주되어 +9시간
Console.WriteLine(fromDb.ToUniversalTime()); // 00:00 ── 로컬로 간주되어 -9시간
사고는 이런 형태로 일어납니다. DB에 일본 시간으로 저장된 09:00(Unspecified)을, 표시 전에 「혹시 몰라서 변환」할 생각으로 ToLocalTime()을 호출하면, UTC로 간주되어 18:00이 됩니다. 반대로 「저장 전에 UTC로 맞추자」는 변환이 경로 어딘가에 이중으로 들어가 있으면, 9시간이 두 번 빠지게 됩니다. 더 골치 아픈 점은 이 동작이 실행 머신의 시간대 설정에 의존한다는 것입니다. 개발 머신(JST)에서는 9시간, UTC로 설정된 서버에서는 0시간 어긋나므로, 「제 컴퓨터에서는 재현되지 않습니다」라는 문의로 이어집니다. 서두에서 말한 「서버 이전으로 9시간 어긋났다」는 대부분 이런 구조입니다.
2.1 DateTimeOffset과의 차이와 구분 사용
DateTimeOffset은 날짜/시간에 더해 UTC로부터의 오프셋(+09:00 등)을 항상 가지므로, 값 하나만으로 전 세계의 어느 순간인지가 유일하게 결정됩니다. 로그 기록, 거래 시각, 시스템 이벤트 기록 같은 「시점 기록」용도에서는 공식 가이드가 DateTimeOffset을 기본 날짜/시간 타입으로 검토하도록 명시하고 있습니다.2 Kind의 암묵적 해석에 의존할 여지가 처음부터 없으므로, 이 글에서 다루는 사고의 대부분이 구조적으로 발생하지 않게 됩니다.
다만 만능은 아닙니다. DateTimeOffset이 가지는 것은 오프셋일 뿐 시간대가 아니라는 점에 주의하십시오. +09:00이 일본인지 한국인지는 알 수 없고, 일광절약시간 조정 규칙도 가지고 있지 않습니다.2 「그 지역 벽시계의 움직임」을 재현하고 싶다면 뒤에서 설명할 TimeZoneInfo를 함께 사용해야 합니다. 구분 사용법을 표로 정리합니다.
| 타입 | 가지고 있는 정보 | 적합한 용도 | 비고 |
|---|---|---|---|
DateTimeOffset |
날짜/시간+UTC 오프셋 | 발생 시각 기록, 로그, API 경계 | 신규 코드의 기본값2 |
DateTime(Kind=Utc 운용) |
날짜/시간만 | 내부 계산, 기존 자산과의 호환 | Kind 관리는 전부 직접 해야 함 |
DateOnly / TimeOnly |
날짜만/시각만 | 업무 날짜, 영업 시간, 마감 시각 | .NET Framework에서는 사용할 수 없음2 |
TimeSpan |
시간 간격 | 경과 시간, 두 시점의 차이 | |
TimeZoneInfo |
시간대의 정의(조정 규칙 포함) | 변환, 일광절약시간 판정 | 4장 |
기존 DateTime 자산을 전부 DateTimeOffset으로 다시 쓰는 것은 현실적이지 않은 경우가 많으므로, 당사가 개선 작업에서 자주 택하는 절충안은 「내부와 저장은 Kind=Utc인 DateTime으로 통일하고, 경계(API·직렬화)는 DateTimeOffset이나 “o” 서식 문자열로 처리」하는 것입니다. 어느 쪽이든 다음 장의 원칙이 토대가 됩니다.
3. 원칙 ── 저장과 통신은 UTC나 오프셋 포함, 표시만 로컬
날짜/시간 처리의 설계 원칙은 3줄로 정리됩니다.
- 발생 시각은
DateTime.UtcNow나DateTimeOffset.UtcNow로 취득하여, UTC(또는 오프셋 포함) 상태로 그대로 들고 다닌다 - 경계(DB·API·파일·레지스트리)를 넘어갈 때는, 서식과 기준을 사양으로 명시한다
- 로컬 시각으로의 변환은 화면·보고서에 출력하기 직전 딱 한 번만 한다
로컬 시각으로 저장하는 설계는, 「값의 의미」를 서버의 OS 설정이라는 외부 상태에 의존시키는 설계입니다. 온프레미스의 일본 설정 서버에서 동작하는 동안은 문제가 드러나지 않지만, 클라우드 VM으로의 이전, 해외 리전에서의 DR 구성, 개발 환경과 프로덕션의 설정 차이──이 중 어느 하나만으로도 의미가 바뀝니다. UTC로 저장하면 값의 의미는 어떤 환경에서도 동일합니다. 표시용 변환은 이용자 쪽 설정(또는 사용자 마스터의 지점 시간대)으로 수행하므로, 같은 데이터를 도쿄와 베를린에서 봐도 각각 올바른 벽시계 시각이 됩니다.
3.1 경계의 문자열 표현은 ISO 8601 / “o” 서식
문자열로 경계를 넘어갈 때(JSON, CSV, 로그, 설정 파일)는 ISO 8601을 따르는 라운드트립 서식 “o”를 사용합니다. “o”는 DateTime의 Kind, DateTimeOffset의 오프셋을 문자열에 남기며, DateTimeStyles.RoundtripKind를 지정해서 파싱하면 원래 값으로 되돌릴 수 있습니다.3
using System.Globalization;
// 기록: 2026-07-03T13:30:00.0000000+09:00
DateTimeOffset now = DateTimeOffset.Now;
string s = now.ToString("o", CultureInfo.InvariantCulture);
// 다시 읽기: 오프셋을 유지한 채 복원된다
var restored = DateTimeOffset.Parse(
s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
CultureInfo.InvariantCulture를 반드시 함께 사용하십시오. 컬처를 기본값 그대로 두면, 일본 연호 등 그레고리력이 아닌 컬처로 동작하는 단말에서 연도 표기가 바뀝니다. 반대로 하면 안 되는 것이, "yyyy/MM/dd HH:mm" 같은 기준 정보를 갖지 않는 서식으로 저장·통신하는 것입니다. 이 문자열을 받은 쪽은 「어느 기준인지」를 추측할 수밖에 없고, 그 추측은 환경이 바뀌면 빗나갑니다. 참고로 System.Text.Json의 기본 날짜/시간 표현도 ISO 8601 계열이므로, JSON 경계에서는 순순히 기본값에 따르는 것이 안전합니다.
「경계를 넘어가는 데이터는 형식을 사양에 명시한다」는 사고방식은 날짜/시간에만 한정되지 않습니다. 문자 인코딩과 줄바꿈 코드에 대해서도 완전히 같은 논의를 「Windows의 문자 인코딩과 줄바꿈 코드」에서 다뤘습니다. 경계의 암묵적 처리는 종류가 달라도 같은 방식으로 무너집니다.
3.2 타임스탬프와 「업무 날짜」를 구별한다
하나 더, 서두에서 말한 「해외 지사에서만 일보의 날짜가 하루 전날이 되는」 문제를 풀기 위한 구별입니다. 타임스탬프(전 세계에서 유일한 시점)와 업무 날짜(「7월 3일의 일보」라는 라벨)는 서로 다른 것입니다. 업무 날짜를 「자정의 DateTime」으로 들고 있다가 UTC 변환을 거치면, UTC+9의 7월 3일 00:00은 UTC의 7월 2일 15:00이 되어, 날짜 부분을 잘라내는 순간 하루 전날로 밀려납니다. 이것이 전형적인 발생 기전입니다.
업무 날짜는 DateOnly(.NET Framework라면 yyyy-MM-dd 형식의 문자열이나 연월일 값)로 들고 있으며, 「어느 시간대로 날짜를 잘라낼지」를 사양으로 정합니다. 「일보의 날짜는 지점의 현지 시각 기준」「마감은 본사 JST 기준」과 같이 적혀 있다면, 구현은 UtcNow를 해당 시간대로 변환한 뒤 날짜를 잘라내기만 하면 됩니다. 사양에 적혀 있지 않다면, 그것은 구현자의 머신 설정이 사양이 되어 있다는 뜻입니다.
4. 시간대 변환 ── TimeZoneInfo와 ID 체계
시간대 변환은 TimeZoneInfo의 ConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime으로 수행합니다. 주의할 점은 이 API들이 DateTime의 Kind와 변환 대상 시간대의 정합성을 검사한다는 것입니다. 예를 들어 Kind=Utc인 값을 「변환 대상은 도쿄」라고 전달하면 ArgumentException이 발생합니다.8 즉 Kind 관리가 허술한 코드는 시간대 변환 API조차 제대로 호출할 수 없습니다. 2장의 이야기가 바로 여기로 이어집니다.
4.1 Windows의 시간대 ID와 IANA ID
시간대를 지정하는 ID에는 2가지 체계가 있습니다.
| Windows ID | IANA ID | |
|---|---|---|
| 예(일본) | Tokyo Standard Time |
Asia/Tokyo |
| 예(독일) | W. Europe Standard Time |
Europe/Berlin |
| 관리 주체 | Windows(레지스트리) | IANA tz database |
| 주요 사용처 | Windows API, .NET (Framework) | Linux, 해외 SaaS, Web API, 다른 언어 |
.NET Framework 시절에는 Windows ID만 사용할 수 있어서, 「Web API에서 Asia/Tokyo가 날아오는데 FindSystemTimeZoneById에 넘길 수 없다」는 변환표 문제가 있었습니다. .NET 6 이후에는 TimeZoneInfo.FindSystemTimeZoneById가 두 ID를 모두 받아들이며, 시스템에 없는 쪽의 ID는 자동으로 변환하여 해결합니다. 명시적으로 변환하고 싶을 때 쓰는 TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId도 추가되었습니다.4
// .NET 6+ : IANA ID가 그대로 통한다(Windows ID "Tokyo Standard Time"이어도 결과는 같음)
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
DateTime utc = DateTime.UtcNow;
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, tokyo)); // 도쿄의 벽시계 시각
Console.WriteLine(TimeZoneInfo.ConvertTimeFromUtc(utc, berlin)); // 베를린의 벽시계 시각
// ID 체계의 상호 변환 (.NET 6+)
if (TimeZoneInfo.TryConvertWindowsIdToIanaId("Tokyo Standard Time", out var ianaId))
Console.WriteLine(ianaId); // Asia/Tokyo
실무 지침으로는, 지점 마스터나 설정 파일에 보관하는 시간대 ID는 IANA ID로 통일하는 것을 권장합니다. 해외 SaaS·Linux 컨테이너·다른 언어와의 연동에서 공통으로 통용되는 것은 IANA ID이며, .NET 쪽은 6 이후라면 원칙적으로 그대로 받을 수 있습니다. .NET Framework로 동작하는 부분이 남아 있다면, 그 경계에서만 Windows ID로 변환합니다. 참고로 FindSystemTimeZoneById는 ID를 찾지 못하면 TimeZoneNotFoundException을 던지므로, 마스터에 ID를 등록하는 화면이나 시작 시 검증에서 조기에 걸러내십시오.
중요한 전제 조건이 하나 있습니다. Windows에서 IANA ID를 해석하는 기능은 ICU 라이브러리에 의존합니다. NLS 모드나 전역화 불변 모드(InvariantGlobalization=true)로 동작하는 앱에서는 IANA ID를 해석할 수 없고, TryConvertIanaIdToWindowsId도 실패합니다.5 또한 OS에 ICU가 포함되어 있지 않은 오래된 환경(Windows Server 2019, Windows 10의 1809 이전 등)에서 .NET 6을 구동할 경우도, 앱 로컬 ICU를 함께 배포하지 않는 한 같은 제약을 받습니다(.NET 7 이후로는 이런 OS에서도 ICU가 사용되도록 변경되었습니다9). 즉 「개발 머신(Windows 11)에서는 Asia/Tokyo가 통하는데, 고객사의 Server 2019에서만 TimeZoneNotFoundException」이 실제로 발생합니다. IANA ID를 마스터에 채택하는 경우에는, (1) 실행 환경의 OS와 .NET 버전에서 IANA 해석이 동작하는지 확인한다, (2) 컨테이너 등에서 크기를 줄이려는 목적으로 InvariantGlobalization을 안이하게 활성화하지 않는다, (3) 보험으로 TryConvertIanaIdToWindowsId로 Windows ID로 떨어뜨려 재시도하는 폴백을 시작 시 검증에 넣어 둔다──이 3가지를 세트로 갖춰 두십시오.
5. 일광절약시간(DST) ── 일본 국내 전용이라도 걸려드는 상황
일본에는 현재 일광절약시간이 없기 때문에 「DST는 우리와 관계없다」고 생각하기 쉽습니다. 하지만 다음 중 하나라도 해당하면 확실히 걸려듭니다.
- 해외 지사·해외 출장자의 단말에서 앱이 동작한다(단말의 로컬 시간대가 DST를 가짐)
- 해외산 SaaS / Web API와 연동하여, 현지 시각 기준의 타임스탬프나 일정을 받는다
- 해외 리전의 서버·VM에서 집계나 배치가 동작한다
- 해외 지사의 현지 시각으로 마감하는 집계(「각 지점의 0시 마감」 등)가 있다
DST가 있는 시간대에서는 전환일에 2가지 종류의 비정상적인 시각이 생겨납니다. 존재하지 않는 시각(봄, 시계가 앞으로 가는 순간 건너뛰는 구간. 독일이라면 3월 말의 02:00~03:00)과, 모호한 시각(가을, 시계가 되돌아가면서 두 번 나타나는 구간)입니다. .NET에서는 TimeZoneInfo.IsInvalidTime / IsAmbiguousTime으로 판정할 수 있습니다.6 그리고 ConvertTimeToUtc 같은 변환 API는 존재하지 않는 시각을 전달하면 ArgumentException을 던지고, 모호한 시각은 표준시 쪽으로 해석합니다.8
var berlin = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
// 2026-03-29는 독일의 DST 시작일. 02:00~03:00의 현지 시각은 존재하지 않음
var t = new DateTime(2026, 3, 29, 2, 30, 0); // Kind = Unspecified
Console.WriteLine(berlin.IsInvalidTime(t)); // True
// TimeZoneInfo.ConvertTimeToUtc(t, berlin)은 ArgumentException이 된다
「현지 시각 문자열을 받아 UTC로 변환해서 저장한다」는 입력 경로가 있다면, 이 예외는 1년에 딱 하루만 발생하는 버그로 숨어 있게 됩니다. 입력 검증 단계에서 IsInvalidTime을 확인하고, 모호한 시각은 「표준시로 취급한다」는 것을 사양에 명시해 두는 것이 현실적인 결론입니다.
5.1 정기 실행과 DST ── 두 번 실행되거나 실행되지 않는 문제
정기 실행은 또 하나의 대표적인 취약 지점입니다. 현지 시각 02:30에 매일 실행되는 작업은 DST 시작일에는 그 시각이 존재하지 않고, 종료일에는 두 번 존재합니다. 스케줄러의 구현에 따라 「건너뛰어짐」「두 번 실행됨」「한 시간 밀려서 실행됨」과 같이 동작이 갈리기 때문에, 「매일 한 번만 실행된다」는 전제로 작성된 집계 처리가 이중 집계나 결측을 일으킵니다. 대책은 3가지를 조합하는 것입니다.
- 스케줄 기준을 UTC(또는 DST가 없는 시간대)로 한다. 현지 시각에 기동하는 것이 요건이 아닌 배치라면, 이것만으로 문제가 사라집니다
- 처리를 멱등하게 만든다. 「대상일의 집계가 이미 존재하면 건너뛴다」는 실행 완료 마커를 넣으면, 두 번 기동해도 무너지지 않습니다
- 집계 키는 업무 날짜로 갖는다(3.2절). 기동 시각에서 날짜를 역산하지 않는다
작업 스케줄러에서의 정기 실행 설계(중복 실행 방지, 실패 시 원인 분리)는 「작업 스케줄러 작업이 실행되지 않는다·0x1로 끝난다」에서, 상시 실행 서비스 쪽에서 타이머를 갖는 설계는 「Windows 서비스 만들기와 운영」에서 자세히 다루고 있습니다. 어느 방식이든 DST와 스케줄의 관계는 마찬가지로 사양에 적어 두어야 합니다.
6. DB와의 경계 ── SQL Server / SQLite / ORM
경계 중에서 사고가 가장 많은 곳이 DB입니다. DB의 날짜/시간 타입은 「어느 기준인지」를 보관하지 않는 경우가 많고, 저장하는 순간 Kind나 오프셋 정보가 사라지기 때문입니다.
6.1 SQL Server의 날짜/시간 타입
| 타입 | 범위·정밀도 | 기준 정보 | 신규 채택 기준 |
|---|---|---|---|
datetime |
1753년~, 약 1/300초 정밀도 | 없음 | 피한다(공식적으로 신규 사용을 권장하지 않는다고 명시)10 |
datetime2 |
0001년~, 최대 100나노초 정밀도 | 없음 | ◎ UTC로 저장하는 열의 본진10 |
datetimeoffset |
datetime2에 상응+오프셋 |
오프셋을 보관 | ○ 현지 시각 재현이 요건인 열10 |
datetime은 라운딩 단위가 거칠고 범위도 좁은 구세대 타입으로, 공식 문서가 「신규 작업에서는 피하고, datetime2 / datetimeoffset 등을 사용할 것」이라고 명시하고 있습니다.10 기존 스키마의 datetime을 무리하게 이전할 필요는 없지만, 새 테이블에서 이것을 선택할 이유는 없습니다.
datetime2에 UTC로 저장할지, datetimeoffset으로 할지는 「입력 시점의 오프셋을 나중에 재현할 필요가 있는가」로 결정합니다. 감사나 컴플라이언스 상 「이용자의 현지 시각으로 몇 시였는지」를 남겨야 하는 요건이 있다면 datetimeoffset, 시점만 특정할 수 있으면 충분하다면 UTC의 datetime2로도 충분합니다. 다만 datetimeoffset이 가지는 것도 오프셋뿐이고, 시간대(조정 규칙) 자체는 아니라는 점은 DateTimeOffset과 같습니다. 존(zone)까지 필요하다면 IANA ID를 별도 열로 둡니다.
6.2 SQLite에는 날짜/시간 타입이 없다
SQLite에는 애초에 날짜/시간을 위한 저장 타입이 없어서, Microsoft.Data.Sqlite는 DateTime / DateTimeOffset을 TEXT로 저장합니다.11 TEXT의 ISO 8601 계열 서식은 「서식과 시간대가 통일되어 있으면」 문자열 정렬=시각 정렬이 되지만, 반대로 말하면 UTC와 로컬이 한 열에 섞이는 순간, 정렬도 범위 검색도 조용히 무너집니다. SQLite를 사용할 경우 「이 열은 UTC, 서식은 이것」이라는 것을 앱 쪽 규범으로 고정하는 수밖에 없습니다. 연결이나 트랜잭션까지 포함한 SQLite의 실무는 「C#에서 SQLite를 업무 앱에 사용하기」에 정리해 두었습니다.
6.3 EF Core / Dapper에서의 주의점 ── 읽어오면 Kind가 사라진다
기준 정보를 갖지 않는 타입(datetime2, SQLite의 TEXT 등)에서 DateTime을 읽어오면, 당연히 Kind는 Unspecified가 됩니다. 「저장할 때는 UTC로 통일했는데, 읽어온 값에 ToUniversalTime()을 호출하는 곳이 있어서 이중 변환이 되었다」는 사고가 여기서 생겨납니다. 대책은 경계에서 Kind를 복원하는 것입니다. EF Core라면 값 컨버터로 한꺼번에 선언할 수 있습니다.
// EF Core: 「이 열은 UTC」를 모델 쪽에서 한꺼번에 선언한다
modelBuilder.Entity<Order>()
.Property(o => o.CreatedAtUtc)
.HasConversion(
// 쓰기: UTC가 아닌 값(DateTime.Now의 혼입 등)도 경계에서 UTC로 정규화한다.
// Unspecified는 로컬로 간주되어 변환된다는 점에 주의
v => v.Kind == DateTimeKind.Utc ? v : v.ToUniversalTime(),
// 읽기: Kind를 복원한다
v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
쓰기 쪽 정규화는 어디까지나 마지막 보험입니다. Unspecified의 변환은 실행 머신의 시간대 설정에 의존하므로, DateTime.Now나 Unspecified 값을 저장 경로에 흘려보내는 코드 자체는 발견하는 즉시 고칩니다. 보험과 규범을 이중으로 갖춘다는 이해를 가지고 있으면 됩니다. Dapper나 순수 ADO.NET이라면, 매핑 직후에 DateTime.SpecifyKind를 통과시키는 변환 계층을 한 곳에 모읍니다. 함께 효과를 내는 것이 명명 규칙으로, 열 이름·속성 이름에 기준을 새겨 넣는 것(CreatedAtUtc, updated_at_utc)만으로도, 리뷰에서 「이 값에 ToUniversalTime을 쓰는 건 이상하다」고 알아챌 확률이 크게 올라갑니다. 문서보다 이름이 더 많이 읽히기 때문입니다.
7. 시각 동기화와 테스트 ── w32time과 TimeProvider
7.1 머신의 시계가 맞다는 전제를 의심한다
여기까지의 이야기는 「머신의 시계 자체는 정확하다」는 전제였지만, 그 시계를 맞추고 있는 것은 Windows 시간 서비스(w32time)입니다. w32time은 NTP로 네트워크상의 시각 원본과 동기화하며, Active Directory 환경에서는 도메인 계층을 따라 동기화합니다. Kerberos 인증을 비롯해 시각 어긋남에 민감한 구조들의 토대입니다.12
앱 설계에 대한 함의는 2가지입니다. 첫째, 클라이언트 PC의 시계를 업무 로직의 근거로 삼지 않는다는 것입니다. 동기화가 멈춘 단말은 아무렇지 않게 몇 분씩 어긋나므로, 이벤트 순서나 마감 시각 판정은 서버 쪽 시각으로 수행하고, 클라이언트 시각은 참고 정보로만 남겨 둡니다. 둘째, 「시각이 이상하다」는 문의가 들어오면 앱보다 먼저 w32tm /query /status로 단말의 동기화 상태를 확인하는 것입니다. 앱의 버그 조사를 시작하기 전에 1분 만에 원인을 가려낼 수 있습니다.
7.2 DateTime.Now를 직접 쓰는 것을 그만둔다 ── TimeProvider
날짜/시간 처리 테스트를 가로막는 가장 큰 요인은 코드 곳곳에 직접 작성된 DateTime.Now입니다. 「월말 마감 판정」「연도 전환 시 순번 초기화」「DST 전환일 스케줄」을 테스트하고 싶어도, 현재 시각을 고정할 수 없다면 그날이 올 때까지 검증할 수 없습니다.
.NET 8부터는 표준 시각 추상화인 TimeProvider가 도입되었습니다. GetUtcNow() / GetLocalNow() / LocalTimeZone / 타이머 생성까지 하나의 추상화로 교체할 수 있습니다. .NET Framework 4.6.2 이후와 .NET Standard 2.0에서도 NuGet의 Microsoft.Bcl.TimeProvider로 같은 타입을 쓸 수 있으므로, 오래된 자산에도 도입할 수 있습니다. 테스트용 구현인 FakeTimeProvider는 Microsoft.Extensions.TimeProvider.Testing 패키지에서 제공됩니다.7
public sealed class DailyReportService
{
private readonly TimeProvider _clock;
private readonly TimeZoneInfo _siteTimeZone;
public DailyReportService(TimeProvider clock, TimeZoneInfo siteTimeZone)
{
_clock = clock;
_siteTimeZone = siteTimeZone;
}
// 업무 날짜는 「지점의 시간대로 날짜를 자른다」고 명시한 구현 (3.2절)
public string GetReportDateKey()
{
var localNow = TimeZoneInfo.ConvertTime(_clock.GetUtcNow(), _siteTimeZone);
return localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
}
프로덕션에서는 TimeProvider.System을 전달하고, 테스트에서는 FakeTimeProvider로 시각을 자유롭게 고정·전진시킵니다.
[Fact]
public void 연도가_바뀌어도_업무_날짜가_올바르게_전환된다()
{
// JST 기준 12월 31일 23:30으로 고정하고 시작
var clock = new FakeTimeProvider(
new DateTimeOffset(2026, 12, 31, 23, 30, 0, TimeSpan.FromHours(9)));
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo");
var svc = new DailyReportService(clock, tokyo);
Assert.Equal("2026-12-31", svc.GetReportDateKey());
clock.Advance(TimeSpan.FromHours(1)); // 연도 전환을 한순간에 재현
Assert.Equal("2027-01-01", svc.GetReportDateKey());
}
FakeTimeProvider는 시각의 고정·수동 전진에 더해 로컬 시간대 교체(SetLocalTimeZone)도 할 수 있으므로, 「독일 설정 단말에서 날짜가 하루 전날이 되는」 버그를 CI의 일본 머신에서 재현할 수 있습니다. .NET Framework의 오래된 프로젝트에서 NuGet 추가조차 어려운 경우라면, DateTimeOffset UtcNow { get; }만 가진 자체 제작 IClock 인터페이스로도 효과는 같습니다. 중요한 것은 추상화의 화려함이 아니라, 「현재 시각」을 주입 가능한 의존성으로 취급하는 것입니다.
테스트 케이스로 최소한 넣어 두어야 할 시각은, 경험상 이 5가지입니다: 연말연시(연도 전환), 월말(31일·30일·2월), 윤년의 2월 29일, 대상 시간대의 DST 전환일(봄·가을), 자정 전후(업무 날짜의 경계). 모두 「그날에만」 동작하는 버그의 대표적인 서식지로, FakeTimeProvider가 있으면 전부 몇 밀리초 만에 테스트할 수 있습니다.
8. 체크리스트와 판단표
날짜/시간 처리는 UUID와 마찬가지로 「어쩐지 잘 돌아가니까」라는 이유로 방치되기 쉬운 기반 요소입니다(「UUID는 충돌하지 않는가」에서 다룬 구도와 매우 비슷합니다). 신규 설계와 리뷰에서 확인할 항목을 고정해 두면, 담당자에 따른 편차가 사라집니다.
신규 설계 시 체크리스트:
- 발생 시각 취득이
DateTimeOffset.UtcNow/TimeProvider.GetUtcNow()로 통일되어 있는가 - 저장·통신의 기준(UTC인지 오프셋 포함인지)과 서식(“o” / ISO 8601)이 사양서에 명시되어 있는가
- DB의 열 타입과 기준이 정해져 있는가(SQL Server라면
datetime2(UTC)나datetimeoffset. 열 이름에Utc를 새겨 넣는다) - 타임스탬프와 업무 날짜를 구별하고, 날짜를 자르는 시간대를 정했는가
- 시간대 ID의 관리 체계(IANA ID 권장)와, 잘못된 ID일 때의 오류 처리를 정했는가
- 정기 실행의 DST 방침(UTC 기준 스케줄+멱등화)을 정했는가
TimeProvider/IClock이 주입 가능하며, 시각 경계의 테스트 케이스가 있는가
리뷰 시 grep으로 찾아낼 코드:
| 발견하면 의심해야 할 코드 | 무슨 일이 일어날 수 있는가 | 고치는 방법 |
|---|---|---|
DateTime.Now |
서버 이전 시 어긋난다. 테스트 불가능 | UtcNow+표시 시 변환. TimeProvider 주입 |
ToLocalTime() / ToUniversalTime() |
Unspecified의 암묵적 해석(2장) | 경계에서 Kind를 확정시키고, 표시 직전에만 변환 |
DateTime.Parse(s)(styles 미지정) |
실행 환경의 컬처·시간대 의존 | ParseExact+InvariantCulture+RoundtripKind |
ToString("yyyy/MM/dd HH:mm")로 저장·통신 |
기준 정보가 사라진다 | “o” 서식+InvariantCulture |
new DateTime(...)을 비교·저장에 직접 사용 |
Unspecified 혼입 | SpecifyKind를 하거나 DateTimeOffset으로 전환 |
SQL Server의 신규 datetime 열 |
정밀도·범위·미래 지속성 | datetime2 / datetimeoffset10 |
이 체크리스트의 대부분은 신규 개발이라면 첫날에 결정할 수 있는 내용입니다. 반대로 가동 이후의 수정은 이미 저장된 데이터의 기준을 추정해서 마이그레이션하는 작업(어느 기간의 데이터가 어느 기준으로 들어갔는지에 대한 고고학)이 되어, 비용이 한 자릿수 달라집니다.
9. 정리
날짜/시간과 시간대의 사고는 증상만 보면 「9시간 어긋난다」「날짜가 하루 전날이 된다」「배치가 두 번 실행된다」로 제각각이지만, 원인은 한결같이 기준 정보를 갖지 않은 값이 경계를 넘어가는 것입니다. 대책도 한결같습니다. 취득은 UTC, 저장과 통신은 UTC 또는 오프셋 포함+ISO 8601(“o”), 표시만 로컬. 경계에서는 서식과 기준을 사양에 명시하고, DB의 열 이름에 기준을 새겨 넣는다. 시간대는 TimeZoneInfo와 IANA ID로 다루고, DST의 존재하지 않는 시각·모호한 시각은 입력 검증과 멱등한 정기 실행으로 받아낸다. 그리고 TimeProvider를 주입해서, 연도 전환도 DST 전환일도 테스트로 재현할 수 있게 해 둔다──여기까지 해 두면, 환경이 바뀐 날에 불려 다니는 쪽에서, 바뀌기 전에 미리 지적하는 쪽으로 넘어갈 수 있습니다.
당사에서는 서버 이전·클라우드화에 따른 시각 어긋남의 원인 조사, 날짜/시간 처리 관련 설계 리뷰, 해외 지사 대응(시간대·DST 대응) 개선 지원을 다루고 있습니다. 이미 저장된 데이터의 기준이 혼재되어 버린 경우의 점검과 이전 계획까지 포함해서, 판단이 어려운 경우 도와드릴 수 있습니다.
관련 글
- 작업 스케줄러 작업이 실행되지 않는다·0x1로 끝난다 ── 원인 분리와 안전한 운영 설계
- C#에서 SQLite를 업무 앱에 사용하기 ── WAL 모드·배타적 제어·손상 대책·EF Core와의 구분 사용
- Windows 서비스 만들기와 운영 ── 작업 스케줄러와의 구분 사용부터 BackgroundService의 서비스화까지
- Windows의 문자 인코딩과 줄바꿈 코드 - 문자 깨짐과 CRLF/LF의 기본
관련 상담 분야
합동회사 코무라소프트는 업무 앱의 날짜/시간·시간대 처리 설계 리뷰, 서버 이전이나 클라우드화에 따른 시각 어긋남·날짜 어긋남의 원인 조사, 해외 지사 대응 및 레거시 자산의 날짜/시간 처리 개선을 다루고 있습니다.
참고 자료
-
Microsoft Learn, DateTime.Kind Property. Kind의 기본값이 Unspecified라는 점, Kind 값이 ToLocalTime / ToUniversalTime의 변환 결과에 미치는 영향(Unspecified가 ToLocalTime에서는 UTC, ToUniversalTime에서는 로컬로 간주된다는 점)에 대해. ↩ ↩2 ↩3
-
Microsoft Learn, Choose between DateTime, DateOnly, DateTimeOffset, TimeSpan, TimeOnly, and TimeZoneInfo. DateTimeOffset을 애플리케이션 개발의 기본 날짜/시간 타입으로 검토해야 한다는 점, DateTimeOffset이 오프셋만 가지고 시간대와는 연결되지 않는다는 점, DateOnly / TimeOnly가 .NET Framework에서는 사용할 수 없다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Standard date and time format strings. 라운드트립 서식 “o”가 ISO 8601을 따르며 DateTime의 Kind·DateTimeOffset의 오프셋을 문자열에 보관한다는 점, DateTimeStyles.RoundtripKind로 다시 파싱하여 왕복시킬 수 있다는 점에 대해. ↩ ↩2
-
Microsoft Learn, What’s new in .NET 6. .NET 6에서 TimeZoneInfo.FindSystemTimeZoneById가 IANA / Windows 양쪽의 시간대 ID를 받아들여 자동 변환한다는 점, TryConvertIanaIdToWindowsId / TryConvertWindowsIdToIanaId가 추가되었다는 점에 대해. ↩ ↩2
-
Microsoft Learn, .NET globalization and ICU. Windows에서 IANA 시간대 ID의 해석과 상호 변환 API가 ICU에 의존한다는 점, NLS 모드·전역화 불변 모드에서는 사용할 수 없다는 점, ICU가 없는 OS에서는 앱 로컬 ICU로 대응할 수 있다는 점에 대해. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.IsInvalidTime(DateTime) Method. 일광절약시간으로의 전환에서 발생하는 「존재하지 않는 시각」의 정의와 판정 방법, 짝을 이루는 IsAmbiguousTime(모호한 시각의 판정)에 대해. ↩ ↩2
-
Microsoft Learn, What is TimeProvider?. TimeProvider가 .NET 8 이후 표준으로 탑재되었으며, .NET Framework 4.6.2+ / .NET Standard 2.0에서는 Microsoft.Bcl.TimeProvider 패키지로 사용할 수 있다는 점, GetUtcNow / GetLocalNow / LocalTimeZone / 타이머 생성의 추상화, 테스트용 FakeTimeProvider가 Microsoft.Extensions.TimeProvider.Testing 패키지로 제공된다는 점에 대해. ↩ ↩2
-
Microsoft Learn, TimeZoneInfo.ConvertTime Method. DateTime.Kind와 변환 대상 시간대의 정합성이 요구되며 불일치 시 ArgumentException이 발생한다는 점, 모호한 시각이 표준시 쪽으로 해석된다는 점, 존재하지 않는 시각을 전달하면 ArgumentException이 발생한다는 점에 대해. ↩ ↩2
-
Microsoft Learn, Globalization APIs use ICU libraries on Windows Server 2019. .NET 7 이후로는 ICU를 포함하지 않는 Windows Server 2019 등에서도 ICU 라이브러리가 사용되도록 변경되었다는 점, 그 이전에는 앱 로컬 ICU를 수동으로 배치해야 했다는 점에 대해. ↩
-
Microsoft Learn, datetime (Transact-SQL). 신규 작업에서는 datetime을 피하고 time / date / datetime2 / datetimeoffset을 사용해야 한다는 점, datetime2 / datetimeoffset의 정밀도가 높고 datetimeoffset이 시간대 오프셋을 지원한다는 점에 대해. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Data types (Microsoft.Data.Sqlite). SQLite의 원시 타입이 4개뿐이라는 점, Microsoft.Data.Sqlite가 DateTime / DateTimeOffset을 TEXT로 저장한다는 점에 대해. ↩
-
Microsoft Learn, Windows Time Service (W32Time). Windows 시간 서비스가 NTP로 네트워크상의 컴퓨터 시각을 동기화한다는 점, Active Directory 도메인에서의 동기화 계층, Kerberos 인증이 시각 동기화에 의존한다는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
WPF의 고DPI 대응 ── 「DPI에 강할 텐데」인데도 흐릿하고・번지는 원인과 대처
WPF는 DIP(1/96인치)로 레이아웃되어 처음부터 System DPI Aware이지만, DPI가 다른 모니터로 옮기면 전체가 번지고, 비트맵이나 단선은 흐릿해집니다. 원인 구분, .NET Framework 4.6.2/.NET에서의 Per-Mo...
WinForms의 고DPI 대응 - 4K 모니터에서 흐려지거나 깨지는 원인과 현실적인 대처법
4K 모니터나 150% 스케일링 환경에서 WinForms 앱이 흐려지거나 레이아웃이 깨지는 원인을 DPI 가상화와 DPI 인식 모드(System Aware / Per-Monitor V2) 관점에서 정리합니다. .NET과 .NET Framework...
C#에서 SQLite를 업무 앱에 쓰는 법 ── WAL 모드・배타 제어・손상 대책・EF Core와의 구분
Microsoft.Data.Sqlite로 SQLite를 업무용 Windows 앱에 통합하는 실무 지식을 정리합니다. 연결 문자열과 풀링, WAL 모드의 원리, SQLITE_BUSY와 쓰기 경로 단일화, 트랜잭션을 이용한 고속화, 타입 매핑의 함정...
Windows 서비스 만드는 방법과 운영 ── 태스크 스케줄러와의 구분 사용부터 BackgroundService의 서비스화까지
상주 처리를 Windows 서비스로 만들어야 할지, 태스크 스케줄러로 충분한지를 판단표로 정리하고, .NET Worker Service(UseWindowsService)를 이용한 서비스 제작 방법, 실행 계정・복구 옵션・이벤트 로그・안전한 정지 ...
Windows의 세션 분리를 어떻게 이해할까 ── Session 0・RDP・다중 사용자 동시 실행
Windows 앱 개발자가 혼란스러워하기 쉬운 '세션' 개념을 정리합니다. 서비스가 UI를 표시할 수 없는 Session 0 분리의 이유, RDP 접속 시 세션의 동작, 명명된 개체의 세션 분리, 공유 PC・RDS 환경에서 흔히 발생하는 설계 실...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
Windows 앱 개발
상주 처리, 장비 연동, 운영 로그, 유지 보수 가능한 구조가 필요한 Windows 데스크톱 애플리케이션을 지원합니다.
기술 상담 & 설계 리뷰
설계 방향, 아키텍처 경계, 수명 관리, 기존 Windows 자산 처리 방법을 정리하는 데 도움을 드립니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- 서버 이전 후 시간이 9시간 어긋나는 이유는 무엇인가요?
- 근본 원인은 「이 시각이 어느 기준인지」에 대한 정보를 갖지 않은 값이 DB·API·파일 같은 경계를 넘어가는 것입니다. .NET의 DateTime은 기본적으로 Kind가 Unspecified가 되며, ToLocalTime()은 UTC로 간주해 +9시간, ToUniversalTime()은 로컬로 간주해 -9시간 변환을 조용히 수행합니다. 이 동작은 실행 머신의 시간대 설정에 의존하기 때문에, 일본 시간으로 맞춰진 개발 머신에서는 문제가 드러나지 않다가, UTC로 설정된 클라우드 VM으로 이전한 날 한꺼번에 드러납니다.
- DateTime과 DateTimeOffset 중 어느 쪽을 사용해야 하나요?
- 신규 코드의 기본값은 DateTimeOffset입니다. UTC로부터의 오프셋을 항상 가지고 있으므로 값 하나만으로 전 세계의 어느 순간인지가 유일하게 결정되며, Kind의 암묵적 해석으로 인한 사고가 구조적으로 발생하지 않습니다. 공식 가이드에서도 애플리케이션 개발의 기본 날짜/시간 타입으로 검토하라고 명시하고 있습니다. 다만 오프셋은 시간대 자체가 아니므로, 일광절약시간 조정 규칙이 필요한 경우에는 TimeZoneInfo를 함께 사용합니다. 기존 DateTime 자산이 많은 경우에는, 내부와 저장은 Kind=Utc인 DateTime으로 통일하고 경계에서만 DateTimeOffset을 쓰는 절충안도 현실적입니다.
- 일본 국내 전용 앱이라도 일광절약시간(DST) 대책이 필요한가요?
- 해외 지사나 해외 출장자의 단말에서 동작한다, 해외산 SaaS나 Web API와 연동한다, 해외 리전의 서버에서 배치가 동작한다 중 하나라도 해당한다면 필요합니다. DST가 있는 시간대에서는 전환일에 「존재하지 않는 시각」과 「모호한 시각」이 생겨나며, TimeZoneInfo의 변환 API는 존재하지 않는 시각을 전달하면 ArgumentException을 던집니다. 현지 시각 02:30에 동작하는 정기 작업은 DST 시작일에 건너뛰어지거나 종료일에 두 번 실행되므로, UTC 기준 스케줄과 멱등한 처리 설계가 대책이 됩니다.
- 날짜/시간 처리 테스트는 어떻게 작성해야 하나요?
- DateTime.Now를 코드에 직접 쓰는 것을 그만두고, .NET 8 표준인 TimeProvider를 주입해 현재 시각을 바꿔 끼울 수 있게 만듭니다. .NET Framework 4.6.2 이후에서도 Microsoft.Bcl.TimeProvider 패키지로 같은 타입을 쓸 수 있습니다. 테스트에서는 FakeTimeProvider로 시각을 고정·전진시킬 수 있고 로컬 시간대 교체도 가능하므로, 연도가 바뀌는 시점이나 DST 전환일의 버그를 CI에서 재현할 수 있습니다. 최소한 테스트해야 할 시각은 연말연시, 월말, 윤년의 2월 29일, DST 전환일, 자정 전후, 이렇게 5가지입니다.
저자 프로필
기사 저자의 프로필 페이지입니다.
Go Komura
합동회사 코무라소프트 대표
Windows 소프트웨어 개발, 기술 상담, 장애 조사를 중심으로 재현이 어려운 장애 조사와 기존 자산이 남아 있는 프로젝트에 강점이 있습니다.
공개 링크