여권 사진이 한 장 있고, .NET 8 서비스에서 이름, 여권 번호, 날짜, 기계 판독 영역(MRZ)을 데이터로 받아야 한다고 해 봅시다. 이 글에서는 그 클라이언트를 순수 C# 12로 만듭니다. HttpClient, System.Net.Http.Json, System.Text.Json만 쓰고 SDK는 쓰지 않습니다. 첫 호출, 응답을 담는 레코드, 오류가 아닌 상태 값, 절대 두 번 과금되지 않는 재시도, 코드 기준의 오류 처리, 그리고 ASP.NET Core용 타입 클라이언트까지 다룹니다.

사용하는 API는 doc.cheap의 여권·신분증 OCR API입니다. 동기 방식이라 POST 한 번에 응답 한 번이 돌아오고, 폴링할 작업 ID는 없습니다.

계정 없이 첫 호출

공개 sandbox 키는 sk_sandbox_public입니다. 가입이 필요 없고 크레딧도 차감되지 않습니다. IP 주소당 시간당 10건, 그리고 전체 기간 동안 소수의 무료 시도로 제한되며, 이를 넘으면 403 registration_required가 돌아옵니다.

요청 본문은 JSON이고 필수 필드는 image 하나입니다. 파일 바이트를 base64로 인코딩해 넣습니다. JPEG와 PNG만 받으며, 그 밖의 형식은 415 unsupported_media_type으로 거부됩니다. 판정은 파일 이름이 아니라 파일 시그니처로 합니다.

using System.Net.Http.Headers;
using System.Net.Http.Json;

var http = new HttpClient { BaseAddress = new Uri("https://api.doc.cheap/v1/") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "sk_sandbox_public");

var image = Convert.ToBase64String(await File.ReadAllBytesAsync("passport.jpg"));
using var response = await http.PostAsJsonAsync("scans", new { image });

Console.WriteLine((int)response.StatusCode);
Console.WriteLine(await response.Content.ReadAsStringAsync());

요청 스키마는 엄격합니다. 본문에 알 수 없는 키가 있으면 422 validation_failed로 거부되므로, 옵션 이름의 오타는 조용히 무시되지 않고 분명하게 실패합니다.

무엇이 돌아오는가

모든 응답은 같은 8개 그룹을 가지며, 모든 키가 항상 존재합니다. meta, document, holder, fields, mrz, images, quality, authenticity입니다. 일부를 생략하고 값을 가린 예시는 다음과 같습니다.

{
  "meta": {
    "schema_version": "1.0",
    "id": "<SCAN_ID>",
    "status": "recognized",
    "billed": true,
    "confidence": "high",
    "created_at": "<ISO_TIMESTAMP>",
    "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>" }
}

아무것도 읽지 못했을 때 document와 holder는 null입니다. mrz.status는 passed, failed, absent 중 하나이며, passed는 체크 디지트가 일치한다는 뜻입니다.

응답을 담는 레코드

JSON은 snake_case입니다. .NET 8에는 JsonNamingPolicy.SnakeCaseLower가 들어 있으므로 레코드는 평범한 C# 이름을 그대로 쓸 수 있습니다. 알 수 없는 속성은 기본적으로 무시되니, 실제로 쓰는 것만 선언하면 됩니다.

using System.Text.Json;

public static class Api
{
    public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
    {
        PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    };
}

public sealed record Scan(Meta Meta, DocumentInfo? Document, Holder? Holder, Mrz Mrz);

public sealed record Meta(
    string Id, string Status, bool Billed, string Confidence,
    string CreatedAt, string? Reference);

public sealed record DocumentInfo(
    string? Kind, string? Country, string? Number,
    string? IssueDate, string? ExpiryDate, bool? IsExpired, int? DaysRemaining);

public sealed record Holder(
    string? GivenNames, string? Surname, string? BirthDate,
    string? Sex, string? Nationality);

public sealed record Mrz(string Status, string? Reason, IReadOnlyList<string>? Lines, string? Text);

public sealed record ApiError(string Code, string Message, string? DocsUrl, string? RequestId);
public sealed record ErrorEnvelope(ApiError Error);

