Technical Article

Delphi를 사용한 BIFF8 피벗 테이블 생성: SXDB 및 SXLI

레거시 Excel 이진 포맷(BIFF8)의 거의 모든 구성원은 2바이트 타입 식별자와 2바이트 길이 정보를 가진 단일 레코드 구조입니다. 셀 데이터는 LABELSSTNUMBER이며, 병합 셀 영역은 MERGEDCELLS입니다. 레코드들을 순차적으로 순회하며 타입 워드에 매핑해 분기하는 것만으로 대부분의 워크시트를 해독할 수 있습니다. 그러나 피벗 테이블(PivotTable)은 이 규칙성을 깨뜨립니다. 피벗 테이블은 단일 레코드가 아닙니다. 동일 OLE 복합 문서 스트림의 두 개별 위치에 흩어져 상호작용하는 수십 개의 레코드들로 이루어진 작은 프로그램에 가까우며, 개별 레코드 간의 상호 연결 관계는 저장 위치와 비트 단위 압축 정렬 방식에 의해 대단히 엄격하게 제한됩니다. 이 구조는 대다수의 BIFF8 리더기가 파싱을 생략하거나 단순히 변경 불가능한 로우 바이트로 보존하는 주원인이며, 바닥부터 이를 새로 작성하려면 Excel 자체가 제어하는 복잡한 상호 참조 구조를 그대로 재현해야 하기 때문입니다.

피벗 테이블 구현이 까다로운 이유는 두 핵심 데이터가 유기적으로 연합된 구조이기 때문입니다. 원본 데이터의 독립적 스냅샷 정보를 보관하는 전용 하위 스트림을 지닌 피벗 캐시(pivot cache)가 있고, 개별 필드들의 그리드 축 배치를 정의하는 테이블 뷰(table view)가 존재합니다. 캐시와 뷰는 인덱스 번호를 기반으로 상호 참조합니다. 이 인덱스 번호가 단 하나라도 어긋나면, 엑셀 파일 오픈 시 데이터 새로고침 오류를 마주하거나 빈 표만 출력됩니다.

피벗 캐시만을 위한 전용 하위 스트림

피벗 캐시는 통합 문서 전역 스트림 내부에서 완전한 독립형 BIFF 하위 스트림 형태로 상주합니다. 이 스트림은 피벗 캐시를 뜻하는 문서 타입 0x0006(통합 문서의 0x0005, 워크시트의 0x0010과 구별됨)이 적용된 BOF 레코드로 개시되고 대응하는 EOF로 종결됩니다. 이 프레임 내부 구조는 고정되어 있습니다. SXDB 레코드는 캐시의 헤더 역할을 담당합니다. 여기에 총 레코드 수, 캐시 필드 개수, 그리고 테이블 뷰가 이 캐시와 자신을 바인딩하기 위해 대입할 스트림 식별자가 기록됩니다. 이후 원본 데이터의 각 열(column)마다 필드 정의를 기술하는 SXFDB 레코드가 수반되고 그 타입을 가리키는 SXFDBType이 정의된 뒤, 열에 기록되어 있던 고유 데이터 값들이 타입 식별자 항목 형태로 고유 값당 하나씩 내보내집니다.

이 항목 레코드 보관 방식 덕분에 캐시 메모리가 효율적으로 가동됩니다. 텍스트 값은 SXSTRING, 숫자는 SXNUM, 논리값은 SXBOOLEAN, 수식 오류 정보는 SXERR로 변환됩니다. 캐시는 원본 셀 격자를 그대로 다 기록하지 않고, 각 필드별 고유 데이터 값 목록 정보와 n번째 레코드 행이 각각 어떤 고유 데이터 번호를 묘사하는지에 대한 참조 인덱스 매핑 테이블을 생성해 압축 보관합니다. 이것이 코드로 피벗 테이블을 생성할 때 단순히 셀을 복사해 넣는 것으로 해결되지 않는 이유입니다. 원본 데이터 범위를 검사하고, 기입된 값들로부터 필드 타입을 추론하며, 고유 데이터로 중복을 제거해 항목 목록을 빌드하고, 각 레코드 행마다 참조 번호 조합(tuple) 형태의 관계 데이터를 수집해 기록해야 합니다. HotXLS는 이 동작을 수행합니다. 순수 숫자 열은 SXNUM 항목으로 내보내고, 혼합 문자열 열은 SXSTRING 항목으로 재정렬하며, 날짜 정보는 동일 숫자 경로상에 직렬 숫자(date serial) 형태로 변환해 보관합니다.

