기술 문서

Delphi에서의 병렬 XLSX 파싱: 메모리 관리자 병목 현상과 그 해결책

Delphi 및 C++Builder용 기본 Excel 라이브러리인 HotXLS는 3단계의 로드 구조(시트 XML 직렬 압축 해제 -> 시트 데이터 병렬 파싱 -> 잔여 하위 요소 직렬 판독)를 구현하여 XLSX 시트 데이터를 다중 스레드로 병렬 파싱합니다. 최초 릴리즈 시에는 병렬 로드 가속 성능이 약 12~25% 향상에 그쳤는데, 원인은 Delphi의 기본 메모리 관리자가 메모리 동시 기입 시 임시 직렬 락(lock)을 강제해 개별 작업 스레드들의 병렬 구동을 사실상 차단했기 때문입니다. 이 문제를 해결하기 위해 셀당 메모리 힙 할당 횟수를 약 20회에서 9.1회로 절반 이상 단축함으로써, 8스레드 기동 시 병렬 연산 가속 성능을 최대 1.90배로 크게 개선할 수 있었습니다. 본 기사에서는 실측 측정 데이터 분석, 시행착오 과정, 그리고 성능 가속을 완성해 낸 두 가지 실제 해결책을 차례대로 안내해 드립니다

HotXLS는 XLSX 워크시트를 병렬로 어떻게 파싱할까요?

HotXLS는 Open 처리 구조를 3단계로 분할하고 이 중 오직 2단계 작업만을 작업 스레드(worker thread) 풀로 기동해 병렬 처리합니다. 이 제약을 두는 원인은 엑셀의 ZIP 압축 컨테이너 설계 때문입니다. ZIP 압축 파일은 내부적으로 단 하나의 공유 입력 스트림과 단일 압축 해제(inflate) 스테이트 머신을 공유하므로, 두 개 이상의 스레드가 한 파일의 여러 압축 영역을 동시에 해독해 들어갈 수 없습니다. 여기에 락(lock)을 씌워 우회해 보았자 압축 해제 동작 자체가 구조적으로 차례대로 한 항목씩 처리되어야 하므로 스레드 동기화 오버헤드만 가중될 뿐 병렬성 개선이 전혀 되지 않습니다. 따라서 1단계(Phase A)에서는 단일 스레드로 ZIP 내부의 시트별 XML 데이터를 메모리 내에 가상 적재하는 TMemoryStream 변환 단계를 먼저 거칩니다. 이 1단계 연산은 벤치마크 테스트 상 8개 시트 기준 단 4ms 내외로 끝나므로 성능 병목과 거리가 멉니다. 핵심인 2단계(Phase B)에 진입해 비로소 스레드 풀을 가동하여 각 메모리 스트림의 ParseWorksheetXml 함수를 다중 병렬 처리하며, 이것이 로드 지연의 대부분을 차지합니다. 이후 3단계(Phase C)에서는 다시 직렬 처리로 복귀해 메모와 그림, 차트, 표 등의 잔여 요소들을 순차적으로 처리해 끝냅니다

병렬 구동을 돕는 스레드 풀 자체는 매우 간결하고 단순하게 설계되었습니다. 스레드들이 InterlockedIncrement로 안전하게 보호되는 동시 카운터 수치를 읽어 들여 처리할 시트 인덱스를 각자 확보하므로, 스레드 스케줄러를 복잡하게 두지 않고도 분량 차이가 심한 다양한 시트들을 여러 스레드가 고르게 균형 맞춰 나누어 처리해 냅니다. 기동되는 스레드 개수는 최소값(시트 개수, CPU 코어 개수)으로 자동 지정되며, 구동 중 스레드에서 예외(exception) 발생 시 AcquireExceptionObject로 감지해 저장해 두었다가 병렬 처리가 끝난 직후 메인 스레드 화면에 안전하게 예외를 다시 호출(re-raise)해 줍니다. 처리할 시트가 없거나 단 1개인 경우 번거롭게 스레드 풀을 기동하지 않고 일반 직렬 루프로 fall-back 처리합니다. TXLSXWorkbookParallelParse 속성을 참(true)으로 설정하여 스레드 풀을 켜고, ParallelParseThreads 속성에 사용을 권장하는 제한 스레드 개수를 기입하여 통제할 수 있습니다(0 기입 시 시스템 사양에 맞게 자동 조율). 이 병렬 가속은 단일 서식 시트를 수십 번 복제해 생성하는 대량 시트 구성 환경에서 최대의 가속 성능을 보장합니다

