콘텐츠로 이동

장기 실행 워커에서 PDF를 안전하게 렌더링하기

장기 실행 PHP 워커(RoadRunner, Swoole, Laravel Octane)는 많은 요청에 걸쳐 프로세스를 살아 있는 상태로 유지합니다. 요청마다 동일한 글꼴을 다시 파싱하고 동일한 이미지를 다시 디코딩하면 CPU를 낭비하고 상주 메모리가 늘어납니다. 이를 피하기 위해 NextPDF는 두 가지 수명을 분리합니다.

  • 프로세스 수명, 공유: FontRegistryImageRegistry는 파싱된 글꼴 테이블과 디코딩된 이미지 캐시를 보관합니다. 워커 부팅 시 한 번만 생성합니다.
  • 요청 수명, 폐기 가능: Document, 즉 DocumentFactory::create()가 반환하는 객체입니다. 이를 빌드하고 기록한 뒤 스코프를 벗어나게 둡니다. 그러면 가비지 컬렉터가 전체 객체 그래프를 회수합니다.

이 레시피는 워커 부팅 시퀀스, 요청별 본문, 그리고 최대 메모리를 일정하게 유지하는 주기적 리셋을 제공합니다.

Terminal window
composer require nextpdf/core:^3

워커 패턴 자체에는 추가 확장 모듈이 필요하지 않으며, 워커 런타임(RoadRunner / Swoole / Octane)은 선택 사항입니다. 동일한 팩토리 패턴은 일반 CLI for 루프에서도 실행되며, 하니스도 바로 이 방식으로 검증합니다.

워커에 권장되는 진입점은 DocumentFactory입니다. 공유된 FontRegistryImageRegistry로 한 번만 구성합니다.

  • FontRegistry::warmup()은 지정한 글꼴 파일을 파싱하고 파싱된 테이블을 캐시합니다. 그런 다음 FontRegistry::lock()은 레지스트리를 고정하여, 어떠한 요청별 코드도 공유 글꼴 세트를 변경할 수 없게 합니다. isLocked()는 현재 상태를 보고합니다. 일단 고정되면 레지스트리는 동시에 실행되는 코루틴 간에도 안전하게 공유할 수 있습니다.
  • 다음과 같이 ImageRegistrymaxCacheBytes 예산으로 구성합니다. 예산을 초과하면 가장 오래 사용되지 않은 항목이 제거됩니다. 예산보다 큰 단일 이미지는 캐시를 스래싱하지 않고 캐시를 완전히 건너뜁니다.
  • ImageRegistry::reset()은 레지스트리를 완전히 동작하는 상태로 유지하면서 캐시된 모든 이미지를 제거합니다. 다음 요청은 필요에 따라 이를 다시 채웁니다. 최대치를 기준선으로 되돌리려면 일정한 주기로(N개 요청마다, 또는 memoryUsage()가 임계값을 넘을 때) 이를 호출합니다.

팩토리가 생성하는 각 문서는 독립적인 PDF입니다. ISO 32000-2 §7.5.5는 한 번도 업데이트되지 않은 파일의 트레일러에는 Prev 항목이 없다고 정의하며, 모든 워커 요청은 바로 그러한 1세대 파일을 생성합니다. 따라서 요청들은 글꼴 및 이미지 캐시를 공유하더라도 문서 상태는 공유하지 않습니다. 서브셋 글꼴의 BaseFont 태그(ISO 32000-2 §9.6.4)는 파싱된 글꼴이 공유 레지스트리에 존재하므로 여러 요청에 걸쳐 안정적으로 유지됩니다.

이 레시피의 API 표면은 NextPDF\Core\DocumentFactory, NextPDF\Typography\FontRegistry, NextPDF\Graphics\ImageRegistry, 그리고 NextPDF\Support\MemoryReport의 PHPDoc에서 생성됩니다. 아래에서 사용하는 주요 멤버는 DocumentFactory::create(), FontRegistry::warmup() / lock() / isLocked() / memoryUsage(), ImageRegistry::reset() / memoryUsage(), 그리고 MemoryReport::$currentBytes / $peakBytes / $entryCount / utilizationPercent()입니다.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot (run ONCE, before the request loop) ---------------------
$fonts = new FontRegistry();
$fonts->lock(); // freeze the shared font set
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
// --- Per request ---------------------------------------------------------
$doc = $factory->create();
$doc->setTitle('Worker output');
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, 'Generated in a shared-registry worker', newLine: true);
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
// $doc leaves scope here → GC reclaims the whole document tree.

전체 예제는 하니스 출력 채널을 준수합니다. 여기에는 부팅 시퀀스, 경계가 있는 요청 루프, 주기적 reset(), 그리고 메모리 최대치 어서션이 표시됩니다. 이것은 재현성 하니스가 두 번 실행하는 스크립트입니다.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot: shared, process-lifetime registries --------------------
$fonts = new FontRegistry();
$fonts->lock(); // share-safe once locked
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
$resetEvery = 4; // reset cadence in requests
$peakAfterReset = 0;
// --- Simulated request loop ---------------------------------------------
for ($request = 1; $request <= 12; $request++) {
$doc = $factory->create();
$doc->setTitle("Worker Request #{$request}");
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, "Worker Request #{$request}", newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->cell(0, 8, 'Shared FontRegistry / ImageRegistry across requests.', newLine: true);
// The harness captures the LAST request's PDF via the side channel.
if ($request === 12) {
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
} else {
$doc->getPdfData(); // force render, then drop
}
unset($doc); // explicit end-of-request
// Bound the cache high-water mark on a fixed cadence.
if ($request % $resetEvery === 0) {
$images->reset();
\gc_collect_cycles();
$report = $images->memoryUsage();
$peakAfterReset = \max($peakAfterReset, $report->currentBytes);
}
}
$final = $images->memoryUsage();
fwrite(STDERR, \sprintf(
"fonts.locked=%s images.entries=%d images.current=%dB peak_after_reset=%dB\n",
$fonts->isLocked() ? 'yes' : 'no',
$final->entryCount,
$final->currentBytes,
$peakAfterReset,
));