SXDBB와 데이터 압축용 비트 패킹 기법

레코드별 인덱스 매핑 테이블은 가장 독특한 엔지니어링 설계를 보여주며, SXDBB 레코드에 저장됩니다. 일반적인 인코딩 방식을 쓴다면 개별 필드의 인덱스 번호를 일반적인 16비트 정수 형태로 기록할 것입니다. 하지만 Excel은 그렇게 처리하지 않습니다. 각 필드가 가진 고유 항목 개수를 구분하는 데 필요한 최소한의 가변 비트(bit) 크기만 가변 배정해 압축합니다. 사용 비트 두께는 ceil(log2(itemCount + 1)) 공식으로 유도됩니다. 여기서 + 1이 핵심적인 힌트입니다. 이 예외값은 "해당 행에 이 필드 값이 비어 있음"을 지칭하는 빈칸 식별자(sentinel)를 위해 예약되며, 이에 따라 고유 데이터가 3종류라면 공백 식별자를 더한 총 4가지 상태 판독이 요구되므로, 일반 산식의 1비트가 아닌 최소 2비트의 크기가 배정됩니다. 데이터가 전혀 기입되지 않은 공백 열은 0비트로 판정되어 비트 배치 루프에서 생략됩니다.

단일 레코드 행을 구성하는 비트 정보들은 전 필드에 걸쳐 빈틈없이 병렬 연결되며, 다음 레코드가 개시되는 지점 위치는 새로운 바이트 경계면(byte boundary)으로 정렬해 가동됩니다. 레코드들은 비트 단위로 무제한 연결되지 않고 바이트 정렬되므로, 루프에서 몇 비트 여백 공간(padding bit) 손실을 감수하는 대신 가변 인덱스 테이블의 고속 랜덤 액세스 탐색 속도를 보장받습니다. 바이트 내 비트 주입 순서는 LSB(최하위 비트 우선) 방식을 따릅니다. 이 두 규칙 하에서 인코더는 단순한 비트 시프트 연산 방식으로 구현되며, 디코더 역시 그 역연산 구조로 가동됩니다.

// Width of one field's index in the SXDBB stream.
// citmTotal distinct items need ceil(log2(citmTotal + 1)) bits,
// the +1 reserving a "blank" sentinel value.
function BitsForFieldItems(itemCount: Integer): Integer;
var
  capacity: Integer;
begin
  Result := 0;
  if itemCount <= 0 then
    Exit;            // empty field contributes zero bits
  Result := 1;
  capacity := 2;
  while capacity < itemCount + 1 do
  begin
    Inc(Result);
    capacity := capacity * 2;
  end;
end;

이 비트 패킹 기술을 대충 설계하면 안 되는 핵심적인 사유는, BIFF 규격 레코드 1개의 크기 한계 장벽이 8224바이트라는 점 때문입니다. 피벗 데이터를 보관하는 레코드 역시 8224바이트 제약을 피할 수 없으며, 수천 행 이상의 대규모 소스 데이터를 피벗 캐싱하려 시도할 시 이 제한 폭을 즉시 돌파해 버립니다. 따라서 인덱스 테이블은 파편화 분할 처리를 거쳐야 합니다. HotXLS는 단일 SXDBB 본문 페이로드 실크기 상한을 레코드 2바이트 타입 식별자와 2바이트 길이 헤더 영역을 제한 8220바이트로 클램프 제한하고, 이를 압축된 단일 행 바이트 치수로 나누어 레코드 1개당 무사히 저장될 수 있는 최대 보증 행 수를 산출한 뒤, 전체 행 개수에 맞춰 분할된 다수의 후속 SXDBB 레코드 체인 형태로 나누어 내보내집니다. 분할된 각 레코드는 바이트 경계 정렬 상태로 정렬 개시되므로, 특정 레코드 행 정보가 잘려 두 레코드에 걸치는 오동작이 차단됩니다. 각 행별 고유 비트 폭을 판독해 낼 수 있는 리더기는, 분할 배출된 다수의 SXDBB 레코드를 마치 단일 가상 비트 어레이처럼 일관된 속도로 순회 탐색합니다.

