1만 개의 스프레드시트에 대해 밤새 배치 변환을 실행하면, 아침이 되었을 때 그중 세 개가 False로 돌아온다. 불리언 저장 결과가 주는 사후 분석은 그것이 전부다: 실패 건수는 알지만 어느 파일, 어느 시트, 열두 가지 가능한 원인 중 무엇 때문이었는지는 전혀 알 수 없다. losLab의 네이티브 Delphi/C++Builder Excel 파일 컴포넌트인 HotXLS는 그 단일 비트를 구조화된 진단 정보로 대체한다. IXLSWorkbookProgress 인터페이스는 Diagnostics 목록과 OnDiagnostic 이벤트를 제공하며, 모든 Open, SaveAs, Recalculate 호출에 대해 안정적인 숫자 코드, 심각도 레벨, 실패한 작업, 발생한 시트를 보고한다
불리언 저장 결과는 왜 대규모에서 실패하는가?
실패한 파일 하나가 불리언 결과가 만드는 문제가 아니다: 그런 파일 천 개가 문제다. 1만 개 중 세 개 파일에 대해 SaveAs가 성공이 아닌 다른 무언가를 반환하면, 다음 질문은 항상 같다: 이 셋은 재시도 가능한가, 아니면 사람이 필요한가? 네트워크 공유의 권한 오류는 계산 엔진이 평가할 수 없는 수식과 같은 사건이 아니며, 둘 다 형식 한계를 조용히 초과한 워크시트와도 다르다. 통과/실패 결과만 가지고 작업하면 이 모든 것이 동일한 지원 티켓이 되어버리고, 누군가는 각 파일을 손수 Excel에서 열어 원인이 명확해질 때까지 들여다봐야 한다. 그 수동 트리아지야말로 불리언 API의 진짜 비용이며, 배치 크기에 비례해 선형적으로 커지는데, 이는 오류 처리에서 절대 원치 않는 특성이다
IXLSWorkbookProgress 내부: TXLSDiagnostic이 담는 것
IXLSWorkbookProgress는 작업이 어떻게 진행되고 있는지와 그 안에서 무엇이 잘못되었는지 모두를 보고하기 위해 HotXLS가 사용하는 인터페이스이며, 이 둘이 하나의 계약을 공유하는 데는 이유가 있다: 둘 다 오래 걸리는 Open, SaveAs, Recalculate 호출이 작업 도중 예외를 일으키지 않고 전달해야 하는 것들이기 때문이다. 진행 상황 쪽은 OnProgress와 OnProgressEx로, 단계·상태·현재/전체 쌍과 함께 발생한다. 진단 정보 쪽은 이 글이 다루는 부분이다: TXLSDiagnostics 목록을 반환하는 Diagnostics 속성, 가장 최근 항목을 위한 단축 속성인 LastDiagnostic, 그리고 각 TXLSDiagnostic 레코드가 생성되는 순간 발생하는 OnDiagnostic 이벤트다. 각 레코드는 숫자 Code, TXLSDiagnosticSeverity, 그것을 만들어낸 TXLSDiagnosticOperation, 사람이 읽을 수 있는 Message, SheetIndex와 SheetName, 그리고 이 항목을 촉발한 하위 수준 반환값을 보존하는 NativeCode를 담는다
var
Book: TXLSXWorkbook;
Diag: TXLSDiagnostic;
I: Integer;
begin
Book := TXLSXWorkbook.Create;
try
if Book.SaveAs('quarterly-report.xlsx') <> 1 then
for I := 0 to Book.Diagnostics.Count - 1 do
begin
Diag := Book.Diagnostics[I];
Writeln(Format('[%d] severity=%d sheet="%s": %s',
[Diag.Code, Ord(Diag.Severity), Diag.SheetName, Diag.Message]));
end;
finally
Book.Free;
end;
end;
이렇게 Diagnostics를 읽는 것만으로도 이미 불리언 결과를 넘어서는데, Code와 SheetName이 미스터리를 구체적이고 필터링 가능한 사실로 바꿔주기 때문이다. TXLSDiagnostic 레코드는 이 예제가 출력하는 것보다 더 멀리 나아간다: RecordId와 StreamOffset은 BIFF 스트림 안의 바이트 단위 포렌식을 위해 존재하며, PartName은 문제가 발생한 xl/worksheets/sheet3.xml 같은 OOXML zip 항목을 담는다. 이를 중심으로 도구를 만들기 전에 알아둘 가치가 있는 것: 현재 릴리스에서는 내장 진단 호출 지점 어느 것도 RecordId나 StreamOffset을 채우지 않으므로, 둘 다 생성자 기본값인 -1에 머무는데, 이는 "0"이 아니라 "해당 없음"을 의미한다. 이들이 비어 있는 것을 핸들러의 버그가 아니라 정상으로 취급하라
엔진 두 개, 형태 하나, 조용한 차이 하나
HotXLS는 이 동일한 보고 모델 뒤에 두 개의 엔진 — 레거시 .xls를 위한 BIFF8 파사드와 .xlsx를 위한 OOXML 파사드 — 을 제공하며, 이 둘은 IXLSWorkbookProgress를 동일하게 노출하지는 않는다. .xls 엔진인 TXLSWorkbook은 IXLSWorkbookProgress를 공식적으로 구현하므로 그 인터페이스 타입이 요구되는 어디에든 넘길 수 있다. .xlsx 엔진인 TXLSXWorkbook은 동일한 이름과 타입으로 같은 Diagnostics, LastDiagnostic, OnDiagnostic, OnProgress, OnProgressEx 멤버를 노출하지만, 그 인터페이스의 공식 구현이 아니라 평범한 클래스로서 노출하므로 IXLSWorkbookProgress 매개변수를 그 자체로는 만족시키지 못한다. 실무에서는 대부분의 코드가 한 번에 하나의 구체적인 워크북 클래스만 다루므로 이는 거의 문제되지 않지만, IXLSWorkbookProgress 타입 하나로 헬퍼를 작성해 두 엔진의 워크북 객체를 서로 바꿔가며 넘길 수는 없다는 뜻이다. 형식 분리에서 곧바로 이어지는 유일한 필드 차이는 PartName이다: OOXML만 이름 붙일 zip 파트를 가지므로 XLSX 엔진만 이를 채운다
진단 코드를 안전하게 분기 조건으로 삼을 수 있게 하는 것은 무엇인가?
진단 정보에서 하드코딩된 비교를 걸어도 좋은 유일한 부분은 Code 필드다; Message는 그렇지 않은데, 산문 텍스트는 누구도 이를 호환성을 깨는 변경으로 취급하지 않은 채 나중 릴리스에서 표현이 바뀌거나 재번역되거나 더 자세히 확장될 수 있는 바로 그런 종류이기 때문이다. HotXLS의 내장 진단 코드는 이미 그 구분을 염두에 두고 설계된 것처럼 읽힌다: 저장 관련 코드는 1000에서 1005, 열기 관련 코드는 1100과 1101, 계산 관련 코드는 1200과 1201, 지원되지 않는 형식 코드는 1300에 있으며, 모든 대역에 걸쳐 코드가 연속으로 이어지는 대신 각 대역 안에 간격이 남아 있다. 그 간격 덕분에 벤더는 여러분의 switch 문이 이미 의존하고 있는 코드들을 재번호 매김하지 않고도 이를테면 1006 같은 새로운 저장 시점 실패 모드를 추가할 수 있다. 이는 프로덕션에서 코드 매칭을 확정하기 전에 이 컴포넌트뿐 아니라 어떤 진단 API에서든 확인해 볼 가치가 있는 것이다. 번호 매김이 아무리 안정적으로 보여도 여러분 자신의 디스패치 로직에 기본 분기를 남겨두라. 진화하는 파서나 라이터가 계속 발견하는 것이 바로 새로운 실패 모드이기 때문이다. NativeCode와 ExceptionClass는 에스컬레이션이 필요할 때를 위해 Code 한 단계 아래에 있다: NativeCode는 Structured Storage 호출의 HRESULT 같은 하위 반환값을 보존하고, ExceptionClass는 예외가 관련되었을 경우 그 Delphi 예외 타입을 기록하는데, 이는 보통 전체 스택 트레이스를 첨부하지 않고도 정확한 지원 요청을 여는 데 충분하다
심각도와 작업이 다음 코드의 동작을 결정한다
심각도와 작업은 진단 정보를 로그 한 줄에서 라우팅 결정으로 바꿔주는 요소다. TXLSDiagnosticSeverity는 Info, Warning, Error, Fatal로 구성되고, TXLSDiagnosticOperation은 모든 항목에 그것을 만들어낸 호출 — Open, Save, Calculate, Export — 을 태그한다. 이 두 축은 설계상 독립적이다: xlsDiagnosticUnhandledException은 실제로 그것을 일으킨 호출이 무엇이든 그에 맞춰 Operation이 설정된 채 발생하는 고정된 코드 하나이므로, Code가 무엇이 잘못되었는지에 답하는 동안 Operation은 별개로 어디서인지에 답한다. 즉 열기 도중의 예외와 저장 도중의 예외에 각각 별개의 코드가 필요하지 않다. 이런 조합 가능성 덕분에 라우팅도 기계적이 된다: 경고를 로그로 남기고 넘어가는 것은, Aborted 플래그로 취소된 저장이 전형적인 예다; 오류를 세면서 배치를 계속 실행하는 것은, 직렬화에 실패한 워크시트가 전형적인 예다; 치명적 심각도에서는 배치를 멈추는데, 그 레벨은 이미 처리되지 않은 예외가 호출을 되감았음을 의미하며 계속 진행하면 반쯤 갱신된 상태로 작업할 위험이 있기 때문이다. 솔직히 짚어둘 것 하나: Info는 새로 만들어진 TXLSDiagnostic이 시작하는 기본값으로 열거형에 존재하지만, 오늘날 HotXLS 릴리스에 내장된 모든 진단 호출 지점은 오직 Warning, Error, Fatal만 일으킨다; Info는 향후 사용을 위해 예약되어 있을 뿐 오늘날 엔진이 실제로 발생시키는 것은 아니다
// same Diagnostics loop as above, routed by severity instead of printed flat:
for I := 0 to Book.Diagnostics.Count - 1 do
begin
Diag := Book.Diagnostics[I];
case Diag.Severity of
xlsDiagnosticWarning:
Writeln(Format('WARN [%d] %s', [Diag.Code, Diag.Message]));
xlsDiagnosticError:
begin
Writeln(Format('ERROR [%d] %s (sheet %s, native %d)',
[Diag.Code, Diag.Message, Diag.SheetName, Diag.NativeCode]));
Inc(FailedSheetCount);
end;
xlsDiagnosticFatal:
raise Exception.CreateFmt('Fatal HotXLS diagnostic %d: %s', [Diag.Code, Diag.Message]);
end;
end;
OnDiagnostic을 배치 파이프라인에 연결하기
매 호출 후 Diagnostics를 폴링하는 것은 파일 하나에는 통하지만, 다시 1만 개짜리 밤샘 배치로 돌아가면 통하지 않게 되는데, Diagnostics는 매 Open, SaveAs, Recalculate 호출 시작 시점에 초기화되기 때문이다. 루프 안에서 세 번째 파일 뒤에 이를 읽으면 세 번째 파일의 진단 정보만 보인다; 앞의 두 파일이 보고했던 것은 이미 사라지고 없다. OnDiagnostic은 이 컬렉션을 스트림으로 바꿔줌으로써 이 문제를 해결한다: 루프가 시작되기 전에 한 번만 구독하면, 같은 핸들러가 파일마다 순서대로 발생하며, 인스턴스 필드를 통해 파일 이름도 여전히 범위 안에 있다
type
TBatchConverter = class
private
FCurrentFile: string;
FFailedFiles: TStringList;
procedure HandleDiagnostic(Sender: TObject; Diagnostic: TXLSDiagnostic);
end;
procedure TBatchConverter.HandleDiagnostic(Sender: TObject; Diagnostic: TXLSDiagnostic);
begin
if Diagnostic.Severity >= xlsDiagnosticError then
FFailedFiles.Add(Format('%s: [%d] %s (sheet %s)',
[FCurrentFile, Diagnostic.Code, Diagnostic.Message, Diagnostic.SheetName]));
end;
// inside the batch loop:
Book.OnDiagnostic := HandleDiagnostic;
for I := 0 to FileNames.Count - 1 do
begin
FCurrentFile := FileNames[I];
if Book.Open(FCurrentFile) = 1 then
Book.SaveAs(ChangeFileExt(FCurrentFile, '.xlsx'));
end;
이 콜백은 실제로 어떤 비용을 치르는가
OnDiagnostic은 구조적인 이유로 저렴하다: 이는 이미 무언가 잘못되었을 때만 발생하며, 잘못되는 경우는 워크북이 가진 셀·행·워크시트 개수에 비하면 드물다. 이를 OnProgress와 OnProgressEx와 대조해 보면, 이들은 일상적인 진행 상황을 보고하며 처음부터 호출 빈도를 염두에 두고 설계되어야 했다. HotXLS는 Open과 SaveAs 도중 워크시트 수준 진행 상황을 셀이나 행마다가 아니라 시트마다 한 번씩 발생시키는데, 이것이 수백만 개 셀을 가진 워크북에서도 호출당 오버헤드를 작게 유지하는 방법이다. Recalculate는 한 걸음 더 나아가 자신의 진행 이벤트를 의존성 그래프의 대략 4%마다 한 번으로 조절하므로, 전체 재계산은 UI 스레드를 이벤트로 범람시키는 대신 심장박동 같은 신호를 준다. 진단 정보는 그런 스로틀링이 전혀 필요 없었는데, 이벤트 개수가 파일 크기가 아니라 실제 문제의 개수로 제한되기 때문이다
성능이 여전히 여러분에게 달린 유일한 곳은 핸들러 자체 안이다. OnDiagnostic은 Open, SaveAs, Recalculate를 실행하는 스레드에서 동기적으로 발생하므로, 예를 들어 원격 로깅 서비스로의 동기적 쓰기처럼 블로킹하는 핸들러는 그 호출의 실제 소요 시간의 일부가 되어버린다. 파일 하나라면 눈에 띄지 않는다. 1만 개 파일 배치에 곱해지면 밤사이 끝나는 작업과 점심때까지도 여전히 실행 중인 작업의 차이가 된다. 그러니 핸들러가 해야 할 일을 버퍼링해 두고 비동기적으로 흘려보내되, 느린 부분을 인라인으로 처리하지는 말라
구조화된 진단 정보는 불리언 결과가 가장 약한 바로 그 지점, 즉 파일 하나가 아니라 여러 파일을 다루는 워크플로에서 가장 값어치를 한다. 워크북 감사 및 변환 파이프라인이 가장 명확한 예다: 파일마다 단순한 통과/실패만 기록하는 대신 각 파일의 Diagnostics 목록을 그 감사 기록에 첨부하면, 보고서는 무엇이 실패했는지뿐 아니라 왜 실패했는지도 말해주는데, 이것이 우리의 워크북 감사 및 변환 워크벤치 구축에 관한 글이 애초에 제대로 해내려 하는 대부분이다. 진행 상황과 진단 정보의 같은 짝짓기는 이미 그 자체로 진행 상황 보고가 필요한 어떤 워크플로에도 어울리는데, 이는 정확히 HotXLS의 대용량 워크북 성능 가이드가 다루는 영역이다. 거기서는 긴 Open이나 SaveAs 호출이 흔해서 OnProgress가 이미 연결되어 있고, OnDiagnostic은 그 옆에 자연스럽고 거의 공짜인 추가 사항이다
이 중 어느 것도 파이프라인 어딘가에 Excel이 설치되어 있어야 하지 않으며, 일반적인 예외를 잡아서 그 의미를 추측할 필요도 없다. IXLSWorkbookProgress와 그 Diagnostics, LastDiagnostic, OnDiagnostic 멤버는 이 글이 전반적으로 다뤄온 전체 진단 코드 레퍼런스 및 Open, SaveAs, Recalculate 표면과 함께 Delphi와 C++Builder용 HotXLS 컴포넌트 표준판에 포함되어 있다