Technical Article

Delphi에서 PDFium을 사용한 대용량 PDF 온디맨드 스트리밍

스캔 문서 보관 파일은 단일 PDF 크기가 수 기가바이트에 달할 수 있습니다. 이러한 파일을 여는 뷰어 프로그램은 일반적으로 사용자 화면에 표시할 단 한 페이지, 예컨대 목차 페이지나 사용자가 북마크를 눌러 진입하는 특정 페이지만 필요로 합니다. 단 두 페이지를 렌더링하기 위해 전체 파일을 가상 메모리에 읽어 들이는 것은 매우 비효율적입니다. 주소 공간을 과도하게 점유하고, 초기 로딩 대기 시간을 늘려 사용자 사용성을 저해하며, 32비트 Delphi 프로세스 환경에서는 페이지가 표시되기도 전에 메모리 한계로 즉시 작동을 멈출 수 있습니다. PDFium은 이를 방지하도록 설계되었습니다. 전체 데이터를 한 번에 가져오지 않고, 실시간으로 필요한 특정 바이트 영역만 요청하는 콜백 구조를 통해 문서를 효율적으로 로드할 수 있습니다.

컴포넌트는 스트림 어댑터 메커니즘을 통해 이 기능을 구현합니다. 사용자가 임의의 TStream 객체를 제공하면, PDFium은 해당 스트림으로부터 필요한 블록 데이터를 온디맨드 방식으로 인출해 처리합니다. 대상 파일이 로컬 디스크에 있든, 데이터베이스 BLOB 필드에 보관되어 있든, 아니면 다른 형태의 TStream 구현체 뒤에 숨겨져 있든 관계없이 사전에 메모리로 데이터를 일괄 복사하지 않습니다.

PDFium의 데이터 요청 메커니즘

PDFium C API는 호출자가 제공하는 FPDF_FILEACCESS 구조체 정보 객체를 통해 문서를 가져옵니다. 이 구조체는 세 가지 핵심 부분(길이 정의 필드, 실제 데이터 읽기 콜백, 매개변수를 전송할 사용자 정의 불투명 포인터)으로 구성됩니다. 데이터를 수용하는 주 진입점은 FPDF_LoadCustomDocument. 해당 구조를 기반으로 문서가 활성화되면 PDFium은 파일 끝부분의 트레일러를 분석해 상호 참조 테이블을 탐색한 후, 특정 데이터 연산이 필요한 부분만 가려내어 탐색합니다. 초기 로드 시에는 파일의 꼬리 부분과 몇몇 카탈로그 요소만 확인하며, 400번째 페이지를 그릴 때는 다른 범위는 무시하고 오직 400번째 페이지의 본문 스트림과 하위 리소스만 선택적으로 읽습니다.

이것이 일시 로딩(buffered load)과 스트리밍 로딩(streaming load)의 결정적인 차이점입니다. 일시 로딩은 파일 처리를 시작하기 전에 데이터 전체를 끝까지 다 읽습니다. 반면 스트리밍 로딩은 관계를 역전시켜 PDFium이 주도적으로 읽기 명령을 제어하며, 사용자가 열어보지 않는 데이터 범위는 아예 물리 디스크에서 읽어 오지도 않습니다. 수 기가바이트 파일을 페이지 단위로 탐색할 때, 이 방식의 도입 유무가 로딩 지연 시간을 수십 초에서 0초로 단축시키는 차이를 만들어 냅니다.

스트림 어댑터 구조

Delphi TStream 객체를 FPDF_FILEACCESS 형식 규격에 연동하는 가교 역할을 하는 객체가 TPdfStreamAdapter. Its constructor takes the stream and an ownership flag, captures the stream length once, fills in the FPDF_FILEACCESS record, and wires the read callback. When PDFium later calls back with an offset and a size, the adapter seeks the stream to that offset and copies exactly that range into the buffer PDFium provided.