테이블 뷰 구조: 본문을 장식할 SXLI 및 필터 페이지 정보용 SXPI

캐시 빌드가 완료되면 테이블 뷰 설정이 수반됩니다. 핵심 요소는 축 라인 정보(axis line items)로, 화면에 피벗 테이블 격자를 그리기 위해 배치된 가로세로 개별 필드 정보 조합 명세 목록입니다. 이 명세 데이터는 SXLI 레코드(타입 0x00B5) 형태로 보관됩니다. 이 레코드 역시 8224바이트를 초과하면 신규 분할 레코드로 쪼개져 저장되며, 효율적인 기록을 위해 다음과 같은 차분 압축 기법을 씁니다. 각 데이터 행은 바로 윗행 데이터 정보와의 차이점(동일 상위 필드 포맷 반복 횟수 코드)만 기록해 둠으로써, 중첩된 하위 분류 축을 반복 기록할 때 가로 부모 필드 속성 텍스트가 중복 인쇄 보관되는 낭비를 줄입니다. 루프의 합계 표시 행 정보와 분할 배출된 개별 레코드의 시작점 행 정보는 이 반복 카운트 값을 언제나 0으로 자동 소독해 기록함으로써, 리더기가 이전 레코드를 역추적하며 데이터를 해독해야 하는 성능 병목을 원천 방지합니다.

피벗 격자 상단부의 필터 제어 드롭다운 메뉴 영역을 의미하는 페이지 축 정보는 개별 레코드로 관리됩니다. SXPI 레코드(타입 0x00B6)는 개별 필터 항목당 10바이트 고정 데이터 규격 어레이 형태로 정보를 보관하며, 피벗 필드 번호 isxvd, 매핑 캐시 데이터 번호 iCache, 배열 순서 정보 ipos, 이전 OLE 대응 객체 일련번호 objId가 들어갑니다. 유심히 추적해야 할 변수는 iCache 정보입니다. 필터 조건 없이 전체 정보를 노출하는 "(All)" 필터 상태일 경우, 실제 데이터 캐시 주소 대신 지정 특수 예약 코드 0x7FFD를 기입해 표기합니다. 코드로 피벗을 처음 빌드해 내보내면 기본값으로 이 "(All)" 예약 코드가 채워지며, 화면 사용자가 드롭다운 특정 요소를 누르는 시점에 실제 캐시 인덱스 주소로 값이 교체되어 필터 조건이 가동됩니다. 여기에 각 필드 외형을 설명하는 SXVDSXVDEx 정보, 배치 정렬 순서를 지칭하는 SXIVD 어레이, 그리고 숫자 서식을 지칭하는 SXFormat 같은 보조 레코드들이 유기적으로 엮여 캐시 데이터를 참조합니다.

두 가지 저장 프로세스의 병행: 로우 바이너리 보존 및 객체 모델 묘사

HotXLS 내부적으로 피벗 저장 경로를 이원화하여 완전 격리 설계한 이유는 원본 파일 규격 구조를 온전히 보존(fidelity)하기 위해서입니다. 외부 디스크에서 가져온 타 문서 데이터 내부의 피벗 정보는 Excel이나 다른 서드파티 인코더가 생성한 바이너리이며, 일반적인 공용 규격에 수반되지 않는 특수한 확장 데이터 노드나 특이 정렬 명세를 내포하고 있을 확률이 높습니다. 이 정보를 지키는 유일한 방책은 유입된 로우 바이트 데이터를 훼손하지 않고 고스란히 다시 출력해 주는 것입니다. 따라서 기존 파일 로드 시에는 피벗에 FromRawBlobs = True 플래그를 할당해 보관하고, 저장 시에 보존해 둔 로우 바이너리 블록들을 그대로 방출(replay)합니다. 데이터를 파싱하거나 임의로 재해석하는 단계를 생략하므로 완벽한 바이트 단위 보존이 완수됩니다.

