콘텐츠로 이동

진행률 추적을 지원하는 Connect 기반 일괄 PDF 생성

엔진의 독립 실행형 HTTP 서비스 배포판인 NextPDF Connect를 사용해, 하나의 클라이언트 프로세스에서 문서 목록을 끝까지 처리합니다. 이 레시피는 각 렌더 요청을 비동기 작업 엔드포인트 POST /api/v1/jobs에 제출하고, 각 작업이 종료 상태에 도달할 때까지 GET /api/v1/jobs/{id}로 폴링하며, 서버가 보고하는 작업별 statusprogress 필드를 읽은 다음, 완료된 모든 PDF를 GET /api/v1/jobs/{id}/result에서 다운로드합니다.

작업 수명 주기는 고정되어 있으며 단순합니다. 작업은 pending 상태에서 running 상태로 진행된 뒤, 정확히 하나의 종료 상태인 completed, failed, 또는 cancelled 중 하나에 도달합니다. 상태 응답은 서버가 추적할 때 0에서 100까지의 progress 정수를 담고, 종료되지 않은 모든 폴링 응답에는 다음 요청까지 얼마나 기다려야 하는지를 알려 주는 Retry-After 헤더가 포함됩니다. 각 제출에 Idempotency-Key를 지정하면, 재시도된 제출이 두 번째 렌더를 시작하는 대신 동일한 작업을 반환합니다.

이 레시피는 와이어 수준에서 전송 계층의 동작을 그대로 드러내는 경로를 택합니다. REST 표면을 직접 사용하며 언어별 소프트웨어 개발 키트(SDK)를 가정하지 않으므로, 동일한 흐름을 어떤 HTTP 클라이언트로도 옮길 수 있습니다.

서버 측에는 표준 Connect 배포판을 사용합니다:

Terminal window
composer require nextpdf/server

아래 프로덕션 샘플의 PHP 클라이언트는 PSR-18 및 PSR-17을 준수하는 HTTP 클라이언트와 메시지 팩터리를 사용합니다. 프로젝트에서 표준으로 채택한 구현체가 있다면 그것을 설치하십시오. 예시는 다음과 같습니다:

Terminal window
composer require psr/http-client psr/http-factory

비동기 작업 표면은 제출조회를 분리합니다. 문서마다 긴 HTTP 연결 하나를 계속 열어 두지 않습니다. 대신 작업을 제출해 식별자를 받은 뒤, 작업이 끝날 때까지 비용이 적은 상태 엔드포인트를 폴링합니다. 이 구조 덕분에 일괄 처리를 다루기 쉬워집니다. 클라이언트는 N 개의 차단된 연결을 유지하지 않고도 N 개의 독립 작업을 한 번에 추적할 수 있습니다.

이 흐름에는 세 개의 엔드포인트가 관여합니다:

  • POST /api/v1/jobs는 동기식 /api/v1/render 엔드포인트와 동일한 렌더 요청 본문, 즉 page_size, orientation, 그리고 순서가 있는 operations 배열을 받습니다. 새 작업에는 201 Created를 반환하고, 200 OKIdempotency-Key가 이미 제출한 작업과 일치할 때 반환합니다.
  • GET /api/v1/jobs/{id}는 현재 작업 레코드를 반환합니다. 작업이 종료되지 않은 경우 Retry-After 헤더(서버는 2초 간격을 사용함)와 poll_url 필드도 설정됩니다. 촘촘한 루프로 폴링하지 말고 이 헤더를 준수하십시오.
  • GET /api/v1/jobs/{id}/result는 완성된 PDF를 application/pdf로 스트리밍합니다. 409 Conflict는 작업이 completed에 도달하지 않은 경우 반환되므로, 상태 폴링이 종료 상태를 확인한 후에만 호출하십시오.

성공 응답은 모두 동일한 봉투를 사용합니다. 작업 필드를 담은 data 객체와 request_id, timestamp, duration_ms, api_version이 들어 있는 meta 객체입니다. 읽어야 할 작업 필드는 data 아래에 있습니다. 즉 data.status, data.progress, data.job_id이며, 완료된 작업에서는 data.result_url입니다.