// Verbatim from the component: the stream-to-FPDF_FILEACCESS bridge
constructor TPdfStreamAdapter.Create(AStream: TStream; AOwnsStream: Boolean);
begin
  inherited Create;
  if AStream = nil then
    raise EPdfError.Create('TPdfStreamAdapter: AStream is nil');
  FStream := AStream;
  FOwnsStream := AOwnsStream;

  // FPDF_FILEACCESS.m_FileLen is a 32-bit unsigned long. Refuse a stream
  // that would silently truncate past 4 GiB.
  if AStream.Size > High(FPDF_DWORD) then
    raise EPdfError.Create('TPdfStreamAdapter: stream exceeds the 4 GiB limit');

  FillChar(FFileAccess, SizeOf(FFileAccess), 0);
  FFileAccess.m_FileLen  := FPDF_DWORD(AStream.Size);
  FFileAccess.m_GetBlock := GetBlockCallback;
  FFileAccess.m_Param    := Self;
end;

소유권 플래그는 사용한 가상 스트림 인스턴스를 소멸시키는 주체를 설정합니다. False를 대입하면 호출자가 제어 권한을 유지하므로, 전체 로딩 작업이 수행되는 동안 해당 스트림 인스턴스가 파괴되지 않도록 유효 기간을 직접 책임져야 합니다. True를 입력하면 어댑터가 제어권을 상속받아 문서 해제 타이밍에 스트림도 자동 소멸시켜 줍니다. 어느 쪽을 택하든 간에 대상 스트림 인스턴스는 PDFium이 수행할 실시간 탐색 작업 기간보다 반드시 오래 유지되어야 합니다. 초기 로드뿐만 아니라 문서를 탐색하는 도중에도 PDFium이 내부 FPDF_FILEACCESS 구조 포인터를 지속해서 참조하기 때문입니다.

콜백을 정적 클래스 함수로 선언해야 하는 이유

m_GetBlock 필드에 등재되는 실시간 데이터 호출 콜백은 cdecl 호출 방식을 준수하는 표준 C 함수 포인터 형태여야 합니다. Delphi 클래스 메서드는 C 호출자가 인지하거나 제공할 수 없는 Self 객체 포인터 매개변수를 내부적으로 몰래 수반하므로 직접 등록이 불가능합니다. 따라서 어댑터 내부에서는 콜백 함수를 cdecl; static 속성이 부여된 class function 형식으로 선언하여, 내부 Self 참조 인수가 없고 C 컴파일러 규격 프레임을 충족하는 정적 독립 함수 형태로 변환되도록 유도합니다.

이 방식은 호출 규약 오류는 해결하지만, 함수 내부에 Self 포인터가 없어 실제로 데이터를 가져와야 할 가상 스트림 인스턴스에 어떻게 도달할 것인가라는 두 번째 의문을 유발합니다. 해결의 실마리는 불투명 사용자 매개변수 필드입니다. 어댑터 매개변수 레코드를 선언할 때 m_Param 영역에 자신의 실제 객체 포인터 주소를 입력해 보관합니다. PDFium은 콜백 함수를 실시간 구동할 때마다 이 포인터 값을 첫 번째 인수로 고스란히 반환해 줍니다. 정적 함수는 전달받은 포인터를 TPdfStreamAdapter 타입으로 다시 형변환(cast)하여 내부 스트림 주소를 추적해 나갑니다. 이것이 객체 지향 메커니즘이 없는 C 환경의 진입 장벽을 통과해 원본 인스턴스 데이터를 연동해 나가는 고전적인 트램펄린(trampoline) 방식입니다.

// Verbatim from the component: the cdecl trampoline back to the instance
class function TPdfStreamAdapter.GetBlockCallback(
  param   : Pointer;
  position: FPDF_DWORD;
  pBuf    : PByte;
  size    : FPDF_DWORD): Integer; cdecl;
var
  Adapter: TPdfStreamAdapter;
begin
  Result := 0;
  if (param = nil) or (pBuf = nil) or (size = 0) then
    Exit;
  Adapter := TPdfStreamAdapter(param);   // recover the instance from m_Param
  if Adapter.FStream = nil then
    Exit;
  try
    Adapter.FStream.Position := Int64(position);
    Adapter.FStream.ReadBuffer(pBuf^, Int64(size));
    Result := 1;
  except
    Result := 0;  // report failure by return value, never by raising
  end;