사용자가 코드로 신규 생성하는 피벗 테이블은 다른 규칙을 대입해야 합니다. 보존할 기존 바이너리가 없으므로, 메모리상에 정의된 순수 객체 모델(필드 목록을 담은 TXLSPivotCache 및 배치 속성을 규정한 TXLSPivotTable) 정보를 기반으로 동작합니다. 신규 피벗은 FromRawBlobs = False 플래그를 대입하고, 파일 저장 주기에서 BOF = 0x0006 캐시 하위 스트림을 생성하고, 비트 패킹 계산식을 대입해 SXDBB 레코드를 빌드하며, 객체 배치 데이터로부터 SXLISXPI 서식 정보를 구조적으로 그려냅니다. 이 플래그 분류 덕분에 두 피벗 연산 프로세스가 충돌 없이 상호 공존합니다. 이 장치가 없다면 기존 서식 정보가 훼손되거나 신규 생성이 불가능해지는 절충안에 직면할 것입니다. 로우 데이터 보존 프로세스에서도 규격 외 확장 레코드가 발견되면 이를 보조 레코드 체인(SupplementalRecords 어레이)에 담아 보관하므로, 데이터 분석기 조회 시 누락되는 데이터 범주가 없도록 안전하게 관리해 줍니다.

코드를 통한 피벗 테이블 생성 구현

위에 언급한 모든 압축 및 바인딩 처리는 하나의 메서드로 단일 처리됩니다. AddPivotTable은 가상 데이터의 원본 A1 스타일 영역 주소 명세, 테이블이 배치될 시작점 셀 눈금 오프셋, 그리고 테이블 이름을 매개변수로 받습니다. 메서드는 영역을 파싱하고 필드 형식을 분석해 캐시 데이터를 빌드하며(동일 영역에 기구축된 캐시가 확인될 시 이를 공용 재참조해 자원을 아낍니다), 생성 주기에 필요한 필드 속성이 정의된 TXLSPivotTable 객체를 반환합니다. 사용자는 반환된 객체를 받아 가로세로 배치할 격자 축을 지정하고 데이터 계산 옵션을 할당합니다. 호출 원형은 아래와 같으며, 캐시 하위 스트림 묘사 및 비트 압축 레코드 생성은 파일 저장 시 엔진이 알아서 처리합니다.

uses
  lxHandle, lxPivot;

var
  Book : TXLSWorkbook;
  Sheet: IXLSWorkSheet;
  Pivot: TXLSPivotTable;
begin
  Book := TXLSWorkbook.Create;
  try
    Book.Open('Sales.xls');
    Sheet := Book.Sheets[1];

    // Source A1:E500 on 'Data'; anchor the pivot at row 3, col 1.
    Pivot := Sheet.AddPivotTable('Data!$A$1:$E$500', 3, 1, 'SalesByRegion');
    if Pivot <> nil then
    begin
      Pivot.AddRowField('Region');
      Pivot.AddColumnField('Quarter');
      Pivot.AddDataFieldByName('Revenue', xlpaSum);
    end;

    Book.SaveAs('Sales-Pivot.xls');
  finally
    Book.Free;
  end;
end;

원본 영역의 최상단 첫 행 정보는 각 필드 명칭을 식별하는 헤더 키워드로 자동 해독되므로, AddRowField('Region')처럼 좌표 대신 문자열 정보 매핑만으로 원하는 축을 안전하게 지정할 수 있습니다. 획득 객체는 FromRawBlobs = False 상태의 신규 포맷이므로 저장 시 하위 스트림 빌드 경로를 거치며, 원본 영역의 데이터 정보가 삭제되더라도 자체적으로 가동될 수 있는 완벽한 독립형 캐시 스트림을 내장해 파일 크기를 최적화합니다. 이는 원본 데이터를 노출하지 않고 요약 정보만 담아 외부 수신처에 도면을 가공해 발송해야 할 때 매우 이상적인 기술 속성입니다.

외부에서 전달받은 불명확한 파일의 피벗 및 캐시 레코드 바이너리 분석과 로우 바이너리 데이터 보존 기법은, 통합 문서 구조 감사 및 변환 워크벤치 안내 문서에서 내용을 다룹니다. 또한 가공할 원본 행 데이터가 수만 건을 초과하여 분할 배출될 SXDBB 스트림 크기가 과도하게 늘어날 때 대처하는 기술은, 대형 통합 문서 성능 향상 기술 팁을 통해 런타임 성능 보존 방식을 확인할 수 있습니다. 두 기술 모두 Delphi 및 C++Builder용 HotXLS spreadsheet component 라이브러리에 패키징되어 수식 계산, 차트 작성 기술과 함께 제공됩니다.