현재 릴리스와 관련해 분명히 알아야 할 사항이 있습니다. 서버는 POST에 응답하기 전에 제출된 작업을 인라인으로 처리하므로, 실제로 제출 응답이 이미 종료 status를 담고 있을 수 있고 결과가 첫 번째 폴링에서 준비되어 있을 수도 있습니다. 여기에 문서화된 폴링 및 진행률 계약이 안정적인 API 형태입니다. 처리 백엔드가 큐 기반 워커 풀로 옮겨가더라도 서버는 이를 변경하지 않으므로, 폴링하도록 작성된 클라이언트는 오늘도 올바르며 그 변경 후에도 계속 올바릅니다. 폴링 루프를 작성하십시오. 첫 응답이 종료되지 않은 상태라고도, 이미 종료된 상태라고도 가정하지 마십시오.

서버 OpenAPI 문서와 JobHandler 라우팅에 정의된 Connect 비동기 작업 REST 표면은 다음과 같습니다:

  • POST /api/v1/jobs: 렌더 작업을 제출합니다. Idempotency-Key 요청 헤더는 선택 사항입니다. 본문은 렌더 요청입니다(operations는 필수이며 최소 하나의 연산을 담아야 함). 응답: 201 신규, 200 멱등 재생, 422 잘못된 본문, 409 멱등성 충돌, 429 속도 제한.
  • GET /api/v1/jobs/{id}: 상태를 폴링합니다. 작업 레코드와 함께 200으로 응답합니다. 비종료 상태인 동안에는 Retry-After 헤더가 존재합니다. 작업이 존재하지 않거나 다른 클라이언트에 속하면 404입니다.
  • GET /api/v1/jobs/{id}/result: PDF를 다운로드합니다. 작업이 completed이면 200 application/pdf이고, 아직 완료되지 않았으면 409, 알 수 없으면 404입니다.
  • DELETE /api/v1/jobs/{id}: pending 또는 running 작업을 취소하거나, completed 작업을 삭제합니다(204).

작업 레코드는 서버가 직렬화한 다음 필드들을 data 아래에 그대로 담습니다.

  • job_id: 식별자입니다(job_ 접두사와 24 개의 16 진수 문자).
  • status: pending, running, completed, failed, cancelled 중 하나입니다. 앞의 두 상태는 비종료 상태이고, 나머지 세 상태는 종료 상태입니다.
  • created_at, 그리고 설정된 경우 started_atcompleted_at: ISO-8601 타임스탬프입니다.
  • progress: 0에서 100까지의 정수로, 서버가 해당 작업에 대해 추적할 때만 존재하며, 그렇지 않으면 존재하지 않습니다(알 수 없음으로 처리).
  • error: 메시지 문자열이며, failed 작업에서만 존재합니다.
  • result_url: completed 작업에서만 존재하며, 결과 다운로드 경로입니다.
  • poll_url: 작업이 비종료 상태인 동안에만 존재합니다.

인증에는 Authorization 헤더의 베어러 토큰을 사용합니다: Authorization: Bearer npk_live_{kid}_{secret}.

이 예시는 와이어 수준에서 하나의 작업을 처음부터 끝까지 처리하므로, 세 번의 호출과 각 호출이 반환하는 필드를 확인할 수 있습니다. 제출하고, 한 번 폴링한 뒤, 다운로드합니다. 아래 프로덕션 샘플은 여기에 일괄 처리 루프, Retry-After 대기, 전체 오류 처리를 추가합니다.

Terminal window
# 1. Submit an async render job. Capture the job_id from data.job_id.
curl -sS -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: invoice-2026-04-0001" \
-d '{"page_size":"A4","orientation":"portrait","operations":[{"type":"add_text","text":"Invoice 0001"}]}'
# 2. Poll status. Read data.status and data.progress; honour Retry-After.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
# 3. Once data.status is "completed", download the PDF binary.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-0001.pdf

다음은 독립적으로 동작하는 클라이언트입니다. 렌더 요청 일괄을 제출하고, 동시에 진행 중인 작업 수를 제한하며, 서버가 Retry-After로 요청한 주기에 맞춰 각 작업을 폴링합니다. 또한 서버가 보고하는 progress를 출력하고, 완료된 모든 PDF를 다운로드하며, 실패를 기록합니다. PSR-18 HTTP 클라이언트와 PSR-17 팩터리(Connect 레시피가 표준으로 채택한 전송 계약)를 사용하고, 각 호출에서 발생할 수 있는 가장 구체적인 예외를 잡습니다. 전송 실패는 Psr\Http\Client\ClientExceptionInterface로, 일괄 처리를 계속할 수 없는 서버 응답은 타입이 지정된 BatchJobException으로 처리합니다. 비어 있는 catch 블록은 하나도 없습니다. 각 블록은 로그를 남긴 뒤 다시 발생시키거나, 정의된 결과를 기록합니다.

