PHP 폼으로 여권 사진이 들어오고, 코드에서는 이름, 여권 번호, 날짜, 기계 판독 영역(MRZ)을 데이터로 받아야 한다고 해 봅시다. 이 글에서는 그 판독기를 두 번 만듭니다. 한 번은 ext-curl만 쓰는 순수 PHP 8.1로, 한 번은 Http 클라이언트를 쓰는 Laravel 서비스로 만듭니다. 두 버전 모두 요청을 한 번 보내고, 결과에 따라 분기하고, 두 번 결제하지 않고 재시도하며, 모든 거부를 대응할 수 있는 오류 코드로 바꿉니다.

사용하는 API는 doc.cheap의 여권·신분증 OCR API이고, 이곳은 doc.cheap 자체 블로그이니 제품 관련 선택은 그 점을 감안해 판단하세요. 여기서 쓰는 패턴(이미지마다 멱등성 키 하나, 안정적인 오류 코드로 분기, 호출마다 과금 플래그 저장)은 PHP에서 호출하는 어떤 유료 API에도 그대로 적용됩니다.

계정 없이 첫 호출

문서에는 공개 sandbox 키 sk_sandbox_public이 나와 있습니다. 실제 인식을 실행하고, 가입이 필요 없으며, 크레딧도 차감되지 않습니다. IP 주소당 시간당 10건의 요청과 클라이언트당 전체 기간 10건의 무료 인식이 허용되며, 이를 넘으면 403 registration_required가 돌아옵니다.

요청은 JSON 본문을 담은 POST https://api.doc.cheap/v1/scans입니다. 필수 필드는 image 하나로, 파일 바이트를 base64로 인코딩한 JPEG 또는 PNG입니다.

<?php
$ch = curl_init('https://api.doc.cheap/v1/scans');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode(['image' => base64_encode(file_get_contents('specimen.jpg'))]),
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer sk_sandbox_public',
        'Content-Type: application/json',
    ],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL, $body, PHP_EOL;

호출은 동기 방식입니다. 필드가 이 응답으로 바로 돌아오므로 폴링할 작업 ID도, 운영할 웹훅도 없습니다. 테스트에는 합성 견본을 쓰고, 본인 여권은 절대 쓰지 마세요.

본문은 엄격하게 검사됩니다. 오타가 난 옵션 이름 같은 알 수 없는 키는 경로를 알려 주는 메시지와 함께 422 validation_failed로 거부되므로, 오타는 무시되지 않고 분명하게 실패합니다. 크기 한도를 넘는 본문은 413 payload_too_large입니다. 사진 크기 조정과 모든 옵션은 여권 인식 가이드에서 다룹니다.

무엇이 돌아오는가

모든 응답은 같은 8개 그룹을 가지며, 모든 키가 항상 존재합니다. meta, document, holder, fields, mrz, images, quality, authenticity입니다. json_decode($body, true) 이후 알 수 없는 값은 null이며, 키가 빠지는 일은 없습니다. 일부를 생략하고 값을 가린 예시는 다음과 같습니다.

{
  "meta": { "id": "<SCAN_ID>", "status": "recognized", "billed": true, "confidence": "high", "reference": null },
  "document": { "kind": "passport", "country": "<ISO3>", "number": "<DOCUMENT_NUMBER>", "expiry_date": "<YYYY-MM-DD>", "is_expired": false },
  "holder": { "given_names": "<GIVEN_NAMES>", "surname": "<SURNAME>", "birth_date": "<YYYY-MM-DD>", "sex": "<M|F|X>", "nationality": "<ISO3>" },
  "mrz": { "status": "passed", "reason": null, "lines": ["<MRZ_LINE_1>", "<MRZ_LINE_2>"], "text": "<MRZ_TEXT>" }
}

코드가 다음에 무엇을 할지는 네 부분이 결정합니다.

  • **meta.status**는 다섯 문자열 중 하나입니다: recognized, no_document_found, unreadable, unsupported_document, rejected. 다섯 가지 모두 HTTP 200으로 옵니다. 데이터를 담고 있는 것은 첫 번째뿐입니다.
  • **document와 holder**는 정리된 값이며, 날짜는 YYYY-MM-DD 형식입니다. 아무것도 읽지 못한 그룹은 그룹 전체가 null입니다.
  • **mrz.status**는 passed, failed, absent 중 하나입니다. passed는 영역을 찾았고, 모든 체크 디지트가 맞았으며, 그 안의 내용이 인쇄된 페이지와 모순되지 않는다는 뜻입니다.
  • **meta.billed**는 이 호출에서 크레딧이 차감되었는지를 알려 줍니다.

