Python으로 만드는 여권 리더는 처음에는 스무 줄 정도지만, 프로덕션에서 버텨야 할 때는 백 줄 정도가 됩니다. 늘어난 여든 줄은 OCR과는 관계가 없습니다. 모든 유료 API가 던지는 세 가지 질문에 대한 답입니다. 사진 품질이 나쁘면 어떻게 되는가, 요청이 타임아웃되어 다시 보내면 어떻게 되는가, 그리고 어떤 호출에 비용이 들었는지 내 기록이 어떻게 아는가.

이 튜토리얼은 requests만으로 그 스크립트를 만듭니다. 여권 및 신분증 OCR API인 doc.cheap을 사용하며, 이 글은 doc.cheap의 자체 블로그이므로 제품 선택은 그 점을 감안해 판단하세요. 여기서 다루는 패턴(이미지마다 하나의 멱등성 키, 안정적인 오류 코드에 따른 분기, 호출별 비용 플래그 저장)은 Python에서 호출하는 어떤 유료 API에도 그대로 적용됩니다.

샌드박스 키로 한 번 요청하기

문서에는 공개 샌드박스 키 sk_sandbox_public이 나와 있습니다. 유료 키와 같은 인식 엔진이 동작하고 계정이 필요 없습니다. IP 주소당 총 10건의 문서를 무료로 인식할 수 있고, 결과와 관계없이 시간당 최대 10건의 요청을 보낼 수 있습니다. 무료 여권 OCR API 페이지에는 같은 첫 호출이 curl 한 줄로 있습니다. 나중에 계정을 만들면 카드 없이 매달 100건의 무료 문서가 추가됩니다.

import base64
import requests

with open("specimen.jpg", "rb") as f:
    image = base64.b64encode(f.read()).decode("ascii")

response = requests.post(
    "https://api.doc.cheap/v1/scans",
    headers={"Authorization": "Bearer sk_sandbox_public"},
    json={"image": image},
    timeout=60,
)
scan = response.json()
print(response.status_code, scan["meta"]["status"], scan["meta"]["billed"])

json=은 Content-Type: application/json을 자동으로 설정합니다. 이미지는 JPEG 또는 PNG를 base64로 인코딩해 본문에 담아 보냅니다. 호출은 동기식입니다. 필드가 이 응답에 바로 담겨 오므로, 폴링할 작업 ID도 운영할 웹훅도 없습니다.

테스트에는 합성 견본을 사용하고, 본인 여권은 절대 사용하지 마세요. ICAO의 가상 "Utopia" 문서와 여러 발급 기관이 공개하는 견본 페이지가 바로 이런 용도로 존재합니다.

먼저 사진 크기를 줄이세요. 휴대폰 사진은 수 메가바이트가 될 수 있고, base64가 여기에 3분의 1을 더합니다. 여권 가이드는 긴 변 약 1600 px, JPEG 품질 85를 권장합니다. 한도를 넘는 본문은 처리가 시작되기 전에 payload_too_large로 거부됩니다.

응답 읽기

응답의 모든 키는 항상 존재하며, 알 수 없는 값은 json() 이후 None이 되고 키가 빠지는 일은 없습니다. 코드의 다음 동작을 결정하는 부분은 네 가지입니다.

  • **meta.status**는 문서를 읽었는지를 알려 줍니다. recognized, no_document_found, unreadable, unsupported_document, rejected 다섯 문자열 중 하나이며, 데이터를 담는 것은 첫 번째뿐입니다.
  • **document와 holder**에는 정리된 값이 들어 있습니다. document.kind(passport, 신분증 등), ISO 3166-1 alpha-3 국가 코드, 번호, ISO YYYY-MM-DD 형식의 날짜, 그리고 holder.surname, holder.given_names, holder.birth_date입니다. 스캔에서 해당 그룹의 값을 하나도 얻지 못하면 그룹 전체가 None입니다.
  • **mrz.status**는 passed, failed, absent 중 하나로, 기계 판독 영역을 찾았는지와 검증 숫자가 일치하는지를 나타냅니다. mrz.text는 읽어 낸 영역 그대로이므로 검증 숫자를 직접 확인할 수 있습니다.
  • **meta.billed**는 이 호출이 잔액에서 차감되었는지를 알려 줍니다.

클라이언트 전체의 구조를 결정하는 핵심은 이것입니다. 읽지 못한 사진은 오류가 아니라 HTTP 200입니다. 그래서 코드는 두 번 분기합니다. 거부는 HTTP 오류 코드로, 결과는 meta.status로 분기합니다. no_document_found에서 예외를 던지면 재시도 루프가 절대 읽히지 않을 사진을 계속 다시 보냅니다. 성공으로 처리하면 필드가 하나도 없는 문서를 저장하게 됩니다.