인라인 $documents 목록은 사용자 고유의 입력으로 교체하십시오. 생성자가 PSR 인터페이스를 기대하는 위치에는 프로젝트의 구체적인 HTTP 클라이언트와 팩터리를 주입하십시오.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Raised when a Connect job response prevents the batch from proceeding.
*
* Distinct from the PSR-18 transport exception: this means the request was
* delivered and the server answered, but the answer is one the batch
* cannot act on (a non-success status code, or a job that ended in a
* terminal failure).
*/
final class BatchJobException extends RuntimeException
{
}
/**
* Drives a batch of async render jobs over the NextPDF Connect REST surface.
*
* The client submits each render request, polls every job on the cadence
* the server requests through Retry-After, and downloads each completed
* PDF. It enforces bounded concurrency so a large batch never opens more
* in-flight jobs than the host should track at once.
*/
final readonly class ConnectBatchRunner
{
/**
* @param non-empty-string $baseUrl Connect base URL, no trailing slash
* @param non-empty-string $bearerToken Connect API key (npk_live_...)
* @param positive-int $maxInFlight Concurrent jobs cap
* @param positive-int $maxPolls Per-job poll attempts before giving up
*/
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private string $bearerToken,
private int $maxInFlight = 8,
private int $maxPolls = 150,
) {}
/**
* Render every document in the batch and write each completed PDF.
*
* @param array<non-empty-string, array<string, mixed>> $documents
* Map of stable document key to render request body. The key
* doubles as the Idempotency-Key, so a re-run of the same batch
* does not duplicate server-side work.
* @param non-empty-string $outputDir Directory for the written PDFs
*
* @throws BatchJobException When the batch cannot proceed at all
* @throws ClientExceptionInterface When the transport cannot send a request
*
* @return array<non-empty-string, string> Map of document key to a
* human-readable outcome line
*/
public function run(array $documents, string $outputDir): array
{
$this->assertWritableDir($outputDir);
$outcomes = [];
// Process in bounded windows so the in-flight job count never
// exceeds the configured cap, regardless of batch size.
foreach (array_chunk($documents, $this->maxInFlight, preserve_keys: true) as $window) {
$jobIds = [];
foreach ($window as $key => $body) {
$jobIds[$key] = $this->submit($key, $body);
}
foreach ($jobIds as $key => $jobId) {
$record = $this->pollToTerminal($jobId);
$outcomes[$key] = $this->finish($key, $record, $outputDir);
}
}
return $outcomes;
}
/**
* Submit one render job and return its identifier.
*
* @param non-empty-string $idempotencyKey Stable per-document key
* @param array<string, mixed> $body Render request body
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return non-empty-string The job_id from data.job_id
*/
private function submit(string $idempotencyKey, array $body): string
{
$request = $this->requestFactory
->createRequest('POST', $this->baseUrl . '/api/v1/jobs')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken)
->withHeader('Content-Type', 'application/json')
->withHeader('Idempotency-Key', $idempotencyKey)
->withBody($this->streamFactory->createStream($this->encode($body)));
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
// 201 new job; 200 idempotent replay. Anything else stops the batch.
if ($status !== 201 && $status !== 200) {
throw new BatchJobException(
sprintf('Submit for "%s" returned HTTP %d.', $idempotencyKey, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobId = $data['job_id'] ?? null;
if (!is_string($jobId) || $jobId === '') {
throw new BatchJobException(
sprintf('Submit for "%s" returned no job_id.', $idempotencyKey),
);
}
return $jobId;
}
/**
* Poll one job until it reaches a terminal state.
*
* Honours the Retry-After header on every non-terminal poll. Gives up
* after maxPolls attempts and reports the wait as a failure so the
* batch records it rather than blocking forever.
*
* @param non-empty-string $jobId
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return array<string, mixed> The terminal job record (data object)
*/
private function pollToTerminal(string $jobId): array
{
$url = $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId);
for ($attempt = 0; $attempt < $this->maxPolls; $attempt++) {
$request = $this->requestFactory
->createRequest('GET', $url)
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new BatchJobException(
sprintf('Poll for job "%s" returned HTTP %d.', $jobId, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobStatus = is_string($data['status'] ?? null) ? $data['status'] : 'unknown';
$progress = is_int($data['progress'] ?? null) ? $data['progress'] : null;
$this->logProgress($jobId, $jobStatus, $progress);
// Terminal states: completed, failed, cancelled.
if (in_array($jobStatus, ['completed', 'failed', 'cancelled'], strict: true)) {
return $data;
}
// Non-terminal: wait the interval the server asked for.
$this->waitRetryAfter($response->getHeaderLine('Retry-After'));
}
throw new BatchJobException(
sprintf('Job "%s" did not finish within %d polls.', $jobId, $this->maxPolls),
);
}
/**
* Act on a terminal job record: download a completed PDF, or report.
*
* @param non-empty-string $key Document key
* @param array<string, mixed> $record Terminal job record (data object)
* @param non-empty-string $outputDir Where to write the PDF
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return string A human-readable outcome line
*/
private function finish(string $key, array $record, string $outputDir): string
{
$jobStatus = is_string($record['status'] ?? null) ? $record['status'] : 'unknown';
$jobId = is_string($record['job_id'] ?? null) ? $record['job_id'] : '';
if ($jobStatus !== 'completed') {
// A failed job carries an error message; surface it, do not swallow.
$error = is_string($record['error'] ?? null) ? $record['error'] : 'no detail';
return sprintf('%s -> %s (%s)', $key, $jobStatus, $error);
}
$path = rtrim($outputDir, '/\\') . DIRECTORY_SEPARATOR . $key . '.pdf';
$this->download($jobId, $path);
return sprintf('%s -> completed, written to %s', $key, $path);
}
/**
* Download a completed job result and write it to a server-derived path.
*
* @param non-empty-string $jobId
* @param non-empty-string $path Caller-controlled output path
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*/
private function download(string $jobId, string $path): void
{
$request = $this->requestFactory
->createRequest('GET', $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId) . '/result')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new BatchJobException(
sprintf('Result download for job "%s" returned HTTP %d.', $jobId, $response->getStatusCode()),
);
}
$bytes = $response->getBody()->__toString();
if (!str_starts_with($bytes, '%PDF')) {
throw new BatchJobException(
sprintf('Result for job "%s" is not a PDF.', $jobId),
);
}
if (file_put_contents($path, $bytes) === false) {
throw new BatchJobException(sprintf('Could not write result to "%s".', $path));
}
}
/**
* Sleep for the server-requested interval, with a safe floor and ceiling.
*/
private function waitRetryAfter(string $retryAfter): void
{
$seconds = ctype_digit($retryAfter) ? (int) $retryAfter : 2;
// Clamp to a sane band so a hostile header cannot stall or busy-loop.
$seconds = max(1, min(30, $seconds));
sleep($seconds);
}
/**
* Emit a progress line. Replace with your logger.
*/
private function logProgress(string $jobId, string $jobStatus, ?int $progress): void
{
$pct = $progress === null ? 'n/a' : $progress . '%';
fwrite(STDERR, sprintf("[%s] status=%s progress=%s\n", $jobId, $jobStatus, $pct));
}
/**
* Decode a response envelope and return its data object.
*
* @throws BatchJobException When the body is not the expected envelope
*
* @return array<string, mixed>
*/
private function decodeData(string $json): array
{
try {
/** @var mixed $decoded */
$decoded = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BatchJobException('Response body is not valid JSON.', previous: $e);
}
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
throw new BatchJobException('Response is missing the data envelope.');
}
/** @var array<string, mixed> $data */
$data = $decoded['data'];
return $data;
}
/**
* @param array<string, mixed> $body
*
* @throws BatchJobException
*/
private function encode(array $body): string
{
try {
return json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException $e) {
throw new BatchJobException('Render request body is not encodable.', previous: $e);
}
}
/**
* @param non-empty-string $dir
*
* @throws BatchJobException
*/
private function assertWritableDir(string $dir): void
{
if (!is_dir($dir) || !is_writable($dir)) {
throw new BatchJobException(sprintf('Output directory "%s" is not writable.', $dir));
}
}
}
// ---------------------------------------------------------------------------
// Wiring. Provide your project's concrete PSR-18 client and PSR-17 factories.
// ---------------------------------------------------------------------------
/** @var ClientInterface $httpClient */
/** @var RequestFactoryInterface $requestFactory */
/** @var StreamFactoryInterface $streamFactory */
$baseUrl = getenv('NEXTPDF_CONNECT_URL');
$token = getenv('NEXTPDF_CONNECT_TOKEN');
if ($baseUrl === false || $baseUrl === '' || $token === false || $token === '') {
fwrite(STDERR, "Set NEXTPDF_CONNECT_URL and NEXTPDF_CONNECT_TOKEN.\n");
exit(2);
}
/** @var array<non-empty-string, array<string, mixed>> $documents */
$documents = [
'invoice-0001' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0001'],
],
],
'invoice-0002' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0002'],
],
],
];
$runner = new ConnectBatchRunner(
httpClient: $httpClient,
requestFactory: $requestFactory,
streamFactory: $streamFactory,
baseUrl: rtrim($baseUrl, '/'),
bearerToken: $token,
maxInFlight: 8,
);
try {
$outcomes = $runner->run($documents, getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: sys_get_temp_dir());
} catch (BatchJobException $e) {
fwrite(STDERR, 'Batch stopped: ' . $e->getMessage() . "\n");
exit(1);
} catch (ClientExceptionInterface $e) {
fwrite(STDERR, 'Transport failure: ' . $e->getMessage() . "\n");
exit(1);
}
foreach ($outcomes as $line) {
echo $line, "\n";
}

