코스트코 영수증을 스마트폰으로 찍어서 업로드하면, 몇 십 초 후에 모든 품목명과 가격이 추출되어 나온다. 검수 화면에서 필드를 클릭하면 원본 이미지 위에 초록색 박스가 해당 텍스트 위치를 정확히 가리킨다. 간단해 보이는 이 과정 뒤에는, 생각보다 많은 기술적 결정과 도전이 숨어 있다.

이 글은 AI OCR 시스템을 직접 설계하고 개선해온 경험을 바탕으로, 사진 한 장이 구조화된 데이터가 되기까지의 전체 여정을 다룬다.


1. 두 개의 AI를 하나로 — 왜 Gemini + PaddleOCR인가

OCR 시스템을 설계할 때 가장 먼저 부딪히는 질문이 있다. 하나의 엔진으로 모든 걸 처리할까, 아니면 역할을 나눌까?

우리는 역할 분리를 선택했다.

Gemini (Google AI): "이 이미지에 무엇이 쓰여 있는가"를 담당한다. 영수증 사진을 보고 "품목_1은 KS 먹는 샘물, 가격은 3,520원"이라고 판단하는 역할. 맥락을 이해하고, "이건 상호명이고 저건 할인 금액이다"를 구분하는 데는 Vision Language Model이 압도적이다.

PaddleOCR (Baidu 오픈소스): "그 텍스트가 이미지의 어디에 있는가"를 담당한다. 이미지의 모든 글자를 감지하고, 각 글자의 정확한 픽셀 좌표를 반환한다. 결정론적이고, 빠르고, 로컬에서 돌아간다.

이 분리에는 뼈아픈 교훈이 있다. 처음에는 Gemini에게 텍스트 추출과 위치(bbox) 좌표를 동시에 요청했다. 간단한 문서에서는 잘 동작했다. 그런데 코스트코 영수증처럼 품목이 17개가 넘어가는 밀집 문서에서 문제가 터졌다. Gemini가 좌표 계산에 출력 토큰을 소진하면서, 뒷부분 품목 3개를 아예 누락해버린 것이다. 17개에서 14개로. 가장 중요한 데이터가 사라졌다.

그래서 원칙을 세웠다: Gemini는 "무엇"만, PaddleOCR는 "어디"만. 그리고 두 결과를 텍스트 유사도로 매칭하는 "스냅핑" 단계를 넣었다. Gemini가 "3,520 T"를 읽었고, PaddleOCR가 이미지의 (324, 665) 위치에서 "3,520 T"를 감지했으면, 둘을 연결한다. 이 방식으로 추출 품질과 위치 정확도를 모두 확보할 수 있었다.


2. 사진 한 장이 데이터가 되기까지

사용자가 영수증 사진을 업로드하면 다음 단계를 거친다.

이미지 전처리 — 사진을 "읽을 수 있는" 상태로

스마트폰으로 찍은 영수증 사진은 대체로 상태가 나쁘다. 기울어져 있거나, 초점이 안 맞았거나, 감열지가 바래서 글씨가 거의 안 보이기도 한다.

전처리 파이프라인은 이미지를 진단하고, 문제가 있는 경우에만 보정한다:

  • 회전 보정: 90°/180°/270° 뒤집힌 사진을 바로 세움
  • 기울기 보정 (deskew): 1~15° 비뚤어진 스캔을 수평으로 교정
  • 샤프닝: 초점이 안 맞은 이미지에 언샤프 마스크 적용
  • 대비 향상 (CLAHE): 바래진 감열지 영수증의 글씨를 선명하게

핵심은 조건부 적용이다. 고품질 스캔에서는 아무것도 하지 않는다. 이미 깨끗한 이미지에 샤프닝을 걸면 오히려 노이즈가 생기기 때문이다.

Auto-crop — 스캔 앱처럼 배경 제거

책상 위에서 찍은 사진에는 나무 질감, 손, 그림자 같은 배경이 가득하다. OpenCV의 Otsu 이진화로 밝기를 분리하고, 가장 큰 윤곽선을 찾아서 문서 영역만 잘라낸다. 코스트코 영수증 사진의 경우 원본 1536×2048에서 영수증 영역인 958×1695로 crop된다.

문서가 이미지의 80% 이상을 차지하면 (깨끗한 PDF 등) crop을 건너뛴다.

문서 유형 분류 — "이건 영수증인가, 계약서인가"

