기술 문서

Delphi에서 HotXLS로 XLSX 워크시트 복제하기

시트 하나를 정확히 완성해 두었다고 하자. 헤더 밴드는 병합돼 있고, 열 너비는 데이터에 맞춰져 있으며, 상단 두 행은 고정돼 있고, 인쇄 영역과 여백도 깔끔한 A4 출력에 맞춰 설정돼 있다. 탭 색도 지정해 두어서 재무팀이 바로 찾을 수 있다. 이제 이런 시트가 지역별로 열두 개 더 필요하다. 모두 같은 레이아웃에서 시작해야 한다. 이 시트를 코드로 열두 번 다시 조립하는 순간부터 미묘한 드리프트가 스며든다. 7번째 지역 시트는 어떤 열이 1포인트 더 좁아지고, 11번째 시트는 freeze 설정이 빠지며, 그 사실은 PDF가 관리자 책상에 올라갈 때까지 아무도 눈치채지 못한다. 실제로 필요한 것은 Excel의 우클릭 메뉴에 있는 Move or Copy, Create a copy를 프로그래밍으로 수행하는 일이다. 완성된 시트를 기준으로 독립적인 사본을 찍어 내는 것이다

HotXLS의 XLSX 엔진은 원래도 시트 이동, 시트 삭제, 시트 간 셀 범위 복사는 할 수 있었다. 하지만 v2.91.0 전까지는 워크시트 전체를 한 번에 복제하는 기능이 없었다. 이 릴리스는 두 개의 진입점을 추가한다. TXLSXWorksheet.CopyFrom은 한 워크시트의 시트 수준 상태를 다른 워크시트에 복사하고, TXLSXSheets.Duplicate는 새 시트를 추가한 뒤 내부적으로 CopyFrom을 실행한다. 흥미로운 점은 단순히 무언가를 복사한다는 사실이 아니다. 무엇을 깊게 복사하고 무엇은 일부러 깊게 복사하지 않는지, 그리고 그 경계가 왜 거기에 놓였는지가 핵심이다

완성된 시트를 한 번의 호출로 복제하기

상위 수준 작업은 Duplicate다. 원본 시트의 1-based 인덱스를 넘기면, 원본의 레이아웃과 데이터를 반영한 완전히 새로운 워크시트가 반환된다. 이 인덱스 규칙은 XLSX 쪽의 Items[]와 맞춰져 있어서 첫 번째 시트는 0이 아니라 1이다. 범위를 벗어난 인덱스를 넘기면 예외 대신 nil이 돌아온다. 이는 XLSX 시트 컬렉션의 나머지 실패 계약과도 같은 방식이다

var
  Book: TXLSXWorkbook;
  Template, Copy: TXLSXWorksheet;
begin
  Book := TXLSXWorkbook.Create;
  try
    Template := Book.Sheets.Add('Template');
    Template.Cells[1, 1].Value := 'Quarterly Statement';
    Template.Range['A1:C1'].Merge;
    Template.ColWidth[1] := 18;
    Template.FreezePanes(2, 1);          // freeze top row + first column
    Template.TabColorIsAuto := False;
    Template.TabColor := $FF1F4E79;

    // Clone with an explicit name...
    Copy := Book.Sheets.Duplicate(1, 'Region-North');
    // ...or let it pick the Excel-style default name.
    Copy := Book.Sheets.Duplicate(1);    // -> "Template (2)"

    Book.SaveAs('regions.xlsx');
  finally
    Book.Free;
  end;
end;

이 예제에서 두 가지는 짚고 넘어갈 가치가 있다. 첫째, FreezePanes는 인자를 행 먼저 받는 FreezePanes(ARow, ACol) 형식이므로 Cells[Row, Col] 인덱싱과 나란히 맞물린다. 따라서 duplicate는 정확히 같은 freeze split을 물려받는다. 둘째, 메서드 이름은 Duplicate이고 더 직관적으로 보이는 Copy는 아니다. 이것은 단순한 스타일 취향의 문제가 아니다. CopySystem 유닛의 표준 루틴으로, 문자열과 동적 배열에서 매우 자주 사용된다. 클래스 위에 Copy라는 메서드를 두면 메서드 본문 안에서 그 이름을 가리게 되고, 결국 몇 달 뒤에야 발목을 잡는 이름 해석 모호성을 만들게 된다. Duplicate라는 이름은 그 문제를 통째로 피해 가며, 호출 위치에서도 의미가 올바르게 읽힌다

기본 이름은 Excel의 규칙을 따른다

