기술 문서

Delphi에서 VBA 소스 다시 쓰기와 MS-OVBA 재압축

천 개의 매크로 사용 보고서 템플릿 전체에 걸쳐 하드코딩된 워크시트 참조 이름을 바꾸는 작업은 각 파일을 VBA 편집기에서 손으로 여는 것을 배제한다. 네이티브 Delphi/C++Builder Excel 컴포넌트인 HotXLS는 VBA 모듈의 소스를 편집 가능한 SourceCode 속성으로 노출하고, Microsoft가 VBA 저장소를 위해 정의한 MS-OVBA 압축 알고리즘으로 모든 편집을 재압축해 고전 XLS VBA 저장소, 독립 VBA 프로젝트 파일, 매크로 사용 XLSM 워크북 중 어디로든 결과를 다시 쓰는 방식으로 이 경우를 처리한다. 이 경로 어디에도 Excel 인스턴스도, VBA 편집기도, 매크로 레코더도 관여하지 않는다

VBA 모듈 스트림이 텍스트 파일이 아닌 이유

XLS 워크북이나 독립 VBA 프로젝트 파일 안의 VBA 모듈은 읽히기를 기다리며 스트림에 앉아 있는 소스 텍스트가 아니다 — 이는 작은 이진 컨테이너다. 먼저 컴파일된 성능 캐시가 오는데, 이는 캐시가 여전히 호스트 버전과 일치할 때 로드 시 모듈 재컴파일을 건너뛰기 위해 Office가 사용하는 바이트다. 그 뒤에 실제 소스 텍스트가 이어지는데, VBA 저장소를 위해 MS-OVBA가 구체적으로 정의하는 독자적인 압축 방식을 거친다. 이 방식은 zip도, deflate도, Windows 압축 API가 기본으로 만들어내는 어떤 것도 아니다. 바로 이 때문에 대부분의 서드파티 Excel 라이브러리는 모듈의 소스를 읽을 수 있으면서도 — 압축 해제는 이 문제의 더 쉬운 절반이다 — 그것을 다시 쓰는 데는 미치지 못한다. 재압축은 미묘하게 잘못된 비트 하나가 Excel이 열기를 거부하는 파일을 만들어내는 지점이기 때문이다. 읽기 쪽에 대한 공개된 글은 존재하지만, 기존 모듈을 검사용으로 풀어헤치는 것을 넘어 실제로 재압축을 수행하는 쓰기 쪽 구현은 드물어서, 이는 Excel 파일 형식 중 가장 덜 문서화된 구석 중 하나로 남아 있다

HotXLS의 SourceCode 속성은 실제로 무엇을 바꾸는가?

HotXLS는 모든 VBA 모듈을 순수한 SourceCode: WideString 속성을 가진 TXLSVBAModule 객체로 표현하며, 여기에 새 값을 대입하는 것은 보이는 그대로 단순하다: 모듈은 메모리 상에서 변경됨으로 표시되고, 프로젝트가 저장될 때까지 밑에 있는 OLE 스트림은 전혀 건드려지지 않는다. 프로젝트 자체는 고전 XLS 엔진의 IXLSWorkbook.VBAProject나 OOXML 매크로 사용 엔진의 TXLSXWorkbook.ParsedVBAProject에서 나오며, 둘 다 TXLSVBAProject를 반환한다. 그 모듈들은 1부터 시작하는 Item[] 인덱서와 Count 속성 뒤에 자리하므로, 워크북의 모든 모듈에 걸친 일괄 편집은 그저 정수 범위에 대한 루프일 뿐이다

var
  Wb: TXLSWorkbook;
  Project: TXLSVBAProject;
  I: Integer;
  Updated: WideString;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('MonthlyReport.xls');
    if Wb.HasVBAProject then
    begin
      Project := Wb.VBAProject;
      for I := 1 to Project.Count do
      begin
        Updated := StringReplace(Project[I].SourceCode,
          'ReportSheet2025', 'ReportSheet2026', [rfReplaceAll]);
        if Updated <> Project[I].SourceCode then
          Project[I].SourceCode := Updated;   // marks the module dirty
      end;
      Wb.SaveAs('MonthlyReport.xls');          // recompresses on write
    end;
  finally
    Wb.Free;
  end;
end;