두 클라이언트의 모양을 결정하는 요점은 이것입니다. 읽을 수 없는 사진은 오류가 아니라 200입니다. 분기는 두 번 합니다. 거부는 오류 코드로, 결과는 meta.status로. no_document_found에서 재시도하면 절대 읽히지 않을 사진을 다시 보내게 되고, 성공으로 처리하면 필드가 하나도 없는 문서를 저장하게 됩니다.

PHP에서 MRZ를 직접 검증하기

mrz.lines와 mrz.text는 읽은 그대로의 영역이며, 응답에서 독립적으로 검증할 수 있는 유일한 부분입니다. 여권(TD3 형식)의 두 번째 줄에는 다시 계산하기 쉬운 체크 디지트가 세 개 있습니다. 여권 번호, 생년월일, 만료일입니다. 각 디지트는 7, 3, 1이 반복되는 가중치를 쓰며, 숫자는 그 값 그대로, A부터 Z는 10부터 35, 채움 문자 <는 0으로 셉니다.

<?php
function mrz_check_digit(string $field): int
{
    $weights = [7, 3, 1];
    $sum = 0;
    foreach (str_split($field) as $i => $char) {
        $value = match (true) {
            ctype_digit($char) => (int) $char,
            ctype_upper($char) => ord($char) - 55, // A = 10 ... Z = 35
            default => 0,                          // 채움 문자 <
        };
        $sum += $value * $weights[$i % 3];
    }
    return $sum % 10;
}

function td3_line2_checks(string $line): array
{
    return [
        'number' => mrz_check_digit(substr($line, 0, 9)) === (int) $line[9],
        'birth_date' => mrz_check_digit(substr($line, 13, 6)) === (int) $line[19],
        'expiry_date' => mrz_check_digit(substr($line, 21, 6)) === (int) $line[27],
    ];
}

$scan['document']['kind']가 passport일 때 $scan['mrz']['lines'][1]에 대해 실행합니다. mrz.status를 믿기 위해 이것이 필요한 것은 아닙니다. 테스트에서, 또는 영역을 보관해 두었다가 무엇을 읽었는지 나중에 증명하고 싶을 때 유용합니다.

누가 내는가: billed 플래그

sandbox 키는 잔액을 전혀 건드리지 않습니다. 라이브 키에서는 문서 유형이 확인되고, 다음 중 하나 이상을 얻었을 때만 스캔이 과금됩니다. 체크 디지트가 맞는 MRZ, 인쇄된 페이지의 필드 5개 이상, 또는 디코딩된 바코드. 아무것도 찾지 못한 경우, 읽을 수 없는 사진, 지원하지 않는 유형, 서비스 쪽 실패는 비용이 들지 않습니다. sk_sandbox_public에서도 billed는 같은 스캔이 과금되었을지 알려 주므로, 이 값으로 테스트해 볼 가치가 있습니다.

이 플래그를 여러분의 주문이나 사용자 행 옆에 저장해 두면, 별도의 대조 단계 없이 기록이 청구서와 일치합니다. 계정에는 매달 100건의 무료 인식이 제공되며, 유료 크레딧보다 먼저 사용됩니다.

두 번 과금될 수 없는 재시도

위험한 재시도는 타임아웃 뒤의 재시도입니다. 첫 요청이 서버에 도달했는지 알 수 없기 때문입니다. Idempotency-Key 헤더(1~255자)를 보내고, 같은 이미지에 대한 모든 시도에서 같은 값을 재사용하세요. 라이브 키에서 서버는 키와 본문 전체의 지문을 함께 비교합니다.

  • 같은 키, 같은 본문, 결과가 저장됨: 첫 번째 결과가 돌아오고 다시 과금되지 않습니다.
  • 같은 키, 다른 본문: 409 idempotency_conflict. 본문은 루프 밖에서 한 번만 만드세요.
  • 첫 요청이 아직 처리 중: 409 idempotency_in_progress. 기다렸다가 같은 키로 재시도하세요.
  • 같은 키, 저장된 결과 없음(예: retain_hours: 0 이후): 409 idempotency_replay_unavailable. 새 키로 보내세요.

