기술 기사

Delphi에서 직접 스트리밍 리더로 Excel 파일 스트리밍하기

백만 개의 행과 열두 개의 열을 가진 스프레드시트는 데이터베이스 보고 작업에서 흔히 볼 수 있는 출력물입니다. 전체 통합 문서를 TXLSWorkbook에 로드하는 일반적인 방법으로 열게 되면, 첫 번째 비즈니스 논리 코드가 실행되기도 전에 천이백만 개의 각 셀을 라이브 객체로 구체화해야 합니다. 디스크의 파일은 60메가바이트의 압축된 XML일 수 있습니다. 하지만 이것이 확장된 객체 트리는 그보다 몇 배나 더 크며, 모델이 설계상 무작위 액세스(random-access) 기반이기 때문에 이 모든 것이 한 번에 메모리에 상주해야 합니다. 처음부터 끝까지 한 번 읽고 버릴 보고서에 대해, 필요하지도 않은 구조를 위해 엄청난 양의 메모리가 소비되는 셈입니다

동일한 파일을 처리하는 두 번째 방법이 있습니다. 모델을 구축하는 대신 워크시트 XML을 한 번에 한 셀씩 순방향으로만 스캔하고 확인이 끝난 셀은 그냥 흘려보내는 것입니다. 아무것도 축적되지 않습니다. 리더가 현재 구문 분석 중인 부분과 몇 개의 작은 조회 테이블 외에는 아무것도 유지하지 않으므로 시트가 1천 행이든 1천만 행이든 메모리 사용량은 거의 일정하게 유지됩니다. 이것이 바로 HotXLS 직접 리더(direct reader)가 수행하는 작업이며, 이 기사의 나머지 부분에서는 이 리더가 어떻게 작은 용량을 유지하고 그 대가로 무엇을 제공하는지에 대해 설명합니다

인메모리 모델이 확장되지 않는 이유

XLSX 파일은 ECMA-376에서 설명하는 XML 파트들로 이루어진 ZIP 패키지입니다. 각 워크시트는 그 자체 파트인 xl/worksheets/sheetN.xml이며 그 안의 모든 행은 <c> 셀 요소를 담고 있는 <row> 요소입니다. 일반적인 로드 경로는 해당 파트를 읽고 각 셀에 대해 주소 지정 가능한 객체를 구성하여, 나중에 Cells[12345, 7]을 요청하면 일정한 시간 안에 응답을 받을 수 있도록 합니다. 무작위 액세스는 통합 문서 모델의 핵심이며 바로 이 기능 덕분에 편집, 수식 계산 및 스타일링이 편리해집니다

그에 대한 대가는 무작위 액세스를 위해 모든 것이 동시에 존재해야 한다는 점입니다. 부분적으로만 구축한 구조에는 인덱싱할 수 없습니다. 따라서 전체 로드의 최대 메모리는 셀 개수의 함수가 되며, 수백만 개의 값이 채워진 셀이 있는 시트의 경우 특히 여러 작업이 공유 시스템에서 동시에 실행될 때 서비스가 감당하기 힘든 수준까지 올라갑니다. 실제로 필요한 액세스 패턴이 순차적인 경우라면, 사용하지 않을 기능에 무작위 액세스 비용을 지불하는 셈입니다

트리를 구축하지 않는 순방향 전용 SAX 스캔

직접 리더는 ZIP 패키지를 열고 SAX 스타일의 풀 파서(pull parser)를 통해 각 워크시트 파트를 탐색합니다. 여기서 SAX는 파서가 시작 요소, 텍스트 실행, 끝 요소를 만날 때마다 파싱 이벤트를 보고하고 다음으로 넘어감을 의미합니다. 파서 뒤에는 어떤 노드 트리도 남겨두지 않습니다. 리더는 r 속성에서 현재 행과 열을 추적하고 이벤트가 도착할 때 셀의 유형, 스타일 인덱스, 값, 수식 텍스트를 수집하며 닫는 </c> 태그가 보이면 하나의 셀을 내보내고 이를 잊어버립니다. 다음 셀은 동일한 소수의 지역 변수를 재사용합니다

셀과 셀 사이에는 아무것도 유지되지 않으므로 셀 개수에 비례하여 메모리 공간이 늘어나지 않습니다. 이것이 주목할 만한 속성입니다. 2백 행 시트와 2천만 행 시트는 리더에게 동일한 상주 메모리를 요구하며 이들 간의 차이는 스캔이 실행되는 시간뿐입니다. 모델의 주요 기능인 무작위 액세스를 포기하는 대신 셀 개수가 돌파할 수 없는 메모리 상한선을 얻게 됩니다

