수정 이력(7건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- Codex 리뷰에 따라 상담·문의 링크에 /ko/ 로케일 접두를 붙였습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 관련 기사 링크를 한국어 permalink에 맞추는 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 기사 서두에 「이 기사의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 모은 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건)에 대응해 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하세요.
- 「9시간 어긋남」 실험을 `FakeTimeProvider`로 대신할 수 있다고 적어 둔 부분을 고쳤습니다. `SetLocalTimeZone`이 바꾸는 것은 해당 `TimeProvider`의 `LocalTimeZone` 프로퍼티뿐이며, 프로세스 전체의 `TimeZoneInfo.Local`은 바뀌지 않습니다. 실험 코드가 호출하는 `ToLocalTime()`/`ToUniversalTime()`이 보는 것은 후자이므로, 페이크를 넣어도 CI 머신의 설정 결과가 그대로 나옵니다. 뒤집으면 이것이 업무 코드에서 `ToLocalTime()`을 걷어내야 하는 이유이기도 해서, 변환 대상을 인자로 받는 테스트 가능한 형태의 예로 바꿨습니다. 7.2절 서술에도, 주입한 시계를 거치는 코드에만 효과가 있다는 점을 보완했습니다.
- 서두에 용어표와 목적별 읽는 순서를 추가했습니다. 로컬에서 9시간 어긋남을 재현하는 5분 실험(최소 코드와 `tzutil`로 전환, JST와 UTC 결과 대비)을 신설하고, UTC로 저장한 뒤 표시 시에 변환하는 파이프라인 그림, 업무 날짜와 타임스탬프를 나눈 테이블 설계 예를 추가했습니다.
- 최초 공개
이 글을 인용하기(DOI(등록된 아카이브): 10.5281/zenodo.21635360)
아래 DOI는 이전에 등록된 아카이브를 가리키며 현재 본문과 다를 수 있습니다. 현재 본문을 참조할 때는 이 페이지의 URL을 사용하세요.
Go Komura (2026). 「업무 앱의 날짜·시각과 타임존 ── DateTime의 함정부터 UTC 저장 원칙, 테스트 설계까지」. 합동회사 코무라소프트. https://comcomponent.com/ko/blog/business-app-datetime-timezone-guide/
- DOI(등록된 아카이브)
- 10.5281/zenodo.21635360
- DOI(마지막 등록 버전)
- 10.5281/zenodo.21635361
「서버를 클라우드 VM으로 옮겼더니 장표의 시각이 전부 9시간 어긋났다」「해외 거점에 배포한 단말만 일보의 날짜가 전날이 된다」「심야 집계 배치가 어느 날만 두 번 돈 흔적이 있다」. 날짜·시각과 타임존에 관한 상담은, 이런 「환경이 바뀐 순간에」라는 형태로 들어옵니다. 코드는 한 줄도 바꾸지 않았는데 말입니다.
「우리는 일본 국내 전용 앱이니까 타임존은 상관없다」고 생각하기 쉽지만, 실제 상담의 상당수는 바로 그 국내 전용 앱에서 일어납니다. 클라우드 VM이나 컨테이너는 UTC 설정으로 지급되는 경우가 많고, 해외 라이브러리나 SaaS의 Web API는 UTC나 오프셋이 붙은 타임스탬프를 돌려줍니다. 앱 자신은 일본 시간만으로 산다고 여겨도, 경계 건너편은 이미 UTC의 세계입니다. 「이 시각은 어디 기준인가」를 밝히지 않은 채 값을 주고받으면, 서버 이전이나 클라우드화 당일에 9시간 어긋남으로 한꺼번에 드러납니다.
이 기사에서는 .NET 업무 앱을 전제로, DateTime의 Kind와 암묵 변환의 함정, DateTimeOffset과의 구분, 「저장과 통신은 UTC 또는 오프셋 포함, 표시만 로컬」이라는 원칙, TimeZoneInfo와 서머타임, DB와의 경계, 그리고 TimeProvider를 이용한 테스트 설계까지 한 바퀴 정리합니다. 설계 리뷰에서 매번 확인하는 항목을 체크리스트로 정리하는 데까지 갑니다.
이 기사의 대상 독자와 읽는 순서
대상 독자는 .NET(C#)으로 업무 앱을 만들거나 유지보수하는 개발자입니다. 「우리는 국내 전용이니까 타임존은 상관없다」고 생각하는 분이야말로 대상 독자입니다. 코드 예는 C# / .NET 8로 썼지만, .NET Framework에서도 쓸 수 있는 범위는 그때그때 따로 적어 둡니다.
긴 기사여서, 목적별 읽는 순서를 먼저 둡니다.
| 목적 | 읽을 곳 |
|---|---|
| 국내 전용·소규모에서 최소한만 잡고 싶다 | 1장(결론) → 2장(Kind의 함정) → 3장(UTC 저장 원칙) → 6장(DB와의 경계). 이 네 가지로 사고의 대부분은 막을 수 있습니다 |
| 지금 일어난 어긋남의 원인을 가려내고 싶다 | 2장, 특히 2.1절의 재현 절차 → 8장의 「리뷰 시 grep으로 잡는 코드」 |
| 해외 거점·해외 SaaS 연동이 있다 | 위에 더해 4장(타임존 ID)과 5장(서머타임) |
| 연말 넘김이나 월말 버그를 테스트로 재현하고 싶다 | 7.2절(TimeProvider) |
| 신규 설계의 리뷰 관점만 필요하다 | 8장의 체크리스트 |
먼저 잡아 둘 용어
본문에 약어 그대로 나오는 용어를 먼저 정리합니다.
| 용어 | 의미 |
|---|---|
| UTC (Coordinated Universal Time) | 협정 세계시. 세계 공통의 기준 시각이며, 일본 시간(JST)은 UTC+9입니다 |
| ISO 8601 | 날짜·시각의 문자열 표기를 정한 국제 규격. 2026-07-03T13:30:00+09:00 같은 형식입니다 |
| IANA tz database | 세계 타임존 정의와 과거 개정 이력을 모은 데이터베이스. IANA (Internet Assigned Numbers Authority)가 관리하며, Asia/Tokyo 같은 ID를 정합니다(4.1절) |
| ICU (International Components for Unicode) | 국제화 처리를 맡는 대표적인 라이브러리. .NET 5 이후는 컬처나 타임존 해석을 여기에 맡깁니다(4.1절)1 |
| NLS (National Language Support) | ICU 이전부터 Windows가 가진 국제화 API. .NET을 NLS 모드로 돌릴 수도 있지만, 그 경우 IANA ID는 해석할 수 없습니다1 |
| DST (Daylight Saving Time) | 서머타임. 일본에는 현재 없지만, 해외 거점이나 해외 SaaS 연동에서는 밟게 됩니다(5장) |
| Kind | DateTime이 가진 「어디 기준인가」라는 속성. Utc / Local / Unspecified의 세 값입니다(2장) |
1. 먼저 결론
한 줄로 말하면, 「취득·저장·통신은 UTC(또는 오프셋 포함), 로컬 시각으로 만드는 것은 화면에 내기 직전 한 번만」입니다. 아래는 그 내역과, 어겼을 때 무엇이 일어나는지입니다.
- 날짜·시각 사고의 근본 원인은 거의 하나, 「이 시각은 어디 기준인가」라는 정보를 갖지 않은 값이 경계(DB·API·파일)를 넘는 것입니다. 코드가 아니라 환경이 바뀐 날에 드러납니다.
DateTime은 Kind(Utc / Local / Unspecified)라는 속성을 가지며, 기본은 Unspecified입니다.ToLocalTime은 Unspecified를 UTC로 간주하고,ToUniversalTime은 로컬로 간주하는 비대칭 암묵 해석이 있으며, 이것이 「9시간 어긋남」의 전형적인 발생원입니다. 2- 신규 코드의 기본은
DateTimeOffset으로 둡니다. 공식 가이드에도 「애플리케이션 개발의 기본 날짜·시각 형으로 검토하라」고 명시되어 있습니다. 3 - 원칙은 「저장과 통신은 UTC 또는 오프셋 포함, 표시만 로컬」. 문자열로 만들 때는 ISO 8601을 따르는 라운드트립 서식 “o”로 쓰고,
DateTimeStyles.RoundtripKind로 다시 읽습니다. 4 - 타임존 변환은
TimeZoneInfo로 합니다. .NET 6 이후는 IANA ID(Asia/Tokyo)와 Windows ID(Tokyo Standard Time)를 둘 다 쓸 수 있고, 상호 변환 API도 있습니다. 5 다만 Windows에서의 IANA 해석은 ICU 의존이며, 오래된 Windows Server나 invariant globalization 구성에서는 실패합니다(4장). 1 - 일본에 서머타임이 없어도, 해외 거점 단말·해외 SaaS 연동·UTC 설정 서버를 거치는 순간에 DST의 「존재하지 않는 시각·모호한 시각」을 밟습니다. 6
DateTime.Now를 직접 쓰지 말고,TimeProvider(.NET 8 표준. 구환경은 Microsoft.Bcl.TimeProvider)를 주입해, 시각을 테스트에서 자유롭게 움직일 수 있는 설계로 합니다. 7
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 21건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
2. DateTime의 Kind ── 세 값의 의미와 암묵 변환 사고
DateTime은 날짜·시각 값(Ticks)에 더해 Kind라는 속성을 하나만 가집니다. 값은 세 가지이며, 기본은 Unspecified입니다. 2
| Kind | 의미 | 주요 발생원 |
|---|---|---|
| Utc | UTC 기준 시각 | DateTime.UtcNow, ToUniversalTime()의 결과 |
| Local | 실행 머신의 로컬 타임존 기준 | DateTime.Now, ToLocalTime()의 결과 |
| Unspecified | 어디 기준인지 불명(기본) | new DateTime(...), DateTime.Parse(많은 경우), DB에서 읽기 |
중요한 점은, 평범하게 코드를 쓰면 Unspecified투성이가 된다는 것입니다. 생성자로 만든 값도, 문자열에서 파싱한 값도, DB에서 읽은 값도, 기본으로는 「어디 기준인지 불명」입니다. 그 자체는 문제가 아닙니다. 문제는 이 Unspecified 값에 대해 변환 메서드가 말없이 기준을 가정한다는 것입니다. 2
| 호출 | 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 로컬에서 「9시간 어긋남」을 재현한다 ── 5분의 미니 실험
「개발 머신에서는 재현되지 않습니다」를 졸업하는 가장 빠른 방법은, 개발 머신의 타임존을 바꿔 같은 코드를 돌려 보는 것입니다. 5분이면 끝납니다.
먼저 다음 콘솔 앱을 준비합니다.
// .NET 8 / C#. DB에서 읽은 「일본 시간 9:00」을 흉내 낸 Unspecified 값
var fromDb = new DateTime(2026, 7, 3, 9, 0, 0);
Console.WriteLine($"OS 타임존 : {TimeZoneInfo.Local.Id}");
Console.WriteLine($"Kind : {fromDb.Kind}");
Console.WriteLine($"ToLocalTime() : {fromDb.ToLocalTime():HH:mm}");
Console.WriteLine($"ToUniversalTime() : {fromDb.ToUniversalTime():HH:mm}");
절차는 다음과 같습니다. 타임존 전환에는 Windows 표준 tzutil을 씁니다(변경이 거부되면 명령 프롬프트를 관리자로 실행하세요).
tzutil /g로 현재 타임존 ID를 적어 둡니다(나중에 되돌리기 위해). 일본 설정의 개발 머신이면Tokyo Standard Time이 표시됩니다.- 그대로 실행해 결과를 적어 둡니다.
tzutil /s "UTC"로 타임존을 UTC로 바꾸고, 앱을 다시 시작한 뒤 같은 결과를 봅니다.TimeZoneInfo.Local은 프로세스 안에서 캐시되므로, 계속 실행 중인 상태로는 바뀌지 않습니다.tzutil /s "Tokyo Standard Time"으로 원래대로 되돌립니다.
같은 코드·같은 입력값인데, 결과는 이렇게 바뀝니다.
| 실행 머신의 타임존 | ToLocalTime() |
ToUniversalTime() |
|---|---|---|
Tokyo Standard Time(UTC+9) |
18:00(UTC로 간주되어 +9시간) | 00:00(로컬로 간주되어 -9시간) |
UTC |
09:00(변화 없음) | 09:00(변화 없음) |
이것이 「개발 머신에서는 재현되지 않는다」의 정체입니다. Unspecified 값에 변환을 적용하는 코드는 실행 머신 설정이라는 외부 상태로 결과가 바뀌므로, JST 개발 머신에서는 요란하게 9시간 어긋나고, UTC 클라우드 VM에서는 전혀 어긋나지 않습니다. 반대로 로컬 시각으로 저장해 온 데이터를 UTC 서버로 옮기면, 그때까지 서로 상쇄하던 어긋남이 겉으로 나옵니다.
이 실험을 FakeTimeProvider로 대신할 수는 없습니다. FakeTimeProvider.SetLocalTimeZone이 바꾸는 것은 해당 TimeProvider의 LocalTimeZone 프로퍼티뿐이며, 프로세스 전체의 TimeZoneInfo.Local은 바뀌지 않습니다. 7 위의 코드가 호출하는 ToLocalTime() / ToUniversalTime()이 보는 것은 후자이므로, 페이크를 넣어도 CI 머신 설정의 결과가 그대로 나옵니다.
뒤집으면, 이것이 업무 코드에서 ToLocalTime()을 걷어내야 하는 이유이기도 합니다. 프로세스 전체 설정을 읽는 메서드는 테스트에서 바꿀 수 없습니다. 타임존 취급을 회귀 테스트에 올리고 싶다면, 변환 대상을 인자로 받는 형태로 합니다.
// .NET 8 / C#. 변환 대상을 밖에서 넘긴다. TimeProvider를 주입하고 있다면
// provider.LocalTimeZone을 넘기면 된다
static DateTime JstWallClockToUtc(DateTime wallClock, TimeZoneInfo zone)
=> TimeZoneInfo.ConvertTimeToUtc(
DateTime.SpecifyKind(wallClock, DateTimeKind.Unspecified), zone);
// 테스트: 실행 머신의 타임존 설정과 관계없이 같은 결과가 된다
var tokyo = TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time");
Assert.Equal(
new DateTime(2026, 7, 3, 0, 0, 0, DateTimeKind.Utc), // 9:00 JST = 0:00 UTC
JstWallClockToUtc(new DateTime(2026, 7, 3, 9, 0, 0), tokyo));
7.2절의 FakeTimeProvider가 효과가 있는 것은, 대상 코드가 provider.GetLocalNow()나 provider.LocalTimeZone을 거치는 경우입니다. DateTime.Now나 ToLocalTime()을 직접 호출하는 코드에는 닿지 않습니다. 머신 설정 자체가 결과를 바꾼다는 것을 확인하고 싶다면, 위의 tzutil 절차가 결국 가장 확실합니다.
2.2 DateTimeOffset과의 차이와 구분
DateTimeOffset은 날짜·시각에 더해 UTC로부터의 오프셋(+09:00 등)을 항상 가지므로, 값만으로 세계의 어느 순간인지가 유일하게 정해집니다. 로그 기록, 거래 시각, 시스템 이벤트 기록처럼 「시점의 기록」 용도에서는, 공식 가이드가 DateTimeOffset을 기본 날짜·시각 형으로 검토하라고 명시합니다. 3 Kind의 암묵 해석에 기댈 여지가 애초에 없으므로, 이 기사에서 다루는 사고의 대부분이 구조적으로 일어나지 않게 됩니다.
다만 만능은 아닙니다. DateTimeOffset이 가진 것은 오프셋이지 타임존이 아니다는 점에 주의하세요. +09:00이 일본인지 한국인지는 알 수 없고, 서머타임 조정 규칙도 갖지 않습니다. 3 「그 땅의 벽시계가 움직이는 방식」을 재현하고 싶다면, 뒤에 나오는 TimeZoneInfo를 함께 써야 합니다. 구분을 표로 둡니다.
| 형 | 가진 정보 | 맞는 용도 | 보충 |
|---|---|---|---|
DateTimeOffset |
날짜·시각+UTC 오프셋 | 발생 시각 기록, 로그, API 경계 | 신규 코드의 기본3 |
DateTime(Kind=Utc 운용) |
날짜·시각만 | 내부 계산, 기존 자산과의 호환 | Kind 관리는 전부 스스로 |
DateOnly / TimeOnly |
날짜만/시각만 | 업무 날짜, 영업 시간, 마감 시각 | .NET Framework에서는 쓸 수 없음3 |
TimeSpan |
시간 간격 | 경과 시간, 두 시점의 차 | |
TimeZoneInfo |
타임존 정의(조정 규칙 포함) | 변환, 서머타임 판정 | 4장 |
기존 DateTime 자산을 전부 DateTimeOffset으로 바꾸는 것은 현실적이지 않은 경우가 많아서, 당사가 수정 작업에서 자주 쓰는 절충안은 「내부와 저장은 Kind=Utc의 DateTime으로 통일하고, 경계(API·직렬화)는 DateTimeOffset 또는 “o” 서식 문자열」입니다. 어느 쪽이든 다음 장의 원칙이 토대가 됩니다.
3. 원칙 ── 저장과 통신은 UTC 또는 오프셋 포함, 표시만 로컬
날짜·시각 처리의 설계 원칙은 세 줄로 모입니다.
- 발생 시각은
DateTime.UtcNow또는DateTimeOffset.UtcNow로 취득하고, UTC(또는 오프셋 포함) 그대로 들고 다닌다 - 경계(DB·API·파일·레지스트리)를 넘을 때는 서식과 기준을 사양으로 명시한다
- 로컬 시각으로의 변환은 화면·장표에 내기 직전 한 번만
그림으로 보면, 변환이 들어가도 되는 곳은 한 곳뿐입니다.
flowchart TD
IN["발생·취득<br/>DateTimeOffset.UtcNow<br/>TimeProvider.GetUtcNow()"]
APP["앱 내부<br/>UTC 그대로 들고 다닌다<br/>비교·정렬도 UTC로 한다"]
DB["DB<br/>datetime2에 UTC로 저장<br/>열 이름은 CreatedAtUtc"]
WIRE["API·파일·로그<br/>ISO 8601의 라운드트립 서식 o<br/>2026-07-03T04:30:00.0000000Z"]
VIEW["표시 직전 변환 한 번만<br/>TimeZoneInfo.ConvertTimeFromUtc<br/>변환 대상은 이용자나 거점 설정으로 정한다"]
USER["화면·장표<br/>그 땅의 벽시계 시각"]
NG["로컬 시각 그대로 저장·송신<br/>값의 의미가 OS 설정에 의존한다<br/>옮긴 날에 9시간 어긋난다"]
IN --> APP
APP --> DB
APP --> WIRE
DB --> APP
WIRE --> APP
APP --> VIEW --> USER
APP -.->|"해서는 안 된다"| NG
로컬 시각으로 저장하는 설계는 「값의 의미」를 서버 OS 설정이라는 외부 상태에 의존시키는 설계입니다. 온프레미스의 일본 설정 서버에서 도는 동안은 문제가 보이지 않지만, 클라우드 VM으로의 이전, 해외 리전의 DR 구성, 개발 환경과 운영의 설정 차──그중 하나라도 있으면 의미가 바뀝니다. UTC로 저장하면 값의 의미는 어느 환경에서나 같습니다. 표시용 변환은 이용자 쪽 설정(또는 사용자 마스터의 거점 타임존)으로 하므로, 같은 데이터를 도쿄와 베를린에서 봐도 각각 올바른 벽시계 시각이 됩니다.
3.1 경계의 문자열 표현은 ISO 8601 / “o” 서식
문자열로 경계를 넘을 때(JSON, CSV, 로그, 설정 파일)는 ISO 8601을 따르는 라운드트립 서식 “o”를 씁니다. “o”는 DateTime의 Kind, DateTimeOffset의 오프셋을 문자열에 남기고, DateTimeStyles.RoundtripKind를 지정해 파싱하면 원래 값으로 되돌릴 수 있습니다. 4
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를 해당 타임존으로 변환한 뒤 날짜를 자르기만 하면 됩니다. 사양에 없다면, 구현자의 머신 설정이 사양이 되어 있다는 뜻입니다.
테이블 설계로 떨어뜨리면, 둘은 열 형부터 별개입니다. SQL Server를 예로 들면 다음과 같습니다(형 상세는 6.1절).
| 용도 | 열 예(SQL Server) | .NET 쪽 형 | 의도 |
|---|---|---|---|
| 타임스탬프(발생한 시점) | created_at_utc datetime2(3) NOT NULL |
DateTime(Kind=Utc) |
UTC로 저장하고, 열 이름에 기준을 새긴다(6.3절) |
| 타임스탬프(현지 시각 재현이 요건) | signed_at datetimeoffset(3) NOT NULL |
DateTimeOffset |
입력 시점의 오프셋까지 남긴다 |
| 업무 날짜(일보·마감의 레이블) | report_date date NOT NULL |
DateOnly |
시각도 타임존도 붙이지 않는다 |
| 날짜를 자르는 기준 타임존 | site_time_zone_id varchar(64) NOT NULL |
string(TimeZoneInfo로 해석) |
거점 마스터에 IANA ID로 둔다(4.1절) |
| 마감 시각·영업 시간 | closing_time time(0) NOT NULL |
TimeOnly |
「매일 17:30」에 날짜를 붙이지 않는다 |
요점은 업무 날짜 열에 시각을 가진 형을 쓰지 않는 것입니다. report_date를 datetime2로 두고 00:00을 넣는 순간에, 이 절 서두에 적은 전날 어긋남 경로가 되살아납니다. 반대로 date 형이면 UTC 변환을 통과시킬 여지 자체가 없습니다.
4. 타임존 변환 ── TimeZoneInfo와 ID 체계
타임존 변환은 TimeZoneInfo의 ConvertTimeFromUtc / ConvertTimeToUtc / ConvertTime으로 합니다. 주의할 점은, 이 API들이 DateTime의 Kind와 변환 원본 타임존의 정합을 검사한다는 것입니다. 예를 들어 Kind=Utc 값을 「변환 원본은 도쿄」로 넘기면 ArgumentException이 됩니다. 8 즉 Kind 관리가 느슨한 코드는 타임존 변환 API조차 제대로 호출하지 못합니다. 2장의 이야기가 여기에 바로 이어집니다.
4.1 Windows의 타임존 ID와 IANA ID
타임존을 지정하는 ID에는 두 체계가 있습니다.
| 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도 추가되어 있습니다. 5
// .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 모드나 globalization invariant 모드(InvariantGlobalization=true)로 도는 앱에서는 IANA ID를 해석할 수 없고, TryConvertIanaIdToWindowsId도 실패합니다. 1 또한 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에 떨어뜨려 재시도하는 폴백을 기동 시 검증에 넣는다──이 세 가지를 세트로 하세요.
5. 서머타임(DST) ── 일본 국내 전용이어도 밟는 장면
일본에는 현재 서머타임이 없어서 「DST는 우리와 상관없다」고 생각하기 쉽습니다. 그러나 다음 중 하나에 해당하면 반드시 밟습니다.
- 해외 거점·해외 출장자 단말에서 앱이 돈다(단말의 로컬 타임존이 DST를 가진다)
- 해외 SaaS / Web API와 연동해, 현지 시각 기준 타임스탬프나 스케줄을 받는다
- 해외 리전의 서버·VM에서 집계나 배치가 돈다
- 해외 거점의 현지 시각으로 마감하는 집계(「각 거점 0시 마감」 등)가 있다
DST가 있는 타임존에서는 전환일에 두 종류의 이상한 시각이 생깁니다. 존재하지 않는 시각(봄, 시계가 앞당겨지는 순간에 건너뛰는 구간. 독일이면 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 시작일에는 그 시각이 존재하지 않고, 종료일에는 두 번 존재합니다. 스케줄러 구현에 따라 「건너뛴다」「두 번 기동한다」「1시간 어긋나 기동한다」로 동작이 갈리므로, 「매일 한 번만 돈다」는 전제로 쓴 집계 처리가 이중 집계 또는 결측을 일으킵니다. 대책은 세 가지의 조합입니다.
- 스케줄 기준을 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과 같습니다. 타임존까지 필요하면 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 Time 서비스(w32time)입니다. w32time은 NTP로 네트워크상의 시각 소스와 동기하고, Active Directory 환경에서는 도메인 계층을 따라 동기합니다. Kerberos 인증을 비롯해, 시각 어긋남에 민감한 구조의 토대입니다. 12
앱 설계에 대한 시사점은 두 가지입니다. 첫째, 클라이언트 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 섣달그믐 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상의 일본 머신에서 재현할 수 있습니다. 다만 효과가 있는 것은 대상 코드가 provider.GetLocalNow()나 provider.LocalTimeZone을 거치는 경우뿐입니다. 바뀌는 것은 그 프로바이더가 돌려주는 타임존이지 프로세스 전체의 TimeZoneInfo.Local이 아니므로, 7 DateTime.Now나 ToLocalTime()을 직접 호출하는 코드에는 닿지 않습니다(2.1절). 「주입한 시계를 거치지 않는 코드는 테스트에서 바꿀 수 없다」는 당연한 귀결입니다. .NET Framework의 오래된 건에서 NuGet 추가조차 어렵다면, DateTimeOffset UtcNow { get; }만 있는 자체 IClock 인터페이스여도 효과는 같습니다. 중요한 것은 추상의 화려함이 아니라, 「현재 시각」을 주입 가능한 의존으로 다루는 것입니다.
테스트 케이스로 최소한 넣어 두고 싶은 시각은, 경험상 이 다섯입니다: 연말연시(연말 넘김), 월말(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 지정 없음) |
실행 환경의 컬처·TZ 의존 | 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, .NET globalization and ICU. Windows에서 IANA 타임존 ID 해석과 상호 변환 API가 ICU에 의존하는 점, NLS 모드·globalization invariant 모드에서는 쓸 수 없는 점, ICU가 없는 OS에서는 앱 로컬 ICU로 대응할 수 있는 점에 대해. ↩ ↩2 ↩3 ↩4
-
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, 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 패키지로 제공되는 점에 대해. TimeProvider.LocalTimeZone은 「해당
TimeProvider의 시간 파악 방식에 따른 로컬 타임존」을 돌려주는 프로퍼티이며, 기본 구현이TimeZoneInfo.Local을 돌려준다고 정의되어 있습니다. 즉FakeTimeProvider로 바꿀 수 있는 것은 그 프로바이더를 거친 변환뿐이며, 프로세스 전체의TimeZoneInfo.Local(DateTime.ToLocalTime()등이 참조하는 값)은 바뀌지 않습니다. ↩ ↩2 ↩3 ↩4 -
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의 원시 형이 네 가지뿐이며, Microsoft.Data.Sqlite가 DateTime / DateTimeOffset을 TEXT로 저장하는 점에 대해. ↩
-
Microsoft Learn, Windows Time Service (W32Time). Windows Time 서비스가 NTP로 네트워크상 컴퓨터의 시각을 동기하는 점, Active Directory 도메인에서의 동기 계층, Kerberos 인증이 시각 동기에 의존하는 점에 대해. ↩
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
WPF의 고DPI 대응 ── 「DPI에 강해야 하는데」 흐려지고 번지는 원인과 대처
WPF는 System DPI Aware이지만, DPI가 다른 모니터로 옮기면 전체가 번지고 비트맵은 흐려집니다. 원인 분리, Per-Monitor DPI 대응, UseLayoutRounding 등 정석 대처, WindowsFormsHost 혼재의...
WinForms의 고DPI 대응 ── 4K 모니터에서 흐려지거나 레이아웃이 깨지는 원인과 현실적인 대처
4K 모니터에서 WinForms 앱이 흐려지거나 레이아웃이 깨지는 원인을 DPI 가상화와 DPI 인식 모드(System Aware / Per-Monitor V2)로 정리합니다. .NET과 .NET Framework의 설정 방법, AutoScale...
C#에서 SQLite를 업무 앱에 쓰기 ── WAL 모드·배타 제어·손상 대책·EF Core와의 구분
Microsoft.Data.Sqlite로 SQLite를 업무 앱에 넣는 실무 지식을 정리합니다. WAL 모드, SQLITE_BUSY와 쓰기 경로 일원화, 타입 매핑의 함정, VACUUM INTO 백업, EF Core와의 구분까지 설명합니다.
Windows서비스 만드는 법과 운영 ── 작업 스케줄러와의 구분부터 BackgroundService의 서비스화까지
상주 처리를 Windows서비스로 둘지, 작업 스케줄러로 충분한지. 판단표와 .NET Worker Service로 서비스를 만드는 방법, 실행 계정·복구 옵션·안전한 중지 처리까지 실무 관점에서 정리합니다.
Windows 프린터 드라이버 제공 종료 ── 업무 앱의 장표·라벨 인쇄는 어떻게 대비할 것인가
Microsoft는 v3/v4 프린터 드라이버의 제공 종료를 단계적으로 진행하고 있으며, 2026년 7월부터는 IPP 클래스 드라이버가 우선됩니다. Windows protected print mode에서 무엇이 사라지는지, 업무 앱의 장표·라벨 ...
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
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 전환일, 자정 전후 다섯 가지입니다.