모든 오류 본문은 같은 형태(code, message, docs_url, request_id, event_id)이며, 분기 기준은 code입니다. 하나의 HTTP 상태에 정반대의 처리가 필요한 코드들이 함께 들어 있기 때문입니다. 오류 처리 가이드는 모든 코드를 묶음별로 정리합니다. 스캔 클라이언트가 만나는 것은 다음과 같습니다.

코드 대응
rate_limited, document_repeated, internal_error, engine_unavailable, service_unavailable, maintenance 기다린 뒤(Retry-After 준수) 재시도
idempotency_in_progress 기다린 뒤 같은 키로 재시도
validation_failed, invalid_request, payload_too_large, unsupported_media_type 요청을 고칩니다. 재시도해도 다시 실패합니다
unauthorized, registration_required, insufficient_credits 키나 계정을 고칩니다. 기다려도 달라지지 않습니다

Retry-After는 정수 초 단위입니다. sandbox의 시간당 한도는 거의 한 시간을 기다리라고 할 수 있으므로, 아래 두 클라이언트는 PHP 워커를 붙잡아 두는 대신 대기 시간이 1분을 넘으면 포기합니다.

순수 PHP 클라이언트

<?php
// scan.php – PHP 8.1+, ext-curl과 ext-json만 사용

const API = 'https://api.doc.cheap/v1/scans';
const RETRYABLE = [
    'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
    'service_unavailable', 'maintenance', 'idempotency_in_progress',
];
const MAX_WAIT = 60; // 초. 이보다 긴 Retry-After는 기다리지 않고 보고한다

final class ScanError extends RuntimeException
{
    public function __construct(public readonly int $status, public readonly array $error)
    {
        parent::__construct(sprintf('%s (%d): %s', $error['code'], $status, $error['message']));
    }
}

