기술 문서

Delphi HotXLS의 워크북 간 복사와 수식 재바인딩

HotXLS의 AddCopy 메서드는 워크시트를 한 Excel 워크북에서 다른 워크북으로 복사할 때, 컴파일된 수식 트리를 직접 복사하는 대신 그 시트의 모든 수식을 A1 스타일 텍스트로 역컴파일한 뒤 대상 워크북 안에서 그 텍스트를 다시 컴파일한다. 차트 시리즈 참조, 서식 있는 텍스트 폰트 인덱스, 외부 링크 번호 매김이 모두 워크북 파일마다 독립적으로 할당되기 때문이다

이 실패는 정확히 예상할 법한 워크북에서 드러난다: 각 지사 보고서에서 시트 하나씩을 뽑아 요약 파일에 덧붙이는 월말 작업이다. 결과 파일을 열어보면 소계 차트는 완전히 다른 지사의 숫자를 그리고, 원본에서 굵은 빨간색이던 메모는 다시 평범한 검은 텍스트가 되어 있으며, 한때 자매 조회 워크북에서 세율을 가져오던 수식은 이제 아무도 설명할 수 없는 고정된 숫자를 보여준다. 여기서는 어떤 예외도 발생하지 않는다 — 파일은 열리고, 숫자는 그럴듯해 보이며, 누군가 옆에 있는 차트의 제목이 잘못되었다는 것을 눈치챌 때까지 그 손상은 그 자리에 그대로 남아 있다

AddCopy는 왜 컴파일된 수식 트리를 그냥 복사할 수 없는가?

AddCopy가 컴파일된 수식 트리를 변경 없이 옮길 수 없는 이유는, 컴파일된 BIFF 수식이 독립적인 텍스트가 아니기 때문이다 — 이는 토큰의 시퀀스이며, 그 토큰 중 여럿은 그것을 만들어낸 워크북 안에서만 올바르게 해석되는 작은 정수다. Sheet2!A1:A10 같은 3D 참조는 컴파일되고 나면 리터럴 이름 Sheet2를 담지 않는다; 대신 BIFF 스펙이 ixti라고 부르는 필드(HotXLS는 자신의 컴파일된 트리에서 같은 값을 FExternID 필드명으로 유지한다)를 담는데, 이는 그 워크북의 비공개 EXTERNSHEET 테이블 안으로의 인덱스로, 그 특정 워크북이 자신의 시트와 외부 워크북을 어떤 순서로 등록했든 그에 따라 번호가 매겨진다. 그 토큰을 EXTERNSHEET 테이블이 다른 순서로 만들어진 워크북으로 변경 없이 옮기면 인덱스 3은 더 이상 Sheet2를 의미하지 않는다 — 그저 그쪽에서 슬롯 3을 차지하고 있는 시트가 무엇이든 그것을 의미하게 되며, Excel에는 그 실수를 표시할 방법이 전혀 없다. 파일 형식 입장에서 보면 그 수식은 완벽하게 형식이 올바르기 때문이다. 이것이 정확히 TXLSWorksheets.AddCopy가 피하기 위해 존재하는 실패다: Delphi나 C++Builder 코드에서 어느 워크북의 자체 시트 컬렉션에서든 호출할 수 있으며, 셀 값·서식·수식·차트·코멘트·병합·페이지 설정 등을 포함해 워크시트를 여러분이 호출하고 있는 워크북일 수도 있고 아닐 수도 있는 소스 워크북에서 복사해, 선택한 이름이나 원본을 구분한 이름으로 대상에 덧붙인다

var
  Summary, Branch: IXLSWorkbook;   // interface-counted: do not Free
begin
  Summary := TXLSWorkbook.Create;
  Branch := TXLSWorkbook.Create;
  Branch.Open('branch-east.xls');

  // Appends a copy of Branch's first sheet onto Summary, renamed to
  // stay unique inside the destination workbook
  Summary.Sheets.AddCopy(Branch.Sheets[1], 'East Detail');
  Summary.SaveAs('consolidated.xls');
