기술 문서

HotXLS, zlib-ng CRC32, 그리고 Delphi 스레드의 스택 오버플로우

HotXLS는 대용량 워크시트 XML 파트를 한 번의 호출로 체크섬 계산할 때 Delphi 워커 스레드를 잡을 수 없는 예외와 함께 크래시시킬 수 있다: zlib-ng는 입력이 대략 119KB를 넘으면 자신의 Chorba 알고리즘으로 전환하는데, 그 알고리즘의 제네릭 C 변형은 기본 1MB 스레드 스택을 뚫고 나갈 만큼 큰 스크래치 배열을 할당한다. Delphi는 반응할 기회조차 얻지 못하는데, 스택 오버플로우는 try/except가 잡도록 만들어진 종류의 예외가 아니기 때문이다

HotXLS는 Excel 워크북을 읽고 쓰는 네이티브 Delphi/C++Builder 라이브러리이며, 이 크래시는 그 워크시트 라이터까지 추적되었다. 문제의 첫 신호는 지원 티켓이었다: 야간 내보내기 작업이 일주일에 대략 두 번 크래시했는데, 항상 실행 도중이었고, Delphi 예외 대화상자도 없었고 기록된 오류도 없었으며, 그저 프로세스가 사라지고 아무 쓸모없는 곳을 가리키는 Windows 오류 보고 항목만 남았다. 책상에서 이를 재현하는 것은 완전히 다른 문제였다. 작은 워크북은 잘 저장되었다. 큰 워크북도 저장이 메인 스레드에서 실행되고 디버거가 이미 붙어 있는 한 잘 저장되었다. 이 크래시를 실제로 재현하는 데는 실제 다중 스레드 내보내기 경로를 통과하는 진짜 프로덕션 크기 파일 배치가 필요했으며, 그 시점에는 디스크 I/O, 메모리 압박, 의심스러운 템플릿이 각각 이미 배제된 뒤였다

워크시트 저장이 어떻게 하나의 거대한 CRC32 호출이 되는가

XLSX 파일은 ZIP 컨테이너이며, ZIP 형식은 모든 항목에 대해 로컬 파일 헤더와 중앙 디렉터리 양쪽에 기록되는 CRC-32 체크섬을 요구한다. HotXLS는 ZLibCRC32라는 작은 래퍼를 호출해 이 체크섬을 계산하는데, 이는 다시 SaveAs가 메모리 안에서 워크시트의 XML 조립을 마친 뒤 zlib-ng 자체의 crc32 루틴을 호출한다. 그리고 오랫동안 이 호출은 압축되지 않은 버퍼 전체를 단 한 번의 호출로 실어 날랐다. 이는 작은 워크시트에는 합리적인 설계다. 하지만 시트가 HotXLS의 대용량 워크북 성능 가이드에서 다루는 종류가 되는 순간, 단일 시트의 XML은 압축되기도 전에 예사로 수백 킬로바이트를 넘어서면서 이는 하나의 아주 거대한 호출이 되어버린다

zlib-ng는 왜 CRC32를 위해 거대한 스택 버퍼를 필요로 하는가?

zlib-ng는 모든 호출에 하나의 CRC-32 구현을 쓰지 않는다. 크기 임계값 아래에서는 의미 있는 추가 메모리가 필요 없는 테이블 조회와 폴딩 트릭으로 버퍼를 순회하지만, 그 임계값 — 대략 119KB, HotXLS가 링크하는 빌드에서는 정확히 118,960바이트 — 을 넘으면 Chorba라는 특화된 고속 알고리즘으로 전환한다. 그 경로의 제네릭 C 구현은 메모리를 속도와 맞바꾼다: 힙이 아니라 스택에 스크래치 배열을 할당하며, 그 크기는 알고리즘의 내부 루프를 빠르게 만들기 위한 것이지 호출 스레드가 우연히 가지고 있는 스택 예산 안에 편하게 맞아떨어지도록 만든 것이 아니다. 이 중 어느 것도 호출자 쪽에서는 보이지 않는다. 체크섬 함수는 보통 리프 호출이다 — 몇 바이트 읽고, 숫자 하나 반환하고, 논할 가치가 있는 할당은 없다 — 그리고 이 가정은 Chorba 임계값을 넘는 버퍼가 하나 들어오기 전까지는 zlib-ng에 대한 압도적 다수의 호출에서 성립한다

function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
  // One call over the whole worksheet XML buffer: fine for a small
  // sheet, but a large enough input pushes zlib-ng onto its Chorba
  // fast path and that path's stack-hungry scratch buffer
  Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;

워커 스레드는 왜 이를 겪고 대화형 디버깅은 왜 전혀 겪지 않았는가