billed 플래그

라이브 키에서 billed는 문서가 실제로 인식되었을 때만 True입니다. 아무것도 찾지 못함, 읽을 수 없는 이미지, 지원하지 않는 유형, 서비스 측 오류는 모두 과금되지 않습니다. 과금되는 문서는 사용량과 관계없이 건당 $0.01 고정입니다. 여권 OCR API 비교 페이지에서 이 가격을 다른 서비스들이 공개한 가격과 나란히 볼 수 있습니다.

샌드박스는 전혀 과금하지 않습니다. sk_sandbox_public에서도 billed는 같은 스캔이 라이브 키였다면 과금되었을지를 알려 주므로 테스트할 가치가 있습니다. 이 플래그를 결과마다 함께 저장하세요. 그러면 한 달 동안의 billed 행을 직접 합산하기만 하면 되고, 청구서와 대조할 필요가 없습니다.

두 번 과금될 수 없는 재시도

위험한 재시도는 타임아웃 이후의 재시도입니다. 첫 요청이 서버에 도달했는지 알 수 없고, 유료 API에서 무작정 재시도하면 이미지 한 장에 두 번 비용을 낼 수 있습니다.

해법은 POST /v1/scans에 붙이는 1~255자의 Idempotency-Key 헤더입니다. 이미지마다 한 번 생성하고 모든 시도에 같은 값을 보내세요. 라이브 키에서는 같은 키와 같은 본문으로 재시도하면 두 번째 인식 대신 첫 번째 결과가 돌아오며, 재전송에는 비용이 들지 않습니다. 레퍼런스의 세 가지 규칙이 코드의 형태를 정합니다.

  • 키는 본문에 묶입니다. 같은 키로 다른 이미지나 다른 옵션을 보내면 409 idempotency_conflict입니다. 본문은 루프 밖에서 한 번만 만드세요.
  • 첫 시도가 아직 실행 중일 때 두 번째 시도가 도착할 수 있습니다. 이때는 409 idempotency_in_progress입니다. 기다렸다가 같은 키로 재시도하세요.
  • 저장된 결과가 없으면 재전송도 없습니다. retain_hours: 0이면 아무것도 저장되지 않으므로, 24시간 안에 같은 키로 재시도하면 두 번째 실행 대신 409 idempotency_replay_unavailable을 받습니다. 아무것도 보관하지 않는 것과 결과를 재전송하는 것은 함께할 수 없으니, 사용 사례별로 선택하세요.

샌드박스 키도 이 헤더를 받지만, 과금되는 것이 없으므로 아무것도 결정하지 않습니다. 그래도 이 코드 경로는 테스트해 둘 가치가 있습니다.

어떤 오류를 재시도할 것인가. 모든 오류 본문은 같은 형태(code, message, docs_url, request_id, event_id)이며, 분기 기준은 코드입니다. 하나의 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는 정수 초 단위입니다. 샌드박스의 시간당 한도는 거의 한 시간을 기다리라고 할 수 있고, 함수 호출 하나가 그렇게 오래 잠들기를 바라는 호출자는 없으므로, 아래 클라이언트는 대기 시간이 1분을 넘으면 포기합니다.

전체 클라이언트

# scan.py
import base64
import os
import time
import uuid

import requests

API = "https://api.doc.cheap/v1/scans"
KEY = os.environ.get("DOC_CHEAP_API_KEY", "sk_sandbox_public")
RETRY = {
    "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
    "service_unavailable", "maintenance", "idempotency_in_progress",
}
MAX_WAIT = 60  # 초. 이보다 긴 Retry-After는 기다리지 않고 오류로 보고한다


class ScanError(Exception):
    def __init__(self, status, error):
        super().__init__(f"{error['code']} ({status}): {error['message']}")
        self.code = error["code"]
        self.docs_url = error["docs_url"]
        self.request_id = error["request_id"]


def retry_after(response, attempt):
    try:
        seconds = int(response.headers.get("Retry-After", ""))
    except ValueError:
        seconds = 0
    return seconds if seconds > 0 else 2 ** attempt