end;

해법: 텍스트로 역컴파일한 뒤 대상에서 다시 컴파일하기

HotXLS는 컴파일된 트리 자체가 워크북 경계를 절대 넘지 않게 함으로써 이 인덱싱 문제를 해결한다. 워크북 간 복사 시 모든 수식 셀에 대해, AddCopy는 소스 수식을 사용자가 Excel의 수식 입력줄에서 보게 될 것과 같은 A1 스타일 텍스트로 역컴파일한 다음, 그 텍스트를 대상 워크북에 넘긴다. 대상 워크북은 자신의 테이블을 사용해 그것을 처음부터 다시 트리로 파싱한다 — Data!D2:D100 같은 시트 한정 참조는 그 시점에는 그저 문자열일 뿐이고, 문자열은 어느 워크북에서든 같은 것을 의미하므로, 대상이 이미 Data라는 이름의 시트를 가지고 있다면 그 참조는 변환할 원시 인덱스가 애초에 오간 적이 없으므로 인덱스 변환 없이 올바르게 해석된다. HotXLS는 필요할 때만 이 왕복 비용을 치른다: 같은 워크북 안에서 시트를 복사하는 것은 더 저렴한 경로를 택하는데, 컴파일된 트리를 그저 메모리 안에서 복제할 뿐이다. 그 안의 모든 인덱스가 남아 있을 곳에서 이미 유효하기 때문이다. 텍스트 우회는 AddCopy가 소스와 대상이 진짜로 서로 다른 워크북 인스턴스임을 감지했을 때만 실행된다. 이 재작성이 무엇이 아닌지도 정확히 짚어둘 가치가 있다. 이는 단일 시트 안에서 행이나 열을 삽입하거나 삭제할 때 실행되는 행·열 이동과는 아무 관련이 없으며, 이는 자매 글에서 자세히 다룬다 — 그 엔진은 한 워크북 안에서 몇 행 위나 아래로 움직인 셀을 추적하기 위해 A1 텍스트를 제자리에서 다시 쓰지만, 이 엔진은 수식이 자신을 컴파일한 워크북을 완전히 떠날 때 실행되며, 여기서는 이동한 행이 문제가 아니라 워크북 고유의 번호 매김이 문제다

// Conceptually, this is what AddCopy does for each formula cell: turn
// the compiled tree back into text using the source workbook's own
// tables, then let the destination workbook parse that text back into
// a tree using its own tables, from scratch
FormulaText := SourceBook.GetUnCompiledFormula(SourceFormula, Row, Col, SourceSheetID);
DestFormula := DestBook.GetCompiledFormula(FormulaText, DestSheetID);

대상에 아직 그 시트나 그 이름이 없다면 어떻게 되는가?