STDOUT은 하니스를 위해 비워 두며, 진행 상황 텍스트는 STDERR로 보냅니다. PDF는 NEXTPDF_COOKBOOK_OUTPUT에만 기록되며, 절대 출력되지 않습니다.

  • 공유하기 전에 잠그십시오. 부팅 시 FontRegistry::lock()을 호출하십시오. 두 코루틴이 접근할 때 아직 변경 가능한 레지스트리라면 데이터 경합이 발생합니다. 상태 점검에서는 어서션으로 isLocked()를 사용하십시오.
  • reset()unset()이 아닙니다. ImageRegistry::reset()은 캐시된 바이너리 데이터를 제거하지만 레지스트리는 사용 가능한 상태로 유지하므로, 주기적으로 호출하기에 적합합니다. 요청마다 레지스트리를 파괴하고 다시 빌드하면 공유 캐시의 전체 취지가 무색해집니다.
  • 초과 크기 이미지 우회. maxCacheBytes보다 큰 이미지는 사용할 때마다 디코딩되고 절대 캐시되지 않으므로, 작업 세트를 밀어내지 않습니다. 이는 의도적입니다. 예산은 드물게 등장하는 큰 이미지가 아니라 일반적인 이미지에 맞게 산정하십시오.
  • 문서는 반드시 스코프를 벗어나야 합니다. Document를 정적 변수, 오래 지속되는 컨테이너 바인딩, 또는 워커가 캡처한 클로저에 보관하면 전체 객체 그래프가 살아 있는 상태로 유지되어 요청별 수집이 무력화됩니다. unset() 호출 또는 스코프 종료가 필수입니다.
  • gc_collect_cycles() 배치. PHP의 순환 컬렉터는 요청 경계를 알지 못합니다. 매 요청마다가 아니라 리셋 주기 이후에 호출하십시오. 이렇게 하면 핫 패스에서 수집 비용을 지불하지 않으면서 최대치를 일정 범위 내로 유지할 수 있습니다.
  • 결정성 관련 주의 사항. 문서 타임스탬프와 트레일러 /ID는 저장할 때마다 다시 생성됩니다(ISO 32000-2 §14.3). 따라서 캡처된 PDF는 시맨틱 프로필(휘발성 바이트가 아니라 구조적 AST와 메타데이터)과 비교됩니다. “적합성”을 참고하십시오.
  • 공유 레지스트리는 반복적인 글꼴 파싱과 이미지 디코딩을 일회성 부팅 비용으로 전환합니다. 그러면 요청별 작업은 레이아웃과 직렬화로 줄어듭니다.
  • 최대 상주 메모리는 maxCacheBytes에 진행 중인 문서 하나의 작업 세트를 더한 값으로 제한됩니다. 주기적 reset()은 캐시를 기준선으로 되돌리므로, 장기 실행 워커는 상승 추세의 톱니파 형태를 보이지 않습니다.
  • performance_budget 프런트매터(wall_ms: 4000, peak_mb: 192)는 12개 요청 루프의 하니스 실행을 제한합니다. 하니스가 이를 강제하며, 임의의 문서에 대한 보장은 아닙니다.
  • 이 레시피는 #31에 대한 §4.3 갭 목록의 “메모리/GC” 커버리지를 제공합니다. 이를 뒷받침하는 examples/14-worker-factory.php가 있으며, 새로운 tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php는 누락된 메모리/GC 어서션(리셋 후 주기에 걸쳐 최대치가 증가하지 않음)을 추가합니다.
  • 워커 패턴은 요청마다 문서 하나를 처리하며 파싱된 글꼴 및 디코딩된 이미지 캐시만 공유합니다. 어떠한 문서 내용도 요청 경계를 넘지 않습니다. 한 요청은 공유 레지스트리를 통해 다른 요청의 문서 데이터를 읽을 수 없습니다.
  • 신뢰할 수 없는 입력은 여전히 일반적인 NextPDF 입력 경계를 통해 흐르며, 워커 패턴은 어떠한 검증도 완화하지 않습니다. 요청별 프로세스와 마찬가지로, 각 요청의 HTML/에셋 입력을 신뢰할 수 없는 것으로 취급하십시오.
설명사양조항reference_id (참조 ID)
문서 수정 날짜는 저장할 때마다 다시 생성되므로, 요청별 출력은 바이트 단위로 안정적이지 않습니다.ISO 32000-2§14.3
각 워커 문서는 한 번도 업데이트되지 않은 파일이며(트레일러에 Prev 없음), 요청들은 문서 상태를 공유하지 않습니다.ISO 32000-2§7.5.5
서브셋 글꼴 태그 접두사는 파싱된 글꼴이 공유 레지스트리에 존재하므로 여러 요청에 걸쳐 안정적으로 유지됩니다.ISO 32000-2§9.6.4

트레일러 /ID와 수정 날짜는 저장할 때마다 다시 생성되므로, 이 레시피는 시맨틱 재현성 프로필(구조적 AST 동등성과 메타데이터 전용 비교)로 검증됩니다. 워커 출력에 대해 비트 단위 또는 구조적 주장을 하는 것은 부정직한 일입니다.