예상 STDOUT은 문서당 한 줄이며, 경로는 사용자의 출력 디렉터리에 따라 달라집니다:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • 작업 필드는 최상위 레벨이 아니라 data 아래에서 읽으십시오. 모든 성공 응답은 { "data": ..., "meta": ... } 봉투로 감싸져 있습니다. 클라이언트가 사용하는 필드는 data.statusdata.progress이며, meta에는 지원 시 상관 관계를 확인하기 위한 request_id가 들어 있습니다.
  • progress는 없을 수 있습니다. 서버는 해당 작업에 대해 추적할 때만 progress를 포함합니다. 없는 필드는 0이 아니라 “알 수 없음”으로 처리하고, 항상 존재하는 status를 기준으로 루프를 구동하십시오.
  • 제출이 이미 종료 상태일 수 있습니다. 현재 릴리스에서 서버는 POST에 응답하기 전에 인라인으로 렌더링하므로, 제출 응답이 status: completed를 담을 수 있고 결과가 첫 번째 폴링에서 준비되어 있을 수 있습니다. 폴링 루프는 pending이 먼저 와야 한다고 가정하지 말고, 시도 0에서도 종료 상태를 받아들여야 합니다.
  • Retry-After를 준수하십시오. 종료되지 않은 상태의 응답은 Retry-After(2초 간격)를 설정합니다. 더 빠르게 폴링하면 요청을 낭비하고 429를 초래합니다. 값을 맹목적으로 신뢰하지 말고 합당한 범위로 제한하십시오.
  • 완료 전 /result 호출은 409입니다. 상태 폴링이 completed를 표시한 후에만 result 엔드포인트를 호출하십시오. 409 Conflict는 작업이 완료되지 않았다는 뜻이며, 전송 오류가 아닙니다.
  • Idempotency-Key는 중복 작업을 방지합니다. 동일한 키로 제출을 재시도하면 원래 작업을 반환합니다(200이며, 201이 아님). 네트워크 재시도가 두 번째 렌더를 절대 시작하지 않도록 문서별로 안정적인 키를 사용하십시오. 다른 본문에 재사용된 키는 409 충돌입니다.
  • 작업은 소유자 범위로 한정됩니다. 한 API 키로 제출된 작업은 다른 키에는 보이지 않으며, 소유자가 다른 GET403이 아니라 404를 반환합니다. 제출에 사용한 것과 동일한 자격 증명으로 폴링하십시오.
  • failed 작업은 error 메시지를 담습니다. failed 종료 상태에서는 data.error를 읽고 기록하십시오. 맹목적으로 재시도하지 마십시오.