AddCopy의 재컴파일은 대상 워크북이 그 수식 텍스트가 참조하는 모든 것을 이미 가지고 있을 때만 성공하며, 실무에서 드러나는 두 가지 공백은 이 배치에서 아직 복사되지 않은 동명의 시트, 그리고 대상에 애초에 존재한 적 없는 워크북 범위의 정의된 이름이다. HotXLS는 시트 복사 도중 재컴파일이 실패해도 예외를 일으키지 않는다 — 셀의 Value 대입은 조용히 수식 텍스트를 평범한 문자열로 저장하는데, 이는 조용한 실패가 아니라 의도적이고 조사 가능한 실패 모드다. =SUM(Q1!B2:B12) 같은 리터럴 텍스트가 계산된 숫자 대신 예상 밖으로 나타난 수식 셀이 있다면, 복사 과정 어딘가에서 무언가가 해석되지 않았다는 신호이기 때문이다. 포기하기 전에 AddCopy는 한 가지 복구를 시도한다: 실패한 수식의 구문 트리를 순회하며 그 수식이 건드리는 모든 정의된 이름 ID를 수집하고, 소스에는 존재하지만 대상에는 아직 없는 워크북 범위 이름마다 그 이름을 옮겨온 뒤 같은 텍스트를 두 번째로 재컴파일한다. 시트 범위 이름은 이 복구가 고칠 수 있는 범위 밖에 있는데, 소스 워크북의 한 시트에 있는 수식에만 보이는 이름은 옮겨갈 동등한 슬롯이 없기 때문이다. 그리고 대상이 이미 같은 철자의 이름을 가지고 있다면 덮어쓰지 않고 그대로 둔다. 호출자가 의도적으로 미리 만들어 둔 이름이야말로 존중받아야 할 이름이라는 가정에서다. 단일 워크북 안에서는 시트 간 수식의 이름 조회가 시트 범위에서 워크북 범위로 자동으로 올라가며, 이는 HotXLS의 정의된 이름과 시트 간 수식에 관한 글이 다루는 메커니즘이다; 실제 워크북 경계를 넘는 것은 그 안전망을 완전히 제거하며, 이름은 의도적으로 함께 옮겨져야 하고 그렇지 않으면 그것에 의존하는 수식은 텍스트로 격하된다

차트 시리즈 참조는 같은 수정이 필요하지만 다른 코드 경로를 거친다

셀 범위를 그리는 HotXLS 차트 시리즈는 정확히 같은 번호 매김 문제에 부딪히는데, 차트의 데이터 범위 참조도 컴파일된 수식 토큰 스트림이기 때문이다 — BIFF 스펙은 이를 담는 레코드를 BRAI([MS-XLS] 2.4.51절)라고 부른다 — 하지만 AddCopy는 일반적인 차트 로딩 경로를 재사용해 이를 고칠 수 없는데, 그 경로 자체가 바로 이 버그를 만들어내는 경로이기 때문이다. 파일을 여는 일반적인 과정에서 차트 레코드가 디스크에서 파싱될 때, 그 수식 트리는 파싱을 수행하는 계산기 인스턴스가 무엇이든 그것을 통해 원시 바이트를 번역함으로써 만들어진다; 소스 차트의 원시 BRAI 바이트를 대상 워크북 자체의 일반적인 레코드 로더에 대신 넣으면, 그 바이트 안에 임베드된 ixti는 대상의 EXTERNSHEET 테이블에 대해 해석되어, 시리즈는 그쪽에서 그 슬롯을 차지하는 시트를 조용히 가리키게 된다 — 셀의 컴파일된 트리를 변경 없이 복사하는 것과 같은 부류의 실수이지만, 아무도 셀 수식을 읽는 방식으로 차트 시리즈 수식을 읽지 않기 때문에 알아차리기가 더 어렵다. HotXLS는 대신 전용 클론 경로로 이 함정을 피한다: TXLSCustomChart.AssignFrom은 각 차트 레코드 자체의 수식 아닌 헤더 바이트를 그대로 복사한 뒤, 일반 셀에 쓰이는 것과 같은 역컴파일-재컴파일 원시 기능을 통해 붙어 있는 범위를 재구성한다. 그래서 새 트리는 사후에 대상의 EXTERNSHEET 테이블에 대해 재해석되는 것이 아니라 처음부터 그 테이블에 맞춰 구성된다

같은 번호 매김 문제, 이번엔 폰트 인덱스 한 번에 하나씩