Gemini Vision이 첫 페이지를 분석하여 문서 유형을 자동 분류한다: 청구서(invoice), 영수증(receipt), 발주서(purchase_order), 계약서(contract), 기타(other). 분류 결과에 따라 추출 전략이 달라진다:

  • 영수증: 품목, 가격, 할인, 합계 위주로 추출. 쿠폰 필터링 활성화
  • 청구서: 공급자, 공급받는자, 세금, 합계 위주
  • 계약서: 당사자, 계약일, 금액, 조건 위주. 교차 검증 모드 사용

문서 유형을 알면 "이 숫자가 가격인지 전화번호인지" 같은 애매한 판단에서 정확도가 크게 올라간다.

Gemini 필드 추출 — AI가 내용을 읽다

전처리된 이미지를 Gemini에 보내면, 구조화된 필드를 반환한다:

품목_1: "KS 먹는 샘물2L X 6"     신뢰도: 0.97
가격_1: "3,520 T"                신뢰도: 0.96
품목_2: "이롬약콩두유"             신뢰도: 0.95
가격_2: "24,980 T"               신뢰도: 0.98
...
합계금액: "173,800"              신뢰도: 0.99

여기서 중요한 건 **신뢰도(confidence)**다. AI가 자신의 답변에 얼마나 확신하는지를 0~1 사이로 표현한다. 이 값이 나중에 자동 승인/수동 검토를 결정한다.

Bbox 스냅핑 — "이 텍스트가 사진의 어디에 있지?"

Gemini가 "무엇"을 추출했다면, 이제 "어디"를 찾아야 한다. PaddleOCR가 crop된 이미지에서 모든 텍스트의 위치를 감지하고, Gemini가 추출한 값과 텍스트 유사도로 매칭한다.

매칭 알고리즘은 배타적 1:1 할당이다. 영수증에서 "12,990"이라는 숫자가 여러 곳에 나타날 수 있다. 하나의 OCR 영역은 하나의 필드에만 할당되어, 같은 값이 여러 필드에 중복 매칭되는 걸 방지한다.

스코어 계산: 텍스트유사도 × 5 - 거리. 텍스트 일치가 5배 더 중요하고, 거리는 동점 처리용이다. 이게 중요한 이유는, 영수증 사진을 책상 위에서 비스듬히 찍었을 때 Gemini의 대략적인 위치 추정이 크게 빗나갈 수 있기 때문이다. 텍스트 내용만 맞으면 위치가 멀어도 올바르게 매칭된다.

후처리 — 코드가 AI를 검증하다

Gemini가 추출한 결과를 그대로 저장하지 않는다. 코드 기반의 후처리가 AI를 검증한다:

쿠폰/할인 필터링: "CPN -500 T" 같은 할인 행을 감지하여 직전 품목에 할인 마커를 추가하고, 할인 행 자체는 필드 목록에서 제거한다.

합계 검증: 추출된 품목 가격을 전부 더해서 합계 금액과 대조한다. 불일치하면 해당 품목들의 신뢰도를 하향하여 수동 검토 대상으로 분류한다. AI를 믿되, 산술은 코드로 검증하는 것이다.

필드 정규화: 날짜는 YYYY-MM-DD 형식으로, 금액은 숫자만 추출하고, 전화번호는 하이픈 포맷으로, 회사명은 (주)/(株) 같은 법인 접두사를 통일한다. 일본어 와레키(令和/平成) 날짜도 서력으로 변환한다.


3. 신뢰도 시스템 — AI를 얼마나 믿을 것인가

OCR 시스템에서 가장 중요한 질문 중 하나다. AI가 95% 확신한다고 하면, 그걸 자동으로 승인해도 될까?

3단계 색분류

레벨 신뢰도 색상 의미
HIGH 95% 이상 초록 자동 승인 가능
MEDIUM 80~94% 주황 빠른 확인 필요
LOW 80% 미만 빨강 반드시 수동 검토

검수 화면에서 검수자는 초록 필드를 빠르게 넘기고, 주황/빨강 필드에 집중할 수 있다. 일괄 승인 기능으로 초록 필드를 한 번에 처리하면 검수 시간이 대폭 줄어든다.

동적 임계값 — 필드별로 다른 기준

여기서 한 단계 더 나아간다. "합계금액"은 역사적으로 99% 정확하지만, "전화번호"는 85%밖에 안 된다. 같은 95% 임계값을 쓰는 건 비효율적이다.

AuditLog(수정 이력)를 분석해서 필드별 실제 정확도를 계산하고, 그에 따라 임계값을 동적으로 조정한다:

  • 정확도 98%인 필드 → 임계값 85%로 낮춤 (더 쉽게 자동 승인)
  • 정확도 70%인 필드 → 임계값 98%로 높임 (거의 항상 수동 검토)