어떤 요소가 메모리에 상주하며 그 두 부분이 필요한 이유

스캔은 완전히 무상태(stateless)인 것은 아니며 그 예외 사항은 시사하는 바가 큽니다. 셀 자체만으로는 해석하기에 충분한 정보를 제공하지 않기 때문에 스캔하는 동안 두 개의 작은 테이블을 메모리에 유지해야 합니다

첫 번째는 공유 문자열 테이블(shared string table)입니다. SpreadsheetML에서 텍스트 셀은 자신의 텍스트를 저장하지 않습니다. t="s"와 함께 숫자 페이로드를 전달하는데, 이는 통합 문서 내의 모든 고유 문자열에 대한 단일 중복 제거 목록인 xl/sharedStrings.xml을 가리키는 인덱스입니다. 수천 개의 행에 걸쳐 동일한 레이블이 반복되는 파일에는 공간적으로 좋은 거래지만, 어느 시트의 어떤 셀이든 그 항목 중 하나를 참조할 수 있기 때문에 리더는 이 문자열 테이블을 미리 로드하여 상주시킬 수밖에 없습니다. 이 테이블 크기는 셀 개수가 아닌 고유 문자열의 수에 의해 결정되므로 엄청나게 큰 시트에서도 크기가 적당하게 유지됩니다

두 번째는 스타일 파트의 숫자 형식 매핑입니다. 숫자 셀과 날짜 셀은 전송 중에 바이트 단위로 동일합니다. SpreadsheetML의 날짜는 단지 일련의 일수(serial day count)일 뿐이므로 둘 다 단순한 숫자입니다. 이 둘을 구별하는 유일한 것은 xl/styles.xmlcellXfs를 통해 숫자 형식 ID를 가리키는 셀의 스타일뿐입니다. 날짜를 원시 일련번호가 아닌 날짜로 보고하기 위해 리더는 스타일에서 형식으로 매핑하는 테이블을 로드하고 상주하게 유지합니다. 파일에 있는 다른 모든 것, 즉 바이트의 대부분을 차지하는 실제 셀 데이터는 저장되지 않고 스트림되어 지나갑니다

모든 셀이 유형과 값을 보고합니다

방출된 각 셀은 TXLSDirectCell 레코드로 도착합니다. 여기에는 시트 인덱스와 이름, 1부터 시작하는 행과 열, 의미론적인 Kind, Variant 형태의 Value, 선행 등호가 없는 Formula 텍스트 및 원시 StyleIndex가 포함됩니다. 종류는 xdkNumber, xdkString, xdkBoolean, xdkDate 또는 xdkError 중 하나이므로 속성에서 의미를 다시 파생시키는 대신 셀의 의미에 따라 분기할 수 있습니다. 수식 셀은 수식 텍스트와 함께 캐시된 결과 종류를 보고하므로 계산된 합계는 생성 방법도 알려주는 숫자로 전달됩니다

type
  TReportScan = class
    procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
      var Abort: Boolean);
  end;

procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
  var Abort: Boolean);
begin
  case Cell.Kind of
    xdkString:  AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
    xdkNumber:  AddToTotals(Cell.Col, Double(Cell.Value));
    xdkDate:    NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
    xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
    xdkError:   LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
  end;
end;

숫자에서 날짜를 구별하기

날짜 문제는 대부분의 단순한 스캐너가 실수하는 부분이기 때문에 자세히 살펴볼 필요가 있습니다. 숫자 셀에는 날짜 유형이 없습니다. 일련값 46000을 가진 셀은 수량일 수도, 가격일 수도, 2025년 2월 17일일 수도 있으며, 셀의 스타일을 통해 도달하는 숫자 형식 ID를 통해서만 파일이 그것이 무엇인지 알려줍니다. ECMA-376은 모든 규격 준수 생산자에 걸쳐 고정된 의미를 갖는 기본 제공 형식 ID 블록을 예약하고 있으며, 날짜 관련 ID는 두 범위에 속합니다. 표준 날짜 및 시간 형식의 경우 14부터 22까지, [h]:mm:ss와 같은 경과 시간 형식의 경우 45부터 47까지입니다. 기본적으로 켜져 있는 DetectDates가 활성화되어 있으면, 리더는 각 숫자 셀의 스타일을 형식 ID로 해석하고 해당 ID가 예약된 범위에 속하는 셀을 이미 Delphi TDateTime으로 변환된 Value와 함께 xdkDate로 보고합니다. 사용자 지정 형식 또한 날짜 및 시간 토큰에 대한 형식 코드를 검사하여 확인되지만 예약된 범위가 신뢰할 수 있는 기반이 됩니다. DetectDates를 끄면 스타일 테이블은 아예 로드되지 않고 모든 숫자 셀은 xdkNumber로 전달되며 스캔은 아주 약간 더 가벼워집니다