차트나 서식 있는 텍스트 셀 안의 워크북 로컬 숫자가 모두 수식은 아니며, 폰트 인덱스는 같은 부류의 문제를 축소판으로 보여준다. 서식 있는 텍스트 런, 그리고 캡션이나 축 폰트를 담는 두 가지 추가 차트 레코드 유형은 폰트 참조를 소유 워크북 자체의 폰트 테이블로의 원시 정수 인덱스로 저장하며, 그 인덱스는 다른 워크북의 테이블에서는 아무 의미가 없다 — 그쪽에서는 완전히 다른 서체, 크기, 색을 가리킬 수도 있다. HotXLS는 숫자가 아니라 값으로 이를 해결한다: 소스 테이블의 그 인덱스에서 실제 폰트 속성을 조회하고, 대상의 폰트 테이블에서 일치하는 항목을 찾거나 만든 뒤, 저장된 인덱스를 그 새 슬롯을 가리키도록 다시 쓴다. 형식상의 특이점 하나가 이 조회 자체를 까다롭게 만든다 — 파일 상의 인덱스는 슬롯 4를 건너뛰는데, 이는 [MS-XLS] 2.5.339절이 문서화하는 번호 매김 간격이다. 그래서 코드는 폰트를 비교하기 전에 인덱스를 1만큼 아래로 옮기고 결과를 쓰기 전에 다시 1만큼 위로 옮겨야 한다

// The file-numbered font index skips slot 4 (MS-XLS section 2.5.339);
// shift into the in-memory slot, migrate the font by value if the
// destination differs, then shift back before writing the result
if Ifnt >= 5 then
  Dec(Ifnt);
if DestFonts.Key[Ifnt] <> SourceFonts.Key[Ifnt] then
  Ifnt := DestFonts.SetKey(0, SourceFonts.Key[Ifnt]);
if Ifnt >= 4 then
  Inc(Ifnt);

이미 워크북 밖을 가리키는 수식은 어떻게 되는가?

AddCopy를 호출하기도 전에 이미 세 번째 워크북을 참조하는 수식은 텍스트 왕복이 감당할 수 없는 경우인데, HotXLS 자체의 수식-텍스트 역컴파일러는 외부 참조에 대해 [Book]Sheet! 형식의 대괄호 텍스트를 의도적으로 합성하지 않으며, 반대편의 컴파일러도 그런 구문을 입력으로 받아들이지 않기 때문이다 — 그래서 이 한 가지 경우는 텍스트를 전혀 건드리지 않는 두 번째 메커니즘을 거친다. 위에서 설명한 이름 마이그레이션 복구를 거쳐도 여전히 셀이 문자열로 남고, 소스 워크북이 실제 파일 이름을 가지고 있다면, AddCopy는 전략을 바꾼다: 텍스트가 아니라 컴파일된 수식 트리 자체를 깊은 복사한 뒤, 노드 단위로 순회하는 전용 재바인딩 패스인 RebindExternRefsInTree에 그 복사본을 넘긴다. 발견하는 모든 범위 참조에 대해 이 패스는 소스의 EXTERNSHEET 항목을 시트 이름 쌍으로 다시 해석하고, 대상 자체의 외부 참조 테이블 안에 동등한 항목을 등록하거나 재사용하며, 대상이 그 소스 파일을 참조한 적이 한 번도 없었다면 완전히 새로운 외부 워크북 링크를 만들어낸다