날짜는 일부러 문자열로 둡니다. 필요한 곳에서 DateOnly.TryParse로 파싱하면, 문서에 이상한 값이 하나 있어도 역직렬화 과정에서 예외가 나지 않습니다.

흐릿한 사진은 예외가 아니다

200이 왔다고 해서 항상 문서를 읽었다는 뜻은 아닙니다. meta.status로 분기하세요.

meta.status 의미 대응
recognized 문서 유형이 확인되었고 데이터를 추출함 document, holder, mrz 사용
no_document_found 화면에 문서처럼 보이는 것이 없음 새 사진 요청
unreadable 문서는 있었지만 쓸 만한 텍스트를 읽지 못함 더 선명한 사진 요청
unsupported_document 문서는 찾았지만 알려진 유형이 아님 사용자에게 알림
rejected 서비스 쪽에서 인식에 실패함 meta.id를 기록하고 나중에 다시 시도

누가 내는가: billed 플래그

sandbox 키는 크레딧을 전혀 차감하지 않습니다. 라이브 키에서는 과금 대상인 스캔만 과금됩니다. 문서 유형이 확인되고, 다음 중 하나 이상을 읽었을 때입니다. 체크 디지트가 맞는 MRZ, 인쇄 영역의 필드 5개 이상, 또는 디코딩된 바코드. 그렇지 않으면 예약된 크레딧은 해제됩니다. sandbox 키에서도 meta.billed는 스캔이 이 조건을 충족했는지 알려 주지만, 잔액은 전혀 건드리지 않습니다.

어느 쪽이었는지는 meta.billed가 알려 줍니다. 이 값을 여러분의 주문이나 사용자 기록 옆에 저장해 두면 과금 내역을 추측으로 대조할 일이 없습니다. 라이브 계정에는 매달 100건의 무료 인식도 제공됩니다.

두 번 과금될 수 없는 재시도

여러분 쪽에서 타임아웃이 났다고 해서 저희 쪽에서 스캔이 실행되지 않았다는 뜻은 아닙니다. Idempotency-Key 헤더(1~255자)를 보내고, 같은 문서를 재시도할 때마다 같은 값을 재사용하세요. 라이브 키에서 서버는 키와 본문의 지문을 함께 비교합니다.

  • 같은 키, 같은 본문: 첫 번째 결과가 그대로 재전송되고, 두 번째 크레딧은 차감되지 않습니다.
  • 같은 키, 다른 본문: 409 idempotency_conflict. 새 키를 쓰세요.
  • 첫 호출이 아직 처리 중: 두 번째 호출은 잠시 기다린 뒤 409 idempotency_in_progress를 받습니다. 키를 유지한 채 재시도하세요.
  • 첫 결과가 더 이상 저장되어 있지 않음: 409 idempotency_replay_unavailable. 새 키를 쓰세요.

크레딧이 예약된 뒤 인식 엔진이 실패하면 예약은 해제되고, 키도 정상적인 재시도를 위해 비워집니다. 자세한 내용은 멱등성으로 안전하게 재시도하기 가이드에 있습니다.

클라이언트 전체

곁에 두고 쓸 버전입니다. 재시도할 가치가 있는 코드만 재시도하고, Retry-After는 60초까지 따르며, 모든 시도에서 키 하나를 유지하고, 그 밖의 오류는 모두 코드를 담은 예외로 바꿉니다.

using System.Net.Http.Json;

public sealed class ScanFailed(int status, ApiError error)
    : Exception($"{status} {error.Code}: {error.Message}")
{
    public int Status { get; } = status;
    public ApiError Error { get; } = error;
}

public sealed class PassportClient(HttpClient http)
{
    static readonly HashSet<string> Retryable =
    [
        "rate_limited", "document_repeated", "internal_error", "engine_unavailable",
        "service_unavailable", "maintenance", "idempotency_in_progress",
    ];

    public async Task<Scan> ReadAsync(
        byte[] imageBytes, string idempotencyKey, string? reference = null,
        CancellationToken ct = default)
    {
        var body = new
        {
            image = Convert.ToBase64String(imageBytes),
            reference,
            options = new { return_portrait = false },
        };

        for (var attempt = 0; ; attempt++)
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, "scans")
            {
                Content = JsonContent.Create(body),
            };
            request.Headers.Add("Idempotency-Key", idempotencyKey);