이 크래시를 촉발하려면 두 조건이 동시에 필요하다: zlib-ng의 Chorba 임계값을 넘는 충분히 큰 워크시트 XML 파트, 그리고 더 넉넉한 것이 아니라 평범한 기본 스택만 가진 스레드다. 프로덕션 내보내기 작업은 둘 다에 해당한다. 이들은 HotXLS 쓰기를 워커 스레드 풀에 분산시키는 서버 사이드 배치 작업으로 실행되는데, 각 스레드는 호출자가 더 많이 요청하지 않는 한 Windows가 예약하는 기본 1MB 스택을 가지며, 각각 신경 쓸 만큼 큰 고객 워크북을 처리한다. 책상 디버깅은 두 조건 어느 것도 안정적으로 만족시키지 못했다: 샘플 파일은 보통 임계값보다 작았고, 단계별 실행은 방금 생성된 워커 안이 아니라 메인 스레드에서 일어나는 경향이 있었다. 그래서 프로덕션에서는 맞아떨어져야 했던 두 조건이 개발자의 책상에서는 거의 맞아떨어지지 않았다

잘못된 함수를 탓하는 크래시 쫓기

팀이 손에 넣을 수 있었던 크래시 보고서는 HotXLS 코드가 아니라 zlib-ng의 deflate 함수 안의 한 위치를 가리켰고, CRC-32 코드도 명백히 가리키지 않았다. 이 하나의 세부사항 때문에 조사의 첫 단계는 압축 경로 쪽으로 향했다: deflate에 전달되는 버퍼 크기, 윈도우 비트, 압축 레벨 등 네이티브 코덱 크래시의 일반적인 용의자들이었다. 그중 어느 것도 근거를 대지 못했다

오해를 부르는 최상단 프레임

스택 오버플로우는 기호화하기 이상한 종류의 크래시인데, 보고될 무렵이면 스택 포인터는 이미 자신을 위해 예약된 공간을 지나쳐 달려간 뒤이기 때문이다. 그 크래시 보고서를 만들어낸 무언가는 아마도 결함이 발생한 주소를 여전히 찾을 수 있는 가장 가까운 심볼로 해석했을 것이고, 실제 원인 바로 옆에 있던 가장 가까운 export된 진입점이 하필 deflate였다. 실제 결함은 CRC-32 경로 안의 Chorba 스크래치 버퍼 할당에 있었으며, 같은 라이브러리로 컴파일되어 바이너리 안에서 충분히 가까이 있었기에 실제로 실행 중이던 함수로 오인되었다

디버거 대신 타임스탬프로 이분 탐색하기

프로세스 전체를 무너뜨리는 크래시는 일반적인 Delphi 디버거 세션이 잡을 수 있는 것을 아무것도 남기지 않으므로, 팀은 의심스러운 모든 호출 주변에 뿌려둔 GetTickCount 체크포인트와 저장 경로에 걸친 수동 이분 탐색으로 되돌아가, 프로세스가 죽은 순간 어떤 작업이 진행 중이었는지를 좁혀갔다. 그와 함께, 알려진 정상 베이스라인 빌드가 같은 프로덕션 파일을 현재 빌드와 나란히 실행했는데, 특히 그 라운드 자체의 변경 사항에서 회귀가 발생한 것이 아님을 배제한 뒤에야 더 위쪽 상류를 살펴보기 위해서였다. 두 확인 모두 깨끗하게 나온 뒤에야 조사는 완벽히 유효한 입력에 대해 예상치 못한 무언가를 하는 서드파티 의존성으로 결론이 났다

try/except는 왜 스택 오버플로우를 잡지 못하는가?

스택 오버플로우는 Delphi 코드가 의도적으로 일으키는 예외가 아니며, Windows가 액세스 위반이나 0으로 나누기를 전달하는 방식으로 전달되지도 않는다. 이는 Delphi의 try/except가 기반으로 하는 것과 같은 구조화된 예외 처리 메커니즘을 통해 보고되는 하드웨어 가드 페이지 결함으로 나타나지만, 발생하는 바로 그 순간에는 보통 핸들러를 실행하거나, 정리 코드를 되감거나, 심지어 결함을 깔끔하게 보고하는 것을 마칠 스택 공간조차 남아 있지 않다. 기본 1MB 예약만 가진 워커 스레드에서, 그 크기의 스크래치 버퍼가 이미 남은 것 대부분을 소비해 버렸다면, 런타임이 작업할 것은 아무것도 남지 않는다

procedure TExportWorker.Execute;
var
  Workbook: TXLSXWorkbook;
begin
  Workbook := TXLSXWorkbook.Create;
  try
    try
      BuildWorksheet(Workbook);
      Workbook.SaveAs(FTargetFile);  // crashes the process here on a
                                      // large enough sheet: try/except
                                      // never gets a chance to run
    except
      on E: Exception do
        LogError('Export failed: ' + E.Message);
    end;
  finally
    Workbook.Free;
  end;