var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.ParallelParse := True;      // enable the parallel worker pool
    Book.ParallelParseThreads := 0;  // 0 = auto: min(sheets, CPU cores)
    if Book.Open('quarterly-ledger.xlsx') <= 0 then
      raise Exception.Create('open failed');
    // ... read cells as usual; the workbook is fully materialized ...
  finally
    Book.Free;
  end;
end;

왜 스레드를 늘렸는데 Delphi에서 XLSX 파싱 속도가 느려졌을까요?

원인은 Delphi 컴파일러의 기본 메모리 관리자(memory manager)가 공유 힙 메모리 영역을 단일 전역 락(global lock)으로 통제하기 때문입니다. 시트 파싱 작업은 엄청난 메모리 할당(수백만 건의 셀 객체, Variant 구조체, WideString 문자열 등)을 동반합니다. 힙 메모리를 건드리는 모든 작업 스레드가 이 하나의 락 대기열에 순서대로 줄을 서게 되므로, 소스 코드 상에서는 다중 스레드로 병렬 작동하는 것처럼 보이지만 실제 물리 연산 단계에서는 스레드들이 차례대로 한 번에 하나씩만 가동되는 기형적인 병목 현상이 발생합니다. 최초 테스트를 진행했을 때 이 문제가 매우 심각하게 목격되었습니다. 8개 시트에 각각 5,000행 4열의 셀 데이터를 수록한 파일을 Win64 환경의 i5-11600K CPU(6코어 12스레드) 사양에서 병렬 Open 함수를 가동했을 때, 원래 예상한 가속률인 최소 40%에 한참 못 미치는 단 12~25% 수준의 향상만 보였습니다. 스레드 개수를 2, 3, 4, 6, 8개로 점차 늘려가며 성능을 대조해도 성능 가속 그래프가 계단식으로 오르지 않고 편평하게 고착되었으며, 상세 기기 로그 상 2스레드 기동 시 처리 속도가 단일 스레드 직렬 구동 시보다 오히려 26%나 저하되는 것으로 보고되었습니다. 전형적인 락 경합(lock contention)으로 스레드들이 서로 락 권한을 뺏고 뺏기며 제자리걸음을 하느라 발생하는 지연 현상입니다

세 가지 실측 측정이 이 병목 문제를 진단해 냈으며, 각각의 측정은 기존의 잘못된 추측들을 완벽하게 교정해 주었습니다. 첫째, 1행으로 구성된 극단적으로 작은 8시트 문서를 열었을 때 단 1.2ms 만에 해제 완료되는 것을 확인하여, 로드 지연 요인이 ZIP 해제 등의 숨겨진 고정 오버헤드가 아닌 100% 시트 파싱 가동 오버헤드임을 증명했습니다. 둘째, 힙 메모리 할당 빈도를 대조해 본 결과 Delphi 메모리 관리자의 병목 현상이 명확히 입증되었습니다. 동일한 200만 건의 객체 및 AnsiString 생성 작업을 8스레드로 처리했을 때 단일 스레드 기동 시보다 연산 속도가 60%나 더 저하되는 하락이 관찰되었습니다. 반면 Delphi 메모리 관리자를 타지 않고 Windows COM API의 BSTR 할당 엔진을 활용하는 WideString 생성의 경우 8스레드 기동 시 정상적으로 약 3.7배의 가속을 유지해 냈습니다. HotXLS가 시트 내부 문자열 가공을 위해 오랜 설계 특징상 WideString을 기본 채택하고 있었던 우연이 병목 제어에 큰 장점으로 작용했던 것입니다. 셋째, GetProcessTimes 측정 결과 병렬 Open이 가동되는 동안 총 소요된 CPU 코어 가동률이 실제 경과 시간 기준 고작 1.3개 코어 분량에 그쳤습니다. 스레드들이 바쁘게 연산을 굴리고 있었던 것이 아니라, 메모리 관리자 전역 락 경쟁에서 기밀이 밀려 연산 대기 수면 상태로 대부분의 코어들이 잠들어 있었음을 보여주었습니다