            using var response = await http.SendAsync(request, ct);
            if (response.IsSuccessStatusCode)
                return (await response.Content.ReadFromJsonAsync<Scan>(Api.Json, ct))!;

            var envelope = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(Api.Json, ct);
            var error = envelope?.Error
                ?? new ApiError("unknown", response.ReasonPhrase ?? "", null, null);

            if (!Retryable.Contains(error.Code) || attempt >= 3)
                throw new ScanFailed((int)response.StatusCode, error);

            var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1 << attempt);
            await Task.Delay(wait > TimeSpan.FromSeconds(60) ? TimeSpan.FromSeconds(60) : wait, ct);
        }
    }
}

키는 메서드 안의 Guid.NewGuid()가 아니라 업로드 ID나 주문 ID처럼 여러분 시스템의 값에서 가져와야 합니다. 여러분의 데이터에서 나온 키는 프로세스가 재시작되어도 살아남지만, 새로 만든 GUID는 그렇지 않습니다.

모든 오류는 같은 형태로 code, message, docs_url, request_id를 가집니다. 분기는 message가 아니라 항상 code로 하고, 지원팀에 문의할 때를 위해 request_id를 로그에 남기세요. 전체 목록은 오류 처리 가이드에 있습니다. 초기에 자주 만나는 세 가지는 다음과 같습니다.

  • 402 insufficient_credits – 라이브 잔액이 바닥났습니다. 재시도 불가.
  • 413 payload_too_large – 본문이 한도를 넘었습니다. 사진 크기를 줄이세요.
  • 401 unauthorized – 키가 없거나 받아들여지지 않습니다. 재시도 불가.

IHttpClientFactory로 만드는 타입 클라이언트

요청마다 새 HttpClient를 만들지 마세요. ASP.NET Core에서는 클라이언트를 한 번만 등록하고 핸들러 관리는 팩토리에 맡깁니다.

builder.Services.AddHttpClient<PassportClient>(http =>
{
    http.BaseAddress = new Uri("https://api.doc.cheap/v1/");
    http.Timeout = TimeSpan.FromSeconds(60);
    http.DefaultRequestHeaders.Authorization = new("Bearer",
        builder.Configuration["DocCheap:ApiKey"]);
});

키는 구성이나 시크릿 저장소에 두고, 소스 코드에는 절대 넣지 마세요. 라이브 키는 sk_live_로, sandbox 키는 sk_sandbox_로 시작합니다. Microsoft.Extensions.Http.Resilience의 복원력 핸들러를 추가한다면 재시도는 한 계층에서만 하세요. 두 계층이 모두 재시도하면 시도 횟수가 곱으로 늘어납니다.

데이터를 덜 보관하기

호출이 끝난 뒤 이미지를 직접 저장하지 마세요. 결과에서 필요한 것만 읽고 바이트는 버리면 됩니다. 위 클라이언트는 이미 return_portrait: false를 보내므로, 얼굴 사진 크롭은 응답에 포함되지 않습니다.

라이브 키에서는 결과가 계정의 이력 보관 기간(기본 1년) 동안 저장됩니다. 특정 호출에 대해 아무것도 남기지 않으려면 options에 retain_hours: 0을 보내세요. 행이 기록되지 않고 썸네일도 만들어지지 않습니다. 대신 그 호출의 재시도는 결과를 재전송할 수 없고 idempotency_replay_unavailable이 돌아옵니다. 둘 다 원한다면 0보다 큰 짧은 기간을 고르세요. 모든 옵션은 여권 인식 가이드에서 다룹니다.

라이브로 전환하기

sk_sandbox_public을 여러분의 sk_live_ 키로 바꾸기만 하면 나머지는 그대로입니다. 등록된 키는 분당 60건으로 제한됩니다. 아직 서비스를 고르는 중이라면 여권 OCR API 비교에서 유료 대안과 각 서비스가 공개한 가격을 볼 수 있습니다.

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