end;

이 except 블록은 안전망처럼 보이고, 대부분의 실패에 대해서는 실제로 안전망이지만, 여기서는 아무 역할도 하지 못한다. 팀은 실무에서 정확히 이를 확인했다: try/except는 아무것도 잡지 못했고, finally 블록도 안정적으로 실행될 기회를 전혀 얻지 못했으며, 운영자는 애플리케이션 수준 로그 항목이 전혀 없는 죽은 프로세스를 보았다. 이는 원래 지원 티켓이 묘사한 것과 정확히 같았다

해법: 하나의 거대한 호출 대신 64KB 조각으로 CRC32에 먹이기

HotXLS가 배포한 해법은 zlib-ng 자체에 대해서도, 워크북을 쓰는 데 사용되는 압축 레벨에 대해서도 아무것도 바꾸지 않는다. ZLibCRC32는 이제 입력을 고정된 64KB 조각(각각 65536바이트)으로 순회하며, 조각마다 zlib-ng의 crc32를 한 번씩 호출하고 진행 중인 체크섬 값을 한 호출에서 다음 호출로 넘긴다. CRC-32는 구조적으로 점진적인 알고리즘이므로, 여러 조각에 걸쳐 만들어진 체크섬은 같은 바이트에 대해 단일 호출로 계산한 것과 비트 단위로 동일하다: 이 수정은 작업을 계산하는 방식이 아니라 작업을 나누는 방식만 바꾼다

function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
  // 64 KB keeps every call comfortably under the Chorba threshold
  CrcChunkSize = 65536;
var
  Cursor: PByte;
  ThisChunk: Longint;
begin
  Result := crc;
  Cursor := PByte(@buffer);
  while count > 0 do
  begin
    ThisChunk := count;
    if ThisChunk > CrcChunkSize then
      ThisChunk := CrcChunkSize;
    Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
    Inc(Cursor, ThisChunk);
    Dec(count, ThisChunk);
  end;
end;

이것이 동작하기 위해 주변의 SaveAs 호출은 아무것도 바뀔 필요가 없었고, HotXLS가 쓰는 ZIP 항목도 아무것도 바뀌지 않았다: 로컬 파일 헤더와 중앙 디렉터리에 최종적으로 들어가는 CRC-32 값은 하나의 거대한 호출이 만들어냈을 값과 정확히 같으며, 그저 더 작은 조각으로부터 조립되었을 뿐이다. zlib-ng를 다운그레이드하거나 더 느리고 할당이 적은 CRC-32 구현으로 폴백하는 것도 크래시를 피할 수 있었겠지만, 애초에 임계값 근처에도 가지 않는 모든 파일에 실질적인 비용을 치르게 했을 것이다. 그래서 이 두 방법 모두 배포되지 않았다

여러분 자신의 워커 스레드에서 zlib-ng를 호출한다면 이것이 의미하는 것

여기서 설명한 스택 오버플로우 실패 모드는 스프레드시트와는 구체적으로 아무 관련이 없다. 압축이든, 압축 해제든, 체크섬이든, 플랫폼 기본 스택만 가진 스레드에서 zlib-ng에 큰 버퍼를 넘기는 어떤 애플리케이션이든 같은 종류의 벽에 부딪힐 수 있다. 이 라이브러리는 입력 크기에 따라 알고리즘을 선택하고, 그 알고리즘 중 일부는 스택에 여유가 있다고 가정하기 때문이다. zlib-ng 자체를 건드리지 않고도 효과가 있는 방어책이 두 가지 있다: 자연스럽게 점진적인 어떤 알고리즘에 대해서든 큰 버퍼를 고정된 조각으로 나누어 크기에 민감한 루틴에 먹이면 촉발 조건 자체가 완전히 사라지며, 조각화가 선택지가 아닌 곳에서는 호출 스레드에 플랫폼 기본값보다 큰 스택을 주는 것이 또 다른 지렛대다. 둘 중 어느 쪽이든, 잘못된 함수를 탓하는 프로덕션 크래시 보고서로부터 문서화되지 않은 크기 임계값을 알아내는 것보다 저렴하다

이 특정 임계값은 충분히 큰 프로덕션 워크북이 잘못된 종류의 스레드에서 그것을 넘어서기 전까지는 보이지 않은 채로 남아 있었으며, 이는 코드가 작은 픽스처가 아니라 실제 파일을 상대로 실행되어야만 드러나는 바로 그런 종류의 실패다. 조각화된 CRC-32 경로는 이제 Delphi와 C++Builder용 HotXLS Excel 컴포넌트의 표준 쓰기 파이프라인 일부로 제공되며, 호출자가 설정할 것도, 켜고 끌 속성도 필요하지 않다