시트 건너뛰기 및 조기 중단

순차 스캐닝에는 무작위 액세스가 따라올 수 없는 조용한 이점이 있습니다. 멈출 수 있다는 것입니다. OnSheet 이벤트는 각 워크시트가 열리기 전에 발생하며 두 가지 스위치를 제공합니다. SkipSheet를 설정하면 해당 전체 파트가 파싱되지 않으며, 이것이 다중 시트 통합 문서에서 나머지를 읽기 위한 비용을 지불하지 않고 필요한 시트만 스캔하는 방법입니다. Abort를 설정하면 전체 스캔이 즉시 종료됩니다. OnCell 이벤트에는 자체 Abort가 있으므로 나머지 수백만 개의 셀을 읽지 않고도 원하는 특정 행, 보초값(sentinel value), 헤더 블록의 끝을 찾은 순간 중지할 수 있습니다. 순방향 전용 스캔에서 아직 일어나지도 않은 작업을 건너뛰는 것이므로, 중단으로 인한 비용이 진정으로 무료입니다

procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
  const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
  // "Data" 시트만 스캔하고 나머지는 읽지 않은 상태로 둡니다.
  SkipSheet := SheetName <> 'Data';
end;

핸들러 없이 셀 세기

최근의 개선 사항 중 하나는 일반적인 질문을 단일하고 저렴한 호출로 바꿔주기 때문에 언급할 가치가 있습니다. 리더는 OnCell 핸들러가 연결되어 있는지 여부에 관계없이 지나가는 모든 채워진 셀의 수를 셉니다. 이전에는 핸들러가 설정되지 않은 경우 채워진 셀 개수는 0으로 반환되었습니다. 세는 것이 값을 내보낼 때 나타나는 부작용이었기 때문입니다. 이제 개수 세기는 셀 방출과 독립적입니다. 즉, 이 통합 문서에 실제로 몇 개의 채워진 셀이 포함되어 있는지 묻고 콜백 없이 스캔하는 비용만으로 답을 얻을 수 있습니다. ReadFileReadStream은 모두 이 합계를 Int64로 반환하며 이후에 CellCount 속성으로 동일한 숫자를 사용할 수 있습니다. -1이 반환되면 파일을 열 수 없거나 OOXML 패키지가 아님을 나타냅니다

var
  Reader: TXLSDirectReader;
  Populated: Int64;
begin
  Reader := TXLSDirectReader.Create;
  try
    // OnCell 핸들러가 없음: 순수하게 채워진 셀을 집계하는 것으로 상주 메모리가 거의 변함없음
    Populated := Reader.ReadFile('quarterly_export.xlsx');
    if Populated < 0 then
      raise Exception.Create('Not a readable XLSX package')
    else
      Writeln(Format('%d populated cells (CellCount = %d)',
        [Populated, Reader.CellCount]));
  finally
    Reader.Free;
  end;
end;

전체 스캔을 위해서는 핸들러를 연결하고 ReadFile을 똑같은 방식으로 호출합니다. 전체 로드와의 차이점이 바로 핵심입니다. quarterly_export.xlsx를 통합 문서에 로드하면 모든 셀이 상주 객체로 확장되어 모든 데이터를 보관하게 되지만 직접 리더는 공유 문자열과 스타일 테이블만 유지하는 동안, 천이백만 개의 셀이 OnCell을 통해 한 번에 하나씩 흘러갑니다. 셀마다 실행된 연산은 뒤에 아무것도 남기지 않으므로 최대 메모리 사용량은 행 개수가 아니라 통합 문서의 고유 문자열 개수에 의해 결정됩니다

직접 리더는 대규모 통합 문서를 한 번 읽어 추출하거나 요약하는 작업에 적합한 도구입니다. 대신 전체 모델의 무작위 액세스가 필요하지만 큰 파일에서도 제대로 동작하기를 원한다면 Delphi에서의 대형 통합 문서 성능에 대한 기사의 조정 방법에서 그 방향을 다룹니다. 반대 방향으로, 대규모 결과물을 소비하는 대신 생성하는 경우에는 서버 일괄 작업을 위한 스트리밍 쓰기 기사에서 쓰기에 대해서도 동일한 상수 메모리 규율을 적용합니다. 이 세 가지 기능 모두 이 블로그의 다른 곳에서 다루는 읽기, 쓰기, 수식 및 형식 지정 API와 함께 Delphi 및 C++Builder용 HotXLS Component의 일부로 제공됩니다