본문으로 건너뛰기

시트의 datetime과 시간대

문서 목록으로

상태: 구현 완료 (2026-08-21)

시트에 적힌 2022-01-24 10:30:00어느 시간대의 시각인지 아무 데도 적혀 있지 않습니다. 값을 읽는 쪽마다 다르게 짐작하므로, 같은 셀이 내보내는 곳에 따라 다른 순간을 가리킵니다. recipe에 시간대를 적는 수단을 두고, 데이터로 저장되는 값은 언제나 UTC로 확정합니다.


1. 문제

1.1 값에 시간대가 없습니다

읽기는 CookingContext.cs의 한 줄입니다.

return DateTime.Parse(rawValue!, CultureInfo.InvariantCulture);

결과의 KindUnspecified입니다 — 시간대를 모르는 시각입니다. 그 값이 그대로 .NET 틱이 되어 나가고, 받는 쪽은 각자 짐작합니다.

내보내는 곳같은 값을 무엇으로 보는가
바이너리 · JSON시간대 없는 시각. 오프셋을 적지 않습니다
PostgreSQL timestamp시간대 없는 시각
MySQL DATETIME(6)시간대 없는 시각
MongoDB BsonDateTimeUTC로 간주합니다
생성 코드C#·C++·Unreal은 네이티브 시각 타입, 나머지 언어는 틱 정수

Mongo만 UTC로 읽는 것이 아니라, 셋 중 어느 것도 틀리지 않았습니다. 값이 아무것도 말해 주지 않으니 각자 정할 수밖에 없습니다.

1.2 오프셋을 적은 셀은 실행 기계에 따라 값이 달라집니다

DateTimeStyles.None으로 파싱하기 때문입니다. 이 저장소에서 측정한 결과입니다(KST 기계).

셀에 적힌 것NoneAdjustToUniversal
2022-01-24 10:30:0010:30 · Unspecified10:30 · Unspecified
2022-01-24T10:30:00Z19:30 · Local10:30 · Utc
2022-01-24T10:30:00+09:0010:30 · Local01:30 · Utc

가운데 열이 지금 동작입니다. Z를 적은 셀이 변환을 도는 기계의 시간대만큼 밀립니다 — 같은 시트가 개발자 PC와 CI에서 다른 값이 됩니다.

2. 결정

데이터 레벨은 무조건 UTC입니다. 저장된 datetime은 UTC의 한 순간이고, 이것은 설정으로 바뀌지 않습니다. recipe의 시간대 설정이 정하는 것은 시트에 적힌 시각을 어느 시간대의 것으로 읽을지, 하나뿐입니다.

{
"TimeZone": "Asia/Seoul", // recipe 전체
"Sources": {
"Xlsx": [
{ "Path": "sheets", "TimeZone": "+09:00" } // 이 항목만 다르게
]
}
}

2.1 적는 형식 — 이름이든 오프셋이든

IANA 이름을 강제하지 않습니다. 세 가지를 모두 받습니다.

형식비고
IANA 이름Asia/Seoul · America/New_York기계에 tzdata가 있어야 합니다
Windows 이름Korea Standard Time · UTC.NET이 양쪽을 다 조회합니다
고정 오프셋+09:00 · -05:30 · +0900 · +09 · ZDST가 없는 고정값입니다

고정 오프셋은 이름을 몰라도, tzdata가 없어도 됩니다. 대신 서머타임이 적용되지 않으므로 — 그것이 고정 오프셋의 뜻입니다 — DST를 쓰는 지역의 과거 데이터에는 이름을 쓰는 편이 맞습니다.

허용 범위는 -14:00 ~ +14:00이고 분 단위여야 합니다. 조회도 실패하고 오프셋으로도 읽히지 않는 값은 시트를 한 장도 읽기 전에 오류입니다 — 항목 설정은 항목마다 한 번 읽으므로, 철자가 틀린 설정이 어느 시트에서 처음 걸리는지에 따라 보고가 달라지지 않습니다.

2.2 셀에 적힌 것과 설정이 만나는 표

TimeZone 미지정TimeZone: "Asia/Seoul"
2022-01-24 10:30:0010:30 (UTC로 간주)01:30 (KST 10:30을 UTC로)
2022-01-24T10:30:00Z10:3010:30 — 셀이 우선
2022-01-24T10:30:00+09:0001:3001:30 — 셀이 우선
엑셀 날짜 셀서식이 그린 시각과 같습니다같습니다