이 방식으로 AI가 잘하는 필드에서 불필요한 수동 검토를 줄이고, 못하는 필드에서 더 엄격하게 체크한다.

피드백 학습 — 같은 실수를 반복하지 않기

검수자가 AI의 추출 결과를 수정하면, 그 수정 패턴이 AuditLog에 쌓인다. "이 유형의 문서에서 공급받는자 필드가 자주 틀린다"는 패턴이 감지되면, 다음 추출 시 Gemini 프롬프트에 주의 힌트가 자동 추가된다.

모델을 재학습하지 않고, 프롬프트 엔지니어링만으로 점진적으로 정확도를 개선한다. 가벼우면서도 효과적인 피드백 루프다.


4. PDF의 두 얼굴

PDF는 겉보기에 단순하지만, OCR 관점에서는 두 가지 전혀 다른 존재다.

텍스트 PDF: 글자가 텍스트 레이어로 내장되어 있다. 복사-붙여넣기가 되는 PDF. 이런 페이지는 OCR이 필요 없다. PyMuPDF로 텍스트와 좌표를 직접 추출하면 픽셀 단위로 정확하다.

스캔 PDF: 글자가 이미지로만 존재한다. 복사하면 아무것도 안 나온다. OCR이 필수.

문제는 하나의 PDF에 두 종류가 섞여 있을 수 있다는 것이다. 예를 들어, 텍스트로 작성된 계약서 본문 뒤에 수기 서명이 스캔으로 첨부된 경우. 이전에는 PDF 전체를 "텍스트 PDF인가, 스캔 PDF인가"로 이분법 판정했는데, 이러면 혼합 PDF에서 한쪽을 놓친다.

해결책: 페이지 단위 분류. 각 페이지의 유의미 문자 수를 세서 100자 이상이면 text_rich, 미만이면 text_poor로 분류한다. text_rich 페이지는 내장 텍스트를 사용하고, text_poor 페이지만 이미지로 변환하여 OCR을 실행한다. 17페이지 PDF에서 13페이지가 텍스트, 4페이지가 스캔이면, 4페이지에만 OCR을 돌린다.

멀티페이지 처리와 메모리

대형 PDF 처리에서 메모리는 항상 문제다. PaddleOCR의 PPStructureV3 모델은 한 페이지 처리에 최대 18.5GB를 사용한다. 17페이지를 한꺼번에 메모리에 올리면 어떤 서버도 버틸 수 없다.

해법은 페이지별 순차 처리다. 한 페이지를 로딩하고, 처리하고, 결과를 저장하고, 이미지를 메모리에서 해제한다. PPStructureV3의 predict()가 stateless이기 때문에 가능한 방식이다. 17페이지 일본어 PDF를 32GB 서버에서 14분 만에 처리할 수 있었다.


5. 기술적 도전들 — 코스트코 영수증이 알려준 것들

라인 그룹핑: 47개를 20개로

초기에는 PaddleOCR가 감지한 개별 토큰(단어 조각)을 하나씩 Gemini에 보냈다. "0012", "かき揚げうどん", "内", "¥1,100" 이 네 토큰이 각각 따로 전달되면, Gemini가 문맥을 잃어버린다. "¥1,100이 かき揚げうどん의 가격인지, 다른 항목의 가격인지" 판단하기 어렵다.

해결책은 y좌표 기반 라인 그룹핑이다. 같은 높이에 있는 토큰을 하나의 줄로 합친다: "0012かき揚げうどん 内 ¥1,100". 47개 토큰이 20줄로 압축되고, Gemini가 줄 단위로 문맥을 파악할 수 있게 된다.

노이즈 필터링: 체크박스가 한자로 읽히다

OCR 엔진은 가끔 이상한 것을 읽는다. 문서의 체크박스(☐)가 한자 "厂"으로 인식되고, 테이블 경계선이 "Γ"이나 "L"로 읽히고, 브라켓이 "「」"로 변환된다. 이런 노이즈가 AI 추출 단계로 넘어가면 결과를 오염시킨다.

필터링 규칙:

  • 단일 문자 + 낮은 신뢰도: "厂"(신뢰도 0.26) → 제거
  • 의미 없는 기호열: "——", "「」" → 제거
  • IRC/CPN 중복행: 할인 관련 중복 라인 → 별도 처리

실제로 47개 토큰 중 16개만 의미 있는 텍스트였던 케이스가 있다. 나머지 31개는 노이즈.

영수증 할인의 복잡한 세계

코스트코 영수증에는 세 종류의 할인이 있다:

  1. CPN 행: "CPN -500 T" — 쿠폰 할인
  2. IRC 접미사: 품목명에 "IRC"가 붙음 — 즉시 할인 적용 품목
  3. 음수 가격: 별도 행에 할인 금액만 표시

