데이터 45행짜리 14열 스프레드시트를 ODS로 저장하면 보통 기대보다 요소 하나가 더 나옵니다. <table:table-row table:style-name="ro1" table:number-rows-repeated="1048530"><table:table-cell table:number-columns-repeated="14"/></table:table-row>이 그 하나입니다. 이 한 줄이 46행부터 그리드 맨 아래까지의 모든 행을 기술합니다. 그것을 문자 그대로, 같은 행을 1,048,530번 쓴 형태로 펼치면 작은 content.xml이 백만 개의 행 레코드와 백만 개의 높이 항목, 그리고 열에 기본 셀 스타일이 있으면 거의 1,500만 개의 스타일 적용된 빈 셀로 변합니다
반복 행 하나의 임포트가 비싼 이유
비용은 펼치기에서 나오고, 펼치기는 임포터를 쓰기 가장 쉬운 방법입니다. 스타일 ro1은 style:row-height="0.452cm", 즉 12.81 포인트를 정합니다. 반복 행마다 SetRowHeight(RowIndex + i, RowHeight)를 한 번씩 호출하는 행 마무리 코드는 객체 모델이 유지하는 행별 높이 테이블에 1,048,530개의 항목을 씁니다. 그 테이블이 쓰기 전마다 기존 키를 검색하는 keyed string list라면 임포트는 반복 수에 대해 이차 복잡도가 되고, 백만짜리 반복 수는 이차 복잡도가 더 이상 이론이 아닌 바로 그 입력입니다
빈 셀은 사태를 악화시킵니다. 이런 파일의 각 <table:table-column>은 보통 table:default-cell-style-name을 명시하고, 반복 행의 모든 빈 셀이 그것을 상속합니다. 스타일이 적용된 빈 셀도 대부분의 객체 모델에서는 셀입니다. 반복 행마다 열마다 하나씩 실체화하면 메모리 청구서가 높이 테이블을 압도합니다
저장 쪽에는 거울상의 문제가 있습니다. 모델이 1,048,575행에 높이가 있다고 말하는 순간, 1행부터 무언가 있는 마지막 행까지 걷는 writer는 백만 개의 <table:table-row> 요소를 내보냅니다. 저렴한 임포트 뒤에 비싼 저장이 따라오면 비용이 다른 버튼으로 옮겨갈 뿐입니다
HotXLS가 반복 행 높이를 저장하는 방법
HotXLS Delphi Excel Component는 반복되는 ODS 행 높이를 런으로 저장합니다. FirstRow, LastRow, Height 하나를 담는 TXLSXRowHeightRun 레코드입니다. TXLSXWorkbook.OpenODS는 반복 수가 1인 행을 마칠 때는 다른 코드처럼 SetRowHeight를 호출합니다. 더 큰 수에서는 그 구간 전체를 하나의 런으로 기록하므로, 예제의 꼬리는 46행부터 1,048,575행까지 12.81 pt가 됩니다. 그리드 끝을 넘는 반복 수는 거부되지 않고 1,048,576행에서 잘립니다
임포터에는 결과를 소스 파일과 diff하는 사람들을 놀라게 하는 규칙이 하나 있습니다. ODS를 쓰는 스프레드시트 애플리케이션은 아무도 크기를 조정한 적 없는 행까지 포함해 모든 행 스타일에 style:row-height를 주고, style:use-optimal-row-height="true"로 표시하는 경향이 있습니다. 셀을 전혀 저장하지 않는 행에서 자동 높이는 그냥 기본 높이이므로, HotXLS는 그런 행에 높이를 기록하지 않습니다. 무언가를 저장하는 행은 optimal 표시가 있어도 높이를 유지합니다. 내용에 맞게 자란 줄바꿈 텍스트 행이 XLSX로 다시 열릴 때 줄어들면 곤란하기 때문입니다. 이 트레이드오프가 맞는 선택입니다. 실제 높이를 버리면 눈에 보이는 레이아웃 변화가 생기고, 중복된 높이를 유지하는 비용은 런 하나입니다
높이를 다시 읽을 때는 평범한 RowHeight 속성을 거칩니다. 먼저 행별 목록을 확인하고 그 행에 명시적 항목이 없을 때만 런으로 폴백하며, HasRowHeight도 같은 순서를 따릅니다. 런은 절대 겹치지 않으므로 폴백은 그 행을 포함하는 첫 런에서 멈춥니다
var
Workbook: TXLSXWorkbook;
Sheet: TXLSXWorksheet;
Spans: TXLSXAxisSpans;
I: Integer;
begin
Workbook := TXLSXWorkbook.Create;
try
// 마지막 행 요소가 1,048,530번 반복되는 시트
Workbook.OpenODS('tail-repeat.ods');
Sheet := Workbook.Sheets[1]; // Sheets는 1부터 시작
// 저장된 행 메타데이터: 한 행 항목 먼저, 그다음 런
Spans := Sheet.GetStoredAxisSpans(True);
for I := 0 to High(Spans) do
Writeln(Spans[I].FirstIndex, '..', Spans[I].LastIndex);
// 두 읽기 모두 같은 런에서 나옴
Writeln(Sheet.RowHeight[46]:0:2, ' pt');
Writeln(Sheet.RowHeight[1048575]:0:2, ' pt');
// 한 행 항목이 런보다 먼저 조회되므로 이김
Sheet.RowHeight[500000] := 36;
// 런 안의 한 행을 지우면 한 행짜리 구멍이 남음
Sheet.ClearRowHeight(500001);
Writeln(Sheet.HasRowHeight(500001)); // FALSE
Writeln(Sheet.RowHeight[500002]:0:2, ' pt'); // 여전히 런 높이
finally
Workbook.Free;
end;
end;
이 예제의 두 편집은 의도적으로 다르게 동작합니다. Sheet.RowHeight[500000] := 36은 런을 그대로 두고 행별 목록에 항목 하나를 추가하는데, 그 항목이 먼저 조회되므로 런을 가립니다. ClearRowHeight는 그렇게 할 수 없습니다. 지워진 행은 런이 여전히 그 행을 덮고 있어도 높이 없음을 보고해야 하기 때문입니다. 그래서 이 메서드는 행별 항목을 지운 뒤 런 목록을 다시 만듭니다. 지워진 행 밖의 모든 런은 그대로 유지하고, 그 행을 포함하던 런은 앞부분과 뒷부분으로 갈라지며, 500,001행 자체에는 아무것도 저장하지 않습니다. GetStoredAxisSpans(True)는 이제 500,000의 한 행 항목 옆에 46..500000과 500002..1048575를 별개의 두 런으로 나열합니다. ClearRowHeights는 행별 목록과 런을 한 번에 비웁니다
상속된 빈 셀 스타일은 어디로 가나
상속된 빈 스타일은 스타일 오버레이, 즉 뒤에 셀 객체 없이 스타일을 실은 워크시트 위의 직사각형이 됩니다. 행을 읽는 동안 임포터는 같은 스타일로 해석되는 인접 열들의 스팬 단위로 반복 셀들을 훑습니다. 스타일이 Default와 정확히 같아 보이는 스팬은 아무것도 저장하지 않습니다. 실제 서식을 가진 스팬, 예컨대 가운데 정렬이나 굵게를 기본으로 하는 열은 그 열 전체를 덮는 행 값 하나가 됩니다
그 행 값이 커밋되고 행이 반복되면, HotXLS는 스팬의 왼쪽 위에 셀 하나만 실체화하고 거기에 상속된 스타일을 적용한 뒤, 여섯 스타일 인덱스(폰트, 채우기, 테두리, 표시 형식, 정렬, 보호)를 Sheet.StyleOverlays.Add로 직사각형 전체, 즉 스팬의 모든 열에 첫 반복 행부터 마지막 반복 행까지 등록합니다. 나머지 1,048,529개 행에 대해 만들어지는 것은 없습니다. Sheet.Cells.Count는 실제 데이터를 담는 셀 수 근처에 머뭅니다
직사각형 안의 셀은 코드가 손대는 순간 오버레이를 물려받습니다. 꼬리가 굵게 열 기본값으로 서식 지정된 시트를 연 뒤 Sheet.Cells[700000, 1].FontIndex를 보면, 한 줄 전에는 그 좌표에 셀이 없었음에도 굵은 폰트로 해석됩니다. 서식은 있지만 비어 있는 열을 가진 XLSX 파일도 같은 기계를 거치며, 행 블록 셀 저장과 구간 스타일 오버레이 글이 여러 오버레이가 층층이 쌓이고 해석되는 방식을 다룹니다. ODS 임포터가 다른 점은 오버레이의 출처뿐입니다. 반복 수에서 스스로 유도합니다
SaveAsODS가 반복 수를 되돌려 쓰는 방법
TXLSXWorkbook.SaveAsODS는 동일한 행들의 구간을 table:number-rows-repeated를 단 하나의 <table:table-row>로 쓰고, 행에 관한 무언가가 바뀌는 지점에서만 구간을 끊습니다. 테이블은 무언가를 실은 가장 낮은 행까지 내려옵니다. 값, 스타일만 있는 빈 셀, 한 행 높이, 런의 끝, 오버레이의 아래 경계, 숨김이나 outline 항목, validation이나 조건부 서식 영역이 그 기준입니다
1행부터 writer는 다음 경계를 앞서 살핍니다. 후보는 다음 실체화된 셀, 런의 시작과 끝, 모든 한 행 항목(높이, 숨김 플래그, outline level, 축소 상태, 수동 페이지 나누기), 오버레이 경계, 병합 범위, 하이퍼링크, 메모, 그림, 그리고 모든 validation과 조건부 서식 직사각형의 경계입니다. 경계까지의 모든 것이 하나의 요소가 됩니다. 각 요소 안에서는 같은 논리가 열 방향으로 돌아서, 빈 구간은 오버레이가 해석하는 스타일 이름과 table:number-columns-repeated를 단 하나의 <table:table-cell>이 됩니다
행 스타일은 공유됩니다. 행 높이는 텍스트로 서식 지정되고, 예제에서는 12.81pt이며, 이 텍스트가 숨김 플래그, 페이지 나누기 플래그와 함께 자동 행 스타일의 키가 됩니다. 세 값이 같은 모든 행은 하나의 style:row-height 속성만 담는 같은 ro<N> 스타일을 가리킵니다. 높이도 없고 보이며 나누기도 없는 행은 행 스타일을 아예 받지 않습니다
var
Workbook, Reopened: TXLSXWorkbook;
Saved: TMemoryStream;
begin
Workbook := TXLSXWorkbook.Create;
Reopened := TXLSXWorkbook.Create;
Saved := TMemoryStream.Create;
try
Workbook.OpenODS('tail-repeat.ods');
Workbook.Sheets[1].RowHeight[500000] := 36;
Workbook.Sheets[1].ClearRowHeight(500001);
// 꼬리는 반복 행 요소 몇 개로 기록됨
Workbook.SaveAsODS(Saved);
Writeln('ODS stream: ', Saved.Size, ' bytes');
Saved.Position := 0;
Reopened.OpenODS(Saved);
// 기댓값: 오버라이드, 구멍, 런 조각들이 모두 왕복을 견딤
Writeln(Reopened.Sheets[1].RowHeight[500000]:0:2); // 36.00
Writeln(Reopened.Sheets[1].HasRowHeight(500001)); // FALSE
Writeln(Reopened.Sheets[1].RowHeight[500002]:0:2); // 런 높이
finally
Saved.Free;
Reopened.Free;
Workbook.Free;
end;
end;
위 시트에서 꼬리는 네 조각으로 갈라집니다. 런 높이의 46행부터 499,999행, 36 pt의 500,000행, 행 스타일 없는 500,001행, 다시 런 높이의 500,002행부터 1,048,575행입니다. 기본 스타일이 오버레이를 남긴 열은 그 오버레이가 끝나는 지점에서 갈라짐을 하나 더할 수 있습니다. 꼬리 위의 45개 데이터 행은 각각 셀을 담고 있어 행마다 한 요소로 쓰입니다
XLSX는 다른 이야기입니다. SpreadsheetML에는 repeat 속성이 없고 <row> 요소는 정확히 한 행을 기술하므로, 런 기반 시트를 .xlsx로 저장하면 런이 덮는 행들을 하나하나 열거해 각각에 ht와 customHeight="1"을 씁니다. 모델은 메모리에서 컴팩트한 채이고, 파일이 얼마나 커지는지는 대상 형식이 결정합니다. ODS로 들어와 ODS로만 나가면 되는 워크북은 그 행별 비용을 한 번도 치르지 않습니다
행 번호를 바꾸는 편집들
행 높이의 두 번째 표현은 행을 움직이는 모든 연산이 그것도 함께 움직일 때만 올바릅니다. TXLSXWorksheet.InsertRows와 DeleteRows는 런을 행별 목록과 함께 이동시킵니다. 편집 지점 앞의 런 부분은 제자리에 남고, 삭제 창 안의 행은 버려지며, 나머지는 삽입·삭제 수만큼 이동합니다. 삽입을 가로지르던 런은 사이에 높이 없는 행들이 놓인 두 런으로 갈라지고, 삭제를 가로지르던 런은 짧아집니다
procedure ShiftAroundTheRun(Sheet: TXLSXWorksheet);
begin
Sheet.RowHeight[500000] := 36;
Sheet.ClearRowHeight(500001);
// 500000에 두 행 삽입: 오버라이드는 500002로, 구멍은 500003으로
Sheet.InsertRows(500000, 2);
Writeln(Sheet.RowHeight[500002]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500003)); // FALSE
Writeln(Sheet.HasRowHeight(500000)); // FALSE: 삽입된 행에는 높이가 없음
// 다시 삭제: 오버라이드와 구멍이 원래 행 번호로 돌아옴
Sheet.DeleteRows(500000, 2);
Writeln(Sheet.RowHeight[500000]:0:2); // 36.00
Writeln(Sheet.HasRowHeight(500001)); // FALSE
Writeln(Sheet.UsedRange.Row2); // 이동한 런의 끝을 포함
end;
왕복이 꼬리를 정확히 제자리에 두지는 않습니다. 삽입이 꼬리 런을 500,002..1,048,575에서 500,004..1,048,577로 밀어내고 그리드는 1,048,576행에서 끝나므로, 그 행을 넘는 부분은 잘립니다. 삭제는 남은 것을 위로 두 행 올리고 런은 1,048,574행에서 끝납니다. 1,048,575행에는 더 이상 저장된 높이가 없습니다. 그리드의 마지막 행들이 중요하다면 삽입·삭제 쌍 이후에 다시 높이를 정하세요. 런 중간에 삽입된 행도 그 높이를 물려받지 않습니다. 높이 없는 행들의 간격을 열 뿐이며, 앞에서 HasRowHeight(500000)이 FALSE를 인쇄한 이유가 이것입니다
나머지 행 이동 연산도 같은 패턴을 따릅니다. TileRangeAxisMetadata는 타일링된 전체 구간의 런을 지운 뒤 소스 런 각각을 복사본마다 오프셋에 맞춰 등록합니다. TXLSXWorksheet.CopyFrom과 TXLSXSheets.AddCopy는 새 시트에 런 목록 자체의 복사본을 주므로, 복제본의 ClearRowHeights는 원본의 높이를 그대로 둡니다. 읽는 쪽에서는 UsedRange가 모든 런의 첫 행과 마지막 행까지 확장되고, XLSX writer의 행 순회도 런을 통과하므로 높이만 있는 행도 여전히 기록됩니다
숨김 행과 outline level은 행별로 유지됩니다
런과 오버레이로 접히는 것은 행 높이와 상속된 빈 셀 스타일, 둘뿐입니다. 숨김 플래그, outline level, 축소 상태는 행마다 한 번 기록됩니다. 숨겨졌거나 <table:table-row-group> 안에 있는 반복 행은 그 속성들 각각에 대해 반복 행마다 항목 하나를 만들고, 저장할 때 그 항목 하나하나가 고유한 경계가 되므로 숨긴 구간은 행마다 하나의 <table:table-row>로 기록됩니다
직접 통제하는 템플릿이라면 시트에서 숨기고 묶는 부분을 데이터가 필요한 만큼만 유지하세요. 리포트 아래 스무 행을 숨기는 것은 공짜입니다. 46행부터 그리드 맨 아래까지 전부 숨기면 로드 때 백만 개의 항목과 저장 때 백만 개의 행 요소가 생기고, 눈에 보이는 차이라고는 아무도 리포트 너머로 스크롤할 수 없게 된 것뿐입니다
ODS 기능이 객체 모델의 어느 부분에 대응하는지를 포함한 형식의 워크북 수준 측면은 ODS 파일 열기와 저장 워크스루에 정리돼 있고, 큰 파일을 위한 다른 지레대들은 대형 워크북 성능 아래 모여 있습니다. 반복과 스타일 속성을 갖춘 행 요소 자체는 ODF 1.3 Part 3의 table 장에 정의돼 있습니다
백만 행 반복은 네이티브 reader가 잘 모델링해야 하는 형식 세부사항입니다. 뒤에서 그것을 흡수해 줄 오피스 스위트가 없기 때문입니다. HotXLS Delphi 스프레드시트 컴포넌트 페이지에 읽고 쓰는 XLS, XLSX, ODS 기능과 지원되는 Delphi·C++Builder 버전이 정리돼 있습니다