여기가 워크북 로컬 번호 매김 문제가 가장 노골적으로 드러나는 지점인데, 외부 참조 토큰은 서로 별개인 세 개의 좌표를 하나의 필드로 묶으며 그 각각이 그것을 작성한 워크북에 비공개이기 때문이다: 어느 외부 워크북인지 — 대상 자신의 외부 워크북 목록 안의 슬롯으로, 그 워크북이 그것들을 등록한 순서가 무엇이든 그에 따라 할당된다; 그 외부 워크북 자체의 시트 목록 안의 어느 시트인지 — 그 특정 외부 워크북에 한정된 1부터 시작하는 인덱스로 저장되며, 대상 자체의 내부 시트 ID와는 완전히 다른 번호 매김 영역이다; 그리고 셀 범위 자체 — 애초에 워크북 상대적이었던 적이 없으므로 번역이 필요 없는 순수한 행·열 좌표다. 처음 둘 중 어느 하나라도 틀리면 Excel은 여전히 파일을 열고, 여전히 수식을 보여주며, 아무 불만 없이 잘못된 외부 셀에 대해 그것을 평가한다. 이 트리 수준 재바인딩조차 해결하지 못하는 노드 종류가 한 가지 있다: 정의된 이름에 대한 참조는 시트 인덱스가 자신의 EXTERNSHEET에 비공개인 것과 똑같이 자신의 워크북 비공개 이름 테이블로의 인덱스이며, 트리 수준에서 이에 상응하는 복구는 존재하지 않는다 — 재바인딩 순회가 트리 어디에서든 이름 참조를 만나는 순간, 부분적으로 올바른 수식을 써넣는 대신 그 수식 전체를 포기한다. 재바인딩이 성공하더라도 대상 셀은 새로 재계산된 숫자를 보여주지 않는다; 복사 시점에 소스 셀이 이미 가지고 있던 값을 보여주는데, 이는 Excel 자체가 링크를 명시적으로 새로 고치기 전까지 다른 파일로의 외부 참조의 마지막으로 알려진 값을 캐시하는 것과 같은 방식으로 캐시된 슬롯에 보관된다. 이것이 올바른 기본값인데, 다른 파일로의 살아있는 링크를 가로질러 재계산하는 것은 열 때마다가 아니라 명시적으로 한 번 촉발하고 싶은 종류의 작업이기 때문이다

이 설계가 치르는 비용

AddCopy의 역컴파일-재컴파일 메커니즘은 공짜가 아니며, 그 비용은 대규모 통합 작업을 스크립트로 짜기 전에 미리 계획해 둘 가치가 있다. 같은 워크북 안에서 시트를 복사하는 것은 저렴한 경로, 즉 컴파일된 트리를 메모리 안에서 그대로 복제하는 경로를 택하는데, 그 안의 모든 인덱스가 남아 있을 워크북 안에서 이미 유효하기 때문이다; 워크북 간 복사는 대신 모든 수식 셀마다 진짜 파싱 비용을 치른다 — 텍스트로 역컴파일한 뒤 그 텍스트를 처음부터 다시 컴파일한다. 수십 개 정도의 수식을 가진 시트에서는 그 차이를 측정할 가치도 없지만, 배치 작업에서 수십 개 시트 중 하나로 복사되는 수만 개 수식 셀을 가진 소스 워크북이라면 재컴파일이 주변의 파일 I/O가 아니라 실행 시간 자체를 지배할 것으로 예상해야 한다. 복사 순서는 속도 외의 또 다른 이유로도 중요하다: AddCopy가 이 배치에서 아직 도달하지 않은 시트를 참조하는 수식은 진짜로 존재하지 않는 시트를 참조하는 수식과 같은 이유로 재컴파일에 실패하므로, 그것에 의존하는 시트 A의 수식보다 시트 B를 먼저 복사하는 작업은 그 수식이 위에서 설명한 그대로 문자열 텍스트나 방금 왔던 소스 파일을 다시 가리키는 외부 링크 폴백으로 격하되는 것을 보게 될 것이다. 그리고 통합 배치 안의 각 소스 워크북은 보통 독립적으로 작성되므로, 어떤 개별 소스 파일도 절대 미리 경고할 수 없었을 한 가지 실패 모드를 명시적으로 테스트해 볼 가치가 있다 — 각자 동료 지사의 숫자를 합산하는 다섯 개의 지사 워크북은, 개별 소스 파일 어느 것에도 순환 참조가 전혀 없었음에도, 요약 워크북 안에서 진짜 순환 참조로 결합될 수 있다. 이 순환은 모든 시트가 같은 곳에 도착하고 재계산이 그 결합된 집합 전체에 대해 실행되고 나서야 비로소 존재하게 된다

워크북 간 워크시트 복사는 Delphi와 C++Builder용 HotXLS Delphi Excel 컴포넌트AddCopy 표준 동작으로 제공된다; 제품 페이지에는 여기서 설명한 차트, 서식 있는 텍스트, 외부 참조 동작을 포함한 전체 워크시트·워크북 API 레퍼런스가 실려 있다