/** HTTP 시도 한 번: [상태, 디코딩된 본문, Retry-After 초]. */
function post_scan(string $apiKey, string $idempotencyKey, string $json): array
{
    $retryAfter = 0;
    $ch = curl_init(API);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => $json,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 60,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . $apiKey,
            'Content-Type: application/json',
            'Idempotency-Key: ' . $idempotencyKey,
        ],
        CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$retryAfter): int {
            if (stripos($header, 'Retry-After:') === 0) {
                $retryAfter = (int) trim(substr($header, 12));
            }
            return strlen($header);
        },
    ]);
    $body = curl_exec($ch);
    if ($body === false) {
        throw new RuntimeException('transport: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    return [$status, json_decode($body, true, flags: JSON_THROW_ON_ERROR), $retryAfter];
}

function scan(string $path, ?string $reference = null, int $attempts = 4): array
{
    $bytes = file_get_contents($path);
    if ($bytes === false) {
        throw new RuntimeException("cannot read $path");
    }
    $apiKey = getenv('DOC_CHEAP_API_KEY') ?: 'sk_sandbox_public';
    $json = json_encode([
        'image' => base64_encode($bytes),
        'reference' => $reference,
        'options' => ['return_portrait' => false],
    ], JSON_THROW_ON_ERROR);
    $idempotencyKey = bin2hex(random_bytes(16)); // 이미지마다 키 하나, 모든 시도에서 재사용

    for ($attempt = 1; ; $attempt++) {
        $last = $attempt >= $attempts;
        try {
            [$status, $payload, $retryAfter] = post_scan($apiKey, $idempotencyKey, $json);
        } catch (RuntimeException | JsonException $e) {
            // 타임아웃, 끊어진 연결, 또는 HTML로 답하는 프록시.
            if ($last) {
                throw $e;
            }
            sleep(2 ** $attempt);
            continue;
        }
        if ($status === 200) {
            return $payload;
        }
        $error = $payload['error'];
        $wait = $retryAfter > 0 ? $retryAfter : 2 ** $attempt;
        if ($last || !in_array($error['code'], RETRYABLE, true) || $wait > MAX_WAIT) {
            throw new ScanError($status, $error);
        }
        sleep($wait);
    }
}

function summarise(array $scan): array
{
    $meta = $scan['meta'];
    if ($meta['status'] !== 'recognized') {
        return ['ok' => false, 'status' => $meta['status'], 'billed' => $meta['billed']];
    }
    $document = $scan['document'] ?? [];
    $holder = $scan['holder'] ?? [];
    return [
        'ok' => true,
        'billed' => $meta['billed'],
        'kind' => $document['kind'] ?? null,
        'country' => $document['country'] ?? null,
        'expiry_date' => $document['expiry_date'] ?? null,
        'surname' => $holder['surname'] ?? null,
        'birth_date' => $holder['birth_date'] ?? null,
        'mrz' => $scan['mrz']['status'],
    ];
}

try {
    echo json_encode(summarise(scan($argv[1], reference: 'demo-1'))), PHP_EOL;
} catch (ScanError $e) {
    fwrite(STDERR, $e->getMessage() . ' ' . $e->error['docs_url'] . ' ' . $e->error['request_id'] . PHP_EOL);
    exit(1);
}

php scan.php specimen.jpg로 실행합니다. 읽을 수 있는 견본이라면 다음과 같은 형태의 한 줄을 출력합니다(값은 가렸습니다).

{"ok":true,"billed":true,"kind":"passport","country":"<ISO3>","expiry_date":"<YYYY-MM-DD>","surname":"<SURNAME>","birth_date":"<YYYY-MM-DD>","mrz":"passed"}

설명할 만한 선택 몇 가지:

  • curl은 4xx나 5xx에서 실패하지 않습니다. 오류 본문에 코드가 들어 있으므로, 클라이언트는 어느 경우든 JSON 본문을 읽습니다.
  • 본문과 키는 루프 전에 한 번만 만듭니다. 그래야 서버 입장에서 모든 시도가 같은 요청이 됩니다.
  • **return_portrait: false**는 소지자의 얼굴 사진 크롭을 응답에서 빼므로, 로그와 저장소에 남는 얼굴이 하나 줄어듭니다.
  • **reference**는 meta.reference(최대 128자)로 돌아옵니다. 스캔을 여러분의 주문이나 사용자와 연결하는 쉬운 방법입니다.
  • 요청 본문은 절대 로그에 남기지 마세요. 그것은 신분증입니다. code, request_id, docs_url을 남기세요. 지원팀에 필요한 것은 그게 전부입니다.

Laravel 버전

Laravel의 Http 클라이언트(Laravel 10 이상)는 이미 루프의 대부분을 처리합니다. retry()는 대기 시간을 정하는 클로저와 시도를 반복할 가치가 있는지 판단하는 클로저를 받고, 배열을 넘긴 post()는 JSON을 보냅니다. 키는 config/services.php에 둡니다.

'doc_cheap' => [
    'key' => env('DOC_CHEAP_API_KEY', 'sk_sandbox_public'),
],

그다음 작은 서비스를 만듭니다.

<?php

namespace App\Services;

use Exception;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class PassportReader
{
    private const RETRYABLE = [
        'rate_limited', 'document_repeated', 'internal_error', 'engine_unavailable',
        'service_unavailable', 'maintenance', 'idempotency_in_progress',
    ];

    public function read(string $bytes, string $idempotencyKey, ?string $reference = null): array
    {
        $response = Http::baseUrl('https://api.doc.cheap/v1')
            ->withToken(config('services.doc_cheap.key'))
            ->withHeaders(['Idempotency-Key' => $idempotencyKey])
            ->acceptJson()
            ->timeout(60)
            ->retry(
                4,
                fn (int $attempt, Exception $e) => $this->waitSeconds($attempt, $e) * 1000,
                fn (Exception $e) => $this->worthRetrying($e),
                throw: false,
            )
            ->post('scans', [
                'image' => base64_encode($bytes),
                'reference' => $reference,
                'options' => ['return_portrait' => false],
            ]);

        if ($response->successful()) {
            return $response->json();
        }

        $error = $response->json('error') ?? ['code' => 'unknown', 'message' => $response->reason()];
        throw new RuntimeException(sprintf('%s (%d): %s', $error['code'], $response->status(), $error['message']));
    }

    private function waitSeconds(int $attempt, Exception $e): int
    {
        $after = $e instanceof RequestException ? (int) $e->response->header('Retry-After') : 0;

        return $after > 0 ? $after : 2 ** $attempt;
    }

    private function worthRetrying(Exception $e): bool
    {
        if ($e instanceof ConnectionException) {
            return true; // 타임아웃이나 끊어진 연결: 키가 있으니 재시도는 안전하다
        }

        return $e instanceof RequestException
            && in_array($e->response->json('error.code'), self::RETRYABLE, true)
            && (int) $e->response->header('Retry-After') <= 60;
    }
}

throw: false를 쓰면 재시도할 가치가 없는 거부는 응답으로 돌아오고, 서비스가 이를 코드 이름이 들어간 예외로 바꿉니다. 여러분의 코드에서는 이 예외에 전용 클래스를 두고 error 배열을 담아 두세요. 그러면 호출하는 쪽이 메시지가 아니라 code로 분기할 수 있습니다.

업로드를 받아 필요한 것만 보관하는 컨트롤러입니다.

public function store(Request $request, PassportReader $reader)
{
    $request->validate(['passport' => ['required', 'file', 'mimes:jpg,png', 'max:10240']]);

    $scan = $reader->read(
        $request->file('passport')->get(),
        (string) Str::uuid(),
        reference: (string) $request->user()->id,
    );

    if ($scan['meta']['status'] !== 'recognized') {
        return back()->withErrors(['passport' => 'We could not read that photo. Please try a sharper one.']);
    }

    $request->user()->passportChecks()->create([
        'scan_id' => $scan['meta']['id'],
        'billed' => $scan['meta']['billed'],
        'expiry_date' => $scan['document']['expiry_date'] ?? null,
        'mrz_status' => $scan['mrz']['status'],
    ]);

    return redirect()->route('profile');
}

큐 작업에서는 키를 작업의 일부로 만드세요. 호출을 작업으로 옮긴다면, 업로드가 도착할 때 멱등성 키를 만들어 작업의 생성자에 넘기세요. 그러면 실패해서 큐 워커가 재시도하는 작업도 같은 키를 보내고, 이미 실행된 스캔은 다시 과금되지 않고 재전송됩니다. handle() 안에서 새로 Str::uuid()를 만들면 이 장치가 무력해집니다.

재시도 경로는 Http::fake()로 테스트하세요. 429 다음에 200을 주는 시퀀스로, 네트워크 호출 없이 두 번째 시도가 일어나고 같은 키를 담는다는 것을 증명할 수 있습니다.

Http::fake([
    'api.doc.cheap/*' => Http::sequence()
        ->push(['error' => ['code' => 'rate_limited', 'message' => 'Slow down', 'docs_url' => null, 'request_id' => 'req_test', 'event_id' => null]], 429, ['Retry-After' => '1'])
        ->push(['meta' => ['id' => 'scan_test', 'status' => 'no_document_found', 'billed' => false]], 200),
]);

$scan = app(PassportReader::class)->read('fake-bytes', 'key-1');

Http::assertSentCount(2);
Http::assertNotSent(fn ($request) => $request->header('Idempotency-Key') !== ['key-1']);

데이터를 덜 보관하기

호출이 끝난 뒤 업로드된 이미지를 저장하지 마세요. 결과에서 필요한 것만 읽고 바이트는 버리면 됩니다. 서비스 쪽에서는 GET /v1/scans/{id}로 다시 읽을 수 있도록 결과가 보관되며, 기간은 요청마다 고를 수 있습니다. options.retain_hours는 0부터 8760까지 받고, 0이면 아무것도 기록하지 않습니다. 대가는 위의 재전송 규칙입니다. 0을 쓰면 같은 키로 한 재시도는 idempotency_replay_unavailable을 받습니다. 둘 다 원한다면 0보다 큰 짧은 기간을 고르세요.

라이브로 전환하기

DOC_CHEAP_API_KEY를 여러분의 sk_live_ 키로 설정하면 나머지는 그대로입니다. 같은 엔드포인트, 같은 응답, 같은 클라이언트입니다. 등록된 키는 분당 60건의 요청을 허용하므로, Retry-After를 지키는 배치 작업에는 별도의 속도 제한기가 필요 없습니다. 아직 서비스를 고르는 중이라면 여권 OCR API 비교에서 유료 대안과 각 서비스가 공개한 가격을 볼 수 있습니다.

질문 하나 드립니다. 지금 PHP에서 MRZ를 직접 파싱하고 계신가요, 아니면 서비스에 맡기고 계신가요? 아래 댓글로 알려 주세요.