파싱된 PDF 파일을 그대로 옮기는 Delphi 객체 모델은 보통 모든 객체를 담은 평평한 리스트 하나와 거기서 뻗어 나가는 포인터 그래프로 귀결됩니다. 그 리스트를 훑으며 항목마다 Free를 호출하면 실행 도중 소멸자 안에서 액세스 바이올레이션으로 죽는데, 바로 앞서 다른 부모를 통해 Free된 객체입니다. 루프를 List.Clear로 바꾸면 크래시는 사라지지만 이번에는 다시 로드할 때마다 프로세스가 문서 하나 분량씩 불어납니다. 두 증상의 원인은 하나입니다. 리스트는 평평한데 소유 관계는 그래프라는 점입니다. 제대로 동작하는 teardown은 어떤 포인터가 자기 대상을 소유하는지 판정하고, 그 폐포를 한 번 모은 뒤, 모든 소유 엣지를 끊고, 그러고 나서야 각 노드를 정확히 한 번씩 해제합니다
등록된 객체를 전부 해제하면 왜 크래시할까요?
HotPDF에서 이 평평한 리스트가 곧 문서의 객체 레지스트리인데, 그 안에 담긴 클래스들은 누가 무엇을 파괴하는지에 대해 서로 다른 규칙을 갖고 있습니다. 각 소멸자만 놓고 보면 모두 합리적입니다. 같은 소유 사슬의 여러 계층에서 온 항목이 섞인 레지스트리에 이것들을 조합하면, 항목별 Free는 어떤 메모리는 두 번 해제하고 어떤 메모리는 한 번도 해제하지 않습니다
HPDFObjs.pas와 HPDFDoc.pas의 세 소멸자가 이 상황을 만듭니다. THPDFDictionaryObject.Destroy는 자기 Items를 훑으면서 IsIndirect가 False일 때만 값을 해제하는데, 간접 자식은 레지스트리에 속해 있다는 가정입니다. THPDFArrayObject.Destroy는 간접 여부를 가리지 않고 들고 있는 모든 항목을 해제합니다. 객체 스트림에서 꺼낸 객체를 감싸는 래퍼인 THPDFIndirectObject(ISO 32000-1 §7.5.7)는 자기 InternalObject 본문을 해제합니다. 이제 간접 딕셔너리 하나, 같은 딕셔너리를 슬롯 하나에 담은 배열, 그리고 본문이 따로 루트로 등록된 래퍼까지 담은 레지스트리를 상상해 보세요. 배열을 먼저 해제하면 레지스트리가 딕셔너리에 도달하기 전에 그것은 이미 사라졌습니다. 래퍼를 해제한 뒤 본문을 해제하면 두 번째 호출은 더 이상 누구의 것도 아닌 메모리 위에서 소멸자를 실행합니다. 딕셔너리만 해제하면 그것이 건너뛴 모든 간접 자식은 프로세스가 끝날 때까지 할당된 채 남습니다
레지스트리 순서를 다시 배치한다고 이 문제는 구제되지 않습니다. 어떤 순서를 고르든 소유 엣지의 엇갈린 쪽에 놓이는 항목 쌍이 반드시 생깁니다. 리스트에는 12번 항목이 40번 항목을 소유한다고 말할 방법이 없기 때문입니다
PDF 객체 그래프의 소유 엣지와 참조
소유 엣지는 그 대상을 출발점 쪽이 파괴할 책임을 지는 포인터이고, 나머지는 모두 참조이며, teardown은 첫 번째 종류만 따라갑니다. HotPDF에서 소유 엣지는 정확히 네 종류입니다. THPDFDictionaryObject의 Items, THPDFArrayObject의 Items, THPDFIndirectObject 뒤에 있는 InternalObject, 그리고 THPDFStreamObject의 두 필드, 즉 Dictionary와 Stream 페이로드입니다
참조도 똑같은 주의가 필요합니다. 참조를 따라가면 그래프 순회가 무한 루프가 되거나 해제된 메모리를 읽기 때문입니다. THPDFLink는 객체 번호와 세대를 담는데, 이것이 정확히 ISO 32000-1 §7.3.10의 간접 참조, 즉 파일의 다른 곳에 저장된 객체를 가리키는 이름입니다. 그 번호를 레지스트리로 해석하면 다른 엣지가 이미 소유한 노드에 착지하므로, teardown은 링크를 전혀 역참조하지 않습니다. 딕셔너리와 배열이 갖고 있는 FParent 역방향 포인터는 반대 방향으로 흐릅니다. 부모가 이미 자식을 소유하므로 그 포인터를 거슬러 오르는 일은 순회가 지나간 노드를 다시 방문할 뿐입니다. 두 종류 모두 teardown 내내 건드리지 않습니다
모으고, 끊고, 해제하기: 3단계 teardown
HotPDF는 보호된 THotPDF.CloseIndirectObjects 메서드 안에서 3단계에 걸쳐 문서의 객체를 해제하며, 그 단계의 순서가 곧 요령 전부입니다. 1단계는 수집입니다. worklist는 레지스트리의 모든 항목으로 시작하고, 각 노드마다 순회는 그 소유 엣지의 대상들을 덧붙이되 이미 본 것은 건너뜁니다. seen 집합은 선형 탐사를 쓰는 원시 포인터의 open addressing 배열로, 절반이 차면 두 배로 늘어나므로 노드마다 할당하는 것이 아무것도 없습니다. 문서가 객체 수십만 개를 운반할 때 이것이 빛을 발합니다. 스트림 페이로드는 THPDFObject 노드가 아니라 TStream 후손이므로 별도의 리스트로 갑니다
아래 두 목록은 컴포넌트 내부 구현을 단순화한 발췌로, 라이브러리 자체의 객체 타입으로 단계들이 어떻게 흐르는지 보여 주기 위한 것입니다. 애플리케이션 코드는 이중 아무것도 직접 호출하지 않습니다. LoadFromFile, LoadFromStream, 소멸자가 대신 실행해 줍니다. 발췌에서는 주소 해시를 시프트와 마스크로 줄였고, seen 집합 테이블이 차올라 두 배로 늘어나는 단계는 생략했습니다
// 내부 teardown의 단순화 발췌. 호출 가능한 API가 아닙니다
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (실제 루틴은 테이블이 절반 차면 두 배로 늘려 재해시합니다)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // 이미 수집됨
Slot := (Slot + 1) and (Length(Seen) - 1);
end;
Seen[Slot] := Pointer(Value);
Inc(SeenCount);
if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;
// 1단계: 레지스트리로 시작해 소유 엣지만 따라갑니다
for I := 0 to IndirectObjects.Count - 1 do
Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
Collect(THPDFIndirectObject(Obj).InternalObject, False)
else if Obj is THPDFStreamObject then
begin
Collect(THPDFStreamObject(Obj).Dictionary, False);
Collect(THPDFStreamObject(Obj).Stream, True);
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
// THPDFLink 값과 FParent 포인터는 절대 따라가지 않습니다
Inc(I);
end;
2단계는 끊기입니다. 소멸자 하나라도 실행되기 전에 모든 소유 엣지가 nil로 설정됩니다. 래퍼에는 MarkAsFreed가 적용되는데, 이것은 InternalObject를 비우고 소멸자가 가장 먼저 검사하는 IsFreed 플래그를 세웁니다. 스트림 객체는 Dictionary와 Stream에 nil이 대입되고, 각 딕셔너리 항목은 Value가 비워지며, 각 배열 슬롯은 nil로 덮어써집니다. 3단계는 레지스트리를 비우고 Nodes의 모든 노드를, 그다음 Streams의 모든 페이로드를 해제합니다
// 내부 teardown의 단순화 발췌, 이어짐. 호출 가능한 API가 아닙니다
// 2단계: 무엇을 해제하기 전에 모든 소유 엣지를 떼어 냅니다
for I := 0 to Nodes.Count - 1 do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
THPDFIndirectObject(Obj).MarkAsFreed
else if Obj is THPDFStreamObject then
begin
THPDFStreamObject(Obj).Dictionary := nil;
THPDFStreamObject(Obj).Stream := nil;
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
THPDFArrayObject(Obj).Items[J] := nil;
end;
// 3단계: 고유한 노드와 페이로드 각각을 정확히 한 번 해제합니다
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);
바로 이 끊기 덕분에 기존 소멸자들을 그대로 둘 수 있습니다. 남은 엣지가 없으니 각 소멸자는 재귀해 들어갈 것을 찾지 못하고 자기 자신만 파괴합니다. 같은 결과에 이르는 다른 길은 모든 소멸자에게 레지스트리를 가르치는 것입니다. 딕셔너리와 배열, 래퍼가 각각 다른 경로가 자식을 해제해 줄지 물어보게 하는 것이죠. 그러면 그래프 지식이 네 클래스와 나중에 추가되는 모든 클래스에 퍼지고, 어디선가 한 번이라도 잘못 답하면 이중 해제가 돌아옵니다. 소유 모델 전체를 단일 teardown 루틴 하나에 두는 편이 더 안전한 배치입니다
곤란한 사례들을 통과시켜 보면 어느 하나도 특별 분기가 필요 없습니다. 두 스트림 객체가 공유하는 딕셔너리는 한 번 수집되고 양쪽에서 떼어져 한 번 해제됩니다. 배열이 자기 부모 딕셔너리를 담는 순환은 seen 집합이 두 번째 방문을 거부하기 때문에 끝납니다. 두 루트로 등록된 래퍼와 본문은 집합 안에서 서로 다른 포인터 둘이므로 둘 다 해제되고, 래퍼의 소멸자는 본문을 건드리지 않습니다. MarkAsFreed가 이미 그 엣지를 가져갔기 때문입니다. 두 스트림 객체의 페이로드로 대입된 하나의 TMemoryStream은 Streams에 정확히 한 번 들어갑니다
그래프가 내려오기 전에 무엇이 멈춰야 할까요?
그래프에서 객체를 빌려 쓰는 것은 전부 먼저 멈춰야 합니다. HotPDF는 PrefetchLoadedPages를 통해 워커 스레드에서 페이지를 렌더링할 수 있고, 문서의 객체들로 컴파일한 비트맵과 디스플레이 리스트를 캐시합니다. 그래프보다 오래 살아남는 워커나 캐시 항목은 해제된 메모리를 읽게 됩니다. 그래서 teardown은 CancelLoadedPagePrefetch로 열리고, 레지스트리를 건드리기 전에 렌더링된 페이지 캐시를 무효화합니다. LoadFromFile, LoadFromStream, THotPDF 소멸자가 모두 이 길을 거치므로 문서를 교체하든 컴포넌트를 해제하든 순서는 동일하며, THotPDF 하나를 여러 문서에 재사용하는 규칙이 이것에 기댑니다
알림 계약도 똑같이 중요합니다. InvalidateRenderedPageCache는 PageIndex = -1로 OnLoadedDocumentModified를 일으키는 메서드인데, 파일을 다시 로드하는 뷰어가 예전 문서의 내부 정리를 두고 편집 알림을 받아서는 안 됩니다. teardown 안에서는 핸들러를 저장하고 그 호출을 둘러싸고 nil로 설정한 뒤 finally 블록에서 복원합니다. 다시 로드할 때도 PageIndex = -1 호출이 한 번 생기지만, 그것은 새 문서의 캐시를 리셋하면서 로드 자체가 일으키는 것이고 뷰어가 다시 그려야 하는 바로 그 호출입니다. 소멸자에서는 그래프가 닫히는 시점에 캐시가 이미 폐기된 뒤라서 teardown은 그곳에서의 무효화를 건너뜁니다
procedure TViewerForm.FormCreate(Sender: TObject);
begin
FPdf := THotPDF.Create(Self);
FPdf.OnLoadedDocumentModified := PdfModified;
end;
procedure TViewerForm.PdfModified(Sender: TObject; PageIndex: Integer);
begin
if PageIndex = -1 then
RepaintAllPages // 직접 만든 뷰어 메서드
else
RepaintPage(PageIndex); // 직접 만든 뷰어 메서드
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile은 실행 중인 프리패치를 취소하고 파싱에 앞서
// 이전 문서의 객체를 해제합니다. PdfModified는 -1 호출을 한 번 봅니다
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
OpenDocument에서 CancelLoadedPagePrefetch를 명시적으로 호출할 필요는 없습니다. 로딩이 대신 수행하기 때문입니다. 그래도 프리패치가 실행 중일 수 있는 상황에서 적재된 문서를 수정하려 한다면 직접 호출하는 것이 여전히 옳은 선택이고, HPDFDoc.pas의 선언 주석도 정확히 그렇게 요청합니다. 더 큰 규모의 워커 풀 렌더링은 병렬 렌더 파이프라인 글이 백그라운드 작업이 어떻게 조절되는지 다룹니다
누수를 할당자 보유와 어떻게 구분할까요?
반복 로드 사이에서 메모리 관리자의 살아 있는 할당 수를 지켜 보고, 프로세스 워킹 셋은 무시하세요. Delphi 메모리 관리자는 해제된 메모리를 재사용을 위해 풀에 남겨 두므로, 문서를 닫은 뒤에도 높은 풋프린트에서 꾸준히 머무는 프로세스가 반드시 누수한 것은 아닙니다. 다시 로드할 때마다 살아 있는 큰 블록 수가 올라가는 프로세스가 진짜 누수입니다. 아래 루프는 하나의 인스턴스에서 같은 파일을 스무 번 로드하고 매 패스 뒤에 큰 블록 수치를 출력합니다
procedure ReportLargeBlocks(const Tag: string);
var
State: TMemoryManagerState;
begin
GetMemoryManagerState(State);
Writeln(Tag, ': ', State.AllocatedLargeBlockCount, ' large blocks, ',
State.TotalAllocatedLargeBlockSize, ' bytes');
end;
var
Pdf: THotPDF;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
for I := 1 to 20 do
begin
Pdf.LoadFromFile('sample.pdf');
ReportLargeBlocks('pass ' + IntToStr(I));
end;
finally
Pdf.Free;
end;
ReportLargeBlocks('after Free');
end.
출력은 모양으로 읽습니다. 첫 패스 뒤에 안정되는 숫자는 보존이거나 평범한 문서별 상태입니다. 매 패스마다 같은 폭으로 오르는 숫자는 누수이고, PDF라면 그 폭은 보통 더 큰 스트림 페이로드, 페이지 콘텐츠와 내장 이미지의 크기입니다. 큰 블록 수치에 착지할 만큼 큰 할당이 바로 그것들이기 때문입니다. 노이즈 대비 폭이 잘 보이도록 페이지가 십수 장 이상인 파일을 쓰세요. 페이지 수에 비례하는 증가는 할당자 보유로는 설명되지 않습니다. 문서가 열려 있는 동안 어떤 객체가 메모리를 쥐고 있는지 찾는 일은 보유 바이트를 포함한 객체 의존성 그래프가 그쪽 질문에 답합니다
자기 Delphi 객체 모델에서 패턴 재사용하기
여기 있는 것은 PDF에만 국한되지 않습니다. 소멸자가 자식을 일관성 없이 소유하거나, 한 자식이 여러 부모에서 도달하거나, 역방향 포인터가 순방향 포인터 곁에 놓인 Delphi 객체 모델이라면 무엇이든 객체별 Free 아래에서 크래시하거나 누수합니다. 해법은 언제나 같은 모양입니다. 각 포인터 필드를 소유와 참조로 분류하고, 재방문을 견디는 포인터 집합으로 소유 엣지의 폐포를 수집하고, 모든 엣지를 끊고, 평평한 리스트를 파괴합니다. 사람들이 건너뛰는 단계는 바로 끊기 단계입니다. 건너뛰면 모든 소멸자가 결국 다른 모든 소멸자를 알아야 합니다
패턴을 복사하기 전에 적어 둘 가치가 있는 경계들이 따라옵니다. 포인터 집합은 객체 주소를 정체성으로 쓰는데, 이것이 건전한 이유는 수집 동안에는 소멸자가 하나도 실행되지 않아 주소가 해제되어 새 할당에 넘어갈 일이 없기 때문입니다. 순회는 자신이 작성된 엣지 종류만 봅니다. 다른 필드를 통해 자식을 소유하는 새 클래스는 수집과 끊기 양쪽 패스에 추가해야 합니다. 그리고 링크는 따라가지 않고 해석하므로, 링크로만 참조되는 객체는 도달되려면 레지스트리 안에 있어야 합니다. HotPDF 파서는 읽은 모든 객체를 등록하며, 여러분 코드에서 손으로 만든 그래프도 같은 규칙을 지켜야 합니다
바이트가 아니라 소멸자 호출 수로 세어 검증하세요. 불쾌한 그래프를 손으로 만듭니다. 두 스트림이 공유하는 딕셔너리 하나, 그 딕셔너리와 자기 부모를 모두 담은 배열 하나, 양쪽 스트림에 대입된 페이로드 하나, 두 번 등록된 루트 하나, 숫자 본문까지 등록된 래퍼 하나입니다. teardown을 돌리고 고유 객체마다 정확히 한 번의 파괴를 기대합니다. 페이로드 하나, 스트림 둘, 딕셔너리 둘, 배열 하나, 래퍼 하나, 숫자 하나입니다. 자기 클래스에서는 각 소멸자에서 올리는 클래스 수준 카운터(직접 추가하는 계측 훅)면 충분하고, 리스트만 비우는 teardown은 0을 보고합니다
HotPDF에서 이 모든 것은 LoadFromFile, LoadFromStream, 소멸자 뒤에 숨어 있으므로 애플리케이션 코드가 보는 것은 결과뿐입니다. 문서를 닫거나 다시 로드하면 메모리가 돌아옵니다. HotPDF는 전체 소스가 포함된 Delphi와 C++Builder용 네이티브 VCL PDF 라이브러리이므로, 여기 설명한 teardown은 HPDFDoc.pas에서 한 줄씩 읽을 수 있습니다. API 레퍼런스와 체험판 빌드는 Delphi용 HotPDF PDF 컴포넌트 페이지에 있습니다