프로덕션 사용 — 폴백, 텔레메트리, 아카이브, 보호
한눈에 보기
섹션 제목: “한눈에 보기”이 페이지에서는 패키지가 단순 렌더링을 넘어 처리해야 하는 네 가지 프로덕션 관심사, 즉 로컬 폴백, 엣지 텔레메트리, R2 아카이브, 인바운드 API 보호 계층을 다룹니다. 각 섹션은 검증된 클래스 동작과 대응됩니다.
로컬 폴백
섹션 제목: “로컬 폴백”Worker에 연결할 수 없고 fallbackToLocal이 true이면, 브리지는 로컬 렌더러에 위임합니다. 이 로컬 렌더러는 LocalRendererFactoryInterface를 통해 제공됩니다. 브리지는 팩토리를 지연 호출하므로, 팩토리의 create()는 폴백 경로에서만 실행됩니다.
<?php
declare(strict_types=1);
use NextPDF\Cloudflare\Contract\LocalRendererFactoryInterface;use NextPDF\Cloudflare\Contract\LocalRendererInterface;
final class ArtisanLocalRendererFactory implements LocalRendererFactoryInterface{ public function __construct( private readonly \NextPDF\Artisan\ChromeHtmlRenderer $chrome, ) {}
public function create(): LocalRendererInterface { return new readonly class($this->chrome) implements LocalRendererInterface { public function __construct( private \NextPDF\Artisan\ChromeHtmlRenderer $chrome, ) {}
/** @param array<string, mixed> $options */ public function render(string $html, array $options = []): string { // Delegate to the local Chrome renderer; return raw PDF bytes. return $this->chrome->renderToString($html, $options); } }; }}렌더러에 팩토리를 연결합니다:
use NextPDF\Cloudflare\CloudflareHtmlRenderer;
$renderer = new CloudflareHtmlRenderer( config: $config, httpClient: $httpClient, requestFactory: $httpFactory, streamFactory: $httpFactory, logger: $logger, localRendererFactory: new ArtisanLocalRendererFactory($chrome), responseFactory: $httpFactory,);폴백이 실행되면 결과의 renderLocation은 리터럴 문자열 local이고 heightPt는 0.0입니다. 로컬 경로는 엣지 위치나 측정된 높이를 보고하지 않습니다. 브리지는 요청된 너비를 widthPt 옵션 키를 통해 로컬 렌더러에 전달합니다.
폴백 결정 로직
섹션 제목: “폴백 결정 로직”다음은 CloudflareHtmlRenderer에서 직접 확인한 동작입니다:
| 상황 | 결과 |
|---|---|
구성이 불완전하고 fallbackToLocal: false | CloudflareNotAvailableException |
구성이 불완전하고 fallbackToLocal: true이며 팩토리 연결됨 | 로컬 렌더링 |
| Worker가 전송 오류를 던지고, 폴백 활성화, 팩토리 연결됨 | 로컬 렌더링, warning으로 기록한 뒤 info로 기록 |
| Worker가 예외를 던지고, 폴백 활성화, Artisan 설치됨, 팩토리 없음 | 누락된 팩토리를 명시하는 CloudflareNotAvailableException |
| Worker가 예외를 던지고, 폴백 활성화, Artisan 미설치 | 누락된 패키지를 명시하는 CloudflareNotAvailableException |
| Worker가 HTTP 오류 / 형식이 잘못된 본문을 반환 | CloudflareRenderException, 절대 폴백하지 않음 |
마지막 행이 핵심 구분점입니다. 오류로 응답하는 Worker는 도달성 실패가 아니라 렌더링 실패로 간주됩니다. 코드는 깨진 렌더링과 도달할 수 없는 엣지를 구분할 수 있도록 이를 다시 던집니다.
엣지 텔레메트리
섹션 제목: “엣지 텔레메트리”성공한 모든 바이너리 경로 렌더링 결과에는 응답 헤더에서 파생된 텔레메트리가 함께 전달됩니다:
$result = $renderer->render($html);
$logger->info('edge render', [ 'edge' => $result->renderLocation, // e.g. 'TPE', 'NRT' 'render_time_ms' => $result->renderTimeMs, 'content_px' => $result->contentHeightPx, 'pdf_bytes' => $result->size(),]);렌더러는 renderLocation을 CF-Ray 응답 헤더에서 읽으며, 마지막 하이픈 뒤의 세그먼트를 가져옵니다. CF-Ray: 8abc123def456-TPE의 경우 위치는 TPE입니다. 헤더가 없으면 위치는 빈 문자열입니다. JSON 응답 경로에서는 대신 JSON renderLocation 필드에서 값을 가져옵니다. 이를 플랫폼 보장이 아니라 Worker에서 오는 관측성 신호로 취급합니다.
R2 아카이브
섹션 제목: “R2 아카이브”R2ArchiveManager는 S3 호환 API를 통해 PDF 바이트를 Cloudflare R2에 업로드하며, AWS Signature V4로 요청에 서명합니다.
use NextPDF\Cloudflare\R2ArchiveConfig;use NextPDF\Cloudflare\R2ArchiveManager;
$r2 = new R2ArchiveManager( config: new R2ArchiveConfig( bucketName: 'pdf-archive', accountId: getenv('CF_ACCOUNT_ID') ?: '', accessKeyId: getenv('R2_ACCESS_KEY_ID') ?: '', secretAccessKey: getenv('R2_SECRET_ACCESS_KEY') ?: '', pathPrefix: 'invoices/', ), httpClient: $httpClient, requestFactory: $httpFactory, streamFactory: $httpFactory,);
$upload = $r2->upload($result->pdfData, 'invoice-2026-0042.pdf', [ 'tenant' => 'acme',]);
if (!$upload->success) { $logger->error('r2 upload failed', ['error' => $upload->error]);}R2ArchiveManager와 R2ObjectKey에서 검증된 동작:
- 객체 키는 날짜로 파티셔닝됩니다:
<pathPrefix><Y>/<m>/<d>/<sanitized-filename>, 예를 들어invoices/2026/05/18/invoice-2026-0042.pdf입니다. - 파일 이름은 정제됩니다.
basename()을 적용해 경로 탐색을 제거한 뒤, 널 바이트와 제어 문자(\x00–\x1f,\x7f)를 제거합니다. 빈 결과는document.pdf가 됩니다. - 사용자 정의 메타데이터는
x-amz-meta-<lowercased-key>헤더로 전송되며, V4 서명 대상 헤더 집합에 포함됩니다. maxFileSizeBytes(기본값104857600)보다 큰 업로드는 요청 전에 거부되며,success: false인R2UploadResult를 반환합니다.R2UploadResult::isValid()는success, 비어 있지 않은key, 비어 있지 않은etag을 요구합니다.
사전 서명된 다운로드 URL
섹션 제목: “사전 서명된 다운로드 URL”$url = $r2->generateSignedUrl('invoices/2026/05/18/invoice-2026-0042.pdf', 900);generateSignedUrl()은 직접 제어하는 X-Amz-Expires(기본값 3600초)를 사용해 AWS Signature V4 쿼리 서명된 GET URL을 구성합니다. 정규 요청은 UNSIGNED-PAYLOAD 콘텐츠 해시 센티넬을 사용합니다. 본문이 서명 대상 요청의 일부가 아니므로, 쿼리 서명된 읽기 URL은 이 형식을 사용합니다. 이는 R2ArchiveManager에서 확인한 패키지 구현의 서명 동작을 설명합니다. Amazon 서비스 문서는 SDO 표준이 아닌 AWS Signature Version 4를 정의하므로, 여기서는 규범 조항을 고정하지 않습니다. 객체 접근 키는 #[SensitiveParameter]이므로, 로그에 남기지 마십시오.
공개 URL
섹션 제목: “공개 URL”R2UploadResult::publicUrl($customDomain)은 도메인이 주어지지 않으면 키만 반환하고, 그렇지 않으면 https://<domain>/<key>를 반환합니다. 제공된 도메인에 스킴이 없으면 HTTPS 스킴을 강제합니다. 비공개 버킷을 공개로 만들지는 않습니다. 이는 R2 버킷 구성의 문제입니다.
인바운드 API 보호
섹션 제목: “인바운드 API 보호”ApiProtection은 Worker 앞단의 PHP 게이트웨이에 도착하는 렌더 요청에 적용하는 계층입니다. 고정된 순서로 세 가지 검사를 실행합니다: API 키, 그다음 페이로드 크기, 그다음 속도 제한.
use NextPDF\Cloudflare\ApiKeyValidator;use NextPDF\Cloudflare\ApiProtection;use NextPDF\Cloudflare\ApiProtectionConfig;
$protection = new ApiProtection( config: new ApiProtectionConfig( maxRequestsPerMinute: 30, maxRequestsPerHour: 500, maxPayloadSizeBytes: 5_000_000, requireApiKey: true, ), keyValidator: new ApiKeyValidator([getenv('GATEWAY_API_KEY') ?: '']),);
$decision = $protection->checkRequest( clientId: $clientIp, payloadSize: strlen($requestBody), apiKey: $request->getHeaderLine('X-Api-Key'),);
if (!$decision->allowed) { http_response_code(429); foreach ($decision->toHeaders() as $name => $value) { header("{$name}: {$value}"); } echo $decision->denialReason; exit;}검증된 동작:
- 순서는 API 키 → 페이로드 크기 → 속도 제한입니다. 첫 번째로 실패한 검사에서 특정
denialReason과 함께 단락(short-circuit)됩니다. ApiKeyValidator::validate()는 타이밍 공격에 안전한 비교를 위해hash_equals()를 사용하며 빈 키를 거부합니다.validateHashed()는 키 보관 시 SHA-256 해시와 비교합니다. 키 매개변수에는#[SensitiveParameter]가 지정되어 있습니다.- 속도 제한 저장소는 프로세스별 인메모리입니다. 분 단위 윈도우(
rateLimitWindowSeconds, 기본값60)와 시간 단위 윈도우(고정3600초)를 추적합니다. 워커 간이나 재시작 후에는 지속되지 않습니다. 프로세스 간 공유 제한이 필요하면 앞단에 공유 저장소를 둡니다. ApiProtectionResult::toHeaders()는 항상X-Content-Type-Options: nosniff와X-Frame-Options: DENY를 추가하고, 속도 제한 헤더(X-RateLimit-Remaining,X-RateLimit-Reset, 그리고 거부될 때Retry-After)를 병합합니다.
렌더 후 서명
섹션 제목: “렌더 후 서명”이 브리지는 PDF에 서명하지 않습니다. 프로덕션 서명 파이프라인은 엣지에서 렌더링한 다음 반환된 바이트를 엔진으로 넘겨 서명합니다:
render()→CloudflareRenderResult::$pdfData.$pdfData를nextpdf/core(또는 PAdES B-B 서명을 위한 NextPDF Pro)에 넘깁니다. 장기 검증(LTV) 프로필은 Enterprise 기능이며, 이 코어 브리지는 두 기능 모두를 제공한다고 주장하지 않습니다.
서명 단계는 자체 프로세스에 두어 서명 키가 엣지 경계를 절대 넘지 않도록 합니다.
관련 항목
섹션 제목: “관련 항목”- /integrations/cloudflare/security-and-operations/ — 핀닝, SSRF 방어, 시크릿 교체, 운영 런북.
- /integrations/cloudflare/troubleshooting/ — 실패 모드 카탈로그.
- /integrations/cloudflare/configuration/ — 모든 필드와 기본값.