end;

4GB 임계치 상한선 설정 및 검사 논리가 필수적인 이유

FPDF_FILEACCESS의 크기 정의 필드인 m_FileLen은 부호 없는 32비트 정수(unsigned 32-bit integer) 형식입니다. 최대로 표시 가능한 용량은 4GB에서 1바이트 부족한 수치입니다. 반면 Delphi TStream은 크기를 64비트 정수인 Int64 단위로 확인하므로 상한 범위를 초과하는 매우 큰 크기의 대용량 파일도 정의할 수 있습니다. 스트림 실 크기가 4GB 임계 한계치를 넘어서는 순간, PDFium 라이브러리 엔진 측에 원본 크기 정보를 정상적인 방법으로는 알려줄 수 없게 됩니다.

잘못된 대처는 단순히 크기 값을 대입하여 오버플로(wrap)되도록 방관하는 것입니다. 5GB 실데이터 길이를 32비트 한계 필드에 대입하면 값이 잘려 나가 약 1GB 전후의 작고 그럴듯해 보이는 숫자가 입력됩니다. PDFium은 이를 진짜 파일 크기로 오인하고 약 1GB 부근까지만 데이터를 구문 분석하려 시도합니다. 원본의 진짜 트레일러와 상호 참조 정보는 잘려 나간 범위 한참 뒤인 진짜 파일 끝부분에 저장되어 있기 때문에, 라이브러리 엔진은 파일 처리를 즉각 포기하게 되며, 이로 인해 근본 원인과 무관한 파일 손상 경고를 만나게 됩니다. 2단계 상위 계층에서 정수 오버플로가 발생했음을 인지하지 못한 채 멀쩡한 파일의 구조 오류만 검사하며 디버깅 시간을 허비하게 되는 것입니다.

어댑터는 이를 방지하기 위해 입력을 사전에 차단합니다. 생성자 단계에서 스트림 실측 크기를 High(FPDF_DWORD) 최댓값과 즉시 비교하여, 정의 가능 크기를 넘어서는 대용량 스트림 유입 즉시 EPdfError 예외를 고의로 발생시킵니다. 인스턴스 생성 시점에서 명확하고 즉각적인 예외 처리를 유도하는 것이 진짜 원인을 빠르게 규명하는 길입니다. 오버플로를 묵인하고 잘라내어 대입하면 나중에 원인을 알 수 없는 엉뚱한 결함에 직면하게 됩니다. 4GB 상한은 이 로딩 메커니즘 자체의 본질적인 기술적 제약 사항이며, 컴파일만 가능하도록 속임수를 쓰는 대신 외부 화면에 오류로 즉시 노출해 처리하는 것이 설계 관점에서 옳습니다.

오류는 통신 경계를 넘지 않아야 합니다

읽기 연산 중에 언제든 예기치 못한 실패가 나타날 수 있습니다. 사용한 스트림이 네트워크 기반 데이터 블록이라 일시적으로 제한 시간을 초과하거나, 데이터베이스 BLOB 관련 핸들이 도중에 차단되거나, 문서가 아직 열려 있는 와중에 파일 자체가 외부에서 강제 삭제되어 크기가 잘려 나갈 수 있습니다. PDFium이 콜백에 대해 보장받고자 하는 약속은 오직 성공 시 0이 아닌 값, 실패 시 0인 반환 코드뿐입니다. 이것은 표준 C 스택 프레임 영역이므로 Pascal 예외를 가로채 전파할 수 있는 내부 기능이 없습니다.

따라서 어댑터 내부의 트램펄린 코드는 포인터 탐색(seek) 및 물리 읽기 작업을 try/except 구문으로 견고히 감싸 발생한 예외를 자체 격리하고 0을 대신 반환합니다. Delphi 수준의 예외 신호가 콜백을 넘어 그대로 외부로 누출되면, 예외 처리를 감당할 수 없는 PDFium의 C++ cdecl 스택 구조를 깨뜨며 소멸하게 됩니다. 이는 결국 사용 가능한 디버깅 스택 흔적도 남기지 않은 채 프로그램이 강제 크래시되는 최악의 파국으로 귀결됩니다. 0을 정상적으로 돌려주는 방식이 통신 규약을 안전하게 보장하는 방법입니다. PDFium은 특정 블록 읽기에 실패했음을 판단하고 로드 연산을 안전하게 취소하며, FPDF_LoadCustomDocument는 문서를 불러올 수 없음을 상태로 보고하고, 컴파일된 Pascal 컴포넌트는 이를 상위 단계에 친숙한 EPdfError 신호로 최종 변환해 표시해 줍니다.