두 가지가 결정입니다.

  • 미지정은 「UTC로 적혀 있다」는 뜻입니다. 값을 건드리지 않으므로 지금 나가는 데이터와 한 바이트도 달라지지 않고, 지금 동작에 이름이 붙습니다.
  • 셀이 오프셋을 적었으면 셀이 우선합니다. 이미 순간을 가리키는 값을 다시 지역 시간으로 해석할 여지는 없습니다. 파싱을 AdjustToUniversal로 바꾸는 것이 이 줄의 구현이고, 1.2의 기계 의존성도 함께 사라집니다.

2.3 서머타임의 두 자리

이름으로 적은 시간대에만 생깁니다.

경우예 (America/New_York)처리
없는 시각 — 시계가 건너뛴 구간2022-03-13 02:30셀 위치와 함께 오류. 그 시각은 그 지역에 존재하지 않았고, 대신 고를 값을 이 도구가 정할 수는 없습니다
겹치는 시각 — 시계가 되돌아간 구간2022-11-06 01:30표준시 오프셋으로 읽고(-05:00), 그런 셀이 몇 개였는지 한 줄로 보고합니다

겹치는 시각을 오류로 하지 않는 이유는, 없는 시각과 달리 읽을 수 있는 값이 있기 때문입니다. 둘 중 하나를 고르는 규칙을 문서에 적어 두는 것이, 1년에 한 시간짜리 구간 때문에 변환을 멈추는 것보다 낫습니다.

2.4 설정의 범위

DefaultDelimiter와 같은 자리입니다 — recipe 전체 설정과 소스 항목별 설정, 항목이 우선. 한 번의 변환이 두 팀의 시트를 함께 읽고 그 두 팀이 다른 시간대에서 적었다면, 한쪽 때문에 다른 쪽이 밀리는 일은 없어야 합니다.

LayoutOptions가 아니라 정식 프로퍼티인 이유는 CLAUDE.md의 기준 그대로입니다: 어느 레이아웃을 쓰든 datetime 컬럼은 있을 수 있으므로, 레이아웃을 몰라도 찾을 수 있어야 합니다.

2.5 명령줄에서 강제하기

--time-zonerecipe 전체 설정과 항목별 설정 둘 다를 덮어씁니다.

tabbit -r recipe.json --time-zone "Asia/Seoul"
tabbit -r recipe.json --time-zone "+09:00"

기본값이 아니라 강제인 이유가 있습니다. 이 옵션이 쓰이는 상황은 recipe가 시간대를 잘못 적고 있는 실행입니다 — 다른 사무실의 시계로 적힌 것이 뒤늦게 드러난 시트, 설정이 생기기 전의 자료를 다시 변환하는 경우입니다. 기본값이라면 바로 그 잘못 적힌 항목별 설정에 밀립니다.

적용은 임포트가 시작되기 전에 recipe 위에 도장을 찍는 방식입니다. 시간대를 읽는 자리가 두 곳(전체·항목)이므로, 세 번째 답을 그 둘 옆으로 들고 다니면 읽는 쪽마다 「셋 중 무엇이 이겼나」를 알아야 합니다. 한곳에서 한 번 정하면 그 아래는 늘 읽던 것을 읽습니다.

적용한 값은 로그에 한 줄로 남습니다. 모든 날짜를 옮기는 플래그를 나중에 되짚으려면 명령줄을 찾아야 하는데, 그것이 산출물에 남지 않는 유일한 것입니다.

3. 하지 않는 것

이유
타입 크기 변경 (초 단위·32비트)따로 검토합니다. 이번 변경은 저장 폭을 건드리지 않고 .NET 틱 i64를 그대로 씁니다
값에 오프셋을 함께 저장와이어가 8바이트 정수 하나이고, UTC로 확정하면 오프셋을 들고 다닐 이유가 없습니다
출력 쪽 시간대 변환읽는 쪽의 표시 문제입니다. 데이터는 UTC로 두고, 지역 시간은 소비자가 만듭니다
timespan기간이지 시각이 아니므로 시간대와 무관합니다
구글 시트의 properties.timeZone 자동 적용Spreadsheets.Get 응답에 이미 들어 있어 추가 호출 없이 기본값으로 쓸 수 있습니다. 다만 시트가 신고한 값이 조용히 데이터를 옮기는 것이므로, recipe에 적은 것만 적용하고 이쪽은 나중에 따로 봅니다