이 루프는 감사 패스의 모양이기도 하다. 천 개의 템플릿을 건드리기 전에, 대부분의 팀은 먼저 그중 실제로 매크로를 담고 있는 것이 몇 개고 그 매크로들이 무엇을 참조하는지 알고 싶어 하는데, 이는 워크북 감사 및 변환 워크벤치 뒤에 있는 시나리오다 — 여기서 다시 쓰기 루프를 구동하는 것과 같은 Project.Count가 그곳에서는 파일별 매크로 집계가 된다

MS-OVBA 압축 컨테이너 내부

MS-OVBA의 압축 형식은 소스 바이트를 스펙이 CompressedContainer라고 부르는 것으로 포장한다: 반드시 0x01이어야 하는 서명 바이트 하나, 그 뒤로 각각 최대 4096바이트의 압축 해제된 데이터를 커버하는 CompressedChunk 블록의 시퀀스다. 16비트 청크 헤더는 세 필드를 담는다 — 반드시 3이어야 하는 3비트 서명, 12비트 크기 필드, 그리고 청크의 페이로드가 리터럴 바이트인지 토큰 압축된 시퀀스인지 표시하는 CompressedChunkFlag 비트다. 이 플래그가 설정되면 페이로드는 플래그 바이트가 앞에 붙은 8개 토큰 그룹의 연속이며, 각 토큰은 단일 리터럴 바이트이거나 CopyToken이다: 같은 청크 안에서 이미 압축 해제된 이전 바이트로의 오프셋/길이 역참조인데, 오프셋과 길이 사이의 비트 폭 분할은 압축 해제기가 현재 청크 안에서 얼마나 깊이 들어왔는지에 따라 달라진다. MS-OVBA의 이 부분(§2.4.1, Compression and Decompression)이야말로 손으로 만든 구현이 그 비트 폭 계산의 off-by-one 오류로 하루를 날려버리는 경우가 가장 흔한 곳이다

HotXLS는 왜 토큰 매칭 대신 원시 청크를 쓰는가

HotXLS의 쓰기 경로는 그 알고리즘의 토큰 매칭 절반을 완전히 우회한다. 편집된 모듈을 재압축할 때, 모든 청크는 CompressedChunkFlag가 해제된 채로 나가는데, 이는 청크가 역참조 토큰이 아니라 리터럴 바이트를 담는다는 뜻이다 — MS-OVBA 하에서 이는 합법적인데, 압축된 컨테이너는 전부 압축되지 않은 청크로만 구성되는 것이 허용되기 때문이다. 그리고 이는 손으로 정확히 처리하기 가장 어려운 알고리즘 부분, 즉 유효한 역참조를 찾아 오프셋/길이 쌍을 청크 안의 현재 위치에 따라 달라지는 비트 폭에 채워 넣는 작업을 정확히 제거해 버린다. 이 절충은 정확성이 아니라 파일 크기에서 드러난다 — 다시 쓰인 모듈 스트림은 완전히 토큰 압축된 청크만큼 작아지지는 않고, 원본 소스 텍스트 크기에 4096바이트 블록당 2바이트 헤더를 더한 정도에 가까워진다. Excel을 포함해 스펙의 압축 해제 쪽을 구현하는 모든 리더는 여전히 그 결과를 올바르게 연다. 원시 청크도 토큰 압축된 청크만큼이나 유효한 CompressedChunk이기 때문이다

모듈을 다시 쓸 때 HotXLS가 건드리지 않는 것

재압축은 모듈 스트림의 일부만 교체한다. 모든 모듈 스트림은 먼저 성능 캐시를, 그다음 압축된 소스를 저장하며, 프로젝트의 dir 스트림은 각 모듈에 대해 그 경계가 정확히 어디에 떨어지는지를 MODULEOFFSET 항목에 기록한다. HotXLS는 그 오프셋을 읽고, 그 이전의 모든 바이트는 발견한 그대로 정확히 유지하며, 압축된 컨테이너만 그 오프셋부터 다시 만든다

소스 텍스트 자체는 UTF-8이 아니라 VBA 프로젝트 자체의 코드 페이지를 통해 왕복한다 — 이는 애초에 Office가 그 프로젝트를 작성할 때 사용한 것과 같은 레거시 코드 페이지다. 그 코드 페이지의 문자 집합 밖의 문자를 도입하는 SourceCode 편집은, HotXLS가 문자열을 바이트로 다시 인코딩할 때 거부되는 대신 조용히 최적 근사 대체 문자로 치환된다. 그래서 코멘트나 문자열 리터럴에 떨어진 흔치 않은 지역 문자가 이 손실을 알아차릴 가장 유력한 지점이다. 같은 프로젝트 안의 외부 참조와 라이브러리 바인딩은 관련되어 있지만 별개인 보존 경로를 따르며, 이는 VBA 외부 링크 보존에 관한 자매 글에서 다룬다. 다시 쓰기 패스가 다른 워크북이나 타입 라이브러리에 링크된 프로젝트를 건드리기 전에 읽어볼 가치가 있다