이 방식을 활용한 문서 오픈 절차

스트리밍 방식으로 문서를 로드하도록 유도하는 전용 컴포넌트 메서드는 LoadCustomDocument입니다. 기존의 LoadDocument에 대한 단순한 오버로드 형태로 선언하지 않고 개별 명칭으로 명명한 이유는, TMemoryStream 등의 소형 스트림 전달 시 예기치 않게 일반 임시 로딩 엔진으로 자동 배분되어 버리는 불상사를 원천 차단하기 위함입니다. 이 메서드는 어댑터를 빌드하고, FPDF_LoadCustomDocument를 호출하며, 불러온 문서가 메모리에서 완전히 소멸할 때까지 어댑터 인스턴스도 함께 유지시킵니다.

var
  Pdf: TPdf;
  FileStream: TFileStream;
begin
  Pdf := TPdf.Create(nil);
  FileStream := TFileStream.Create('Archive_4GB.pdf', fmOpenRead or fmShareDenyWrite);
  try
    // Hand stream ownership to Pdf: it frees FileStream when the document closes.
    Pdf.LoadCustomDocument(FileStream, True);
    // PDFium has read only the trailer and catalog so far.
    // Rendering a page pulls just that page's bytes through the callback.
    // ... render or inspect pages here ...
  finally
    Pdf.Free;  // closes the document, which frees the adapter and the stream
  end;
end;

이와 동일한 함수 호출 메커니즘을 TMemoryStream, 데이터베이스 레코드 세트의 BLOB 스트림, 혹은 기타 사용자 정의 TStream 하위 구현 클래스 객체 전반에 적용할 수 있습니다. 대용량 파일 내의 특정 극소수 범위 데이터만 조작할 필요가 있을 때 온디맨드 로딩 기법의 가치가 극대화됩니다. 예컨대 대규모 스캔 문서 아카이브 뷰어, 특정 몇 페이지만 발췌하여 요약 이미지를 그리는 썸네일 작성기, 그리고 문서 전체 텍스트 검색을 위해 페이지별로 색인을 떠서 긁어 나가는 검색 인덱싱 스파이더 프로그램 등입니다. 반면 파일 크기 자체가 매우 작거나 결국 모든 페이지 범위 정보를 온전히 다 읽어 들여야 하는 일괄 인쇄 작업 등에서는, 기존의 일시 로딩 방식이 구조상 간단하며 이 스트리밍 기술은 성능상 큰 실익이 없습니다. 파일의 전체 용량 대비 실제로 액세스해 소비할 바이트 비율이 이 기술의 도입 여부를 판단할 결정적인 기준점입니다.

페이지 정보가 온디맨드 스트리밍 방식으로 가동된 후에 고민해야 할 다음 단계는, 화면 사용자가 크기를 조절하거나 빠르게 스크롤할 때의 반응 속도를 최적으로 보장하는 것이며, 이 주제는 렌더 캐시 및 화면 줌 배율 제어 아티클에서 확인해 볼 수 있습니다. 만약 스트리밍 중인 문서를 화면에 보여주되 파일 내보내기나 정보 수정을 엄격히 금지하고 싶다면, 보안 PDF 화면 미리보기 안내 문서의 기법을 이 온디맨드 스트리밍 로딩 연동 경로와 조합하여 활용해 보십시오. 두 방식 모두 본 문서에서 설명한 실시간 스트리밍 메커니즘을 기반으로 구현되며, 이는 Delphi 및 C++Builder용 PDFium Component 제품군에 포함되어 렌더링, 텍스트 추출, 주석 관리 API와 함께 종합적으로 제공됩니다.