Delphi에서 PDF 페이지를 병렬로 렌더링하는 것은 '모든 작업 스레드에 자체 렌더러를 제공하라'는 한 가지 규칙으로 요약됩니다. losLab PDF Library는 바로 이 작업을 위해 RenderPagesToFilesParallel을 제공하며, 작업자당 하나의 TPDFlib 인스턴스를 사용하여 TTask 풀 전체에 페이지 범위를 분산시킵니다. 이로써 멀티코어 머신은 배치 래스터 작업을 코어 수에 거의 비례하는 처리량으로 변환합니다. 반대로 스레드 간에 단일 인스턴스를 공유하면 속도가 부드럽게 느려지는 것이 아니라 메모리가 손상되고 크래시가 발생합니다
이 문서는 야간 배치 작업으로 500페이지 분량의 PDF를 500개의 PNG로 변환해야 하고, 시스템에 16개의 유휴 코어가 있으며, 스레드를 적용하려는 첫 시도가 GDI+ 내부에서 무너졌을 때 읽어보아야 할 기술 문서입니다. 요약하자면, 여기서 스레드 안전성은 설정하는 플래그가 아니라 구조적인 속성입니다. 이 문서의 나머지 부분에서는 왜 안전한 형태가 이렇게 설계되었는지, 그리고 실제 속도 향상의 한계가 어디에 있는지 알아봅니다
TPDFlib는 병렬 렌더링에 스레드 안전한가요?
아닙니다. 그리고 이를 고려하여 설계하기 전에 그 이유를 명확히 이해해야 합니다. 단일 TPDFlib 인스턴스는 단일 스레드 전용으로 선언되어 있으며, 가장 위험한 부분은 TPDFPageTree.GetPage: 이 메서드는 페이지를 선택하는 과정의 부작용으로 인스턴스의 공유 FPagePointer 필드를 수정합니다. 동일한 인스턴스를 호출하는 두 스레드는 이 필드에서 경쟁(Race condition)을 벌이게 되며, 작업자 A가 3페이지의 절반을 렌더링하고 있을 때 작업자 B가 페이지 트리를 40페이지로 다시 가리킬 수 있습니다. API 레벨에서 아래와 같은 코드를 작성하는 것을 막지 않으며 몇 페이지 동안은 정상 작동하는 것처럼 보일 수도 있어, 이러한 종류의 버그 중 가장 까다로운 유형에 속합니다
// 이렇게 하지 마십시오: 하나의 인스턴스를 여러 스레드가 공유
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
Pdf.LoadFromFile('report.pdf', '');
TParallel.For(1, Pdf.PageCount,
procedure(Page: Integer)
begin
// 모든 스레드가 동일한 인스턴스에 재진입하여 FPagePointer에서 데이터 경쟁이 발생합니다
Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
end);
Pdf.Free;
end;
실패는 결정론적이지 않으며, 이는 빠른 테스트 환경에서 발견되지 않다가 고객 머신의 다른 코어 수나 더 큰 용량의 문서 환경에서 불쑥 감지될 수 있는 원인이 됩니다. RenderPageToFile 전체를 락(Lock)으로 감싸는 임시방편도 도움이 되지 않습니다. 왜냐하면 전체 렌더링 호출 동안 뮤텍스(Mutex)를 쥐고 있으면 작업들이 순차화되어 병렬성을 통해 얻고자 하는 성능 향상 효과를 완전히 상실하게 되기 때문입니다
왜 각 렌더링 작업자마다 자체 TPDFlib 인스턴스가 필요한가요?
인스턴스가 바로 격리(Isolation)의 단위이기 때문입니다. 모든 작업자가 파일을 독립적으로 로드한 비공개 TPDFlib를 소유하면, 각자 고유한 페이지 트리, 자체 FPagePointer 및 개별 렌더링 상태를 갖게 되므로 경쟁이 발생할 공유 자원이 남지 않습니다. 이러한 안전성에는 미리 감당해야 할 대가가 있습니다. 모든 작업자가 전체 문서를 메모리로 파싱하므로, 피크 메모리 사용량은 단일 인스턴스 비용의 대략 N배가 됩니다. 300MB PDF에 대해 8개의 작업자를 실행하면 한 번에 8개의 전체 파싱 결과가 메모리에 상주하며, 대용량 입력의 경우 CPU가 아니라 이 메모리 제약이 작업자 수를 결정하는 요인이 됩니다. 문서가 매우 크고 CPU보다 메모리 용량에 제한이 있는 경우, 렌더링 스레드를 늘리는 것보다 전체 문서 파싱 없이 대용량 PDF 처리하기에 소개된 직접 액세스 방식이 종종 더 좋은 해결책이 됩니다
단일 호출 API: RenderPagesToFilesParallel
losLab PDF Library는 안전한 패턴 전체를 단일 메서드로 래핑하여 제공하므로, 일반적인 경우에는 이를 직접 구현할 필요가 없습니다. RenderPagesToFilesParallel은 파일 이름과 비밀번호, DPI, 시작 및 종료 페이지(포함), 페이지별 래스터 경로로 직접 전달되는 Options 값, %p가 페이지 번호로 대체되는 출력 파일명 패턴, 그리고 0 이하의 값이 자동 지정을 의미하는 작업자 제한 수를 인수로 받습니다. 성공적으로 렌더링된 페이지 수를 반환하며, CoInitialize 및 GDI+에 의존하기 때문에 Windows 전용 경로입니다
var
Pdf: TPDFlib;
Rendered: Integer;
begin
Pdf := TPDFlib.Create;
try
// 파일명, 비밀번호, DPI, 시작페이지, 종료페이지, 옵션, 패턴, 최대작업자수
Rendered := Pdf.RenderPagesToFilesParallel(
'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
// MaxWorkers = 0 -> 자동: 최소값(페이지 수, CPU 코어 수)
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
왜 모든 작업 스레드마다 CoInitialize를 호출해야 하나요?
GDI+는 페이지 렌더링 하부의 래스터라이저이며, GDI+는 아파트 스레드(Apartment-threaded) 모델로 작동합니다: 즉, 호출이 발생하는 모든 스레드에서 COM이 초기화되어 있어야 합니다. VCL 애플리케이션의 메인 스레드는 일반적으로 이것이 이미 구성되어 있지만, 새로 생성된 TTask 작업자는 그렇지 않으므로 초기화되지 않은 스레드에서 렌더링 경로를 호출하면 크래시가 발생하기 십상입니다. 따라서 각 작업자는 진입 시 CoInitialize(nil)를 호출하고 종료 시 CoUninitialize를 쌍으로 묶어 전체 수명 주기를 감싸야 합니다. 이는 메인 스레드 밖에서 수행하는 모든 GDI+ 또는 COM 작업에 요구되는 규칙이며, 비공개 인스턴스라는 첫 번째 규칙과 함께 작업자별 격리를 실현하는 나머지 절반의 핵심 요소입니다. 동일한 GDI+ 래스터 경로는 PDF 출력을 위한 렌더링 엔진 선택에서 다루는 단일 스레드 엔진도 구동합니다
정적 샤딩과 동적 페이지 할당의 차이
8개의 작업자에게 500페이지를 분배하는 가장 단순한 방법은 각 작업자에게 약 62페이지씩 정적인 슬라이스를 고정 할당하는 것입니다. losLab PDF Library는 부하 분산(Load balance) 때문에 이 방식을 사용하지 않습니다. 페이지 렌더링 비용은 천차만별입니다: 본문 텍스트 페이지는 수 밀리초 만에 렌더링되지만, 조밀한 벡터 맵이나 전체 페이지를 덮는 스캔 이미지는 50배 더 오랜 시간이 걸릴 수 있습니다. 작업을 정적 샤드로 나누면 무거운 페이지가 모여 있는 슬라이스를 담당한 작업자가 다른 작업자들이 대기 상태로 전환된 후에도 오랫동안 계속 실행되므로, 전체 처리 시간은 평균이 아닌 가장 운이 나쁜 샤드에 의해 결정됩니다. 대신 각 작업자는 짧은 임계 영역(Critical section) 안에서 공유 카운터로부터 다음 페이지를 동적으로 가져와서 렌더링하고 다시 돌아와 다음 페이지를 요청하므로, 모든 코어가 전체 범위가 완료될 때까지 계속 바쁘게 작동합니다
// 풀 내부에서 각 작업자가 수행하는 작업(간략화됨)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
procedure
var
LocalLib: TPDFlib;
PageNum: Integer;
begin
CoInitialize(nil); // GDI+는 아파트 스레드로 동작합니다
try
LocalLib := TPDFlib.Create; // 작업자당 하나의 비공개 인스턴스
try
LocalLib.LoadFromFile(FileName, '');
while True do
begin
IdxLock.Enter; // 원자적으로 다음 페이지를 가져옵니다
try
PageNum := NextPage;
Inc(NextPage);
finally
IdxLock.Leave;
end;
if PageNum > EndPage then Break;
LocalLib.RenderPageToFile(DPI, PageNum, 0,
Format('page_%d.png', [PageNum]));
end;
finally
LocalLib.Free;
end;
finally
CoUninitialize;
end;
end;
작업 스레드 전반의 구조화된 로깅
로그 없이 500페이지 중 213페이지에서 중단되는 배치 작업을 디버깅하는 것은 매우 어렵지만, 단순하게 구현한 로그는 그 자체로 동시성 버그를 유발할 수 있습니다. losLab PDF Library는 TPDFlib.Logger 속성을 통해 연결할 수 있는 TPDFlibLogger를 함께 제공하며, 기본값은 nil이므로 로그를 설정하지 않았을 때는 비용이 전혀 발생하지 않습니다. 이 로거는 콜백 기반입니다: OnLog를 설정하고 호스트가 원하는 경로로 기록을 전달하며, llDebug / llInfo / llWarn / llError 레벨로 필터링할 수 있습니다. 또한 PDFlibErrorMessage는 원시 숫자 에러 코드를 사람이 읽을 수 있는 텍스트로 변환하여 Error 기록이 단순한 정수 이상의 정보를 제공하게 만듭니다. 선택 사항인 파일 싱크는 공유되는 단 하나의 리소스이며, 여러 작업자가 하나의 로그 파일에 안전하게 덧붙일 수 있도록 TCriticalSection으로 보호됩니다. 명확히 구분해야 할 점은 오직 파일 싱크 부분만 동기화되므로, 직접 만든 스레드 풀에서 단일 로거를 공유하고 OnLog에서 UI를 직접 건드리는 경우에는 여전히 메인 스레드로 호출을 마샬링(Marshal)하는 작업을 직접 처리해야 한다는 것입니다
var
Pdf: TPDFlib;
Log: TPDFlibLogger;
begin
Log := TPDFlibLogger.Create;
Log.Level := llInfo; // llDebug, llInfo, llWarn, llError
Log.FileName := 'render.log'; // 선택적 공유 싱크 (락으로 보호됨)
Log.OnLog :=
procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
begin
if Level = llError then
// UI 스레드로 직접 마샬링하십시오. OnLog는 작업 스레드에서 구동됩니다
WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
end;
Pdf := TPDFlib.Create;
Pdf.Logger := Log; // 기본값은 nil이며, 설정하지 않으면 비용이 발생하지 않습니다
try
Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
'out\page_%p.png', 0);
// 이제 에러에 텍스트 정보가 동반됩니다 (예: 401 -> "Wrong password or permission denied")
finally
Pdf.Free;
Log.Free;
end;
end;
실제 어느 정도의 속도 향상을 기대할 수 있나요?
시간이 어디에 쓰이는지 객체지향적이고 명확하게 평가해 보아야 합니다. 병렬 렌더링은 작업이 실제로 CPU 병목인 경우에만 효과를 발휘하기 때문입니다. 고해상도(High-DPI) 출력 및 복잡한 벡터 또는 셰이딩이 들어간 페이지는 연산량이 많으므로 CPU를 가득 채울 때까지 코어 수에 거의 비례하여 성능이 확장됩니다. 하지만 단순한 페이지들은 다릅니다: 이 경우 작업자별 LoadFromFile 오버헤드와 출력 파일을 디스크에 기록하는 디스크 비용이 렌더링 시간 자체를 압도할 수 있어, 느린 단일 디스크에서 8개의 작업자가 경합을 벌이면 깔끔한 단일 순차 루프보다 더 늦게 끝날 수도 있습니다. MaxWorkers를 과도하게 잡기보다 물리 코어 수에 맞춰 설정하고, 원본 PDF가 클 때는 메모리를 모니터링하십시오. 배치 작업이 입출력(IO) 병목으로 판명되면 해결책은 더 빠른 스토리지나 작업자 수를 줄이는 것이지 스레드를 늘리는 것이 아닙니다. 목적에 맞게 설계된 작업에 투입되는 경우, 여기에 표시된 배치 렌더링 경로는 Delphi 및 C++Builder용 losLab PDF Library의 표준 기능이며, 사용자가 직접 해결해야 할 스레드 안전성 문제를 완벽히 방지하면서 유휴 코어를 완료된 페이지로 전환해 줍니다