def scan(path, reference=None, attempts=4, session=None):
    session = session or requests.Session()
    with open(path, "rb") as f:
        image = base64.b64encode(f.read()).decode("ascii")
    body = {"image": image, "reference": reference, "options": {"return_portrait": False}}
    headers = {
        "Authorization": f"Bearer {KEY}",
        "Idempotency-Key": str(uuid.uuid4()),  # 이미지마다 키 하나, 모든 시도에서 재사용
    }

    for attempt in range(1, attempts + 1):
        last = attempt == attempts
        try:
            response = session.post(API, headers=headers, json=body, timeout=60)
            payload = response.json()
        except (requests.RequestException, ValueError):
            # 타임아웃, 끊긴 연결, 또는 HTML로 응답한 프록시.
            if last:
                raise
            time.sleep(2 ** attempt)
            continue

        if response.ok:
            return payload
        error = payload["error"]
        if error["code"] not in RETRY or last:
            raise ScanError(response.status_code, error)
        wait = retry_after(response, attempt)
        if wait > MAX_WAIT:
            raise ScanError(response.status_code, error)
        time.sleep(wait)


def summarise(scan):
    meta, document, holder, mrz = scan["meta"], scan["document"], scan["holder"], scan["mrz"]
    if meta["status"] != "recognized":
        return {"ok": False, "status": meta["status"], "billed": meta["billed"]}
    document, holder = document or {}, holder or {}
    return {
        "ok": True,
        "billed": meta["billed"],
        "kind": document.get("kind"),
        "country": document.get("country"),
        "expiry_date": document.get("expiry_date"),
        "surname": holder.get("surname"),
        "birth_date": holder.get("birth_date"),
        "mrz": mrz["status"],
    }


if __name__ == "__main__":
    import sys

    try:
        print(summarise(scan(sys.argv[1], reference="demo-1")))
    except ScanError as err:
        print(err, err.docs_url, err.request_id, file=sys.stderr)
        sys.exit(1)

python scan.py specimen.jpg로 실행합니다. 설명할 만한 선택 몇 가지:

  • requests는 4xx나 5xx에서 예외를 던지지 않습니다. raise_for_status()를 호출할 때만 던집니다. 오류 본문에 코드가 들어 있으므로 클라이언트는 어느 경우든 JSON 본문을 읽습니다. raise_for_status()를 쓰면 그 정보를 잃게 됩니다.
  • 본문과 키는 루프 전에 한 번만 만듭니다. 그래야 서버 입장에서 모든 시도가 같은 요청이 됩니다.
  • Session은 연결을 재사용합니다. 재시도 사이에서도, 여러 이미지를 묶어 처리할 때도 마찬가지입니다. 파일을 많이 스캔할 때는 세션을 넘겨 주세요.
  • **return_portrait: False**는 소지자 사진의 크롭을 빼고 받습니다. 페이지 크롭과 필드는 그대로 받고, 로그와 저장소에 남는 얼굴이 하나 줄어듭니다.
  • **reference**는 meta.reference(최대 128자)로 돌아옵니다. 스캔을 내 주문이나 사용자와 연결하는 쉬운 방법입니다. 신분증의 앞뒷면은 두 번의 호출이므로 같은 reference를 주세요.
  • 요청 본문은 절대 로그에 남기지 마세요. 그것은 신분증 자체입니다. code, request_id, docs_url을 로그로 남기세요. 지원팀에 필요한 것은 그게 전부입니다.

데이터를 덜 보관하기

업로드된 이미지는 요청이 처리되는 동안만 메모리에 머물며, 영구 저장소에는 절대 기록되지 않습니다. 결과는 GET /v1/scans/{id}로 다시 가져올 수 있도록 원하는 기간 동안 보관됩니다. 요청마다 options.retain_hours에 0~8760을 지정할 수 있고, 0이면 아무것도 저장하지 않습니다. JSON이 한 번만 필요하다면 retain_hours: 0을 보내고 위에서 설명한 재전송과의 트레이드오프를 받아들이세요. 처리는 EU에서 이루어집니다.

라이브로 전환하기

DOC_CHEAP_API_KEY를 본인 키로 설정하면 나머지는 그대로입니다. 같은 엔드포인트, 같은 응답 형태, 같은 클라이언트입니다. 등록된 키는 분당 60건의 요청을 허용하므로, Retry-After를 지키는 배치 작업이라면 별도의 속도 제한기가 필요 없습니다. 크레딧은 암호화폐(BTC, ETH, TRX, 또는 Ethereum이나 Tron의 USDT)로 $1부터 구매할 수 있으며, 현재 카드 결제는 없습니다.

응답 중 Python에서 다루기 불편한 부분이 있다면 admin@doc.cheap으로 알려 주세요.