인자 하나짜리 오버로드를 호출하거나 빈 이름 문자열을 넘기면, 새 시트 이름은 원본 이름 뒤에 (2) 접미사가 붙는 방식으로 결정되며, 그 이름이 이미 존재하면 숫자를 올리면서 유일한 이름이 될 때까지 계속 찾는다. Template 시트를 한 번 복제하면 Template (2)가 되고, 다시 복제하면 Template (2)가 이미 있으므로 Template (3)가 된다. 이것은 Excel의 Create a copy 명령이 만들어 내는 이름과 똑같아서, 코드가 만든 통합 문서도 사용자가 손으로 복제했을 때 기대하는 모양을 그대로 보이게 된다. 유일성 검사는 현재 살아 있는 시트 컬렉션 전체를 대상으로 하므로, 이전 duplicate가 만든 이름뿐 아니라 당신이 수동으로 만든 이름도 피해 간다

지역별이나 월별로 시트를 생성한다면 명시적 이름을 받는 오버로드를 쓰는 편이 낫다. Region-North, Region-South처럼 예측 가능한 이름 체계는 나중에 다시 참조하기 쉽고, defined name이나 시트 간 수식을 읽을 때도 (2), (3) 같은 접미사 나열보다 훨씬 알아보기 쉽다

CopyFrom이 깊게 복사하는 것

내부적으로 Duplicate는 새 시트를 추가한 다음 CopyFrom(ASource)을 호출한다. 이미 만들어 둔 시트 위에 복제하고 싶을 때는 이 메서드를 직접 호출해도 된다. CopyFrom은 먼저 퇴화 케이스 두 가지를 막는다. nil에서 복사하려는 경우와, 시트를 자기 자신 위에 복사하려는 경우다. 두 경우 모두 즉시 반환하고 아무것도 하지 않는다. 그 뒤부터가 실제 복사이며, 범위는 의도적으로 넓다

가장 먼저 셀 데이터가 이동한다. CopyFrom은 source에서 UsedRange, 즉 채워진 셀과 병합 영역을 포함하는 가장 타이트한 bounding box를 구한 뒤, 기존 CopyRangeTo 메커니즘을 재사용해 모든 값, 수식, 셀별 스타일 인덱스를 A1부터 시작하는 target으로 옮긴다. 그 위에 템플릿 시트를 완성된 것처럼 보이게 만드는 시트 수준 상태 전체를 다시 얹는다

  • 병합 범위는 좌표 기준으로 다시 만들어지므로 배너가 같은 사각형을 그대로 차지한다
  • 열 너비와 행 높이, 숨김 상태, collapsed 상태, outline-level 목록을 그대로 복사해 비기본 행과 열의 정렬을 동일하게 유지한다
  • freeze panes와 view 상태, 즉 zoom level, gridline 및 zero-value 표시, right-to-left 방향, view type까지 이어받는다
  • 시트 보호 상태와 각 동작별 permission 옵션도 복사하므로, 잠긴 템플릿은 같은 방식으로 잠긴 상태를 유지한다
  • 여백, 방향, 용지 크기, 배율과 fit-to-page, print area, print title, header/footer, print-gridlines와 print-headings 플래그까지 포함한 page-setup 블록 전체를 복제한다
  • AutoFilter 범위, 탭 색, 시트 visibility도 함께 옮겨 간다

결과적으로 source와 동일하게 인쇄되고, 필터링되고, 보이는 시트가 만들어진다. 셀, 병합, dimension 목록은 alias가 아니라 실제로 새 시트에 다시 만들어지므로 duplicate는 완전히 독립적이다. 사본의 셀 하나에 999를 써 넣어도 source는 원래 값을 유지한다. 지역별 보고서를 병렬로 뻗어나가게 만들 clone에서 이 독립성은 가장 중요한 속성이고, 배포된 SheetCopy 데모도 그 사실을 명시적으로 검증한다

얕게 남겨 두는 것과 그 이유

이제 솔직한 부분이다. 차트, 내장 이미지, XLSX table, data validation, conditional-formatting rule은 복사되지 않는다. 이것은 문서화된 의도적 경계이지, 빠뜨린 것이 아니다. 왜 그렇게 설계했는지를 이해해야 나중에 놀라지 않고 그 위에 워크플로를 짤 수 있다

이 컬렉션들은 모두 단순한 필드 복사로는 유지되지 않는 identity와 reference를 안고 있다. 차트는 source data range를 가리키고 OOXML 패키지 안의 drawing relationship를 소유한다. 관계와 series reference를 다시 매핑하지 않은 채 객체만 복제하면 차트가 잘못된 데이터를 바라보거나, Excel이 복구를 요구하는 패키지가 생긴다. table은 통합 문서 안에서 유일해야 하는 이름, 특정 열에 묶인 header row, 별도의 relationship를 가진다. conditional format와 data validation도 좌표 범위에 붙고, validation은 종종 다른 범위를 수식으로 참조한다. 이런 객체를 올바르게 깊게 복사하려면 reference를 다시 쓰고 새 identity를 발급해야 한다. 이는 실제 작업이며, 실제 실패 모드를 동반한다. 객체만 복사하고 reference를 고치지 않는 반쪽짜리 복사는 차라리 복사하지 않는 것보다 더 나쁘다. 파일을 열면 repair prompt가 뜨고, 조용히 내용이 사라질 수 있기 때문이다. 그래서 엔진은 깨끗하게 복사할 수 있는 것만 복사하고, reference를 재작성해야 하는 컬렉션은 caller에게 맡긴다. 대상이 무엇을 가리켜야 하는지 아는 쪽은 결국 caller이기 때문이다

