2기가바이트짜리 PDF를 뻔한 방식으로 병합하거나 분할하면 실제 소요 시간과 주소 공간이라는 두 가지 대가를 동시에 치르게 된다. 뻔한 방식이란 각 입력을 로드하고, 작업을 수행하고, 출력을 쓰는 것이다. 문제가 생기는 지점은 로드다. 300DPI에서 600DPI로 옮겨 간 스캔 아카이브는 선형 해상도가 두 배가 되고 디스크상에서는 대략 네 배가 되므로, 1년 내내 400 MB짜리 파일들을 잘 처리하던 같은 조립 작업이 입력이 1기가바이트를 넘는 순간부터 버벅거리기 시작하는데, 흔히 페이지 수를 세는 것 말고는 아무것도 하지 않는 상태에서도 그렇다. 작업 자체가 더 어려워진 것은 전혀 아니다. 열고, 세고, 범위를 고르고, 이어 붙이는 것이 전부다. 다만 그 크기에서는 전체 트리 로드가 더 이상 합리적인 기본값이 아니게 되었을 뿐이다. Delphi와 C++Builder를 위한 losLab의 PDF 라이브러리인 PDF Library for Delphi는 Direct Access 계층으로 이 문제에 답한다: 문서 전체를 메모리에 구축하는 대신 교차 참조 테이블을 제자리에서 훑어보는 스트리밍 리더가 뒷받침하는 DA 접두사 함수 집합이다
전체 로드에서 메모리가 소모되는 곳
PDF를 "평범하게" 로드한다는 것은 xref를 파싱하고, 모든 간접 객체를 메모리 상의 트리로 풀어내고, 객체 스트림을 디코딩하고, 페이지 트리와 폰트, 주석을 조작 가능한 객체로 연결하는 것을 의미한다. 편집 워크플로에는 이것이 올바른 절충이다. 병합, 분할, 검사 작업에는 대부분 낭비다. 3만 페이지짜리 스캔 아카이브는 수백만 개의 간접 객체를 담고 있을 수 있는데, 분할 작업이 실제로 읽어야 하는 것은 그중 수백 개뿐이다: 요청된 범위 안의 페이지 노드들, 그리고 그 노드들이 참조하는 것들뿐이다
Direct Access 계층은 이 모델을 뒤집는다. DAOpenFile과 DAOpenFileReadOnly는 파일 끝부분 몇 킬로바이트에 해당하는 트레일러와 xref만 파싱하고 파일 핸들을 반환한다. 객체는 호출이 실제로 필요로 할 때 그때그때 가져온다. 실무적으로는 수 기가바이트짜리 파일을 여는 데 걸리는 시간이 작은 파일을 여는 것과 비슷해지며, 메모리 사용량은 파일이 담고 있는 내용이 아니라 실제로 건드린 내용을 따라간다
로드하지 않고 거대한 파일을 살펴보기
아래 패턴은 라이브러리 자체의 대용량 파일 벤치마크에서 가져온 것이다: 읽기 전용으로 열고, 질문하고, 닫는다. 문서 트리는 아예 존재하지 않는다
var
Lib: TPDFlib;
Handle, Pages: Integer;
begin
Lib := TPDFlib.Create;
try
Handle := Lib.DAOpenFileReadOnly('archive-2025.pdf', '');
if Handle = 0 then
raise Exception.Create('Direct access open failed');
Pages := Lib.DAGetPageCount(Handle);
Writeln('pages : ', Pages);
Writeln('title : ', Lib.DAGetInformation(Handle, 'Title'));
Lib.DACloseFile(Handle);
finally
Lib.Free;
end;
end;
가능할 때는 언제나 읽기 전용 모드를 우선할 가치가 있다: 이렇게 하면 다른 프로세스가 파일을 붙잡고 있는 동안에도 수집 단계가 실행될 수 있고, 의도 자체를 문서화하는 효과도 있다. 변형을 일으키는 함수를 실수로 호출하는 탐색 단계는, 아카이브를 손상시키는 대신 즉시 실패한다
PageRef는 페이지 번호가 아니라 객체 핸들이다
DA API에서 가장 흔한 단일 실수는 함수가 PageRef를 기대하는 자리에 페이지 번호를 넘기는 것이다. 페이지 단위로 동작하는 거의 모든 DA 호출은 페이지 번호가 아니라 페이지 객체를 가리키는 참조 핸들을 받는다: DAExtractPageText, DARenderPageToFile, DARotatePage, DACapturePage가 모두 참조를 기대한다. 사람이 보는 번호를 DAFindPage로 변환하면 이 참조를 얻을 수 있다:
PageRef := Lib.DAFindPage(Handle, 250); // 페이지 번호 -> 객체 핸들
if PageRef <> 0 then
begin
Text := Lib.DAExtractPageText(Handle, PageRef, 0);
Lib.DARenderPageToFile(Handle, PageRef, 5, 150, 'page250.png');
end;
대신 250이라는 원시 숫자를 그대로 넘겨도 오류가 발생하지 않는다. 그 핸들 값 뒤에 우연히 자리한 객체가 무엇이든 그것을 대상으로 삼게 되는데, 운이 좋으면 눈에 띄게 실패하지만 운이 나쁘면 고객에게 나가는 문서에 엉뚱한 페이지의 텍스트를 추출해 넣게 된다. DA 계층을 자체 서비스 코드로 감쌀 때는 이 변환을 건너뛸 수 없게 만들어라: 경계에서는 페이지 번호를 받고, 즉시 DAFindPage를 호출하고, 내부에서는 오직 참조만 넘기도록 하라
이름 붙인 목록으로 수백 개의 파일 병합하기
파일이 두 개뿐이라면 MergeFiles(First, Second, Output)만으로 충분하다. 배치 조립은 파일 목록을 통할 때 더 잘 확장된다: 입력들을 목록 이름 아래에 등록한 다음, 그 목록을 한 번에 병합하는 방식이다
Lib.AddToFileList('Statements', 'jan.pdf');
Lib.AddToFileList('Statements', 'feb.pdf');
Lib.AddToFileList('Statements', 'mar.pdf');
Lib.MergeFileList('Statements', 'q1-statements.pdf');
// 결과를 저렴하게 검증: 다시 direct access 사용
Handle := Lib.DAOpenFileReadOnly('q1-statements.pdf', '');
Writeln('merged pages: ', Lib.DAGetPageCount(Handle));
Lib.DACloseFile(Handle);
병합 계열 함수는 세 가지 변형이 있으며, 그 차이는 속도만이 아니다. MergeFileListFast는 구조 트리 보존을 건너뛰고, MergeFileListStrict는 엄격 모드를 강제하며, 접미사가 없는 버전은 균형 잡힌 기본값이다. 여기서 나오는 운영 규칙은 이렇다: 입력 중 하나라도 접근성 구조가 반드시 살아남아야 하는 태그 PDF라면(PDF/UA용으로 만들어진 것이 명백한 예다) 기본값이나 Strict 변형을 사용하라. Fast는 구조 트리를 조용히 버리기 때문이다. 태깅이 없는 평범한 스캔 아카이브라면 Fast는 공짜로 얻는 성능이다. 개발자의 그날 기분이 아니라 파이프라인 단위로 결정하고, 사용한 변형을 작업 로그에 기록하라
로드 없이 분할하기: 범위 추출
분할도 같은 무로드 철학을 따른다. ExtractFilePages(InputFileName, Password, OutputFileName, RangeList)는 '1-500', '501-1000' 같은 범위 목록이나 쉼표로 구분한 선택을 이용해 페이지 범위를 파일에서 파일로 곧바로 끌어오며, 원본은 결코 문서 트리가 되지 않는다. 다른 이유로 문서가 이미 로드되어 있다면, ExtractPageRanges는 현재 문서로부터 새로운 메모리 상의 문서를 만들어 내고, CopyPageRanges는 다른 로드된 문서로부터 ID로 범위를 끌어온다. 통합 인쇄 스트림을 명세서 단위로 분할할 때는, 파일-대-파일 형태가 4 GB짜리 입력이 RAM으로 부풀어 오르는 것을 막아 주는 방식이다
구조에 대해 거짓말하는 파일들
대용량 파일 파이프라인은 소용량 파일 파이프라인이 결코 경험하지 못하는 빈도로 손상된 파일과 마주치는데, 단순히 입력이 더 많은 시스템을 거쳐 가기 때문이다. 명시적으로 처리해 둘 가치가 있는 실패 유형이 두 가지 있다
첫째, 헤더 밀림이다. 메일 게이트웨이와 인쇄 스풀러는 때때로 PDF 앞에 바이트를 덧붙이는데, 그러면 %PDF 마커가 더 이상 오프셋 0에 있지 않게 되고 파일 안의 모든 xref 오프셋이 동일한 양만큼 틀어진다. 스트리밍 리더는 이를 감지해서 드러내 주고(플랫 수준에서는 DAShiftedHeader, TSmartPDFReader에서는 ShiftedHeader), 읽는 동안 이를 보정한다. 직접 구현한 오프셋 산술은 보통 이렇게 하지 않는데, 이것이 바로 "우리가 생성하는 파일에서는 다 되는데 고객 X의 파일에서는 실패한다"는 전형적인 증상이 나오는 이유다
둘째, 손상된 교차 참조 테이블이다. DACopyFile(InputFileName, OutputFileName, PageCount)는 xref를 재구성하면서 파일 전체를 새 사본으로 스트리밍하며, 그 부산물로 페이지 수를 반환한다. 까다로운 다운스트림 소비자 앞에 정규화 단계로 이 함수를 실행해 두면, 간헐적인 파싱 실패라는 부류 전체가 예측 가능한 복구 단계 하나로 바뀐다. 그리고 자체 편집 내용을 저장해야 할 때는, DAAppendFile이 이를 증분 업데이트로 기록하는데, 수 기가바이트를 다시 쓰는 대신 새 리비전을 덧붙이므로 저장 비용이 파일 크기가 아니라 변경 크기에 비례하게 된다
전달 관련 세부 사항: 선형화와 합성
두 가지 인접한 기능이 대용량 파일 파이프라인을 마무리 짓는다. 조립된 출력물을 브라우저 내 보기용으로 HTTP를 통해 서비스한다면, LinearizeFile이 바이트 범위 스트리밍을 위해 이를 재구성해서, 500 MB짜리 패킷의 나머지가 다운로드를 마치기도 전에 첫 페이지가 표시되도록 해 준다. 이 함수는 모든 병합이 끝난 뒤 마지막 단계로 실행하라. 이후의 어떤 수정이든 파일을 다시 비선형화해 버리기 때문이다. 그리고 단순 이어 붙이기가 아니라 합성이 필요할 때, 예를 들어 모든 명세서 뒤에 표지를 찍거나 두 개의 원본 페이지를 하나의 출력 시트에 배치해야 할 때는, DACapturePage가 어떤 페이지든 재사용 가능한 템플릿으로 바꿔 주고 DADrawCapturedPage가 그것을 임의의 사각형 위치에 있는 대상 페이지에 배치해 주는데, 이 역시 수 기가바이트짜리 원본에 대해 전체 문서 로드 없이 이루어진다
한계, 그리고 읽기 전용으로 남는 것
포맷 자체가 Direct Access보다 훨씬 먼저 한계에 부딪힌다. DA 계층 전체에서 오프셋은 Int64이므로, 실질적인 한계는 사용 가능한 디스크 공간과 클래식(비스트림) 교차 참조 테이블의 10자리 xref 오프셋 필드다. 수 기가바이트짜리 스캔 아카이브는 실무에서 전혀 특별할 것이 없으며, 객체는 호출이 요청할 때만 읽히므로 파일 크기와 무관하게 메모리 사용량은 제한된 상태를 유지한다
직접 답할 만큼 자주 나오는 질문이 두 가지 있다. 기본 경로로 병합하면 문서 구조가 그대로 옮겨 가므로 책갈피와 링크가 살아남는다; Fast 변형은 구조 트리를 속도와 맞바꾸는 쪽이며, 바로 그 이유로 태깅되지 않은 입력에만 아껴 써야 한다. 안전한 습관은 병합된 출력물을 열어 개요를 훑어보고 배포 전에 내부 링크 몇 개를 표본 확인하는 것이다. 편집에 관해서는: 읽기 전용 탐색과 전체 로드 사이에 쓸모 있는 중간 지점이 있다. DARotatePage, DAMovePage, DAHidePage를 비롯한 페이지 수준 작업들과 양식 필드 읽기는 핸들에 대해 직접 동작하며, DAAppendFile이 그 편집 내용을 증분 리비전으로 영속화해 준다. 페이지 안의 마킹 연산자를 다시 쓰는 것과 같은 콘텐츠 수준 편집은 여전히 전체 문서 계층의 몫이다
관련 글
병합된 출력물이 계속 접근 가능해야 한다면, 구조 트리에 관한 배경 지식은 태그 PDF 접근성에 관한 글에서 다루는데, Fast 병합 변형이 정확히 무엇을 버리는지도 설명한다. 분할한 범위에서 콘텐츠를 뽑아내는 방법은 텍스트, 이미지, 폰트 추출 가이드를 참고하라
전체 Direct Access 함수 목록은 라이브러리와 함께 제공된다; 에디션과 체험판 다운로드는 PDF Library for Delphi 제품 페이지에서 확인할 수 있다