이 실무 노하우는 스프레드시트 처리뿐만 아니라 일반적인 모든 Delphi 다중 스레드 개발 환경에 공통 적용됩니다. 프로그램이 메모리를 다량 기입하도록 코딩되어 있다면, 이 메모리 할당 횟수 자체를 극단적으로 단축하기 전에는 스레드 개수를 아무리 늘려 보아야 가속 성능을 볼 수 없으며 오히려 스레드 생성 오버헤드만 가중해 속도를 갉아먹게 됩니다. 이 교정을 탑재하기 전까지, 당사는 대량의 메모리 연산 파일에서는 스레드를 늘려 봤자 거의 아무런 혜택이 없다고 사용자들에게 정직하게 안내해 왔습니다

어떻게 셀 하나당 힙 할당이 20회나 일어났을까요?

임시 힙 할당 카운터 모듈을 SetMemoryManager API에 연결하여 실시간 카운트해 본 결과 충격적인 숫자가 검출되었습니다. 셀 하나가 작성될 때마다 내부적으로 평균 20회 가량의 메모리 관리자 할당 호출이 유발되었고, 총 287만 건의 할당 중 대부분이 32바이트 미만의 초소형 버퍼 생성 요청이었던 것입니다. 놀랍게도 주범은 실제 셀 객체(cell object) 생성이 아니었습니다. XML 파서인 TXMLScaner.GetTokenValue가 매 호출마다 무조건 새로운 AnsiString 인스턴스를 무차별 생성하고 있었는데, 셀 하나를 처리할 때마다 요소명 대조, 속성명 대조, 속성값 획득, 본문 문자열 획득 과정 등에서 평균 15~20회 가량 이 함수가 가동되었던 것입니다. 설상가상으로 RTL의 UTF8ToWideString 디코딩 함수마저 매 변환마다 임시 UnicodeString 인스턴스를 메모리 상에 매번 새로 기입해 자원을 낭비하고 있었습니다. 셀 객체 자체의 생성 할당은 전체의 8%에 불과한 약 16만 건에 지나지 않았습니다. 이 통계는 셀 객체 풀(cell object pool)을 구축하려던 당사의 최초 가속화 계획을 즉각 폐기하게 만들었습니다. 엉뚱한 8% 영역을 개선해 봤자 아무런 체감 가속을 볼 수 없었기 때문입니다

var
  OldMM, NewMM: TMemoryManagerEx;
  AllocCount, TinyCount: Int64;

function CountingGetMem(Size: NativeInt): Pointer;
begin
  AtomicIncrement(AllocCount);
  if Size <= 32 then
    AtomicIncrement(TinyCount);   // the small-object churn we care about
  Result := OldMM.GetMem(Size);
end;

// install before Open, restore afterwards
GetMemoryManager(OldMM);
NewMM := OldMM;
NewMM.GetMem := CountingGetMem;
SetMemoryManager(NewMM);

이 간단한 10분짜리 메모리 검출 기법은 Delphi 성능 튜닝이 필요한 모든 프로젝트에 도용해 적용해 볼 만한 훌륭한 팁입니다. 할당 크기별로 카운트하는 소스 코드 추가 오버헤드는 거의 무시할 수 있는 수준이면서, 실제 시스템 성능을 잡아먹는 진짜 할당 유발 함수 좌표를 정확하게 특정해 줍니다. 당사의 사례에서는 파서의 논리 구조가 아닌, XML 스캐너 내부의 사소한 RTL 문자열 할당 습관 두 줄이 진짜 범인이었음을 입증해 주었습니다. 기존의 일반 상용 프로파일러들은 파서 함수 전체가 느리다고만 뭉뚱그려 보고했으나, 메모리 래퍼 코드는 정확한 버그 유발 소스 라 2곳을 지목해 해결해 주었습니다