일괄 처리 비용은 각 렌더 비용의 합에 폴링 오버헤드를 더한 것입니다. 클라이언트 측에서는 두 가지 레버를 제어할 수 있습니다. 첫째, 동시성을 제한하십시오. maxInFlight 상한은 한 번에 추적하는 작업 수를 고정하므로, 일괄 처리 크기와 관계없이 클라이언트의 열린 요청 수와 메모리를 일정하게 유지합니다. 이 값은 서버의 워커 수에 맞추고, 그보다 높게 설정하지 마십시오. 진행 중인 작업이 워커보다 많으면 각 작업의 큐 대기 시간만 길어집니다. 둘째, 폴링 간격을 존중하십시오. 각 폴링은 비용이 적은 상태 읽기이지만, 촘촘한 루프는 요청량을 늘리고 속도 제한기를 작동시킵니다. 서버의 2초 Retry-After가 올바른 기본값이며, 러너는 단일 느린 작업이 바쁜 루프에 빠지거나 윈도를 멈추게 하지 않도록 1~30초 범위로 제한합니다.

매우 큰 일괄 처리는 모든 것을 미리 제출하지 말고 윈도 단위로 처리하십시오(러너는 array_chunk를 사용함). 이렇게 하면 클라이언트의 추적 상태와 서버의 큐 깊이를 모두 제한하므로, 잘못 구성되었거나 지나치게 큰 일괄 처리가 수천 건을 제출한 뒤가 아니라 윈도 내부에서 실패합니다.

  • 베어러 토큰을 로그와 URL에서 제외하십시오. API 키는 오직 Authorization 헤더로만 전달됩니다. 쿼리 문자열, 로그 줄, 기록된 출력물에 절대 넣지 마십시오. 러너는 job_idstatus를 로그에 남기며, 자격 증명은 절대 남기지 않습니다.
  • 출력 경로를 서버가 제어하는 키에서 도출하지 마십시오. 러너는 각 출력 경로를 사용자의 코드가 선택한 문서 키와 고정 출력 디렉터리를 결합해 구성하며, 서버 응답의 값으로는 절대 구성하지 않습니다. 작업 필드를 파일 시스템 경로에 보간하지 마십시오. 그렇게 하면 경로 순회 취약점이 생깁니다.
  • 다운로드한 바이트를 검증하십시오. 러너는 파일을 쓰기 전에 200 응답이 /result에서 온 것인지와 %PDF 헤더를 확인합니다. 다운로드 상태가 성공이라는 것만으로는 본문이 PDF라는 증거가 되지 않습니다.
  • 결과를 검사하기 전까지 신뢰할 수 없는 것으로 취급하십시오. 작업이 완료되었다는 것은 서버가 바이트를 렌더링했다는 뜻이지, 그 바이트를 전달해도 안전하다는 뜻은 아닙니다. 결과를 클라이언트나 다운스트림 시스템에 넘기기 전에 구조적 검사 단계를 거치십시오.
  • 최소 권한 키를 사용하십시오. 비동기 작업 표면은 코어 등급 렌더링입니다. 일괄 처리에는 정확히 필요한 연산으로 범위가 한정된 키를 발급하고, 시크릿 관리 정책이 정한 일정에 따라 교체하십시오.
  • 폴링 예산을 제한하십시오. maxPolls는 멈춰 버린 작업이 클라이언트를 영원히 붙잡는 것을 막습니다. 일괄 처리는 무기한 차단하는 대신 시간 초과를 결과로 기록하며, 이는 하나의 불량 작업이 나머지에 대한 서비스 거부를 일으키지 않도록 합니다.

이 레시피는 어떠한 규범적 표준 주장도 하지 않습니다. NextPDF Connect 비동기 작업 REST 엔드포인트(POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result)를 사용하고, 서버가 정의하는 작업 레코드 필드(status, progress, error, result_url, poll_url)를 읽습니다. 다운로드된 결과에 대한 %PDF 헤더 검사는 응답이 PDF 마커로 시작한다는 사실만 확인할 뿐, 유효성 또는 적합성 판정이 아닙니다. 문서 집합 전체에 대한 표준 검사가 필요하면 Enterprise 일괄 적합성 도구를 사용하십시오. Connect 기반 일괄 표준 검사를 참조하십시오. 이는 여기서 다루는 렌더링 작업과 다른 표면입니다.