실전 워크플로는 이렇게 된다. duplicate로 셀, 레이아웃, print setup을 먼저 얻고, 그 다음 차트, table, validation, conditional format를 사본 위에서 다시 만든다. 처음 만들 때 썼던 API를 그대로 다시 호출하면 된다. duplicate 쪽 범위를 대상으로 다시 생성하므로 reference는 설계상 올바르게 나온다. 예를 들어 차트가 A1:C10을 읽는다면, copy의 A1:C10을 가리키는 새 차트를 만들어야 한다. AutoFilter는 filter range 자체는 복사되므로, live criteria만 다시 적용하면 된다. conditional-formatting과 data-validation rule 역시 병합 셀과 보고서 템플릿 레이아웃 글에서 설명한 동일한 호출로 다시 추가할 수 있다. copy가 이미 해당 merge table과 range model을 물려받기 때문이다

복제가 보고서 파이프라인에 들어가는 위치

워크시트 복제는 placeholder 기반 생성과 자연스럽게 짝을 이룬다. Delphi의 템플릿 기반 보고서 생성 가이드가 다른 사람이 편집하는 레이아웃에 데이터를 써 넣는 문제를 해결한다면, duplicate는 그런 레이아웃이 한 통합 문서 안에서 여러 번 필요할 때의 문제를 해결한다. 둘을 합치면 패턴은 깔끔하다. 토큰, 병합, print setup이 들어 있는 깨끗한 Template 시트를 하나 유지해 두고, 지역이나 기간마다 Duplicate를 호출해 clone을 만든 뒤, 그 clone의 토큰을 해당 데이터 조각으로 채우고 다음으로 넘어간다. 원본 템플릿은 절대 변형되지 않으므로 다음 복제의 신뢰할 수 있는 소스로 남고, 모든 출력 시트는 바이트 수준으로 동일한 레이아웃에서 출발한다

순서에 관한 한 가지 습관이 불필요한 혼란을 막아 준다. 데이터를 채운 뒤 복제하지 말고, 먼저 복제한 뒤 채워 넣어야 한다. 템플릿은 구조와 서식을 담는 곳이지 지난 분기 숫자를 담는 곳이 아니다. 비어 있지만 서식이 완성된 시트를 복제해야 각 duplicate가 깨끗한 상태에서 시작한다. 이미 데이터가 들어 있는 시트를 복제하면 그 데이터도 함께 따라온다. CopyFrom이 used range를 충실하게 복사하기 때문이다. 때로는 그것이 원하는 동작일 수 있지만, fan-out 보고서에서는 대개 그렇지 않다

짧은 검증 습관

깊은 복사와 얕은 복사의 경계는 찾기 전까지는 눈에 잘 보이지 않으므로, 모든 것이 잘 들어왔을 것이라고 믿기보다 다섯 줄짜리 점검을 작업에 넣는 편이 낫다. duplicate한 뒤, copy가 물려받아야 하는 구조적 신호를 다시 읽어 source와 같음을 확인하라

Copy := Book.Sheets.Duplicate(1, 'Region-North');
WriteLn(Format('merged=%d  colA=%.1f  freezeRow=%d  tabAuto=%d',
  [Copy.MergedCells.Count, Copy.ColWidth[1],
   Copy.FreezeRow, Integer(Copy.TabColorIsAuto)]));
// Prove independence: mutate the copy, confirm the source is untouched.
Copy.Cells[2, 2].Value := 999;
// Template.Cells[2, 2].Value is still whatever it was.

병합 개수, 특정 열 너비, freeze row, tab-colour flag는 실제로 복사돼야 하는 층이 제대로 들어왔는지 알려 준다. 따로, 차트나 table, validation, conditional format가 있던 시트에서는 그것들을 사본에서 다시 만들어야 할 목록으로 취급해야 한다. 빠져 있는 것은 설계에 따른 결과이며, 해결책은 버그 리포트가 아니라 몇 번의 재구성 호출이다. 안전한 곳은 깊게 복사하고, reference가 깨질 곳은 얕게 남긴다는 이 모델이야말로 이 기능을 제대로 쓰는 전체 요약이다

여기서 설명한 워크시트 복제와 CopyFrom 시트 상태 복사는 네이티브 HotXLS Delphi spreadsheet component v2.91.0에 포함되어 있으며, clone 후 mutate까지 한 사이클을 끝까지 실행하는 SheetCopy 샘플도 함께 제공된다