해결책: 토큰 값 캐싱(interning) 및 중간 임시 버퍼 없는 UTF-8 디코더 탑재

기존 파서의 골격 구조를 그대로 보존하면서 XML 판독 단계의 비효율적인 할당 처리 두 곳을 완전히 뜯어고쳐, 셀당 메모리 할당 횟수를 절반 미만으로 단축시켰습니다. 첫 번째는 태그 요소명 인턴화(interning) 캐싱 장치입니다. 시트용 XML 문서 내부에는 row, c, v, r, t, s 등 매우 한정적인 태그 단어들과 속성 단어들만이 끝없이 반복되어 출현합니다. 새로 도입된 InternTokenName은 64칸의 소형 캐시 풀을 두고, 방금 스캔한 버퍼 문자열이 캐시 풀에 기등록된 태그 단어와 일치하는지를 임시 할당이 필요 없는 TokenEqualsAnsi 함수로 바이트 단위 정밀 비교합니다. 일치하는 단어가 존재하면 캐시 풀에 보관되어 있던 AnsiString 인스턴스를 즉각 반환합니다. 여기서 자료형의 선정이 매우 중요합니다. AnsiString은 참조 횟수 계산(reference counted) 방식으로 구동되므로 기존 인스턴스 반환 시 참조 카운트만 늘어날 뿐 실제 힙 메모리를 1바이트도 건드리지 않습니다. 반면 WideString 자료형은 참조 횟수 계산 장치가 없어 대입 시마다 Windows의 SysAllocString을 무조건 호출하므로 WideString으로 캐싱을 구현하면 성능 향상이 전혀 일어나지 않습니다. 이 기법은 오직 참조 횟수 계산이 작동하는 정규 문자열 형식에서만 유효하게 구동됩니다

function TXMLScaner.InternTokenName: AnsiString;
var
  Slot: Integer;
begin
  Slot := TokenHash mod 64;
  if TokenEqualsAnsi(FInternNames[Slot]) then
    Result := FInternNames[Slot]    // refcount++ only, no allocation
  else
  begin
    Result := GetTokenValue;        // materialize once, then cache
    FInternNames[Slot] := Result;
  end;
end;

두 번째 튜닝 대상은 실제 셀의 텍스트 데이터였습니다. 기존 코드 경로는 AnsiString 임시 토큰 생성 -> UTF8ToWideString 전달 -> 임시 UnicodeString 인스턴스 생성 -> 최종 WideString 변환이라는 복잡한 변환 과정을 거치며 셀 하나당 매번 2회의 무거운 임시 할당을 유발하고 있었습니다. 이를 대체하는 새로운 XmlUtf8ToWide(TokenPtr, TokenLen) 디코더는 스캐너 버퍼 포인터 위치를 다이렉트로 바라보며 순수 오브젝트 파스칼 소스 기반으로 변환을 처리하는 2단계(two-pass) 고속 UTF-8 디코더입니다. 1단계로 변환될 UTF-16 크기를 정확히 실시간 측정하고, 2단계에서 필요한 크기로 단 1회 할당된 WideString 메모리 공간에 결과 데이터를 버퍼 우회 없이 실시간 해독 기입합니다. 이 변환 방식 도입으로 토큰 하나당 메모리 관리자 힙 할당은 0회가 되었으며, 오직 최종 결과물을 담을 Windows COM 할당 1회로 간소화되었습니다. 안전성을 위한 기술적 특이점 한 가지를 공유해 드립니다. 비정상 손상된 비표준 UTF-8 데이터 유입 시, 새로운 디코더는 표준 RTL처럼 대체 안전 문자(substitution characters)로 억지로 고쳐 쓰지 않고 원본 깨진 바이트를 있는 그대로 흘려보냅니다. 이는 파손된 시트 파일을 강제 해제할 때의 사소한 복구 표시 편차를 제외하면 일반 정상 가동 시 기존 오피스 복호화 결과물과 100% 바이너리가 일치합니다. XML 특수 문자(entities)는 스캐너의 토큰 처리 버퍼 단계에서 이미 UTF-8 바이트로 사전 컴파일을 마친 후 디코더에 유입되므로 정상 처리됩니다

