서명된 만료 URL로 생성 PDF 전달하기
한눈에 보기
섹션 제목: “한눈에 보기”포터블 문서 형식(PDF) 파일을 생성하고 그것을 클라이언트에 건네야 합니다. 가장 간단한 경로는 컨트롤러를 통해 바이트를 곧바로 스트리밍하는 것이지만, 그렇게 하면 다운로드 전체 동안 애플리케이션 워커가 묶이고, 트래픽이 서버를 거치며, 라우트에 도달할 수 있는 누구에게나 파일이 노출됩니다. 이 페이지의 전달 패턴은 그 반대를 합니다: PDF를 생성하고, 바이트를 객체 저장소에 저장하고, 클라이언트가 저장소에서 직접 가져가는 짧은 수명의 **서명된 Uniform Resource Locator(URL)**를 반환합니다. 앱은 URL이 담긴 작은 JavaScript Object Notation(JSON) 페이로드를 돌려주고, 저장소가 바이트를 서빙합니다.
NextPDF 측은 한 번의 호출입니다: 문서의 getPdfData()는 원시 PDF 바이너리를 문자열로 반환합니다. 그 이후의 모든 것 — 객체를 넣고 시간 제한이 있는 서명된 링크를 발행하는 것 — 은 프레임워크나 클라우드 제공자의 일입니다. 서명 프리미티브는 실제이며 문서화된 API입니다: Laravel Storage::temporaryUrl()과 URL::temporarySignedRoute(), Symfony UriSigner, 그리고 그 소프트웨어 개발 키트(SDK)에 있는 Amazon Simple Storage Service(S3) 또는 Google Cloud Storage(GCS) 사전 서명 URL 작업입니다. NextPDF는 자체 URL 헬퍼를 정의하지 않습니다. 그런 것을 찾지 마십시오.
다음 요소를 먼저 확인하십시오.
- NextPDF 코어가 설치되어 있고 문서를 빌드할 수 있습니다.
- 프레임워크가 서명할 수 있는 객체 저장소가 있습니다: S3 또는 S3 호환 버킷, GCS 버킷, 또는 드라이버가 임시 URL을 지원하는 Laravel 디스크.
- 자격 증명은 환경 변수나 시크릿 관리자에 있으며, 결코 커밋된 구성에 있지 않습니다.
이것은 운영 방법 안내입니다. 컨트롤러로 요청을 라우팅하는 방법은 이미 안다고 가정합니다. 대신 바이트를 직접 반환하려면 컨트롤러에서 생성 PDF 반환하기를 참조하십시오.
개념 개요
섹션 제목: “개념 개요”이 패턴은 세 단계로 이루어지며, 첫 번째 단계만 NextPDF를 건드립니다.
- 생성. 문서를 빌드하고
getPdfData()를 호출하여 바이트를 얻습니다. - 저장. 그 바이트를 객체 저장소 키(
reports/2026/r-42.pdf)에 씁니다. - 서명. 프레임워크나 클라우드 SDK에 그 키에 대한 서명된 URL을 만료와 함께 요청하고, 그 URL을 클라이언트에 반환합니다.
바이트를 프록시하는 대신 저장하고 서명하는 이유:
- 대역폭 부담 덜기. 객체 저장소(또는 그 콘텐츠 전송 네트워크 엣지)가 다운로드를 서빙합니다. 애플리케이션 워커는 수백 바이트의 JSON을 반환하고 즉시 자유로워지며, 멀티메가바이트 전송의 길이 동안 묶이지 않습니다.
- 접근 범위 한정. 서명된 URL은 한 객체에 대한 접근을 제한된 시간 창 동안 부여합니다. 버킷 자체는 비공개로 유지됩니다. 무차별 대입할 공개 라우트도, 광범위한 버킷 읽기 권한도 없습니다.
- 만료. 서명은 만료 타임스탬프를 임베드합니다. 그것이 지나면 링크는 죽습니다. 유출된 URL은 스스로 작동을 멈추며, 이는 우발적 공유의 피해 반경을 제한합니다.
두 가지 별개의 서명 모델이 있으며, 무엇이 서명되는지에서 다릅니다.
- 객체 저장소 사전 서명 URL(S3, GCS, 또는 S3/GCS 디스크 위의 Laravel
temporaryUrl())은 저장소 객체를 직접 가리킵니다. 다운로드는 앱에 전혀 도달하지 않습니다. - 애플리케이션 서명 라우트(Laravel
URL::temporarySignedRoute(), SymfonyUriSigner)는 사용자 자신의 라우트를 가리킵니다. 요청은 여전히 앱에 도달하며, 앱이 서명을 검증한 다음 객체로 스트리밍하거나 리다이렉트합니다. 각 다운로드에 대해 인가, 로깅, 또는 회계를 실행해야 할 때, 또는 저장소가 사전 서명할 수 없을 때 이것을 사용하십시오.
API 표면
섹션 제목: “API 표면”| 관심사 | NextPDF | Laravel | Symfony |
|---|---|---|---|
| PDF 바이트 얻기 | NextPDF\Core\Document::getPdfData(): string | same | same |
| 바이트 저장 | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) 또는 Flysystem write() |
| 사전 서명 저장소 URL | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS SDK presigner(아래) |
| 서명된 앱 라우트 | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| 서명된 앱 라우트 검증 | — | signed 라우트 미들웨어 / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
이 전달 패턴이 요구하는 유일한 NextPDF 엔진 호출은 getPdfData()입니다. 문서 자체는 앱이 이미 문서를 빌드하는 방식대로 빌드됩니다(예: 주입된 DocumentFactoryInterface / Symfony PdfFactory). getPdfData()는 NextPDF\Core\Document의 HasOutput 트레이트에 선언되어 있습니다. 그것은 작성기를 한 번 호출하고 전체 PDF를 문자열로 반환합니다. 그 형제 save(string $path): void는 동일한 바이트를 원자적 작성기를 통해 디스크에 씁니다. 저장소가 실제 로컬 파일시스템 경로일 때만 사용하십시오. 객체 저장소의 경우, getPdfData()를 선호하고 저장소 SDK가 전송을 소유하게 하십시오.
문서는
getPdfData()(또는save())를 호출할 때 빌드되며, 그 빌드는 멱등하지 않습니다. 문서당 한 번 호출하고, 문자열을 캡처하고, 그 문자열을 업로드와 계산하는 모든 크기 또는 체크섬에 재사용하십시오.
코드 샘플 — Laravel 임시 URL
섹션 제목: “코드 샘플 — Laravel 임시 URL”Laravel의 파일시스템 추상화가 사용자를 대신해 서명합니다. S3(또는 S3 호환) 디스크에서 Storage::temporaryUrl()은 객체로 곧장 가는 사전 서명 URL을 반환합니다. 클라이언트는 저장소에서 다운로드하고, 액션은 JSON만 반환합니다.
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;use Illuminate\Support\Facades\Storage;use NextPDF\Contracts\DocumentFactoryInterface;use Psr\Log\LoggerInterface;use Throwable;
final class ReportDeliveryController extends Controller{ public function __construct( private readonly DocumentFactoryInterface $documents, private readonly LoggerInterface $logger, ) {}
public function store(int $reportId): JsonResponse { try { // 1. Generate. Build once; getPdfData() returns the raw bytes. $document = $this->documents->create(); $document->addPage(); $document->cell(0, 10, "Report #{$reportId}", newLine: true); $bytes = $document->getPdfData();
// 2. Store under a non-guessable key on a private disk. $key = sprintf('reports/%d/%s.pdf', $reportId, bin2hex(random_bytes(16))); Storage::disk('s3')->put($key, $bytes, ['visibility' => 'private']);
// 3. Sign. A presigned URL straight to the object, valid 10 minutes. $url = Storage::disk('s3')->temporaryUrl($key, now()->addMinutes(10));
return new JsonResponse(['download_url' => $url], 201); } catch (Throwable $exception) { // Log the class, never the message or trace, so detail does not leak. $this->logger->error('Report PDF delivery failed', [ 'report_id' => $reportId, 'exception' => $exception::class, ]);
return new JsonResponse(['error' => 'Could not prepare the report.'], 500); } }}디스크는 드라이버가 임시 URL을 지원하는 것이어야 합니다 — 번들된 s3 드라이버가 그렇습니다. local 드라이버에서 temporaryUrl()을 호출하면, 그것을 위한 생성기를 등록하지 않는 한 던집니다. 로컬 디스크는 사전 서명할 것이 없기 때문입니다.
다운로드를 사용자 자신의 라우트에 유지하고 싶다면 — 요청별 인가를 실행하거나 각 접근을 로깅하기 위해 — 대신 URL::temporarySignedRoute()로 라우트를 서명하십시오. 라우트의 signed 미들웨어는 변조되거나 만료된 링크를 액션이 실행되기 전에 거부합니다.
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
// Mint the link elsewhere:// URL::temporarySignedRoute('reports.download', now()->addMinutes(10),// ['report' => $reportId]);Route::get('/reports/{report}/download', DownloadReportController::class) ->name('reports.download') ->middleware('signed');코드 샘플 — Symfony UriSigner
섹션 제목: “코드 샘플 — Symfony UriSigner”Symfony에는 Laravel 스타일의 저장소 파사드가 없으므로, 프레임워크의 Symfony\Component\HttpFoundation\UriSigner로 사용자 자신의 라우트를 서명한 다음, 그 라우트가 사전 서명 저장소 URL로 리다이렉트하도록(또는 객체를 스트리밍하도록) 합니다. UriSigner::sign()은 키가 적용된 해시를 덧붙이고, checkRequest()는 변조된 링크를 거부합니다. 예제를 Symfony 버전 전반에서 이식 가능하게 유지하기 위해, 서명 전에 자신의 expires 쿼리 매개변수(몇 분 뒤의 Unix 타임스탬프)를 임베드한 다음, 서명이 확인된 후 download 라우트에서 그 매개변수를 직접 검증하십시오. 이는 모든 Symfony 버전에서 작동하는데, UriSigner::sign(string $uri)이 URL만 받기 때문입니다.
<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Service\PdfFactory;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\UriSigner;use Symfony\Component\Routing\Attribute\Route;use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class ReportDeliveryController{ // 1 + 2 + sign: build, store, and return a signed URL to our own route. #[Route('/reports/{reportId}', name: 'report_prepare', methods: ['POST'])] public function prepare( int $reportId, PdfFactory $pdf, UriSigner $signer, UrlGeneratorInterface $urls, ReportStorage $storage, // your storage adapter ): JsonResponse { $document = $pdf->create(); $document->addPage(); $document->cell(0, 10, "Report #{$reportId}", newLine: true);
$key = $storage->put($reportId, $document->getPdfData());
$url = $urls->generate( 'report_download', ['reportId' => $reportId, 'key' => $key], UrlGeneratorInterface::ABSOLUTE_URL, );
// Embed our own expiry (a Unix timestamp 10 minutes out), then sign the // URL only. UriSigner::sign(string $uri) is portable across all versions. $url .= (str_contains($url, '?') ? '&' : '?') . 'expires=' . ((new \DateTimeImmutable('+10 minutes'))->getTimestamp());
return new JsonResponse(['download_url' => $signer->sign($url)]); }
// verify: the signed route. checkRequest() rejects a tampered link; then we // enforce the embedded expiry ourselves. #[Route('/reports/{reportId}/download', name: 'report_download', methods: ['GET'])] public function download( Request $request, UriSigner $signer, ReportStorage $storage, ): Response { if (!$signer->checkRequest($request)) { return new Response('Link invalid.', 403); }
// Enforce the embedded expiry: reject once the timestamp is in the past. $expires = (int) $request->query->get('expires'); if ($expires < time()) { return new Response('Link expired.', 410); }
// Redirect to a presigned storage URL, or stream the object here. return new Response('', 302, ['Location' => $storage->presign( (string) $request->query->get('key'), )]); }}UriSigner는 시크릿으로 구성됩니다(Symfony는 그것을 %kernel.secret% / APP_SECRET 매개변수에서 자동 와이어링합니다). 위 예제는 이식 가능한 경로입니다: UriSigner::sign(string $uri)은 URL만 서명하고 모든 Symfony 버전에 존재하므로, 만료는 사용자 자신의 expires 쿼리 매개변수로 이동합니다. 서명은 그 매개변수를 포함하므로 변조될 수 없으며 — checkRequest()가 통과한 후, download 라우트는 타임스탬프를 현재 시간과 비교하고 그것이 과거가 되면 410 Gone을 반환함으로써 그것을 강제합니다.
UriSigner::sign()이 만료DateTimeInterface인수를 받는 Symfony 버전에서는, 만료를 직접 전달하고 —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))—checkRequest()가 만료된 링크를 대신 거부하게 하여, 수동expires매개변수와 그 검사를 없앨 수 있습니다. 그것에 의존하기 전에 설치된 Symfony에서UriSigner::sign()시그니처를 확인하십시오. 위의 이식 가능한 패턴은 어느 쪽이든 작동합니다.
코드 샘플 — 클라우드 SDK 사전 서명 URL
섹션 제목: “코드 샘플 — 클라우드 SDK 사전 서명 URL”프레임워크 디스크를 통하지 않고 클라우드 SDK로 직접 서명한다면, 형태는 동일합니다: 객체를 넣은 다음, SDK에 그것에 대한 GET 사전 서명을 요청합니다. 이것은 순수 S3입니다(GCS 흐름은 이를 반영합니다: $bucket->object($key)로 객체를 얻고 $object->signedUrl($expiresAt, [...])를 호출).
<?php
declare(strict_types=1);
use Aws\S3\S3Client;use NextPDF\Core\Document;
/** @var Document $document Already built by your generation code. */$bytes = $document->getPdfData(); // NextPDF: the only engine call.
$s3 = new S3Client(['region' => 'eu-central-1', 'version' => 'latest']);$key = 'reports/' . bin2hex(random_bytes(16)) . '.pdf';
// Store the object privately.$s3->putObject([ 'Bucket' => 'my-private-reports', 'Key' => $key, 'Body' => $bytes, 'ContentType' => 'application/pdf',]);
// Presign a GET valid for 10 minutes. The returned URI is the signed URL.$command = $s3->getCommand('GetObject', [ 'Bucket' => 'my-private-reports', 'Key' => $key,]);$signedUrl = (string) $s3->createPresignedRequest($command, '+10 minutes')->getUri();GCS의 경우, getPdfData()로 바이트를 같은 방식으로 빌드하고, Cloud Storage 클라이언트로 객체를 업로드한 다음, $bucket->object($key)로 저장소 객체를 얻고 Carbon/DateTime 만료와 함께 $object->signedUrl($expiresAt, [...])를 호출하여 동등한 링크를 발행하십시오. 두 제공자 모두에서 서명된 URL 만료는 자격 증명 유형에 의해 제한됩니다. 자격 증명이 허용하는 최대 수명은 제공자의 문서를 참조하십시오.
엣지 케이스 및 주의 사항
섹션 제목: “엣지 케이스 및 주의 사항”- 문서를 정확히 한 번 빌드하십시오.
getPdfData()는 빌드를 유발하며, 그 빌드는 멱등하지 않습니다. 한 번 호출하고, 문자열을 보유하고, 그것을 업로드와 계산하는 모든Content-Length, 체크섬, 또는ETag에 재사용하십시오. 바이트를 “다시 읽기” 위해 다시 호출하지 마십시오. temporaryUrl()은 사전 서명 가능한 드라이버가 필요합니다. Laravel의s3드라이버는 사전 서명합니다.local드라이버는Storage::disk('local')->buildTemporaryUrlsUsing(...)로 커스텀 생성기를 등록하지 않는 한temporaryUrl()에서 던집니다. 서명할 수 있는 디스크를 고르거나, 대신 앱 라우트를 서명하십시오.- 객체 콘텐츠 타입을 설정하십시오.
Content-Type: application/pdf(ContentType업로드 옵션, 또는 디스크 메타데이터)로 저장하여 브라우저가 사전 서명 링크를octet-stream으로 다운로드하지 않고 PDF로 열도록 하십시오. - 짧은 만료는 느린 클라이언트보다 먼저 끝날 수 있습니다. 사용자가 발행한 지 한참 뒤에 링크를 클릭하면, 60초 창은 이미 죽었을 수 있습니다. 만료를 발행과 첫 바이트 사이의 현실적인 간격 — 초가 아니라 분 — 에 맞춰 사이징하고, 시간 단위로 늘리는 대신 요청 시 재발행하십시오.
- 서명된 URL은 베어러 접근입니다. 만료 전에 URL을 가진 누구든 객체를 다운로드할 수 있습니다. 만료를 짧게 유지하고, 단일 객체 범위를 선호하고, 전체 서명된 URL을 절대 로깅하지 마십시오 — 서명은 사실상 토큰입니다.
- 사용자 입력을 정제하지 않은 채 객체 키에 임베드하지 마십시오. 키는 사용자가 제어하는 값에 무작위 바이트를 더해 빌드하십시오(
bin2hex(random_bytes(16))). 예측 가능한 키는 버킷이 조금이라도 노출되면 열거를 초래합니다.
이 패턴은 하나의 동기 전송을 하나의 업로드 더하기 작은 JSON 응답과 맞바꿉니다. 애플리케이션 워커는 PDF 빌드와 저장소로의 업로드 동안에만 묶이며, 클라이언트의 전체 다운로드 동안에는 묶이지 않습니다. 다운로드 자체는 클라이언트와 저장소(또는 그 엣지) 사이에서 실행되므로, 앱 워커를 전혀 소비하지 않습니다.
빌드는 여전히 동기적이며 크거나 여러 페이지의 문서에서 여전히 지배적입니다 — getPdfData()는 업로드하기 전에 전체 PDF를 메모리에 실현합니다. 무거운 문서의 경우, 생성과 업로드를 큐 작업으로 옮기고 서명된 URL을 대역 외로 전달하십시오(예를 들어 객체가 준비되면 클라이언트에 알림으로써). 큐 작업에서 PDF 생성하기를 참조하십시오.
보안 참고 사항
섹션 제목: “보안 참고 사항”- 버킷을 비공개로 유지하고, 서명이 접근을 부여하게 하십시오. 전달을 “단순화”하기 위해 객체를 공개적으로 읽을 수 있게 만들지 마십시오. 핵심은 접근이 오직 짧은 수명의 서명을 통해서만 흐른다는 것입니다.
- 짧고 범위가 한정된 만료. 흐름에 맞는 가장 작은 창으로 서명하고, 각 URL을 단일 객체로 범위를 한정하십시오. 그러면 유출된 링크는 스스로 만료되고 그 밖의 어떤 것도 노출하지 않습니다.
- 환경에서 가져온 시크릿. S3/GCS 자격 증명과
UriSigner를 뒷받침하는 SymfonyAPP_SECRET은 환경 변수나 시크릿 관리자에서 가져오며, 결코 커밋된 구성에서 가져오지 않습니다. 서명 시크릿을 회전하면 미해결된 모든 서명 라우트가 즉시 무효화됩니다. - 앱 서명 라우트에서는 서빙하기 전에 검증하십시오. 다운로드가 앱을 거칠 때(Laravel
signed미들웨어, SymfonyUriSigner::checkRequest()), 어떤 저장소 접근이나 인가보다 먼저 서명을 검증하십시오. 변조되거나 만료된 링크를 정의된 상태로 거부하십시오. - 전체 서명된 URL을 절대 로깅하지 마십시오. 서명은 베어러 자격 증명입니다. 서명된 URL이 아니라 객체 키와 상관 식별자를 로깅하고, 실패 시 메시지나 스택 트레이스가 아니라 예외 클래스를 로깅하십시오.
- 빈
catch없음. 모든 예제는 실패 클래스를 로깅하고 정의된 오류 응답을 반환합니다.
적합성
섹션 제목: “적합성”이 가이드는 규범적 표준 주장을 하지 않습니다. 이 전달 패턴이 요구하는 유일한 NextPDF 엔진 호출은 NextPDF\Core\Document::getPdfData()이며, 원시 PDF 바이너리를 반환하는 검증된 공개 메서드입니다. 문서 자체는 앱이 이미 문서를 빌드하는 방식대로 빌드됩니다(예: 주입된 DocumentFactoryInterface / Symfony PdfFactory). 서명 프리미티브는 문서화된 프레임워크 및 클라우드 API입니다 — Laravel Storage::temporaryUrl()과 URL::temporarySignedRoute(), Symfony UriSigner, 그리고 S3/GCS 사전 서명 URL SDK 작업 — 그리고 그 정확한 시그니처, 지원되는 드라이버, 최대 만료 창은 그 업스트림 프로젝트가 관장합니다. 각 플랫폼의 권위 있는 계약은 그 문서를 참조하십시오.
참고 항목
섹션 제목: “참고 항목”- 컨트롤러에서 생성 PDF 반환하기 — 객체 저장소를 루프에 두고 싶지 않을 때 바이트를 직접 스트리밍.
- 큰 생성 PDF를 HTTP 응답으로 스트리밍하기 —
getPdfData()뒤의 버퍼드 대 스트리밍 메모리 모델. - Cloudflare로 엣지에서 렌더링하기 — 이 패턴의 R2 전용 서명 URL 및 엣지 렌더 변형.
- 큐 작업에서 PDF 생성하기 — 빌드와 업로드를 요청 스레드 밖으로 옮기기.