여권 사진을 구조화된 데이터로 읽어 오는 일은 "OCR API를 호출하면 끝"처럼 보이지만, 프로덕션에서 돌리는 순간 세 가지 질문이 뒤따릅니다. 사진이 흐릿하면 어떻게 될까요? 요청이 타임아웃되어 재시도하면, 방금 두 번 결제한 것일까요? 그리고 어느 호출에 비용이 들었는지 여러분의 기록은 어떻게 알 수 있을까요?
이 튜토리얼은 SDK 없이 Node.js 18 이상과 내장 fetch만으로 이 세 가지 질문에 답합니다. doc.cheap을 사용하며, 이곳은 doc.cheap의 블로그이므로 제품 선택에 관한 부분은 적당히 의심하며 읽으시기 바랍니다. 여기서 다루는 패턴(멱등성 키, 고정된 오류 코드에 따른 분기, 호출별 비용 플래그 보관)은 어떤 유료 API에도 적용됩니다.
계정 없이 첫 호출
이 API에는 문서에 공개된 샌드박스 키 sk_sandbox_public이 있습니다. 유료 키와 같은 인식을 수행하며 가입이 필요 없습니다. IP 주소당 총 10건의 인식 문서가 무료이고, 응답 결과와 관계없이 시간당 최대 10건의 요청을 보낼 수 있습니다. 아래 내용을 모두 시험해 보기에 충분합니다. 나중에 가입하면 카드 없이 무료 크레딧 20개가 추가됩니다.
import { readFileSync } from "node:fs";
const image = readFileSync("specimen.jpg").toString("base64");
const response = await fetch("https://api.doc.cheap/v1/scans", {
method: "POST",
headers: {
Authorization: "Bearer sk_sandbox_public",
"Content-Type": "application/json",
},
body: JSON.stringify({ image }),
});
const scan = await response.json();
console.log(scan.meta.status, scan.holder?.full_name, scan.mrz.status);
first.mjs로 저장하고 node first.mjs를 실행하십시오. 호출은 동기식입니다. 작업 ID도, 폴링도, 웹훅도 없습니다. 인식은 요청 안에서 이루어지고 필드는 그 응답으로 돌아옵니다.
테스트에는 본인 여권이 아니라 합성 견본을 사용하십시오. 많은 발급 기관이 견본 페이지를 공개하고 있으며, ICAO의 가상 국가 "Utopia" 문서는 바로 이런 용도로 존재합니다.
이미지 크기는 생각보다 중요합니다. 카메라에서 바로 가져온 휴대폰 사진은 수 메가바이트에 이를 수 있고, base64로 인코딩하면 약 3분의 1이 더 커집니다. 문서에서는 긴 변 약 1600 px, JPEG 품질 85를 권장합니다. 첫 호출이 느리게 느껴진다면 누군가의 서버를 탓하기 전에 meta.timing.upload_ms를 확인하십시오.
돌아오는 응답
JSON 형태는 하나이고 그룹은 여덟 개이며, 모든 키가 항상 존재합니다. 알 수 없는 값은 null이며, 키가 빠지는 일은 없습니다. 인식에 성공한 응답을 줄여서 보면 다음과 같습니다.
{
"meta": {
"schema_version": "1.0",
"id": "01a0af18-cd8d-7a61-9f2d-4c7b8e105da3",
"status": "recognized",
"billed": true,
"confidence": "high",
"timing": { "upload_ms": 214, "processing_ms": 843, "total_ms": 1074 },
"created_at": "2026-09-17T09:41:12Z",
"reference": null
},
"document": {
"kind": "passport", "country": "GRC", "country_name": "Greece",
"number": "AM7304518", "issue_date": "2022-03-10", "expiry_date": "2032-03-10",
"is_expired": false, "days_remaining": 2001
},
"holder": {
"given_names": "ELENI SOFIA", "surname": "PARADEIGMA", "full_name": "PARADEIGMA ELENI SOFIA",
"birth_date": "1994-03-08", "sex": "F", "nationality": "GRC"
},
"fields": [],
"mrz": { "status": "passed", "reason": null, "lines": ["P<GRC…", "AM7304518…"], "text": "P<GRC…" },
"images": { "document_crop": "data:image/jpeg;base64,…", "main_photo": "data:image/jpeg;base64,…" },
"quality": { "overall": "pass" },
"authenticity": { "overall": "not_checked", "checks": [] }
}
(소지자는 문서에 나오는 가상의 견본이며, fields와 images는 생략했습니다.) 알아 두면 좋은 부분은 다음과 같습니다.
document와holder는 정리된 값입니다. 날짜는 ISO 8601, 국가는 ISO 3166-1 alpha-3입니다. 스캔 결과 해당 그룹에 아무것도 나오지 않으면 그룹 전체가null이 되며, 위 코드에서?.를 쓴 이유가 이것입니다.fields[]는 개별 판독값 전체이며, 각각 자체confidence등급(high,medium,low. 정밀한 척하는 퍼센트가 아닙니다)을 가집니다. 그리스 문자로 인쇄된 이름은 그리스 문자로 한 번, 음역해서 한 번, 총 두 번 돌아오며, 그리스 문자 판독값에는 언어가 표시됩니다.mrz.status는passed,failed,absent중 하나이고,mrz.text는 원본 영역이므로 체크 디지트를 직접 다시 계산할 수 있습니다.authenticity.overall은not_checked입니다. 이것은 인식이지 위조 탐지가 아닙니다. 컴플라이언스 팀에 신원 확인 솔루션이라고 소개하지 마십시오.
흐릿한 사진은 예외가 아닙니다
가장 유용하게 기억해 둘 한 가지는 이것입니다. 읽을 수 없는 사진은 오류가 아니라 200입니다. meta.status는 정확히 다섯 가지 문자열 중 하나입니다.
status |
의미 | 대응 |
|---|---|---|
recognized |
읽기 성공 | 데이터 사용 |
no_document_found |
프레임 안에 문서 형태가 없음 | 사용자에게 다시 찍도록 요청 |
unreadable |
문서는 있지만 쓸 만한 텍스트가 없음 | 조명, 초점, 각도 개선 |
unsupported_document |
발견했지만 API가 모르는 유형 | 중단. 재시도해도 결과는 같음 |
rejected |
서비스 측에서 실패 | 한 번 재시도 |
따라서 코드는 두 번 분기합니다. 오류는 HTTP 상태로, 결과는 meta.status로 판단합니다. no_document_found를 예외로 처리하면 절대 읽히지 않을 사진을 재시도하게 됩니다. 성공으로 처리하면 필드가 하나도 없는 문서를 저장하게 됩니다.
흐릿한 사진의 비용은 누가 내는가
모든 응답에는 meta.billed가 들어 있습니다. 라이브 키에서는 문서 유형이 판별되고 데이터가 실제로 추출된 경우, 즉 체크 디지트를 통과한 MRZ, 인쇄 영역의 필드 5개 이상, 또는 올바르게 디코딩된 바코드가 있을 때 true입니다. 그 밖의 경우(문서 없음, 읽을 수 없는 이미지, 지원하지 않는 유형, 내부 오류, 타임아웃)는 비용이 들지 않습니다. 과금되는 문서의 가격은 물량과 관계없이 $0.01 균일가입니다.
샌드박스 키에서는 어느 쪽이든 아무것도 과금되지 않습니다. 공개 키 sk_sandbox_public에서는 그래도 billed가 같은 스캔이 라이브 키였다면 과금되었을지를 알려 주며, 그래서 이 키로 플래그를 테스트할 수 있습니다. 계정 전용 샌드박스 키는 보낸 이미지를 읽지 않고 모든 호출에 내장된 견본 하나로 답하므로, 거기서 billed는 그 견본에 대한 값입니다.
플래그는 호출 단위이므로 결과 옆에 함께 저장하십시오. 그러면 라이브 키에서 한 달(UTC 기준 달력 월) 동안 여러분이 직접 센 billed: true 행의 수가 GET /v1/usage가 그 달의 scans.billed로 보고하는 숫자와 같아지며, 별도의 대사 작업이 필요 없습니다.
이중 과금이 없는 재시도
위험한 재시도는 타임아웃 뒤의 재시도입니다. 첫 요청이 도달했는지 알 수 없기 때문입니다. 이 API는 POST /v1/scans에서 Idempotency-Key 헤더(1–255자)를 받습니다. 라이브 키에서 같은 키와 같은 본문으로 재시도하면, 인식을 다시 실행해 또 과금하는 대신 저장된 첫 결과(이미지 크롭 제외)를 돌려줍니다.
클라이언트 작성 방식을 바꾸는, 레퍼런스의 세 가지 세부 사항은 다음과 같습니다.
- 키는 본문 전체의 지문과 함께 대조됩니다. 키는 같은데 이미지가 다르면 조용히 재생되는 것이 아니라
409 idempotency_conflict가 됩니다. - 키는 결과가 저장되어 있는 동안 기억됩니다.
retain_hours: 0(아무것도 보관하지 않음)을 보냈더라도 키는 24시간 동안 기억됩니다. 그동안 같은 키로 재시도하면409 idempotency_replay_unavailable을 받으므로 스캔이 두 번 실행되지는 않지만 돌려줄 것이 없습니다. 24시간이 지나면 같은 키로 하는 재시도는 새 스캔입니다. 보관 0과 재생 가능한 재시도는 동시에 쓸 수 없으므로 사용 사례마다 하나를 고르십시오. - 샌드박스 키에서는 헤더를 받기는 하지만 아무 효과가 없습니다. 거기서는 아무것도 과금되지 않기 때문입니다. 그래도 해당 코드 경로를 작성하고 테스트할 수는 있습니다.
전체 클라이언트
저희라면 서비스에 넣을 버전입니다. 이미지마다 키를 하나 생성해 모든 재시도에 재사용하고, 문서가 재시도 가능하다고 명시한 오류 코드만 재시도하며, Retry-After는 최대 1분까지 따르고 그보다 오래 기다려야 하면 포기합니다. 그리고 모든 필드를 유지할 수 있도록 원본 스캔을 그대로 돌려줍니다.
// scan.mjs
import { readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
const API = "https://api.doc.cheap/v1/scans";
const KEY = process.env.DOC_CHEAP_API_KEY ?? "sk_sandbox_public";
const RETRY = new Set([
"rate_limited", "document_repeated", "internal_error", "engine_unavailable",
"service_unavailable", "maintenance", "idempotency_in_progress",
]);
export class ScanError extends Error {
constructor(status, error) {
super(`${error.code} (${status}): ${error.message}`);
this.code = error.code;
this.docsUrl = error.docs_url;
this.requestId = error.request_id;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function scan(path, { reference = null, attempts = 4 } = {}) {
const image = (await readFile(path)).toString("base64");
const body = JSON.stringify({ image, reference, options: { return_portrait: false } });
const idempotencyKey = randomUUID(); // 이 이미지에 키 하나, 모든 재시도에 재사용
for (let attempt = 1; ; attempt++) {
let response;
try {
response = await fetch(API, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
signal: AbortSignal.timeout(60_000),
});
} catch (networkError) {
if (attempt >= attempts) throw networkError;
await sleep(1000 * 2 ** attempt);
continue;
}
// 중간의 프록시가 HTML로 응답할 수 있으므로 네트워크 오류처럼 처리합니다.
const payload = await response.json().catch(() => null);
if (payload === null) {
if (attempt >= attempts) throw new Error(`HTTP ${response.status} without a JSON body`);
await sleep(1000 * 2 ** attempt);
continue;
}
if (response.ok) return payload;
const { error } = payload;
if (!RETRY.has(error.code) || attempt >= attempts) throw new ScanError(response.status, error);
// 샌드박스의 시간당 한도는 최대 한 시간을 기다리라고 합니다. 호출 하나 안에서
// 그렇게 오래 기다려 봐야 아무 도움이 안 되므로, 1분을 넘으면 오류로 처리합니다.
const retryAfter = Number(response.headers.get("Retry-After"));
const wait = retryAfter > 0 ? retryAfter : 2 ** attempt;
if (wait > 60) throw new ScanError(response.status, error);
await sleep(1000 * wait);
}
}
export function summarise({ meta, document, holder, mrz }) {
if (meta.status !== "recognized") {
return { ok: false, status: meta.status, billed: meta.billed };
}
return {
ok: true,
billed: meta.billed,
confidence: meta.confidence,
kind: document?.kind ?? null,
country: document?.country ?? null,
number: document?.number ?? null,
expiryDate: document?.expiry_date ?? null,
isExpired: document?.is_expired ?? null,
surname: holder?.surname ?? null,
givenNames: holder?.given_names ?? null,
birthDate: holder?.birth_date ?? null,
mrz: mrz.status,
mrzReason: mrz.reason,
};
}
if (import.meta.url === `file://${process.argv[1]}`) {
try {
const result = await scan(process.argv[2], { reference: "demo-1" });
console.log(summarise(result), result.meta.timing);
} catch (err) {
if (err instanceof ScanError) console.error(err.message, err.docsUrl, err.requestId);
else throw err;
process.exitCode = 1;
}
}
node scan.mjs specimen.jpg로 실행하십시오. 아래는 2026년 9월 24일 공개 샌드박스 키로 생성된 테스트 여권을 처리했을 때의 출력입니다. 문서에서 읽은 값은 …로 바꿨고, 나머지는 출력된 그대로입니다.
{
ok: true,
billed: true,
confidence: 'medium',
kind: 'passport',
country: '…',
number: '…',
expiryDate: '…',
isExpired: false,
surname: '…',
givenNames: '…',
birthDate: '…',
mrz: 'passed',
mrzReason: null
} { upload_ms: 271, processing_ms: 410, total_ms: 691 }
샌드박스 키에서 billed: true는 과금이 아닙니다. 이 스캔이 라이브 키였다면 크레딧 1개가 들었을 것이라는 뜻입니다.
설명이 필요한 몇 가지 선택은 다음과 같습니다.
fetch는 4xx나 5xx에서 예외를 던지지 않습니다. 네트워크 장애에서만 예외를 던지므로, 클라이언트는response.ok를 확인하고 어느 경우든 JSON 오류 본문을 읽습니다.- HTTP 상태가 아니라
error.code로 분기하십시오.POST /v1/scans에서는 멱등성 관련 코드 세 개가409를 공유하고, 서로 다른 장애 세 가지가503을 공유하며, 각각 처리 방식이 다릅니다. 모든 오류 본문은code,message,docs_url,request_id,event_id라는 같은 형태를 가집니다.request_id를 로그에 남기십시오. 지원팀이 필요로 하는 값입니다. 전체 표는 오류 처리에 있습니다. - 모든 것이 재시도 대상은 아닙니다.
validation_failed,payload_too_large,unauthorized,insufficient_credits는 반복이 아니라 수정이 필요합니다. 샌드박스에서는registration_required(무료 한도 소진. 기다려도 다시 채워지지 않음)와document_repeated(같은 이미지를 한 시간 안에 너무 자주 보냄)도 만나게 됩니다. - 요청 본문을 로그에 남기지 마십시오. 신분증이 들어 있습니다.
reference는meta.reference(최대 128자)로 그대로 돌아오므로, 스캔을 여러분의 주문이나 사용자 기록과 연결하는 가장 쉬운 방법입니다. 신분증의 앞뒷면은 두 번의 호출이니 같은reference를 붙이십시오.
데이터를 덜 보관하기
업로드한 이미지는 요청 동안 메모리에만 있고 영구 저장소에는 절대 기록되지 않습니다. 결과는 다릅니다. GET /v1/scans/{id}로 다시 읽을 수 있도록, 여러분이 고른 기간 동안 보관됩니다. 계정 설정에서 24시간, 7일, 30일, 1년 중에서 고를 수 있으며, 새 계정의 기본값은 1년입니다. 요청별로는 options.retain_hours에 0부터 8760까지 지정할 수 있고, 0이면 행을 전혀 쓰지 않습니다. JSON이 한 번만 필요하다면 retain_hours: 0을 보내고, 위에서 설명한 멱등성과의 맞교환을 감수하십시오. 처리는 EU에서 이루어집니다.
위 클라이언트에서 사용한 return_portrait: false는 소지자 사진의 크롭인 images.main_photo를 빼 줍니다. 여러분의 로그와 저장소에서 조심스럽게 다뤄야 할 것이 하나 줄어듭니다. 페이지 전체의 크롭은 여전히 돌아옵니다.
라이브 전환
DOC_CHEAP_API_KEY로 sk_sandbox_public 대신 여러분의 키를 넣으면 그 밖에는 바뀌는 것이 없습니다. 엔드포인트도, 응답 형태도 같습니다. 가입한 키는 분당 60건의 요청을 허용합니다. 크레딧은 암호화폐(BTC, ETH, TRX, 또는 Ethereum이나 Tron의 USDT)로 구매하며 최소 금액은 $1입니다. 현재 카드 결제는 없으므로, 재무팀 대상 데모를 계획하기 전에 알아 두시는 것이 좋습니다. 요금에는 단일 가격과 성공한 경우에만 과금하는 규칙이 있고, 무료 여권 OCR API 페이지에는 가입 없이 하는 첫 호출이 curl 한 줄로 나와 있습니다.
사용해 보시고 Node에서 응답 형태가 다루기 불편한 부분이 있다면 admin@doc.cheap으로 알려 주십시오. 저희가 바라는 것이 바로 그런 피드백입니다.
위 클라이언트는 실제 샌드박스에 대해 실행했으며 출력은 인쇄된 그대로 붙여 넣었습니다. doc.cheap에 관한 모든 설명은 코드와 대조해 확인했습니다.