다시 쓴 매크로를 워크북에 어떻게 되돌려 넣는가?

재압축 단계를 명시적으로 호출하는 것은 없다 — 이는 워크북이나 독립 VBA 프로젝트가 저장되는 순간 자동으로 실행된다. TXLSVBAProject.ApplyChanges는 모든 모듈을 순회하며 마지막 저장 이후 SourceCode가 바뀐 것들을 재압축하고 그 모듈의 스트림만 다시 쓴다. 저장 대상이 파일의 원래 형식을 유지할 때의 고전적인 TXLSWorkbook.SaveAs와, 매크로 사용 XLSM 패키지를 위한 OOXML TXLSXWorkbook.SaveAs 둘 다 디스크에 무언가 쓰이기 전에 내부적으로 이를 호출하며, SaveVBAProjectToFile도 대상이 전체 워크북이 아니라 분리된 VBA 프로젝트 파일일 때 같은 메서드를 호출한다

var
  Wb: TXLSWorkbook;
begin
  Wb := TXLSWorkbook.Create;
  try
    if Wb.LoadVBAProjectFromFile('LegacyMacros.ole') = 1 then
    begin
      Wb.VBAProject[1].SourceCode :=
        StringReplace(Wb.VBAProject[1].SourceCode, 'OldServer', 'NewServer', [rfReplaceAll]);
      Wb.SaveVBAProjectToFile('LegacyMacros_Patched.ole');  // ApplyChanges runs internally
    end;
  finally
    Wb.Free;
  end;
end;
var
  Xlsx: TXLSXWorkbook;
  Project: TXLSVBAProject;
begin
  Xlsx := TXLSXWorkbook.Create;
  try
    Xlsx.Open('Dashboard.xlsm');
    Project := Xlsx.ParsedVBAProject;
    if Assigned(Project) then
    begin
      Project[1].SourceCode := StringReplace(Project[1].SourceCode,
        'ConnStringV1', 'ConnStringV2', [rfReplaceAll]);
      Xlsx.SaveAs('Dashboard.xlsm');   // SyncParsedVBAProject recompresses before the part is written
    end;
  finally
    Xlsx.Free;
  end;
end;

세 대상 모두 밑에서는 동일한 SourceCodeApplyChanges 메커니즘을 공유한다. 이들 사이의 유일한 진짜 차이는 어느 저장 호출이 재압축을 촉발하는지뿐이다

여전히 깨지는 지점

다시 쓰기 패스를 프로덕션 파일에 실행하기 전에 대비할 만큼 흔한 실패 모드 두 가지가 있다. 디지털 서명된 VBA 프로젝트는 소스가 바뀌는 순간 유효하게 서명된 상태이기를 멈추는데, 서명이 프로젝트의 콘텐츠를 커버하기 때문이다. HotXLS는 여러분을 대신해 프로젝트를 다시 서명할 방법이 없으며, Excel은 파일이 다음에 열릴 때 서명을 삭제하거나 표시한다. 그래서 서명된 매크로 프로젝트는 그 서명이 워크플로가 실제로 확인하는 무언가라면 다운스트림에 다시 서명하는 단계가 필요하다. 두 번째 실패 모드는 이미 이를 처리하는 라이브러리를 쓰는 대신 이 압축 형식을 처음부터 직접 재구현하려는 유혹에 빠진 사람들의 것이다: 청크 헤더 안의 서명 니블이든, 크기 필드든, 압축 플래그든 단 하나의 잘못된 비트가 Excel이 열기를 거부하는 파일을 만들어내며, 보통 어느 바이트가 잘못되었는지 아무 힌트도 주지 않는 일반적인 손상 경고 뒤에 숨는다 — 이는 정확히 앞서 설명한 원시 청크 쓰기 전략이 피하려는 그 부류의 버그다

이 중 어느 것도 사용하기 위해 형식을 리버스 엔지니어링할 필요가 없다. Delphi와 C++Builder 개발자는 SourceCode 읽기·쓰기 접근, MS-OVBA 준수 재압축, 그리고 여기서 설명한 세 가지 저장 대상 모두를 나머지 고전 XLS·OOXML 워크북 API와 함께 표준 HotXLS 컴포넌트의 일부로 얻는다