
AI OCR의 출력을 그대로 믿어서는 안 된다 — 「확인 필요 UI」를 만들기 위한 설계
요약
AI OCR 서비스 개발 시 Gemini API를 활용하여 시리얼 번호를 추출하는 파이프라인 설계 방식을 다룹니다. AI의 불확실한 출력을 그대로 노출하지 않고, 확신도(confidence) 정보를 포함한 구조화된 데이터를 UI에 반영하여 사용자의 검증을 유도하는 설계 전략을 제안합니다.
핵심 포인트
- AI OCR 출력은 반드시 '의심'하는 공정을 거쳐 UI로 구현해야 함
- 단순 문자열 배열 대신 확신도와 누락 정보를 포함한 구조적 인터페이스 설계 필요
- Gemini의 구조화된 출력(Structured Output)을 활용하여 데이터 정밀도 향상
- 문자 단위의 불안정성(uncertain_chars)을 UI에 전달하여 사용자 검증 유도
서론
개인 개발로, 이미지에서 시리얼 번호를 읽어들이는 Web 도구인 「시리토루(シリトル)」를 제작하여 정식 출시했습니다. CD 등에 동봉된 영숫자 코드를 스마트폰으로 촬영한 이미지로부터 AI를 통해 한꺼번에 읽어들이는 도구입니다.

