PDF 증분 업데이트(incremental update)를 사용하면 Delphi 애플리케이션에서 원본 바이트는 전혀 건드리지 않은 채 변경된 객체만 추가하여 문서를 수정할 수 있습니다. losLab PDF Library는 ISO 32000-1 §7.5.6에 정의된 증분 섹션만을 작성하는 AppendToStream을 통해 이 기능을 구현합니다. 덕분에 2 GB 크기 파일의 책갈피 하나를 편집할 때 전체 파일을 재작성하는 대신 단 몇 킬로바이트의 출력만으로 작업을 마칠 수 있습니다. 이 메커니즘은 서명된 문서의 서명을 무효화하지 않고 업데이트할 수 있는 이유이기도 합니다
이 기능이 해결하는 문제는 매우 구체적입니다. 전체 저장(full save) 방식은 파일 전체를 다시 작성합니다. 즉, 모든 객체를 다시 직렬화하고, 모든 상호 참조(cross-reference) 오프셋을 재계산하므로 출력 파일은 입력 파일과 바이트 수준에서 연관성을 잃게 됩니다. 40 KB 크기의 송장이라면 문제가 없습니다. 하지만 문서 제목의 오타 하나를 수정하기 위해 2 GB 크기의 스캔 아카이브를 처리할 때, 20바이트의 수정을 위해 2기가바이트를 다시 작성하는 것은 불합리합니다. 또한 해당 파일에 전자 서명이 포함되어 있다면, 재작성으로 인해 서명이 즉시 파괴됩니다
왜 PDF를 저장하면 전자 서명이 손상될까요?
PDF 전자 서명은 문서의 논리적 콘텐츠에 서명하는 것이 아니라, 물리적 파일의 바이트 범위(byte range)에 서명합니다. 서명 사전의 /ByteRange 항목은 암호화 서명이 계산되는 파일의 정확한 구간을 기록합니다. 설령 의미상으로는 완전히 동일한 문서를 생성하더라도 해당 바이트들을 다시 직렬화하는 저장 작업을 실행하면 서명 값이 변경되어 모든 검증기가 서명이 손상된 것으로 보고합니다. 이는 의도된 설계입니다. 서명은 추상적인 문서 모델이 아니라 서명자가 실제로 목격한 바이트들을 증명하기 때문입니다
증분 업데이트는 PDF 규격이 제공하는 탈출구입니다. 증분 저장은 기존 %%EOF 뒤에 새 데이터를 추가할 뿐 서명된 바이트 범위는 전혀 건드리지 않으므로, 기존 서명은 자신이 가리키는 바이트에 대해 유효성을 계속 유지합니다. 검증기는 추가된 변경 사항을 2차 서명, 양식 작성, 주석 등으로 별도 분류하여 허용된 수정인지 여부를 판단합니다. 여러 명의 서명자가 각각 이전 서명 위에 증분 섹션을 덧붙여 나가는 모든 다중 서명 워크플로우가 이 방식에 의존합니다. 서명 파이프라인을 구축 중이라면, Delphi에서의 PAdES 서명 및 검증 동반 기사를 통해 서명 바이트 범위와 증분 섹션이 어떻게 상호작용하는지 자세히 알아볼 수 있습니다
ISO 32000-1 §7.5.6 표준에서 증분 업데이트는 어떻게 작동하나요?
ISO 32000-1 §7.5.6 규격은 이 모델을 세 가지 규칙으로 정의합니다. 첫째, 원본 파일의 내용은 완전히 그대로 유지되며 단 1바이트도 이동하지 않습니다. 둘째, 변경되거나 새로 생성된 객체는 이전의 마지막 %%EOF 뒤에 추가되며, 각각 이전과 동일한 객체 번호를 부여받습니다 (변경된 객체는 단순히 기존 정의를 덮어쓰는 최신 정의를 가집니다). 셋째, 새로운 상호 참조(cross-reference) 섹션과 트레일러가 뒤에 추가됩니다. 트레일러의 /Prev 항목은 이전 상호 참조 섹션의 바이트 오프셋을 가리키며, 이를 통해 뷰어가 가장 최신의 것부터 오래된 순으로 체인을 훑어 내리며 각 객체의 가장 최근 정의를 해석할 수 있도록 합니다
이 구조에서는 두 가지 유용한 특징이 도출됩니다. 첫째, 업데이트 비용은 문서 전체 크기가 아니라 변경된 내용의 크기에 비례합니다. 추가 비용은 수정된 객체들의 크기에 약간의 상호 참조/트레일러 오버헤드가 더해질 뿐입니다. 둘째, 파일 자체가 일종의 버전 이력을 담게 됩니다. 이전의 모든 수정본이 여전히 파일에 물리적으로 존재하므로, 감사관은 이전의 어떤 %%EOF 지점에서든 파일을 잘라내어 해당 시점에 존재했던 정확한 문서를 복원할 수 있습니다. 각 수정 전 문서의 원래 상태를 증명해야 하는 규정 준수(compliance) 워크플로우의 경우, 이러한 내장 감사 추적 기능은 종종 증분 저장을 선택하는 주요 결정 요인이 됩니다
AppendToStream을 사용해 증분 업데이트 작성하기
losLab PDF Library는 성공 시 1을, 실패 시 0을 반환하는 AppendToStream(AppendMode: Integer; OutStream: TStream): Integer 함수를 통해 증분 출력을 지원합니다. AppendMode 매개변수는 대상 스트림에 저장될 대상을 결정합니다. 모드 0은 전체 파일을 작성합니다. 즉, 원본 소스 바이트가 먼저 스트림에 복사된 후 증분 섹션이 추가됩니다. 모드 1은 원본 바이트를 완전히 건너뛰고 오직 증분 섹션 자체(델타)만 작성합니다. 모드 2는 먼저 SetAppendInputFromString을 통해 등록된 호출자 정의 접두사를 작성한 다음, 그 위에 업데이트 섹션을 추가합니다
var
Doc: TPDFlib;
Delta: TMemoryStream;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('contract.pdf', '') <= 0 then
Exit;
// Small edit: the kind of change that should not
// trigger a rewrite of the whole file
Doc.SetInformation(3, 'Amended 2026-07-04'); // key 3 = /Subject
Delta := TMemoryStream.Create;
try
// AppendMode = 1: write only the incremental section.
// Original bytes + Delta = a complete, valid PDF.
if Doc.AppendToStream(1, Delta) = 1 then
Delta.SaveToFile('contract.delta.bin');
finally
Delta.Free;
end;
finally
Doc.Free;
end;
end;
모드 1은 시스템 설계 관점에서 매우 흥미로운 방식입니다. 델타 부분이 독립적으로 구성되어 있어 원본 파일과 별개로 전송할 수 있습니다. 즉, 객체 스토리지에 각 버전을 개별 블롭(blob)으로 저장하거나, 원격지에 델타만 복제하거나, 기본 파일에 증분 체인을 단순히 이어 붙여 원하는 버전을 복원할 수 있습니다. 복원 규칙은 원본 파일을 먼저 두고 각 델타를 순서대로 바이트 수준에서 병합하는 단순 연결 구조입니다. 이것이 바로 §7.5.6 표준이 증분 업데이트 파일에 규정하는 정확한 레이아웃 형태이기 때문입니다
라이브러리는 어떻게 원본 파일을 복사하지 않고 상호 참조 오프셋을 계산할까요?
증분 섹션 내의 상호 참조 항목들은 델타의 시작 지점이 아닌, 결합된 전체 파일의 시작 지점으로부터 측정된 절대 바이트 오프셋(offset) 값을 가져야 합니다. 이 조건은 모드 1을 구현할 때 난관이 됩니다. 작성기는 원본 바이트를 출력하지 않으면서도 기록하는 모든 오프셋이 원본 바이트 뒤에 이어서 존재하듯 시뮬레이션해야 하기 때문입니다. losLab PDF Library는 직렬화기(serializer)에 가상 좌표 공간을 제시하는 내부 스트림 어댑터인 TPDFAppendSectionStream을 통해 이 문제를 해결합니다. 이 어댑터는 원본 파일의 바이트 길이를 기본 오프셋으로 설정하여 생성된 후, 자신의 위치와 크기를 기본 오프셋에 지금까지 추가된 크기를 더한 값으로 보고하며, 오직 새로 기록된 바이트만 호출자가 지정한 실제 스트림으로 전달합니다
이 결과로 모드 1은 디스크나 메모리 상에 원본 문서의 임시 복사본을 전혀 생성하지 않습니다. 단순한 방식(전체 파일을 임시 버퍼에 쓴 다음 꼬리 부분만 잘라내는 방식)은 원본 PDF 전체의 임시 복사본을 들고 있어야 하므로, 기가바이트 규모의 입력에서는 증분 업데이트를 사용하여 피하고자 했던 정확한 비용 지출이 다시 발생하게 됩니다. 이 오프셋 가상화 기술은 라이브러리의 다른 부분에 쓰이는 바이트 참조 시프팅(byte-reference shifting) 기법과 유사합니다. 바이트 참조 시프팅을 활용한 고속 PDF 병합 글에서 이러한 아이디어가 문서 결합에 응용된 예를 확인할 수 있으며, 직접 파일 액세스를 활용한 대용량 PDF 병합 및 분할 가이드에서는 RAM 메모리 용량을 초과하는 대용량 파일의 전반적인 입출력(I/O) 아키텍처를 소개합니다
SaveToStream을 통한 전체 저장 스트리밍
losLab PDF Library의 SaveToStream은 전체 문서를 먼저 중간 AnsiString 버퍼로 렌더링한 후 한 번에 출력 스트림에 쓰는 대신, 문서 직렬화기가 대상 스트림을 대상으로 직접 출력을 유도하도록 처리합니다. 과거의 버퍼링 방식도 작동은 했지만, 전체를 저장할 때마다 메모리에 파일의 또 다른 전체 복사본을 임시로 유지해야 했기 때문에 10 MB 규모에서는 영향이 없으나 500 MB에서는 리소스 부하를 일으키고, 32비트 프로세스 환경에서 수 기가바이트 파일 출력 시에는 한계에 봉착하게 만들었습니다. 스트림 직접 직렬화를 도입하면 메모리 사용량의 피크(peak) 지점이 출력 파일 크기가 아닌 문서 객체 구조의 수에 비례하게 관리됩니다
var
Doc: TPDFlib;
Output: TFileStream;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('archive.pdf', '') <= 0 then
Exit;
// ... edits that justify a full rewrite ...
Output := TFileStream.Create('archive-rewritten.pdf', fmCreate);
try
if Doc.SaveToStream(Output) = 0 then
Writeln('Save failed, error ', Doc.LastErrorCode);
finally
Output.Free;
end;
finally
Doc.Free;
end;
end;
공유 모드 교훈: AppendToFile이 0을 반환했을 때
이 영역에서 발생한 한 가지 회귀(regression) 버그 사례는 다른 실패 유형에도 고루 적용될 수 있어 살펴볼 가치가 있습니다. AppendToFile(FileName)은 디스크 상의 기존 PDF 파일에 증분 업데이트를 직접 추가하는 함수로, 로드 후 수정을 가하고 동일 경로에 내용을 추가하는 감사 추적 워크플로우에 최적화된 메서드입니다. v3.71.2 버전에서 이 함수 호출이 돌연 0을 반환하기 시작했습니다. 원인 분석 결과 문제는 작성기가 아니라 로더(loader) 쪽에 있었습니다. 대용량 문서의 온디맨드 읽기를 지원하기 위해 LoadFromFile은 문서 객체가 해제될 때까지 원본 파일 핸들을 열린 상태로 유지하는데, 이 핸들이 fmShareDenyWrite 모드로 열려 있었던 것입니다. 이후 AppendToFile이 쓰기용으로 동일 파일을 다시 열려고 시도했을 때 로더가 설정해 둔 공유 모드가 이를 거부하였고, 결국 단 1바이트도 기록하지 못한 채 API가 실패하게 되었습니다
해결책은 로더의 공유 모드를 fmShareDenyNone으로 완화하는 것이었습니다. 증분 추가 작업의 특성상 이는 안전한데, 파일 끝부분 뒤에 바이트를 덧붙일 뿐 리더의 수명이 긴 핸들이 서빙 중인 앞부분 영역은 전혀 재작성하지 않기 때문입니다. 이 라이브러리를 래핑하거나 유사한 스트리밍 로더를 구현하는 개발자를 위한 일반적인 교훈은 지연 처리(lazy) 방식으로 작동하며 핸들을 점유 중인 리더와 동일 파일 쓰기 동작 간에 충돌이 발생할 수 있으며, 파일을 열 때 선택하는 공유 모드가 단순한 내부 세부 사항이 아니라 API 계약 조건이라는 점입니다. 작성 중인 코드에서 AppendToFile이 0을 반환한다면, 프로세스 내 다른 모듈이 대상 파일을 지나치게 제한적인 공유 모드로 점유하고 있는 것은 아닌지 가장 먼저 확인해 보십시오
비용의 현실: 증분 업데이트를 사용하면 안 되는 상황
증분 업데이트는 파일 크기를 대가로 쓰기 효율을 얻는 타협안이므로 항상 유리한 것은 아닙니다. 수정이 일어날 때마다 변경된 객체를 추가하는 반면 이전 정의도 파일 안에 유지되므로, 수백 번 편집된 문서는 사용되지 않는 죽은 객체들이 쌓이고 독자가 거쳐야 할 상호 참조의 /Prev 링크 체인이 길어집니다. 더 치명적인 점은 '삭제된' 내용이 실제로 없어지지 않는다는 것입니다. 5번째 개정판에서 지운 텍스트가 4번째 개정판 바이트에는 물리적으로 남아 있어 파일을 잘라내는 방식으로 누구나 다시 읽어 들일 수 있습니다. 따라서 정보 가림(redaction), 위생화(sanitization) 또는 민감한 정보의 완전한 삭제 요구 시에는 반드시 전체 재작성을 수행해야 합니다. 민감한 정보 가림 작업 후 증분 저장을 하는 것은 번거로운 방식으로 정보 유출을 자초하는 행동입니다
문서 정리(compaction, 누적된 증분 부분과 미사용 객체 제거), 암호화 등 문서 전반의 속성을 수정하는 경우(재암호화 시 모든 문자열과 스트림이 재가공되므로 '증분' 성격이 무색해짐), 혹은 수정 이력을 완전히 삭제한 상태로 깨끗한 배포판 문서를 작성하고자 할 때는 전체 저장 방식이 최선의 대안입니다. 합리적인 원칙은 다음과 같습니다. 서명이 포함되어 수시로 수정 및 추가가 일어나는 편집 단계에서는 AppendToStream 또는 AppendToFile을 권장하며, 전체 문서 주기의 최종 마무리 지점, 즉 문서가 사외로 발송되거나 기록 병합이 필요할 때는 전체 SaveToStream 재작성을 사용해 대처해 보십시오
증분 업데이트, 가상 오프셋 델타 출력 및 스트림 직접 직렬화는 모두 Delphi, C# 및 VB.NET을 지원하는 표준 losLab PDF Library의 기본 제공 사양입니다. 제품 페이지에서 본 문서에 다룬 서명 및 대용량 파일 처리 사양들과 함께 저장 및 추가와 관련된 상세 API 목록을 확인하실 수 있습니다