강제 재부팅이든, 강제 종료된 프로세스든, 쓰기 도중 가득 차버린 디스크든, 저장이 절반쯤 진행되다 죽는 상황은 인플레이스 쓰기 방식을 중심으로 만들어진 형식에서는 전통적으로 한 가지를 의미해 왔다: 중단되기 전에 디스크에 도달한 바이트가 곧 되돌려받는 전부이고, 잘린 워크북은 다시 열리지 않는다는 것이다. HotXLS는 자신이 쓰는 모든 XLSX, ODS, 고전 XLS 파일에 크래시 안전 저장 경로를 사용해 이 실패 모드를 없앤다. 모든 SaveAs 호출은 완전한 새 파일을 대상 파일 옆에 만든 임시 파일에 쓴 뒤, Windows API의 단일 원자적 MoveFileExW 이름 변경으로 이를 커밋한다. 그래서 중단된 저장은 새 파일을 만들지 못할 뿐, 이미 가지고 있던 파일을 절대 손상시키지 않는다. 이 단계적 교체 방식은 고전 XLS 뒤에 있는 BIFF8 라이터와 XLSX·ODS 뒤에 있는 OOXML 라이터라는 HotXLS의 두 저장 엔진 모두에 걸쳐 균일하게 실행되며, 스프레드시트든 아니든 여러분 자신의 Delphi 코드가 직접 덮어쓰는 어떤 파일에도 빌려올 가치가 있는 패턴이다
워크북 저장이 절반쯤 진행되다 중단되면 어떻게 되는가?
직접적인 답은 라이터가 대상 파일을 어떻게 건드리는지에 전적으로 달려 있다는 것이다. 흔한 구현 방식인 대상 파일을 열고 새 콘텐츠를 그 안에 직접 스트리밍하는 방식은 아무것도 잘못되지 않는 한 문제없다. 무언가 잘못되는 순간 — 크래시, 강제 프로세스 종료, 쓰기 도중 끊기는 네트워크 공유 — 디스크 상의 파일은 라이터가 도달했던 어중간한 중간 상태 그대로 남는다: XLSX나 ODS에서는 끝내 추가되지 못한 ZIP 중앙 디렉터리, 고전 XLS에서는 리더가 기대하는 레코드가 빠진 BIFF 스트림 같은 것이다. Excel은 이를 우아하게 복구하지 못하며, 완전한 파일을 기대하는 다른 어떤 소비자도 마찬가지다. 그래서 실무적인 결과는 어제는 멀쩡히 열리던 워크북이 오늘은 열리기를 거부하는 것이다
HotXLS는 모든 저장을 어떻게 하나의 원자적 스왑 뒤에 단계화하는가
HotXLS는 저장하는 세 가지 형식 중 어느 것에 대해서도 대상 파일을 직접 쓰기용으로 여는 법이 없다. 그 순서는 매번 동일한 모양을 취한다: 사용자가 디스크에 이미 가지고 있는 파일이 아닌 다른 어딘가에 완전한 출력물을 만들고, 그 빌드가 완전히 성공한 뒤에야 그것을 제자리로 옮긴다. 구체적으로 SaveAs는 대상 경로와 같은 폴더에 빈 임시 파일을 만들고, 전체 새 워크북을 그 임시 파일에 쓴 뒤, 그 쓰기가 오류 없이 반환된 뒤에야 단일 이름 변경으로 임시 파일을 대상 위에 커밋한다. 이 중 어느 것도 옵트인할 속성이 필요 없다: 이는 그저 SaveAs가 일반 파일 경로에 대해 매 호출마다 하는 일일 뿐이다
var
Book: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
begin
Book := TXLSXWorkbook.Create;
try
Sheet := Book.Sheets.Add('Report');
Sheet.Cells[1, 1].Value := 'Nothing special to enable here';
// If this call is interrupted, monthly-report.xlsx on disk stays
// either the old version, complete, or the new version, complete
if Book.SaveAs('monthly-report.xlsx', xlsxOpenXMLWorkbook) <> 1 then
raise Exception.Create('Save failed, see Book.LastDiagnostic');
finally
Book.Free;
end;
end;
같은 원칙은 OOXML 라이터뿐 아니라 고전 XLS 라이터에도 적용되며, 두 임시 파일은 이름 규칙마저 공유한다: 둘 다 hxl 접두사와 함께 Windows GetTempFileNameW API를 호출하므로, 정리 전에 중단된 저장은 워크북 옆에 hxl4C2A.tmp 같은 이름의 남은 파일을 남길 수 있다. 그 파일은 손상이 아니라 메커니즘이 설계대로 정확히 작동했다는 증거다: 미완성 쓰기가 거기서 멈췄을 뿐, 여러분의 실제 워크북은 애초에 쓰기용으로 열린 적조차 없다. 크래시 후 이런 파일을 보게 되면 안전하게 삭제하면 되며 조사할 것은 없다
임시 파일을 %TEMP%가 아니라 워크북 옆에 두는 이유는?
짧은 답은 MoveFileExW의 이름 변경이 원본과 대상이 같은 볼륨에 있을 때만 원자적이며, 호출자에게 아무것도 설정하라고 요구하지 않으면서 이를 보장하는 가장 확실한 방법은 임시 파일의 위치를 대상 경로 자체에서 유도하는 것이라는 점이다. HotXLS는 대상의 폴더를 직접 계산해 그 디렉터리를 GetTempFileNameW에 곧바로 넘기므로, 임시 파일은 매 저장마다 자동으로 곧 교체될 파일과 항상 같은 드라이브, 같은 볼륨에 만들어진다. 만약 라이브러리가 대신 시스템 임시 폴더에 쓰기를 단계화했다면, 다른 드라이브나 매핑된 네트워크 볼륨 상의 대상 경로는 마지막 단계를 볼륨 간 작업으로 바꿔버렸을 것이다. Windows API는 이를 아예 거부하거나, 호출자가 HotXLS가 여기서 설정하지 않는 추가 플래그로 명시적으로 옵트인한 경우 조용히 원자적이지 않은 복사 후 삭제로 격하되어, 이 메커니즘 전체가 막으려던 바로 그 중단 시간대를 다시 열어버린다
커밋 단계: MoveFileExW, 쓰기 통과, 그리고 실패 시 일어나는 일
모든 저장의 마지막 단계는 정확히 하나의 Windows API 호출인 MoveFileExW이며, 각각 별개의 역할을 하는 두 플래그를 가진다. MOVEFILE_REPLACE_EXISTING은 이미 존재하는 파일 위에 이름 변경이 착지할 수 있게 해준다. 이것이 없으면 이미 존재하는 경로를 대상으로 하는 이름 변경은 그냥 실패하는데, 이는 이미 가지고 있는 워크북을 교체하려는 저장의 목적 전체를 무너뜨릴 것이다. MOVEFILE_WRITE_THROUGH는 내구성을 담당한다: 이 함수는 이동이 단지 큐에 들어가는 즉시 반환하는 대신 디스크에서 실제로 완료될 때까지 반환하지 말라고 지시하며, SaveAs가 반환한 직후 발생한 크래시가 여전히 스왑을 진행 중인 상태로 붙잡을 수 있는 더 좁지만 실재하는 경합 조건을 막는다. 임시 파일을 만들 수 없거나 최종 이름 변경이 어떤 이유로든(권한 문제, 잠긴 대상, 볼륨 불일치) 실패하면, HotXLS는 쓰레기를 남기는 대신 임시 파일 자체를 삭제하며, 대상 파일은 호출 전과 정확히 똑같은 상태로 남는다
Result := Book.SaveAs(TargetPath, xlsxOpenXMLWorkbook);
if Result <> 1 then
begin
// TargetPath on disk is unchanged; safe to retry, alert, or
// fall back to a different path without touching prior output
LogWriter.Write(Format('SaveAs failed (%d): %s',
[Book.LastDiagnostic.Code, Book.LastDiagnostic.Message]));
Exit(False);
end;
SaveAs 자체는 HotXLS 전반에서 공유되는 반환 규칙 — 성공 시 1, 실패 시 음수 — 을 유지하지만, 단순한 정수만으로는 저장이 왜 실패했는지 알 수 없고, 모든 음수 결과를 똑같이 취급하면 재시도 정책이 실제로 활용할 수 있는 정보를 버리게 된다. LastDiagnostic 속성, 그리고 그 뒤에 있는 더 완전한 Diagnostics 컬렉션은 HotXLS가 내부적으로 생성한 메시지를 담고 있어, 만들 수 없었던 임시 파일과 Windows가 거부한 이름 변경을 구분해 준다. 실패한 SaveAs마다 Code와 Message를 로그로 남기는 배치 작업은 고객이 저장이 조용히 아무 일도 하지 않았다고 보고하는 그 한 번을 위한 증거를 정확히 쌓아 올린다
고전 XLS는 메모리로, XLSX와 ODS는 디스크로 대가를 치른다
두 저장 엔진은 서로 다른 경로로 같은 크래시 안전 결과에 도달하며, 이미 대량 배치 작업을 위해 둘 중 하나를 튜닝하고 있다면 이 차이가 중요하다. 고전 XLS 라이터는 메모리 핸들을 뒷받침으로 하는 구조화된 스토리지를 사용해 전체 OLE 복합 문서를 먼저 메모리 안에 만들고, 그 완성된 버퍼를 형제 임시 파일로 단 한 번의 쓰기로 복사한다. HotXLS 소스 자체의 근거는 직접적이다: 전체 파일을 먼저 메모리 안에 만드는 것이 실패하거나 취소된 저장이 대상 파일을 잘라먹는 것을 막는 방법이다. XLSX와 ODS 라이터는 대신 ZIP 항목을 생성되는 대로 임시 파일에 스트리밍하는데, 같은 파일 수준 단계화이지만 메모리 프로파일이 다르다. 대용량 XLSX 내보내기를 컨테이너의 메모리 한도 안에 유지하기 위해 이미 StreamingWrite에 의존하고 있다면, 고전 XLS 내보내기에는 같은 형태의 대응 수단이 존재하지 않는다는 것을 알아두라: 크래시 안전 보장 자체는 어느 쪽이든 무조건 적용되지만, 아주 큰 레거시 .xls 내보내기는 어느 경우든 완전한 출력을 RAM에 보관하며, 이 절충은 서버 배치 작업을 위한 스트리밍 쓰기에 관한 글에서 더 깊이 다룬다
HotXLS 밖에서 같은 패턴을 적용하기, 그리고 이 보장이 끝나는 지점
이 패턴을 빌려오는 것은 대체로 HotXLS가 내부적으로 의존하는 것과 동일한 두 Windows API 호출을 연결하는 문제다. GetTempFileNameW는 여러분이 고른 폴더 안에 고유한 이름의 빈 파일을 건네주고, MoveFileExW는 완성된 쓰기를 실제 대상 위에 한 단계로 커밋한다. HotXLS가 모든 SaveAs 전에 실행하는 것과 동일한 루틴의 최소 버전은 이런 모습이다
function SaveFileAtomically(const Path: WideString; const Contents: TBytes): Boolean;
var
Dir, TempName: WideString;
Buffer: array[0..MAX_PATH] of WideChar;
FS: TFileStream;
begin
Result := False;
Dir := ExtractFilePath(ExpandFileName(Path));
FillChar(Buffer, SizeOf(Buffer), 0);
if GetTempFileNameW(PWideChar(Dir), 'app', 0, @Buffer[0]) = 0 then
Exit;
TempName := PWideChar(@Buffer[0]);
try
FS := TFileStream.Create(TempName, fmCreate or fmShareExclusive);
try
FS.WriteBuffer(Contents[0], Length(Contents));
finally
FS.Free;
end;
Result := MoveFileExW(PWideChar(TempName), PWideChar(ExpandFileName(Path)),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
finally
if not Result then
DeleteFileW(PWideChar(TempName));
end;
end;
이 보장에는 맹목적으로 의존하기 전에 알아둘 가치가 있는 실제 경계가 있다. 원본을 교체하기 전에 전체 사본을 단계화한다는 것은 저장이 쓰기 도중 잠깐 옛 파일과 새 파일 모두를 위한 디스크 공간을 필요로 한다는 뜻이며, 대략 워크북 크기의 두 배다. 이는 보고서 하나에는 문제없지만, 거의 가득 찬 볼륨에서 실행되는 수 기가바이트짜리 내보내기라면 확인해 볼 가치가 있다. 임시 파일도 대상과 같은 폴더에 착지해야 하므로, HotXLS가 실행되는 계정은 이미 알고 있는 파일 하나를 덮어쓸 권한만이 아니라 바로 그 폴더에 대한 파일 생성 권한을 구체적으로 필요로 한다. 폴더 수준 쓰기 접근이 아니라 특정 기존 파일명에 대한 인플레이스 편집만 대상 폴더에 허용하는 배포 환경에서는, 동등한 직접 쓰기라면 성공했을 상황에서도 SaveAs가 임시 파일 단계에서 실패하는 것을 보게 될 것이다
명확히 짚어둘 가치가 있는 두 가지 경계가 더 있다. 네트워크 공유나 OneDrive 또는 유사한 클라이언트가 동기화하는 폴더 안의 대상은, Windows가 여전히 이를 단일 볼륨으로 보고하더라도 로컬 NTFS와 다르게 동작할 수 있는데, 그 앞단의 파일시스템 드라이버가 이름 변경을 같은 방식으로 구현하지 않을 수 있기 때문이다. 배포 대상이 네트워크 경로에 저장한다면, 로컬 디스크 동작이 그대로 이어진다고 가정하는 대신 그곳에서 특별히 강제 중단을 테스트해 볼 가치가 있다. 그리고 이 메커니즘 전체는 이름 있는 파일에 저장하는 경우로 범위가 한정되어 있다. 대신 TStream에 대해 SaveAs를 호출하면 HotXLS는 여러분이 넘긴 스트림에 곧바로 쓰며, 단계화하거나 보호할 대상 파일이 없다. 그 시점부터 그 스트림(메모리 버퍼, 네트워크 업로드, 데이터베이스 blob)의 내구성은 전적으로 여러분 코드의 책임이기 때문이다
검증 패스는 이후 정확히 이 보장에 의존할 수 있으며, 워크북 감사 및 변환 워크벤치에 내장된 종류도 포함된다: 다시 열었을 때 짧거나 누락된 파일이 나온다면 이는 저장이 절반쯤 중단되어 디스크에 애매한 무언가를 남긴 것이 아니라, 쫓아야 할 진짜 변환 문제다. 크래시 안전 단계적 쓰기는 Delphi와 C++Builder용 HotXLS 컴포넌트가 생성하는 모든 XLSX, ODS, 고전 XLS 워크북의 SaveAs에 내장되어 있으며, 이를 켜기 위한 별도 설정은 필요하지 않다