양적 성과 분석 및 여전한 가속 한계 경계

이 두 튜닝을 통합 탑재함으로써 셀당 할당 횟수가 약 20회에서 9.1회로 극적으로 낮아졌으며, 병렬 가속 연산 효율은 이론 수식에 정확히 부합하게 급상승했습니다. 동일한 i5 6코어 환경 하의 8시트 5,000행 벤치마크 테스트 상에서, 8스레드 병렬 처리 시 속도 향상 수치가 이전의 14%에서 47.4%로 수직 상승하여 기존 직렬 파싱 대비 무려 1.90배의 가속을 달성해 냈습니다. 기존에 느렸던 2스레드 기동 성능 역시 -26% 하락 상태에서 즉각 23.6% 가속 증가로 반전되었으며, CPU의 토탈 실측 가동률은 1.0코어 가동에서 2.2코어로 2배 넘게 급증했습니다. 힙 할당 횟수 단축 덕분에 단일 스레드로 동작하는 레거시 직렬 복호화 속도마저 보너스로 3%가량 개선되었습니다. 셀당 잔여 9회 수준의 할당은 셀 객체 생성과 데이터 컨테이너 가상 배열 확장 등에 쓰이는 것으로, 가속 성과와 추가 공수를 대조해 본 뒤 튜닝 작업을 이 단계에서 성공적으로 완료했습니다. 잔여 메모리 래퍼 코드는 향후 추가 튜닝이 필요한 시점에 즉각 다시 켜서 실시간 가동할 수 있게 소스 내에 안전 보존해 두었습니다

튜닝 성과와 더불어 한계도 투명하게 공유해 드립니다. HotXLS의 병렬 파싱 알고리즘은 '시트 단위(worksheet granularity)'로 동작합니다. 따라서 물리적으로 단 하나의 시트 내에 무지막지한 셀 데이터가 몰려 있는 파일은 스레드 지정을 아무리 늘려 보아도 무조건 1개의 스레드로만 파싱됩니다. 이와 같이 단일 시트가 극단적으로 거대한 문서 양식의 경우 워크북 객체 전체를 메모리에 로드하지 않고 스트리밍 방식으로 필요한 셀만 빠르게 스캔해 나가는 스트리밍 다이렉트 리더(direct reader)를 도입하는 것이 효과적입니다. 또한 차트나 그래픽 개체, 대량의 메모 등이 파일 구조 대부분을 점유하는 양식의 경우, 3단계(Phase C) 직렬 마크업 복구 단계를 통과하느라 스레드 가속 효율이 낮아질 수 있습니다. 작은 크기의 소형 파일은 스레드 풀 구동 비용이 더 소요되므로 직렬 모드로 자동 바이패스 처리됩니다. 마지막으로 메모리 전역 락 장치가 완전히 사라진 것은 아니므로, 셀당 9.1회의 할당 상태에서도 8개 스레드 동시 기동 시 4배가 아닌 최대 1.90배의 가속을 보이는 수준의 락 오버헤드는 잔존합니다. 스타일 캐싱, 셀 복제, 일괄 콜백 기법을 통합 활용하여 로딩 및 저장 속도를 전반적으로 개선하려는 개발자라면, Delphi용 대용량 엑셀 성능 최적화 가이드를 참고하십시오

병렬 XLSX 파이프라인 파싱 연산, ParallelParseParallelParseThreads properties, 그리고 메모리 할당을 최소화한 신형 XML 고속 리더는 모두 별도의 엑셀 프로그램 설치가 필요 없는 Delphi 및 C++Builder용 전용 라이브러리인 HotXLS Component에 일체화되어 기본 탑재되어 함께 제공됩니다