이미지를 업로드하면 AI가 읽어들인다. 이미지는 Gemini API로 처리하며, 읽어들인 후 파기한다
이 기사는 그 핵심인 AI OCR 파이프라인 설계 (AI OCR pipeline design) 에 대해 쓰고자 합니다. 구체적으로는 「멀티모달 AI (Multimodal AI)에게 OCR을 시켰을 때, 그 출력을 어떻게 UI로 구현할 것인가」에 대한 이야기입니다.
결론부터 말씀드리면, AI의 판독 결과는 사용자에게 보여주기 전에 반드시 「의심하는」 공정을 거쳐야 한다는 것이 이 기사의 주장입니다. LLM/VLM에 의한 OCR은 정밀도가 높은 반면, 확신도가 낮은 출력을 높은 확신도의 출력과 외관상 구분 없이 반환합니다. 가공되지 않은 출력을 그대로 「읽었습니다!"라고 표시하면, 사용자는 잘못된 값을 의심 없이 복사하게 됩니다. 이는 후속 용도에 따라 실질적인 피해를 줄 수 있습니다.
시리토루의 경우, 읽어들인 값의 용도가 「계정이 잠길 수 있는 외부 서비스로의 입력」이었기 때문에, 오류를 사용자에게 인지시키는 메커니즘이 기능 요건으로서 빠질 수 없었습니다. 마찬가지로 「AI가 읽어들인 결과를 인간이 사용하는」 프로덕트를 만드는 분들에게 참고가 되었으면 합니다.
전제: 기술 스택
- Next.js 16 (App Router) + React 19 + TypeScript (strict)
- OCR: Google Gemini 2.5 Flash
- 인증/DB/결제는 Clerk / Supabase / Stripe (이 기사에서는 다루지 않습니다)
OCR 프로바이더는 Gemini로 결정되었지만, 후술하듯이 처음부터 그랬던 것은 아닙니다.
설계의 출발점: 「읽었다/읽지 못했다」는 이진법이 아니다
OCR을 소박하게 구현하면 다음과 같은 인터페이스가 되기 쉽습니다.
interface OcrProvider {
read(image: Blob): Promise<string[]>; // 읽어들인 문자열의 배열
}
이렇게 하면 「읽어낸 문자열」만 반환됩니다. 하지만 실제 OCR에서는 다음과 같은 상황이 일상적으로 발생합니다.
0(제로)와O(오) 중 어느 것인지 확신이 서지 않음I(아이)와1(일),B와8이 헷갈림- 이미지에 코드가 4개 찍혀 있어야 하는데 3개밖에 읽지 못함
- 애초에 자릿수가 예상과 맞지 않음
이것들을 「읽어낸 문자열의 배열」로 뭉뚱그려 버리면, 확신도 (confidence) 정보가 사라집니다. 사용자 입장에서는 확신을 가지고 읽은 값도, AI가 반쯤 추측으로 내놓은 값도 똑같은 모습으로 나열되게 됩니다.
따라서 확신도가 사라지지 않도록, 인터페이스 단계에서 구조에 포함시킵니다.
interface OcrProvider {
read(input: ImageInput, expectedLength: number): Promise<OcrResult>;
}
...
포인트는 두 가지입니다.
- 「검출은 했으나 다 읽지 못했다」를 표현할 수 있도록 한다. 읽기 누락 검출에 사용한다.
detected_count를serials.length와 별도로 가진다. - 「이 값의 3번째 글자가 수상하다」라는 수준의 정보를 UI에 전달할 수 있다.
uncertain_chars를 통해 문자 단위의 불안정성을 반환하게 한다.
구조화된 출력을 사용하고, 자유 문장을 파싱하지 않는다
위의 OcrResult를 Gemini의 구조화된 출력 (structured output) 으로 받습니다. responseJsonSchema에 JSON Schema를 전달하면, 모델이 해당 스키마에 따른 JSON을 반환해 줍니다 (함께 responseMimeType: "application/json" 지정도 필요합니다).
const SERIAL_JSON_SCHEMA = {
type: "object",
properties: {
...
이것은 사소해 보이지만 효과적입니다. 깨지기 쉬운 자유 문장 파싱 (정규 표현식을 이용한 포맷 해석)이 필요 없어지기 때문입니다. 「코드: ABC123과 같이 출력해줘"라고 요청하고 반환값을 정규 표현식으로 파싱하는 접근 방식은, 모델이 조금이라도 포맷을 무너뜨리면 깨집니다. 스키마로 제약을 걸면 그러한 종류의 파싱 처리가 불필요해집니다.
하지만 「구조화된 출력 (Structured Output)이니까 안전하다」라고 전적으로 믿는 것은 금물입니다. 실제로는 JSON.parse 이후, 필드 누락 · 타입 불일치 · 개수 불일치에 대비한 방어적인 정규화 (Normalization) 단계를 한 층 거치고 있습니다 (후술할 가드레일 (Guardrail)과 같은 사상입니다). 예를 들어, 검출 수는 배열의 실제 개수보다 작아지지 않도록 max(모델 신고 값, 실제 개수)로 보정합니다. 스키마는 「형식」을 보장해 주지만, 「내용이 타당한가」까지는 보장하지 않습니다.
프롬프트 측에서는 스키마와 일치하는 형태로 동작하도록 지시합니다. 요점만 꼽자면 다음과 같습니다:
- 라벨 근처에 있는 영숫자를 추출할 것
- 하나의 값은 정확히 N글자 (후술할 자릿수 지정)
- 읽지 못한 코드도 (읽기 누락을 검출하기 위해)
detected_count에 포함할 것 - 망설여지는 문자의 위치를
uncertain_chars에 넣을 것 - 읽은 문자를 「그럴싸하게」 보정하지 말 것 (후술)
핵심: 「수상함」을 판정하는 가드레일
이 부분이 이 기사에서 가장 전달하고 싶은 부분입니다. AI의 출력을 받은 후, 코드 측에서 「이 결과는 수상한가?」를 기계적으로 판정합니다. AI의 자기 신고 (Confidence)에만 의존하지 않습니다.
판정은 2단계로 나눕니다.
하드 판정 (Hard Judgment): 구조적으로 확실한 이상
이는 「AI가 뭐라고 말하든, 구조적으로 이상한」 케이스입니다.
function hardSuspicious(result: OcrResult, expectedLength: number): boolean {
const { serials, detected_count } = result;
// 아무것도 읽지 못함
...
detected_count > serials.length 비교가 detected_count를 별도로 두게 된 이유입니다. 「4개가 있어야 하는데 배열에는 3개밖에 들어있지 않다」를 검출할 수 있습니다.
자릿수 체크에 사용되는 isValidSerial은 ^[A-Z0-9]{N}$로 문자 종류와 자릿수를 동시에 판정합니다. 따라서 「소문자 혼입」과 같은 형식 외의 경우도 자릿수 차이와 마찬가지로 「구조적 이상」으로서 여기서 걸러집니다. 이는 후술할 『함부로 수정하지 않는다』와 일체형입니다 (수정하지 않기 때문에, 형식 외의 상태가 시그널로서 남게 됩니다).
소프트 판정 (Soft Judgment): AI의 자기 신고도 고려
하드 판정에 걸리지 않더라도, AI가 자신 없어 한다면 확인 필요 상태로 넘깁니다.
function isSuspicious(result: OcrResult, expectedLength: number): boolean {
if (hardSuspicious(result, expectedLength)) return true;
// confidence가 low이거나, 불안한 문자가 있는 경우
...
참고로 confidence는 high / medium / low의 3단계이며, confidence 수준만을 트리거로 하여 확인 필요 (△)로 넘기는 것은 low일 때뿐입니다 (medium 단독으로는 〇 상태 유지). 단, confidence와 별개로 망설여지는 문자 (uncertain_chars)가 있다면 그 시점에서 △가 됩니다. 즉, 「medium이라도 수상한 문자가 있으면 △」입니다. 「신뢰도를 갖게 하는 것」뿐만 아니라 「어느 수준부터, 무엇을 근거로 의심할 것인가」의 선을 긋는 것 또한 훌륭한 설계 판단입니다. 전부를 의심하면 사용자의 확인 부담이 늘어나고, 너무 의심하지 않으면 오류가 누락됩니다. 그 중간에 임계값 (Threshold)을 두고 있습니다.
이 판정 결과 (isSuspicious)를 통해, 이미지 1장에 대해 needsReview (확인 필요) 플래그를 세웁니다. 이는 서버 측 · 결과 단위의 판정입니다.
한편, UI에서 값마다 〇 / △ / ×를 나누어 보여주는 것은 클라이언트 측의 또 다른 함수로, 시리얼 1건마다 상태를 산출합니다.
type SerialStatus = "ok" | "warn" | "bad" | "none";
function serialStatus(serial: Serial, length: number): SerialStatus {
const v = serial.value.trim();
...
참고로 서버 측 스키마에서는 uncertain_chars (스네이크 케이스)였으나, 클라이언트에서 받을 때 도메인 모델로 변환하여 uncertainChars
(카멜 케이스 (camelCase))로 취급하고 있습니다. 동일한 데이터이지만, 경계를 넘을 때 명명 규칙을 맞추고 있습니다.
즉, 가드레일은 2층 구조입니다. 서버는 "이 이미지는 확인이 필요한가"를 결과 단위(needsReview)로 판단하고, UI는 "이 1건은 ○인가 △인가 ×인가"를 행 단위(serialStatus)로 표시합니다. 동일한 재료(자릿수, confidence, uncertain_chars)를 사용하지만, 책임이 다릅니다. AI 추출 결과가 형식을 벗어나면 ×, 수동 입력 중이라면 △와 같이 소스별로 구분하여 출력하는 것도 여기서 수행합니다.
사용자는 △와 ×만 중점적으로 확인하면 됩니다. 전부를 똑같이 의심하는 것이 아니라, 의심해야 할 곳으로 시선을 유도하는 것. 이것이 「확인 필요 UI」의 본질입니다.

실제 출력 화면. △는 확신도가 낮거나 의심스러운 문자가 있음, ×는 형식 외(자릿수·문자 종류가 어긋남)
위 화면이 지금까지 설명해 온 판정의 실제 모습입니다. YLWAXWJ7FENODG의 W처럼, AI가 확신을 갖지 못한 문자가 강조되어 해당 행에는 △가 붙어 있습니다. 반면, 자릿수가 예상과 맞지 않는 값에는 ×가 붙으며 "문자 수가 일치하지 않아 판독 실패 가능성이 있습니다"라고 명시됩니다. serialStatus의 출력이 그대로 UI에 나타난 것입니다.
「멋대로 수정하지 않는다」는 판단
가드레일 설계에서 의도적으로 하지 않은 것이 있습니다. 판독된 값을 코드 측에서 정규화(대문자화 등)하여 "수정하지 않는" 것입니다.
예를 들어, 시리얼 코드가 "영문 대문자와 숫자만"으로 구성된 형식이라고 가정해 봅시다. AI가 소문자가 섞인 값을 반환했을 때, toUpperCase()로 수정하고 싶어질 것입니다. 하지만 이는 하지 않습니다.
이유는, "형식에서 벗어나 있다"는 사실 자체가 오독의 시그널이기 때문입니다. 소문자가 섞였다는 것은 해당 문자의 판독이 불안정했을 가능성이 높다는 뜻입니다. 그것을 말없이 대문자로 바꾸면, 잘못된 값을 확신이 있는 것처럼 확정 지어 버리게 됩니다. 따라서 형식 외의 값은 수정하지 않고 그대로 확인 필요(△) 상태로 넘깁니다.
"AI의 출력을 너무 믿지 않는다"와 동시에 "AI의 출력을 멋대로 바꾸지도 않는다". 판단은 사용자에게 맡기고, 판단 재료(의심스러운 부분)만을 정확하게 제시한다는 스탠스입니다.
공백 제거와 인덱스 보정
기계적으로 수행하는 정규화가 딱 하나 있습니다. 판독 값에서 공백을 전부 제거하는 것입니다.
AI는 가끔 코드 중간에 공백을 넣어 반환할 때가 있습니다 (예: ABC 123). 이를 방치하면 자릿수 판정이 어긋납니다 (공백을 포함해 7글자로 카운트됨). 그래서 공백은 제거합니다.
단, 단순히 지우기만 하면 uncertain_chars의 인덱스가 어긋납니다. "제거 전 5번째 문자가 의심스럽다"는 정보는 제거 후에는 4번째 문자를 가리키게 될 수 있습니다. 그래서 공백을 제거하면서 uncertain_chars의 위치를 제거 후의 위치로 올림 보정합니다. 문자열을 1패스(1-pass)로 스캔하며, 공백이 아닌 문자만 새로운 값에 쌓아 올리되, 의심스러운 문자라면 쌓은 후의 위치를 기록합니다.
function stripWhitespace(
raw: string,
uncertain: number[],
...
flagged를 Set으로 만든 이유는 의심스러운 문자인지 여부의 판정을 $O(1)$로 만들어 전체를 1패스($O(n)$)로 끝내기 위해서입니다. 단순하게 "제거 후에 원래 인덱스를 찾는다"라고 구현하면 매번 검색할 때마다 $O(n^2)$이 됩니다. 문자열이 짧아서 실질적인 해악은 작지만, 이런 부분을 정직하게 작성해 두면 나중에 다시 읽었을 때 헷갈리지 않습니다.
이러한 "문자 위치의 메타 정보를 가진 채로 문자열을 가공하는" 처리는 대충 하면 메타 정보가 어긋나서, 공들여 만든 의심 판정이 무의미해집니다. 이 부분을 얼마나 정성스럽게 처리하느냐에 따라 UI의 신뢰성이 달라집니다.
참고로 이 정규화에서 제거하는 것은 공백뿐이며, 대소문자 변환은 하지 않습니다 (앞서 언급한 '멋대로 수정하지 않는다' 원칙에 따라). 공백은 명백한 노이즈이고, 케이스(casing)는 AI의 판독 결과 그 자체라는 선긋기입니다. "지워도 되는 노이즈"와 "건드려서는 안 되는 판독 결과"를 구분하고 있습니다.
자릿수는 고정하지 않고 가변적으로
시리얼 코드의 자릿수는 용도에 따라 달라집니다. 싱글 디스크와 그룹용의 자릿수가 다른 경우가 있습니다. 따라서 자릿수를 하드코딩하지 않고, 범위를 지정하여 사용자가 설정할 수 있도록 했습니다.
function clampSerialLength(input: number): number {
const DEFAULT = 14;
const MIN = 8;
...
이 expectedLength가 앞서 언급한 자릿수 체크(hardSuspicious)의 기준이 됩니다. 사용자가 자릿수를 지정한다 → AI에게도 프롬프트로 "정확히 N글자"라고 전달한다 → 돌아온 값의 자릿수를 검증한다라는 일관된 흐름이 됩니다.
또 다른 경계: 이미지를 AI에게 전달하기 전에 "세척하기"
여기까지는 "AI가 반환한 문자열을 어떻게 다룰 것인가"에 대한 이야기였지만, 그 전 단계에도 설계상 빼놓을 수 없는 또 하나의 경계가 있습니다. 바로 사용자의 이미지를 그대로 제3자 AI(Gemini)에게 보내지 않는다는 경계입니다. 이곳은 비용, 프라이버시, 견고성이 교차하는 지점입니다.
두 단계로 구성했습니다.
① 클라이언트에서 축소한다. 업로드 전에 브라우저의 Canvas를 사용하여 장변 1600px, JPEG 품질 0.85로 압축합니다 (확대는 하지 않고 축소만 수행). 목적은 두 가지로, 제3자 전송 시의 토큰 비용과 전송량을 억제하는 것, 그리고 서버의 수신 바디(body) 상한 내에 맞추는 것입니다.
async function compressImageFile(file: File): Promise<string> {
const url = URL.createObjectURL(file);
try {
...
② 서버에서 "다시 세척한다". 여기가 신뢰 경계(Trust Boundary)입니다. 클라이언트의 압축은 신뢰하지 않습니다. 브라우저 이외의 경로(확장 프로그램이나 API 직접 호출)에서는 이 압축을 거치지 않기 때문입니다. 서버 측에서 sharp를 사용하여 정말 이미지인지(비이미지는 디코딩 단계에서 실패 처리), 상식적인 해상도인지(거대 이미지로 인한 메모리 고갈 = decompression bomb을 상한값으로 거부)를 검증하고, 그리고 재인코딩하여 EXIF/GPS를 포함한 모든 메타데이터를 파기합니다.
// 검증 + EXIF 제거. 추출 처리의 입구에서 반드시 통과시켜야 함
const out = await sharp(buf, { limitInputPixels: MAX_PIXELS, failOn: "error" })
.rotate() // EXIF의 방향 정보를 픽셀에 입힌 후...
...
포인트는 이 검증을 추출 처리의 입구에서 반드시 통과시키는 것입니다. 이렇게 하면 "이후 OCR 프로바이더(Provider)에는 검증된 이미지만 전달된다"는 점을 보장할 수 있습니다. 경계를 한 곳으로 집약하면, 후속 코드들은 이미지의 출처를 신경 쓰지 않아도 됩니다. EXIF/GPS를 제거하는 이유는 팬이 찍은 사진에 섞여 있을 수 있는 위치 정보를 우리가 보관하지 않고, 제3자에게도 넘기지 않기 위해서입니다.
참고로 초기 사양에서는 "여러 장을 Canvas로 한 장에 합성하여 1회 요청으로 보낸다(비용 절감)"는 안도 검토했습니다. 하지만 최종적으로는 1장 = 1회 요청으로 결정했습니다. 이유는 정확도(한 장에 집중하는 것이 읽기 쉬움), 과금 카운트의 명확성(1장 = 1카운트), 구현의 단순함 때문입니다. 여기서도 비용 최적화보다 설계의 가시성을 우선시했습니다.
옆길로 새기: OCR 모델 선정에서 "덧셈"보다 "뺄셈"을 한 이야기
지금까지 OcrProvider라는 추상 인터페이스를 당연하게 사용해 왔지만, 이 추상을 도입한 데에는 경위가 있습니다.
첫 번째 사양에서는 OCR을 Claude의 vision 고정으로 구현할 계획이었습니다. 그다음으로 생각한 것이, Gemini를 주로 사용하고 결과가 의심스러울 때만 Claude로도 읽어서 대조하는 구성입니다. 두 회사의 모델로 읽어서 일치하면 신뢰도를 높이는, 이른바 합의(consensus) 방식입니다. "두 개의 모델로 읽으면 정확도가 올라갈 것"이라는 발상입니다.
그런데 정확도 평가를 해보니, 이 합의 구성은 수지타산이 맞지 않았습니다. Gemini 2.5 Flash 단독으로도 충분한 정확도가 나오고 있었고, 두 회사를 대조하기 위해 늘어나는 레이턴시(Latency), 비용, 구현의 복잡성에 상응하는 리턴을 실측 데이터로는 확인할 수 없었기 때문입니다.
최종적으로는 Gemini 2.5 Flash 전용으로 압축하고, Claude 프로바이더나 "같은 이미지를 두 번 읽어 일치 여부를 확인하는 하이브리드 재독입" 방식도 폐지했습니다. "좋아 보이는 메커니즘"을 실측을 근거로 깎아낸 것입니다.
다만, OcrProvider라는 추상만큼은 남겨두었습니다. GEMINI_MODEL을 환경 변수로 교체하면 모델 업데이트나 A/B 테스트에 그대로 대응할 수 있습니다. **"구현은 한 곳으로 한정하되, 교체 가능한 구조는 유지한다"**는 균형입니다.
교훈으로서, 폴백 (Fallback)이나 컨센서스 (Consensus)는 직관적으로 "정확도가 올라갈 것" 같지만, 실측 결과 필요 없다면 삭제하는 용기를 갖는 것입니다. 복잡함은 공짜가 아니므로, 효과를 측정할 수 없는 것은 부채가 됩니다.
동일한 "실측으로 판단한다"는 정신으로, Gemini 2.5 Flash의 사고 (Thinking) 토큰은 기본적으로 꺼두었습니다. 사고 과정은 출력 토큰으로서 과금되지만, 이러한 종류의 구조화된 추출 태스크에서는 긴 사고 과정이 반드시 정확도에 기여하는 것은 아닙니다. 따라서 환경 변수(GEMINI_THINKING_BUDGET, 0으로 끄기, -1로 동적 사고)를 통해 전환할 수 있도록 하되, 기본값은 꺼둠으로 설정했습니다. 단, 순서가 중요합니다. "비용이 걱정되니까"라며 먼저 끄는 것이 아니라, 실측을 통해 정확도가 떨어지지 않음을 확인한 후에 끕니다.
요약: AI OCR을 "신뢰할 수 있는 UI"로 만드는 패턴
이 기사에서 작성한 설계를 일반화하면 다음과 같은 패턴이 됩니다.
- 인터페이스를 신뢰도(Confidence)가 포함된 구조로 만든다.
string[]가 아니라, value + confidence + uncertain_chars + detected_count 형태를 사용합니다. - 구조화된 출력 (Structured Output)으로 스키마를 강제한다. 깨지기 쉬운 자유 문장 파싱 (Free-text parsing)을 중단합니다. 단,
JSON.parse이후의 방어적 정규화 (Defensive normalization)는 남겨둡니다 (형식은 보장되어도 내용은 보장되지 않기 때문입니다). - 코드 측에서 가드레일 (Guardrail)을 설치한다. 하드 판정 (구조적 이상)과 소프트 판정 (AI의 자기 보고)을 분리합니다.
- 판정을 2개 층으로 나눈다. 서버는 결과 단위로 "이 이미지는 확인이 필요한가"를 판단하고, UI는 행 단위로 "이 항목은 ○△×인가"를 판단합니다.
- 멋대로 수정하지 않는다. 형식 외의 데이터는 시그널로 취급하여 확인 필요 단계로 넘깁니다. 삭제해도 되는 노이즈 (공백)와 건드려서는 안 되는 판독값 (대소문자 구분 등)을 구별합니다.
- 메타 정보를 유지한 채 가공한다. 공백 제거로 인해 인덱스가 어긋나지 않도록 합니다.
- 입력 측에도 신뢰 경계 (Trust boundary)를 둔다. AI에 전달하기 전에 이미지를 검증·재인코딩하고 메타데이터를 폐기합니다.
- 의심해야 할 곳으로 시선을 유도하는 UI. 모든 것을 똑같이 의심하게 만들지 않습니다.
"AI가 읽을 수 있다"와 "사용자가 신뢰하고 사용할 수 있다" 사이에는 생각보다 큰 거리가 있습니다. 그 거리를 메우는 것이 이 기사에서 말하는 "확인 필요 UI"입니다.
참고로, 이 기사에서 다룬 도구 "시리토루 (Shiritoru)"는 이쪽입니다 → 시리토루
다음 기사에서는 이 도구를 개인으로서 프로덕션에 공개했을 때 파산하지 않기 위한 다층 방어 (Rate limit, 비용 예산 가드, fail-open/fail-closed의 구분 사용)에 대해 작성할 예정입니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기