같은 템플릿과 같은 TrueType 페이스로 렌더링한 두 청구서가 고객 성씨만 다른데도 폰트 서브셋 캐시에는 서로 다른 파일 두 개가 남습니다. 성씨가 첫 번째 청구서에 없던 대문자 Q와 소문자 z를 추가하면 남겨질 글리프 집합이 바뀌고, 그에 따라 서브셋과 캐시 키도 바뀝니다. 영속 서브셋 캐시는 문서들의 글리프 레퍼토리가 반복될 때 빛을 발하며, 노출되는 카운터를 보면 배치 한 번으로 여러분의 문서가 그런지 알 수 있습니다
HotPDF에서 디스크 캐시 켜기
HotPDF는 폴더를 지정하기 전까지 서브셋 캐시를 꺼 둡니다. THotPDF의 두 프로퍼티가 이를 제어합니다. FontSubsetCacheFolder는 디렉터리를 지정하고, FontSubsetCacheMaxBytes는 캐시가 담을 수 있는 상한을 정하며 기본값은 268435456바이트(256 MiB)입니다. 캐시는 폴더가 비어 있지 않고 예산이 0보다 클 때만 활성화됩니다. 폴더를 할당하면 디렉터리가 없을 때 만들어 주고, 이미 들어 있는 것들을 곧장 예산에 맞게 잘라냅니다
서브세팅은 진짜 작업이기에 캐싱할 가치가 있습니다. TrueType 서브셋을 만든다는 것은 합성 글리프 클로저를 따라가고, loca와 glyf를 다시 쓰고, 글리프를 색인하는 테이블들을 재구축하고, PDF가 주소 지정할 수 있는 CID 매핑을 만들어 내는 일입니다. 문서 하나 정도에서는 그 비용이 노이즈 속에 사라집니다. 같은 세 개의 페이스로 루프 안에서 수천 건의 명세서를 렌더링하는 리포트 서버에서는 똑같은 서브셋이 계속해서 다시 만들어지는데, 캐시가 돌려주는 CPU가 바로 그것입니다
var
Pdf: THotPDF;
Info: THPDFFontSubsetCacheInfo;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.EnableFontSubsetting := True;
// ASCII 전용 경로: 이 프로퍼티는 AnsiString입니다
Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
Pdf.FontSubsetCacheMaxBytes := 64 * 1024 * 1024; // 기본값 256 MiB 대신 64 MiB
// ... 배치의 각 문서마다 BeginDoc / 그리기 / EndDoc ...
Info := Pdf.GetFontSubsetCacheInfo;
// LogFmt은 직접 만든 로깅 루틴입니다
LogFmt('subset cache: %d hits, %d misses, %d bytes in %d files',
[Info.HitCount, Info.MissCount, Info.CurrentBytes, Info.FileCount]);
finally
Pdf.Free;
end;
end;
캐시는 두 서브세팅 경로 모두 뒤에 있습니다. TrueType과 OpenType-CFF 폰트를 다루는 EnableFontSubsetting을 통해 서브세팅되는 페이스와, RegisterUnicodeTTF로 등록한 페이스가 그 대상입니다. 그리기 코드는 그 외에는 아무것도 바꿀 필요가 없습니다. 폴더 경로는 AnsiString으로 선언되어 있으므로 시스템 코드 페이지 밖의 문자를 디렉터리 이름에 쓰는 것은 문제를 부르는 길이고, ProgramData 아래의 평범한 ASCII 경로나 서비스 데이터 디렉터리를 쓰면 이 질문 자체를 피할 수 있습니다
캐시 키에 들어가는 것들
저장된 서브셋을 재사용할 수 있는지는 네 가지 입력이 결정합니다. 소스 폰트의 정확한 바이트, 문서가 남기는 글리프 집합, 서브셋 모드, 그리고 캐시 형식 버전입니다. 각 키는 폰트 바이트의 SHA-256 해시와 사용된 글리프 ID 비트맵의 SHA-256 해시로 만들어지며, 희소 서브셋이면 -s, 컴팩트 서브셋이면 -c 접미사가 붙고 v1- 접두사로 시작합니다. 항목은 디스크에 <key>.hpdfsubset으로 기록되므로 디렉터리 목록만 봐도 배치가 어떤 모드를 만들어 내는지 한눈에 알 수 있습니다
파일 이름 대신 폰트 콘텐츠를 해싱하면 실용적인 결과가 하나 따라옵니다. Arial을 업그레이드하거나 회사 서체의 더 새 빌드로 교체하면 새 키가 저절로 만들어지고, 오래된 항목은 제거 대상이 되기 전까지 그저 히트되지 않을 뿐입니다. 폰트를 업데이트한 뒤 올바른 출력을 얻기 위해 캐시를 지울 필요는 없습니다. 지우는 것은 공간을 더 일찍 회수할 뿐입니다
서브셋 모드는 사람들이 잊는 입력입니다. CompactFontSubsetting은 남긴 글리프를 촘촘한 범위로 재번호 매기고 명시적인 /CIDToGIDMap을 기록하며(ISO 32000-1 §9.7.4.2), PDFACompliance가 설정되면 등록된 페이스는 언제나 컴팩트 서브셋을 받습니다. CompressDocument도 문서가 진행되는 동안 컴팩트 서브셋을 켭니다. 그래서 같은 명세서를 평범한 PDF로 한 번, PDF/A로 한 번 기록하는 서비스는 동일한 글리프에 대해 두 세트의 항목을 채웁니다. 하나는 -s로, 하나는 -c로 끝납니다. OpenType-CFF 페이스는 언제나 희소 경로를 거칩니다
히트율이 갈리는 곳은 글리프 집합입니다. 고정 템플릿의 명세서, 가변 데이터가 대부분 숫자인 양식, 하나의 제품 데이터베이스에서 뽑은 카탈로그는 문서마다 같은 글리프를 재사용합니다. 반면 수만 글리프짜리 CJK 서체에서 문서마다 서로 다른 조각을 뽑아 쓰는 워크로드는 대개 계속 미스가 납니다. 거의 모든 문서의 비트맵이 새것이기 때문입니다. 여러분의 워크로드가 어느 쪽인지 판단하기 전에 대표 배치를 한 번 돌리고 카운터를 읽어 보십시오
캐시가 실제로 동작하는지 어떻게 알 수 있을까?
GetFontSubsetCacheInfo는 아홉 필드를 가진 THPDFFontSubsetCacheInfo 레코드를 돌려주며, HitCount와 MissCount의 대비가 질문에 곧장 답합니다. WriteCount와 EvictionCount는 항목이 재사용될 만큼 오래 살아남는지, 아니면 너무 빠듯한 예산에 밀려나는지를 보여 줍니다. CurrentBytes와 FileCount는 폴더의 현재 상태를 기술합니다
이 카운터를 모니터링에 붙일 때는 두 가지 세부 사항이 중요합니다. 일곱 개의 이벤트 카운터는 THotPDF 인스턴스에 속하며 컴포넌트가 생성될 때 0에서 시작하므로, 문서마다 컴포넌트를 하나씩 만드는 서비스는 판독값 하나당 문서 하나분의 활동만 봅니다. 반면 CurrentBytes와 FileCount는 호출하는 순간 폴더를 열거한 결과이므로 그 폴더를 쓰는 모든 프로세스가 반영되고, 호출할 때마다 디렉터리 스캔 비용이 듭니다. 레코드는 배치 한 번에 한 번, 끝에서 읽고 문서별 루프는 이 호출 없이 유지하십시오
나머지 세 카운터가 경보를 붙일 만한 것입니다. CorruptCount는 로드 시점 검증에 실패해 삭제된 항목을 셉니다. 하나하나가 미스로도 계산됩니다. 정전 뒤에 몇 개 나오는 것은 정상이지만 꾸준히 흘러나오면 저장소를 의심해야 합니다. RejectedCount는 캐시가 저장을 거부한 서브셋을 셉니다. 보통은 서브셋 하나가 전체 예산보다 큰 경우입니다. WriteFailureCount는 완료되지 못한 기록을 세는데, 실제로는 거의 언제나 서비스 계정이 쓸 수 없는 폴더가 원인입니다. 셋 모두 문서 생성을 멈추지 않으므로, 쓰기 권한에 문제가 있는 캐시는 캐시가 아예 없을 때와 정확히 같은 PDF를 만들어 냅니다. 다만 결코 빨라지지 않을 뿐입니다
// SubsetCacheNeedsAttention은 직접 추가하는 헬퍼입니다
// 임계값은 예시이며 배치 크기에 맞게 조정하십시오
function SubsetCacheNeedsAttention(const Info: THPDFFontSubsetCacheInfo;
out Reason: string): Boolean;
begin
Reason := '';
if Info.WriteFailureCount > 0 then
Reason := 'cache folder is not writable for this account'
else if Info.RejectedCount > Info.WriteCount then
Reason := 'subsets exceed FontSubsetCacheMaxBytes, raise the budget'
else if (Info.EvictionCount > Info.WriteCount div 2) and (Info.HitCount < Info.MissCount) then
Reason := 'entries are evicted before they are reused'
else if Info.CorruptCount > 10 then
Reason := 'repeated damaged entries, check the storage';
Result := Reason <> '';
end;
예산, 제거, 그리고 폴더를 비우는 스위치
제거는 파일 수정 시각 기준 LRU(최근 최소 사용) 방식입니다. 성공적인 로드마다 항목의 타임스탬프를 갱신하고, 기록마다 잘라내기가 뒤따르며, 폴더가 다시 예산에 맞을 때까지 가장 오래된 파일부터 나갑니다. 런타임에 FontSubsetCacheMaxBytes를 낮추면 다음 기록을 기다리지 않고 그 자리에서 잘라내므로, 디스크 공간 경보에 반응하는 서비스는 결정한 그 순간에 캐시를 줄일 수 있습니다
이 즉시성에는 날카로운 모서리가 있습니다. 폴더가 설정된 상태에서 예산을 0으로 내리면 0으로 잘라내므로 그 안의 모든 .hpdfsubset 파일이 삭제됩니다. 반면 FontSubsetCacheFolder를 빈 문자열로 설정하면 캐시가 꺼리고 파일들은 제자리에 그대로 남습니다. 두 스위치 모두 다음 문서부터 캐싱을 끄지만, 일주일 치 따뜻한 항목을 지켜 주는 것은 하나뿐입니다. 트러블슈팅에서는 폴더 프로퍼티를 비우고 예산은 그대로 두십시오
// 진단 실행을 위해 캐싱을 일시 중지하고 저장된 항목은 모두 유지
Pdf.FontSubsetCacheFolder := '';
// 주의: 폴더가 설정된 상태에서 예산 0은 폴더를 아예 비워버림
// Pdf.FontSubsetCacheMaxBytes := 0;
// 의도적 리셋: *.hpdfsubset과 남은 임시 파일을 삭제하며,
// 이벤트 카운터는 값이 유지됩니다
Pdf.FontSubsetCacheFolder := 'C:\ProgramData\Reports\fontcache';
Pdf.ClearFontSubsetCache;
캐시를 일시 중지한 진단 실행이 캐시를 배제하는 가장 깔끔한 방법입니다. /BaseFont의 여섯 글자 서브셋 태그는 남긴 글리프 집합에서 유도되므로 같은 글리프는 캐시와 무관하게 같은 태그를 만들고, 임베드된 폰트 프로그램도 일치해야 합니다. 파일 전체 해시는 생성 날짜와 문서 ID 때문에 여전히 다를 수 있으므로 파일이 아니라 폰트 스트림을 비교하십시오
손상되거나 반쯤 기록된 항목은 어떻게 되나?
손상된 항목은 삭제되고 서브셋은 정상적으로 다시 만들어지며, 그 항목에서 나온 무언가가 PDF 스트림에 닿기 전에 이 모든 일이 일어납니다. 로드 시점에 HotPDF는 파일 시그니처, 형식 버전, 서브셋 모드 플래그, 소스 폰트 대비 글리프 개수, 선언된 길이 대비 실제 파일 크기, 저장된 폰트 프로그램의 sfnt 헤더를 검사합니다. 하나라도 어긋나면 항목을 버리고 CorruptCount를 올린 뒤 인메모리 서브세팅으로 넘어갑니다. 깨진 폰트 프로그램이 고객의 PDF에 들어가면 그 실패는 원인에서 아주 멀리, 몇 주 뒤 다른 사람의 기계에 있는 뷰어 안에서 드러날 것이고, 이 검증이 존재하는 이유는 바로 그 실패를 막기 위해서입니다
반쯤 기록된 항목은 애초에 존재하지 않습니다. 기록은 프로세스 ID와 컴포넌트 인스턴스가 이름에 담긴 임시 파일에 쓴 다음, 교체 후 플러시되는 단일 리네임으로 최종 이름 위로 옮겨집니다. 기록 도중 크래시가 나면 .tmp- 파일 하나가 남는데 ClearFontSubsetCache가 이를 쓸어 담고, 실제 항목은 온전한 구버전이거나 온전한 신버전입니다. 컴팩트 항목은 폰트 프로그램과 함께 이전-새 글리프 맵도 저장하므로, 캐시된 컴팩트 서브셋은 같은 /CIDToGIDMap을 재구축하고 PDF/A 출력은 캐시를 우회하지 않고 그대로 사용할 수 있습니다
폴더는 어디에 두어야 할까?
세 가지 조건이 결정합니다. 서비스 계정이 그곳에 쓸 수 있어야 하고, 폴더는 로컬 저장소에 있어야 하며, 배포 단계가 지워버리는 곳이어서는 안 됩니다. 네트워크 공유는 모든 조회를 왕복으로 만들고 리네임 의미론을 파일 서버 손에 넘깁니다. 설치 프로그램이 다시 만드는 애플리케이션 디렉터리 안의 폴더는 매 업데이트마다 캐시가 차갑게 시작된다는 뜻이고, 성능을 가장 세심히 보는 바로 그날에 조용히 그 이점을 지워버립니다
여러 컴포넌트와 여러 프로세스가 하나의 로컬 폴더를 공유할 수 있습니다. 임시 이름은 절대 충돌하지 않고, 최종 리네임은 원자적이며, 경쟁에서 진 쪽의 비용은 미스 하나나 다음 문서에서 다시 시도해야 하는 기록 하나 이상입니다. 한 대의 기계에서는 공유가 더 나은 기본값입니다. 모든 인스턴스가 다른 인스턴스를 위해 캐시를 데워 주기 때문입니다. 눈에 보이는 부작용은 하나입니다. 인스턴스별 카운터는 자기 트래픽만 기술하는 반면 CurrentBytes와 FileCount는 모두의 것을 기술하고, 가끔씩 찍히는 WriteFailureCount는 두 프로세스가 같은 항목을 동시에 교체하려다 나온 것일 수 있습니다. 읽기 전용 폴더도 기존 항목은 계속 제공합니다. 타임스탬프를 갱신하거나 새 항목을 추가하지 못할 뿐입니다
캐시가 잘못된 레버일 때
캐시는 반복되는 작업을 줄입니다. 배치의 첫 문서에는 아무 도움이 되지 않고, 글리프 집합이 결코 반복되지 않는 워크로드에는 거의 도움이 되지 않습니다. 출력이 예측 불가능한 텍스트에 걸쳐 쓰이는 하나의 큰 CJK 페이스가 지배한다면 어떤 글리프가 각 서브셋에 끌려 들어오는지, 왜 그런지를 살펴 보십시오. 폰트 서브셋 클로저와 셰이핑 글리프 노트가 이를 다룹니다. 목표가 CPU가 아니라 파일 크기라면 CompressDocument와 컴팩트 폰트 서브셋 글이 컴팩트 모드가 임베드된 폰트에서 무엇을 바꾸는지 설명합니다. 배치가 폰트와 무관한 이유로 느리다면 폰트와 이미지를 포함한 리포트 출력 산책문이 나머지 시간이 보통 어디로 가는지 보여 주고, EndDoc 폰트 서브셋 순서 글은 문서 마무리 과정에서 서브셋이 언제 만들어지는지 다룹니다
서브셋 캐시는 컴포넌트 안에 살아 있으므로 Delphi나 C++Builder 리포트 서버는 폴더 경로와 예산을 설정하는 것만으로 그 혜택을 받으며, 배포하거나 모니터링할 추가 서비스는 없습니다. 폰트 임베딩, 서브세팅, 그 밖의 PDF 생성 기능은 HotPDF Delphi 컴포넌트 페이지에 정리되어 있습니다