콘텐츠로 이동

스트리밍과 메모리: 프로파일링과 배치 워커 튜토리얼

NextPDF는 단일 패스로 렌더링하며 문서 수준 DOM을 절대 보유하지 않으므로, 입력 측 메모리는 요소 개수가 아니라 중첩 깊이에 따라 제한된 상태로 유지됩니다. 이 페이지에서는 스트리밍 모델, ADR-001이 제약하는 내용, 그리고 장기 실행 큐 워커 내부에서 엔진을 안전하게 실행하는 방법을 설명합니다.

Terminal window
composer require nextpdf/core:^3

NextPDF에는 메모리 프로파일이 서로 다른 두 가지 쓰기 경로가 있습니다.

기본 인메모리 라이터는 전체 문서를 구성한 다음 직렬화합니다. 최대 메모리는 전체 출력 크기에 비례하므로, 일반적인 문서에는 무리가 없지만 매우 큰 문서에는 비용이 큽니다.

스트리밍 라이터는 각 페이지가 구성되는 대로 직렬화하고 다음 페이지가 시작되기 전에 플러시합니다. 출시된 엔진인 StreamingPdfWriter, StreamingCursor, DevNullWriter, 그리고 WriterState 열거형(src/Writer/Streaming/에 위치)은 실제 구현이며 최종이고 테스트를 거쳤으며, 3.1.0부터 제공되고 있습니다. 이는 experimental 등급의 StreamingWriterInterfaceCursorInterface 계약을 통해 노출됩니다. 엔진 클래스는 내부용이므로, 계약에 의존하고 구현은 Core가 제공하도록 합니다. (이전의 .ai/contracts-map.md 주석은 스트리밍을 “계약 전용 / 구현 없음”으로 잘못 설명했는데, 이는 이슈 #610에서 추적 중인 오래된 주석 결함으로 B1 계약 문서에서 수정되었습니다 — 엔진은 3.1.0부터 출시되어 있습니다.)

스트리밍 엔진의 설계 목표는 상주 메모리가 페이지 수에 따라 증가하지 않도록 하는 것입니다. 완료된 각 페이지의 버퍼는 라이터에 전달된 후 해제되며, 상호 참조 테이블과 /Kids 페이지 트리 참조는 PHP 힙에 누적되지 않고 즉시 디스크로 흘려보내는 php://temp/maxmemory:0 임시 스트림에 기록됩니다. 직렬화된 결과는 표준 페이지 트리이며, 그 Count 항목은 어떤 노드의 후손인 리프 노드(페이지 객체)의 개수이고(ISO 32000-2 §7.7.3.3), Kids 항목은 해당 노드의 직계 자식에 대한 간접 참조의 배열입니다(ISO 32000-2 §7.7.3.2). 정확한 메모리 프로파일은 experimental 등급의 속성이며 마이너 릴리스마다 달라질 수 있으므로, 단일 측정값에서 얻은 가정을 하드코딩하지 마십시오.

ADR-001은 HTML 렌더 파이프라인의 메모리 모델을 규정합니다. 토크나이저는 단일 패스로 토큰 목록을 생성하며, 파서는 이를 왼쪽에서 오른쪽으로 소비하면서 콘텐츠 스트림 연산자를 문자열 버퍼에 내보냅니다. 영구적인 요소 트리는 구축되지 않습니다. 파서는 중첩 수준당 최대 하나의 HtmlStyleState만 보유하며, 이는 MAX_NESTING_DEPTH = 100으로 제한되고, MAX_ELEMENT_COUNT = 50_000 하드 캡을 적용합니다. 미리 보기가 필요한 두 가지 작업인 테이블 열 크기 조정과 :has() / :last-child 선택자 계열은 보유된 DOM이 아니라 평면 토큰 목록 위에 구성된 제한된 사전 스캔 인덱스 배열을 사용합니다. Phase 0 벤치마크(docs/architecture/adr-001-memory-benchmark.md, 2026-04-06 실행, PHP 8.5.3, memory_limit=1G)는 50,000개 요소 문서를 측정했으며, 스트림 경로는 최대치 50 MB를 기록한 반면 부분 작업 보유 시뮬레이션은 4 MB였습니다. 보고서의 분석은 그중 약 50 MB를 아키텍처 불변의 누적 콘텐츠 스트림에 기인한다고 보며, 해당 픽스처에서 스트림 모델의 입력 측 이점을 4~5배로 분리합니다. 이 수치들은 단일 장비와 픽스처에서 관찰된 것이며 보장이 아닙니다.

튜닝하기 전에 메모리를 프로파일링하십시오

섹션 제목: “튜닝하기 전에 메모리를 프로파일링하십시오”

무언가를 변경하기 전에 먼저 측정하십시오. HTML 파이프라인은 tools/perf-benchmark.php(composer ai:perf-check로 실행)에 의해 게이트되며, 이 도구는 peak_memory_delta_bytes를 보고합니다 — 이는 절대 프로세스 최대치가 아니라 회귀 판단 기준으로 쓰이는 대상별 증분 최대치입니다. Cycle 36 기준선(docs/architecture/PERFORMANCE-BUDGETS.md §6.3, i9-13900K, 64 GB, PHP 8.5.3, opcache 끔 환경에서 2026-05-17 캡처)은 16개의 target/mode 쌍 중 12개에서 0바이트 최대 델타를 관찰했으며, 0이 아닌 네 개의 델타는 후속 렌더링에서 일정하게 유지되는 최초 접근 시 글꼴 캐시 및 트레이스 버퍼 할당에 기인합니다. 이 값들은 이식 가능한 상수가 아니라 해당 장비에서 관찰된 값으로 받아들이십시오. 자체 문서에 대한 임시 프로파일을 얻으려면, 벤치마크가 대상별 비용을 분리하는 방식과 동일하게 렌더링 전후에 memory_get_peak_usage(true)를 샘플링하고 반복 사이에 memory_reset_peak_usage()로 최대치를 재설정하십시오.

큐 워커는 장기 실행 PHP 프로세스입니다. 프레임워크를 한 번 부팅한 뒤 상주 상태로 머물면서 루프에서 작업을 처리합니다. 바로 이 점이 워커를 빠르게 만들며, 동시에 메모리 위생이 중요해지는 이유이기도 합니다. 단일 요청에서는 보이지 않는 느린 누수가 수천 개의 작업에 걸쳐 누적됩니다. PERFORMANCE-BUDGETS §1은 이 실패 모드를 명시적으로 언급합니다. 여러 PDF를 연달아 렌더링하는 워커는 단일 렌더링이 정상으로 보이더라도 몇 시간 후에 메모리를 소진할 수 있습니다.

NextPDF는 워커 환경을 지원합니다. DocumentFactory를 사용하면 워커가 프로세스 수명 동안 유지되는 FontRegistryImageRegistry를 공유하면서 작업마다 새 문서를 생성할 수 있으므로, 글꼴 및 이미지 파싱이 작업마다 반복되지 않고 한 번만 일어납니다. ADR-001은 HTML 파서가 정적 가변 상태 없이 요청마다 생성되며, 향후의 서식 컨텍스트 객체도 동일한 요청별 범위 지정을 따라야 한다고 기록합니다. 다음 단계는 워커를 안전하게 구성하는 방법입니다.

1 단계 — 작업 간 레지스트리 공유

섹션 제목: “1 단계 — 작업 간 레지스트리 공유”

프로세스 부팅 시 레지스트리를 한 번 생성하고 모든 작업에서 재사용할 때는 examples/14-worker-factory.php를 따르십시오:

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Core\PdfFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// Created once at process boot — not per job.
$fontRegistry = new FontRegistry();
$imageRegistry = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$documentFactory = new DocumentFactory($fontRegistry, $imageRegistry);
$factory = PdfFactory::new()
->withCompress(true)
->withDocumentFactory($documentFactory);
// Per job: a fresh document, shared registries.
$doc = $factory->create();
$doc->addPage();
$doc->setFont('helvetica', '', 11);
$doc->cell(0, 8, 'Rendered inside a worker.', newLine: true);
$doc->save('/path/to/output.pdf');

이미지 레지스트리의 maxCacheBytes는 공유 캐시를 제한하므로 작업 간에 무한정 커질 수 없습니다.

이는 NextPDF 엔진의 보장이 아니라 모든 PHP 워커에 적용되는 일반적인 프로세스 제어 관행입니다. 장기 실행 프로세스가 메모리를 누적하거나 오래된 코드를 무기한 실행할 수 없도록 워커를 주기적으로 재시작하십시오. 두 주요 PHP 큐 시스템 모두 기본 제한과 정상적인 재시작을 제공합니다.

큐 시스템별로 보면, Laravel 큐(https://laravel.com/docs/12.x/queues)의 queue:work 명령은 워커를 장기 실행 프로세스로 실행합니다. 문서화된 옵션은 --memory(기본값 128 MB, 메모리가 한도를 초과하면 워커가 종료됨), --max-jobs(지정한 작업 수 이후 종료), --max-time(지정한 초 수 이후 종료)입니다. queue:restart 명령은 워커에게 현재 작업 이후 정상적으로 종료하도록 신호를 보내므로, 배포나 주기적 타이머가 진행 중인 렌더링을 중단하지 않고 워커를 재활용할 수 있습니다. Laravel Horizon(https://laravel.com/docs/12.x/horizon)는 auto 밸런싱 전략과 정상적인 php artisan horizon:terminate로 Redis 워커를 관리하며, 이 명령은 프로세스 모니터가 슈퍼바이저를 재시작하기 전에 진행 중인 작업을 완료합니다.

한편 Symfony Messenger(https://symfony.com/doc/current/messenger.html)의 경우 messenger:consume 명령은 기본적으로 영구히 실행됩니다. 문서화된 제한 옵션은 --limit(메시지 N개 처리 후 종료), --memory-limit(예: 128M, 메모리가 한도에 도달하면 종료), --time-limit(예: 3600, 해당 간격 이후 종료)입니다. Symfony 문서는 종료된 프로세스가 자동으로 재시작되도록 Supervisor 또는 systemd 아래에서 워커를 실행할 것을 권장하며, messenger:stop-workers는 각 워커에게 현재 메시지를 완료하고 정상적으로 종료하도록 지시하는 캐시 플래그를 설정합니다.

배포할 때마다 워커가 새 코드를 가져오도록 정상적인 재시작 신호를 보내십시오. Laravel의 경우 php artisan queue:restart(또는 php artisan horizon:terminate), Symfony의 경우 php bin/console messenger:stop-workers를 사용합니다. 그러면 프로세스 관리자(Supervisor, systemd, 또는 Horizon/Octane 슈퍼바이저)가 새 코드베이스에 대해 새 프로세스를 시작합니다. 이는 장기 실행 PHP 워커에 대한 일반적인 배포 관행이며 NextPDF와는 무관합니다.

스트리밍 경로의 설계는 완료된 각 페이지를 플러시하고 상호 참조 및 페이지 트리 기록을 디스크 기반 임시 스트림으로 흘려보내 최대 메모리를 제한하므로, 상주 세트가 페이지 수에 따라 증가하지 않도록 의도되었습니다 — 이는 출시된 3.1.0 엔진에서 관찰되었고 골든 기준선 재현성 테스트로 고정되었으나, 해당 프로파일이 experimental 등급의 속성이기 때문에 고정된 수치가 아니라 설계 동작으로 기술됩니다. HTML 파이프라인의 입력 측 메모리는 요소 개수가 아니라 MAX_NESTING_DEPTH = 100으로 제한됩니다(ADR-001). 이 페이지의 모든 구체적인 수치는 날짜가 명시된 산출물인 2026-04-06 ADR-001 벤치마크와 2026-05-17 PERFORMANCE-BUDGETS Cycle 36 기준선에 연결되어 있으며, 해당 문서에 명시된 장비에서 관찰된 것입니다. 이식 가능한 보장이 아니라 관찰값으로 받아들이십시오. 1500 ms / 64 MB의 performance_budget는 캔버스 한계선이며 계약상 상한이 아닙니다.

스트리밍 커서의 writeContent()는 바이트를 페이지 콘텐츠 스트림에 그대로 추가합니다 — 연산자 구문을 검증하지 않습니다. 호출자의 영향을 받는 콘텐츠를 렌더링하는 워커에서는 신뢰할 수 없는 입력을 writeContent()에 절대 전달하지 마십시오. 대신 출시된 커서가 PDF 리터럴 문자열 문법에 맞게 이스케이프하는 writeText()를 사용하십시오. 출력 스트림은 호출자가 소유합니다. 엔진은 스트림에 기록하지만 절대 닫거나 다시 열지 않으므로 출력을 리디렉션할 수 없습니다 — 워커는 라이터의 close()가 반환된 후 핸들을 직접 닫아야 하며, 그렇지 않으면 작업 간에 파일 디스크립터가 누수됩니다. 작업 간 레지스트리 공유는 신뢰 경계가 아니라 성능 최적화입니다. 공유 ImageRegistry는 파싱된 이미지를 캐시하므로, 그 maxCacheBytes의 크기를 신중하게 설정하고, 멀티테넌트 워커에서 테넌트 간 캐시 격리를 가정하지 마십시오.

주장표준조항증거
스트리밍 라이터는 Kids 항목이 노드의 직계 자식에 대한 간접 참조의 배열인 페이지 트리를 내보냅니다.ISO 32000-2§7.7.3.2
스트리밍 라이터는 페이지 트리 노드의 후손인 리프 페이지 객체의 개수와 동일한 Count 항목을 내보냅니다.ISO 32000-2§7.7.3.3

조항은 의역되고 용어집에 고정되어 있으며, 규범적 텍스트는 재현되지 않습니다.