HotXLS는 Excel 문서 속성 타임스탬프를 파일 안에는 UTC로 저장하고 API로는 현지 시간으로 노출합니다. .xls의 TXLSWorkbook.CreatedDate와 LastSavedDate, .xlsx의 TXLSXWorkbook.Created와 Modified가 그렇습니다. v2.384.48부터 두 엔진 모두 쓸 때 현지 시간을 UTC로, 읽을 때 다시 현지 시간으로 변환하며, 그 기준은 타임스탬프 날짜 자체에 적용되는 일광 절약 규칙입니다. 여기까지 두 번의 수정이 필요했고, 두 버그 모두 같은 민망한 이유로 살아남았습니다. 자동화된 왕복 테스트는 모두 통과하는데 Excel의 File > Info 창에는 엉뚱한 날이나 엉뚱한 시각이 보였습니다. Delphi에서 Excel 문서 속성 설정하기 개요를 이미 읽었다면, 이 글은 날짜가 더 이상 단순한 값이 아니게 되는 부분입니다
저장 후 다시 여는 테스트가 하루 오차를 감춘 이유는?
자기 왕복 전송은 오차를 감췄습니다. 작성기와 리더가 같은 틀린 상수를 공유하니 실수가 스스로 상쇄됐기 때문입니다. OLE 속성 세트의 날짜는 FILETIME, 즉 1601-01-01 UTC 이후 100나노초 틱 개수를 담은 64비트 값([MS-DTYP] §2.3.3)입니다. 반면 Delphi TDateTime은 1899-12-30부터의 일수를 세는데, Delphi의 Excel date serial과 1900 vs 1904 체계에서 다룬 것과 같은 serial 원점입니다. 두 epoch 사이의 간격은 109205일이고, 달력 없이도 검산할 수 있습니다. TDateTime으로 표현한 Unix epoch인 25569에 109205를 더하면 134774, FILETIME 일수로 센 Unix epoch가 됩니다. v2.384.17 이전 HotXLS 빌드는 109206을 썼으므로 모든 생성·저장 타임스탬프가 하루 늦게 기록되고 하루 이르게 읽혔습니다. 테스트 스위트는 자기가 대입한 값을 봤고, Excel은 내일을 봤습니다
const
// FILETIME epoch(1601-01-01)부터 TDateTime epoch(1899-12-30)까지의 일수
// 검산: 25569 + 109205 = 134774, FILETIME 일수로 센 Unix epoch
FileTimeDayBias = 109205;
function UtcDateTimeToFileTimeTicks(UtcStamp: TDateTime): Int64;
begin
// 먼저 밀리초 단위로 반올림한 뒤 100 ns 틱으로 스케일합니다.
// Double을 틱으로 곧바로 스케일하면 04:00이 03:59:59.9999가 됩니다
Result := Round((UtcStamp + FileTimeDayBias) * 86400000.0) * 10000;
end;
그 스케치의 반올림 주석이 같은 코드에서 나온 두 번째, 더 작은 교훈입니다. 소수부를 가진 TDateTime에 하루당 864,000,000,000틱을 곧바로 곱하면 이진 부동소수점 오차가 최하위 자리로 새어 들어가고, 정확히 04:00이던 타임스탬프가 03:59:59.9999로 돌아왔습니다. HotXLS v2.384.48은 스케일하기 전에 밀리초 단위로 반올림하므로 정각 값이 그대로 살아남습니다. 같은 릴리스가 이 스케치가 일부러 뺀 시간대 단계를 추가했는데, 여기서 입력은 이미 UTC이기 때문입니다
날짜는 어떤 SummaryInformation 속성 ID에 들어 있을까요?
[MS-OLEPS]가 정의하는 \005SummaryInformation 속성 세트에서 생성 시각은 속성 ID $0C(PIDSI_CREATE_DTM) 아래에, 마지막 저장 시각은 $0D(PIDSI_LASTSAVE_DTM) 아래에, 총 편집 시간은 $0A(PIDSI_EDITTIME) 아래에 있습니다. 오래된 HotXLS 빌드는 마지막 저장 타임스탬프를 $0E, 즉 PIDSI_PAGECOUNT에 썼으므로 Excel에는 저장 날짜가 없고 타임스탬프를 담은 페이지 수 속성이 생겼습니다. v2.384.17부터 리더도 그 레거시 레이아웃을 존중합니다. $0D가 없고 $0E가 VT_FILETIME을 담고 있으면 그 값을 마지막 저장 시각으로 받아들입니다. 이제 모든 PROPVARIANT 읽기는 PropVariantClear로 해제되기도 하는데, malformed 파일이 이 ID 어디에든 문자열을 얹어 놓을 수 있기 때문입니다. 그 스트림을 직접 눈으로 보고 싶다면 COM IStorage 없이 Delphi에서 OLE2 컴파운드 파일 읽기 워크스루가 도달 방법을 보여 줍니다
PIDSI_EDITTIME은 함정 속의 함정입니다. 이 속성은 VT_FILETIME으로 타이핑돼 있지만 기간을 담고 있고, epoch가 더해지지 않은 경과 100 ns 틱의 raw 값입니다. 옛 작성기는 이를 날짜처럼 다뤄 EditTimeMinutes를 1440으로 나눈 결과를 epoch 변환으로 밀어 넣었으니, 125분의 편집이 대략 299년으로 파일에 기록됐습니다. 현재 리더는 크기로 이 인코딩을 알아봅니다. 실제 편집 세션이 세 세기를 넘지는 않으므로 109206일 이상의 값에서는 레거시 오프셋을 빼고 EditTimeMinutes를 채웁니다
var
Book: TXLSWorkbook;
begin
Book := TXLSWorkbook.Create;
try
Book.Title := 'Q3 settlement';
// API 값은 현지 시간입니다. 파일에는 UTC FILETIME을 저장합니다
Book.CreatedDate := EncodeDate(2026, 1, 15) + EncodeTime(9, 30, 0, 0);
Book.LastSavedDate := Now; // HotXLS는 대입한 값을 씁니다. Now를 찍어 넣지 않습니다
Book.RevisionNumber := 7;
Book.EditTimeMinutes := 125; // 기간 값, raw 틱으로 저장
if Book.SaveAs('settlement.xls') <> 1 then
raise Exception.Create('Save failed');
finally
Book.Free;
end;
end;
XLSX 날짜가 정확히 시간대 오프셋만큼 어긋난 이유는?
XLSX 날짜가 시간대 오프셋만큼 어긋난 이유는 docProps/core.xml의 dcterms:created와 dcterms:modified가 Z가 붙은 W3CDTF 값인데, 이는 ECMA-376 Part 2의 core properties 모델에서 UTC를 뜻하고, HotXLS는 그 Z를 붙인 채 현지 시간을 찍곤 했기 때문입니다. UTC+8 머신에서 09:30에 만든 워크북은 09:30:00Z를 실었고, 같은 머신의 Excel은 이를 17:30으로 변환했습니다. 클래식 엔진도 FILETIME 값에서 똑같은 결함을 지녔고, TXLSXWorkbook.CustomProperties.AddDate(vt:filetime으로 기록)로 추가한 커스텀 날짜 속성도 같은 결함을 공유했습니다. v2.384.48부터 세 경로 모두 쓰기 전에 변환하고 타임스탬프에 Z가 붙어 있으면 읽을 때 되돌리며, v2.384.59부터는 읽기 쪽이 소수 초와 명시적인 +hh:mm / -hh:mm 오프셋도 존중합니다
변환 자체는 순진한 수정이 빗나가는 자리입니다. LocalFileTimeToFileTime은 지금 막 적용 중인 오프셋을 적용하므로, 1월 타임스탬프를 7월에 변환하면 일광 절약제 시행 지역에서는 한 시간 어긋납니다. HotXLS는 대신 TzSpecificLocalTimeToSystemTime과 SystemTimeToTzSpecificLocalTime을 호출합니다. 이들은 변환 대상 날짜에서 표준시와 일광 절약시를 골라 주고, 값이 0인 미설정 값은 그대로 통과시켜 몇 시간씩 밀린 1899년 날짜로 변질되지 않습니다
var
Book: TXLSXWorkbook;
begin
Book := TXLSXWorkbook.Create;
try
if Book.Open('report-template.xlsx') <> 1 then
raise Exception.Create('Template not available');
Book.Created := EncodeDate(2026, 7, 1) + EncodeTime(9, 30, 0, 0);
Book.Modified := Now;
Book.CustomProperties.AddDate('ApprovedOn',
EncodeDate(2026, 1, 20) + EncodeTime(17, 0, 0, 0));
Book.SaveAs('report.xlsx');
// 중부 유럽 시간으로 설정된 머신에서 core.xml는 이제 다음을 담습니다
// <dcterms:created xsi:type="dcterms:W3CDTF">2026-07-01T07:30:00Z</dcterms:created>
// (7월은 UTC+2), ApprovedOn은 16:00Z로 기록됩니다 (1월은 UTC+1)
finally
Book.Free;
end;
end;
HotXLS는 타임스탬프를 읽을 때 무엇을 변환하지 않을까요?
HotXLS W3CDTF 리더는 v2.384.59부터 프로파일의 모든 zone 태그 형태를 변환하고, 여전히 건드리지 않는 유일한 경우는 시간대가 없는 시간입니다. 그 릴리스 전에는 파서가 처음 19문자를 가져가 20번째 문자가 Z일 때만 UTC에서 변환했으므로, 소수 초(01:30:00.5Z)나 명시적 오프셋(+08:00)이 붙은 타임스탬프는 조정 없이 현지 시간으로 읽혀 시간대 오프셋만큼 어긋났습니다. HotXLS 2.384.59부터 Created, Modified와 날짜 값 커스텀 속성은 임의 길이의 소수 초와 Z, +hh:mm / -hh:mm 오프셋을 파싱하고, 시점을 UTC로 변환한 뒤 현지 시간으로 바꾸며, 2026-07-01 같은 날짜만 있는 타임스탬프는 그 날짜로 읽습니다. 시간은 있지만 시간대 표식이 없는 타임스탬프, 즉 W3CDTF 프로파일이 허용하지 않고 ECMA-376 Part 2가 규칙을 두지 않는 경우는 여전히 현지 시간 그대로 읽히고, 아예 파싱에 실패한 타임스탬프는 0으로 돌아옵니다. Excel을 거친 워크북은 문제없습니다. 시간대를 떨구는 다른 생성기의 패키지는 표본 검사를 해 보세요
오래된 HotXLS 빌드가 쓴 파일이 또 하나의 정직한 경계입니다. v2.384.48 이전에 쓰인 XLSX 타임스탬프는 Z를 두른 현지 시간이었고 파일 안 어디에도 올바른 것과 구별할 단서가 없으므로, 현재 리더는 이를 시간대 오프셋만큼 밉니다. 그 빌드의 클래식 FILETIME 타임스탬프도 같은 이동을 받고, v2.384.17 이전에 쓰인 생성 날짜는 추가로 하루 늦게 읽힙니다. 옛 상수의 하루가 검출될 수 없기 때문입니다. 편집 시간 인코딩과 $0E 배치만이 인식 가능한 시그니처를 지닙니다. API 값은 읽는 머신의 현지 시간이라는 점도 기억하세요. UTC에서 돌아가는 서비스와 도쿄의 데스크톱은 같은 파일에 대해 서로 다른 CreatedDate 값을 보고하고, 둘 다 올바릅니다
문서 타임스탬프는 어떻게 테스트해야 할까요?
문서 타임스탬프는 자기 코드가 쓰지 않은 것과 비교해 테스트하세요. 이 두 버그 모두 저장 후 다시 여는 검사를 통과했습니다. 대칭적인 실수는 대칭적인 테스트에는 보이지 않기 때문입니다. Excel이 저장한 워크북과 비교하거나, 저장 후 raw 바이트와 XML 텍스트를 단언하고, UTC가 아닌 시간대로 설정된 머신에서 일광 절약 전환을 사이에 둔 날짜를 각각 대입해 스위트를 돌리세요. UTC에서 돌아가는 빌드 에이전트는 옛 코드, 즉 깨진 코드를 흐뭇하게 통과시켜 줍니다
문서 타임스탬프는 작지만 기록 시스템, 검색 인덱스와 감사 추적이 정렬 기준으로 삼는 것입니다. 하루 또는 여덟 시간 어긋난 날짜는 없는 것보다 나쁩니다. 아무도 의심하지 않기 때문입니다. HotXLS Delphi 스프레드시트 컴포넌트는 .xls와 .xlsx 양쪽의 epoch 계산, 속성 ID, UTC 변환을 처리하므로, 코드에서는 평범한 현지 TDateTime 값을 대입하고 파일 형식은 라이브러리에 맡기면 됩니다