AI가 이것들을 "품목"으로 인식하면, 품목 수가 부풀려지고 합계 검증이 실패한다. 3가지 패턴을 규칙 기반으로 감지하여, 할인 행은 직전 품목에 할인 마커로 연결하고, 필드 목록에서는 제거한다.

20픽셀의 유령 — UVDoc이 좌표를 훔치다

가장 최근에 마주친 도전이다. 검수 화면에서 모든 bbox가 한 줄씩 위를 가리키고 있었다. 모든 개별 컴포넌트를 검증했는데 다 정상이었다. PaddleOCR는 텍스트를 정확히 읽었고, 스냅핑은 올바른 영역에 매칭했고, 프론트엔드는 좌표대로 정확히 그렸다.

범인은 PaddleOCR v5에 기본 탑재된 UVDoc — 문서 곡면 보정 기능이었다. 이 기능이 내부적으로 이미지를 변형하면서 텍스트 위치가 수직으로 약 20픽셀 이동했다. 감지 좌표는 변형된 이미지 기준인데, 화면에 보여주는 건 원본 이미지. 딱 한 줄 높이만큼의 차이.

use_doc_unwarping=False 한 줄로 해결했지만, 이 버그를 찾는 데 걸린 시간은 코드를 고치는 시간의 수십 배였다. 라이브러리의 기본값이 항상 내 상황에 맞는 건 아니라는 교훈.


6. 다국어 — 한국어만으로는 부족하다

OCR 시스템이 한국어만 지원하면 쓸모가 제한된다. 현재 지원하는 언어:

  • 동아시아: 한국어, 일본어, 중국어 (간체/번체)
  • 동남아시아: 베트남어, 태국어
  • 기타: 영어, 아랍어, 힌디어

각 언어마다 고유한 도전이 있다. 일본어는 와레키(令和6年 → 2024년) 날짜 변환이 필요하고, 유사 한자 혼동이 잦다 (淡↔渋, 刀↔力). 베트남어는 발음 부호가 OCR에서 자주 누락된다 (Co the' → Có thể). 태국어는 모음이 자음 위아래에 붙어서 bbox가 복잡해진다.

언어 감지는 Gemini 추출 결과의 field_name_i18n 메타데이터에서 자동으로 판단하고, PaddleOCR의 언어별 모델을 자동 선택한다.


7. 검수 화면 — 사람과 AI의 협업 인터페이스

최종 사용자가 마주하는 건 검수 화면이다. 왼쪽에 문서 이미지(auto-crop 적용), 오른쪽에 필드 목록. 필드를 클릭하면 이미지 위에 bbox 하이라이트가 나타나서 "이 값이 문서의 어디에 있는지" 바로 확인할 수 있다.

신뢰도에 따른 색분류(초록/주황/빨강)로 검수자는 "어디를 봐야 하는지" 직관적으로 파악한다. 키보드 단축키(A: 승인, E: 수정, D: 반려)로 빠르게 처리하고, 일괄 승인으로 고신뢰도 필드를 한 번에 넘긴다.

대시보드에서는 문서 유형별 정확도, 수정률, 처리 현황을 모니터링한다. 어떤 유형의 문서에서 AI가 약한지, 시간이 지나면서 정확도가 개선되고 있는지 추적할 수 있다.


마치며

AI OCR 시스템을 만들면서 느낀 건, AI가 잘하는 것과 코드가 잘하는 것이 다르다는 것이다.

Gemini는 이미지를 보고 맥락을 파악하는 데 탁월하다. "이 숫자가 가격인지, 수량인지, 전화번호인지"를 판단하는 건 AI가 훨씬 낫다. 하지만 산술 검증, 패턴 기반 필터링, 좌표 변환, 메모리 관리 — 이런 건 코드가 확실하다.

좋은 시스템은 이 둘의 경계를 잘 그린다. AI에게 추론을 맡기고, 코드로 검증하고, 사람이 최종 판단한다. AI → 코드 → 사람. 이 흐름이 깨지면 — AI가 모든 걸 하려 하거나, 사람이 모든 걸 확인해야 하거나 — 시스템은 실용적이지 않게 된다.

결국 OCR은 "글자를 읽는" 기술이 아니라 "신뢰를 설계하는" 기술이다. AI의 답변을 얼마나 믿을 수 있는지 정량화하고, 그 수치에 따라 사람의 개입 수준을 조절하는 것. 95%의 자동화와 5%의 인간 검수가 만나는 지점에서, 비로소 실무에서 쓸 수 있는 시스템이 된다.