사내 포털의 법인카드 사용내역 등록 화면에 영수증 사진을 첨부하면, 브라우저가 사진 속 글자를 읽어 사용처와 금액을 채워 주는 기능을 만들었습니다. OCR을 위한 서버도 외부 API도 두지 않고 브라우저 안에서만 끝내는 것이 조건이었습니다. 엔진을 고른 근거와 실제로 막혔던 곳을 기록한 글입니다.
사진도 올려야 하는데 내용도 다시 써야 한다고?
법인카드를 쓰면 사내 포털에 사용처·금액·날짜·참여자를 손으로 등록합니다. 매달 꾸준히 쌓이고, 영수증 사진은 이미 첨부하고 있습니다. 사진이 있는데 사람이 다시 보고 또 쳐야 합니다. 귀찮음을 조금이라도 덜고 싶었습니다.
사진 속 글자를 텍스트로 바꾸는 광학 문자 인식(OCR) 은 오래된 기술이지만, 최근에서야 한국어 모델이 브라우저에서 돌릴 만큼 작아졌다는 것을 알게 되어서 가볍게 도입해 볼 만하다고 생각했습니다.
목표는 네 가지로 잡았습니다.
- OCR 대상: 영수증 사진에서 사용처, 금액, 일시, 승인번호, 카드 앞 6자리를 골라내 빈 칸에 채웁니다.
- 구동 위치: OCR을 위한 서버나 외부 API를 추가하지 않고 브라우저 안에서만 끝냅니다.
- UX 고려 사항: 기본은 꺼짐이고, 사용자가 입력한 값은 덮어쓰지 않아야 합니다.
- 시연: 폰에서 사진을 찍어 등록까지 이어져야 합니다.
판정 기준도 준비했습니다.
| 항목 | 기준 |
|---|---|
| 금액·일시 | 90% 이상 |
| 승인번호 | 85% 이상 |
| 사용처 | 후보 3개 안에 정답 80% 이상 |
| 인식 시간 | 같은 방문에서 중앙값 5초, 90%가 8초 안 |
| 외부 요청 | 인식 중 0건 |
| 기존 화면 | 꺼져 있으면 화면과 전송 필드 그대로 |
영수증 내용으로 카테고리를 추정하는 것, 1/N 계산을 바꾸는 것, 법인카드 여부를 판별하는 것은 현실적인 이유가 있어서 범위 밖으로 두었습니다.
시작 전에 약간의 리서치를 통해 전제를 두었습니다. 엔진은 PaddleOCR과 Tesseract 두 개를 실측으로 비교해 고르고, 글자 뭉치에서 값을 골라내는 일은 언어 모델 없이 규칙 코드로 합니다. 모델 파일은 외부 CDN 대신 포털 서버에서 내려받는데, 언어 모델과 CDN을 피한 이유는 둘 다 돈이 들지 않는 방향이기 때문입니다. 무거운 계산은 워커에서 돌려 화면 렌더링을 막지 않습니다.
왜 브라우저에서 읽나
영수증을 읽는 코드가 어디서 도느냐에 따라 세 가지 길이 있었습니다.
| 구분 | 클라우드 OCR API | 자체 서버에서 OCR | 브라우저 온디바이스(채택안) |
|---|---|---|---|
| 방식 | 네이버 클로바·구글 비전 같은 외부 서비스에 사진을 보내고 결과를 받음 | 서버에 OCR 프로그램을 설치하고 사진을 올려 처리 | 브라우저가 모델을 한 번 내려받아 폰·PC 안에서 직접 읽음 |
| 사진 전송 | 외부 업체 서버 | 사내 서버 | 기기 안 |
| 영수증에 찍힌 정보 | 외부로 나감 | 서버 로그·저장소 관리 필요 | 기존 DB에 값만 전송 |
| 비용 | 건당 과금 | 서버 자원 | 0(사용자 기기가 계산) |
| 첫 사용 부담 | 없음 | 없음 | 모델 32MB를 한 번 내려받음 |
| 속도 | 네트워크 왕복 | 왕복 + 서버 대기열 | 기기 성능에 따름 |
| 외부 의존 | 업체 장애·약관 변경 | 서버 운영 | 브라우저 기능 |
예전에 OCR을 구현할 땐 서버로 전송한 뒤 엔진을 구동하거나 API를 호출해야 했는데, 이번에는 브라우저 온디바이스를 선택했습니다. 그 이유는 네 가지가 있습니다.
첫째, 모델이 작아졌습니다. PaddleOCR v5의 한국어 인식 모델은 13.4MB이고 라이선스(Apache-2.0)도 제약이 없어서, 브라우저가 내려받을 만한 크기입니다.
둘째, 브라우저가 돌릴 수 있습니다. 모델 실행 라이브러리 onnxruntime-web(ORT) 은 ONNX 형식의 모델을 브라우저에서 실행해 주는데, C++로 짠 실행 엔진을 WebAssembly(WASM) 로 옮겨 놓았습니다. WebAssembly는 C++ 같은 언어로 만든 프로그램을 브라우저 안에서 원래 속도에 가깝게 돌리는 실행 형식입니다. 이 계산을 화면과 다른 스레드에서 도는 백그라운드 작업 공간인 웹 워커(Web Worker) 에서 돌리면 화면 렌더링에 영향을 주지 않아 UI 버벅임이 없습니다. 내려받은 모델은 Cache Storage에 둡니다. Cache Storage는 브라우저가 요청과 응답을 짝지어 보관하는 저장소라, 두 번째 방문부터는 모델을 내려받지 않습니다.
셋째, 필요하면 GPU도 씁니다. WebGPU는 브라우저에서 그래픽카드로 계산하는 API로, 데스크톱 브라우저에는 들어왔습니다. 모바일은 아직 안정적이지 않아서 이번에는 쓰지 않았습니다.
넷째, 값이 변조될 위험을 감수할 만했습니다. 브라우저가 읽은 결과를 서버로 보내는 구조라서, 마음만 먹으면 브라우저에서 값을 바꿔 보낼 수 있습니다. 정확도를 엄격하게 요구하는 서비스라면 문제가 되겠지만, 이번 기능은 사내에서 쓰는 가벼운 서비스인 데다 등록한 금액은 최종적으로 실제 법인카드 사용 총액과 대조하게 되어 있습니다. 문제가 생기면 첨부한 사진과 실물 영수증으로 값을 확인할 수 있어서, 변조로 생기는 위험은 거의 없다고 판단했습니다.
워커가 읽어 주면 메인에서 고른다
처리는 세 곳으로 나뉩니다.
- 워커: 사진을 디코드하고, 가로로 찍혔으면 돌리고, 글자 영역 검출 모델(det) 로 글자가 있는 곳을 찾고, 회전 박스로 오려 내고, 글자 인식 모델(rec) 로 한 줄씩 읽고, 같은 줄 조각을 합칩니다. 결과는 줄 단위 텍스트·위치·신뢰도입니다.
- 메인 화면: 규칙 파서(rule parser) 가 값을 찾습니다. 규칙 파서는 글자 뭉치에서 사용처·금액·일시·승인번호·카드 앞자리를 정해진 규칙으로 골라내는 코드입니다. 영수증 아래쪽의 신용카드 승인 정보 묶음(결제 블록)을 나눠 결제가 몇 건인지도 판정합니다. 빈 칸에만 값을 채우고 ‘자동’ 표시를 붙이며, 나머지 후보는 칩으로 보여 줍니다.
- 서버: 등록 요청을 받아 이전과 같은 방식으로 저장합니다.
엔진은 두 개입니다. 기본은 PaddleOCR이고, 안 되는 환경에서는 Tesseract로 넘어갑니다.
| 환경 | 시도 순서 |
|---|---|
| 모바일(iOS·Android) | Paddle WASM → Tesseract WASM |
| 데스크톱 Chromium 계열(Chrome·Edge) + 하드웨어 WebGPU | Paddle WebGPU → Paddle WASM → Tesseract |
| 데스크톱 Safari 등 | Paddle WASM → Tesseract |
사용자가 기다리는 시간은 방문 순서에 따라 다릅니다.
| 경우 | 사용자가 느끼는 부담 |
|---|---|
| 첫 방문의 첫 사진 | 모델·실행 파일 32.6MB 다운로드 + 엔진 준비 + 인식 |
| 이후 방문의 첫 사진 | 다운로드 없음 + 엔진 준비 약 0.2초 + 인식 |
| 같은 방문의 다음 사진 | 인식만 |
| 메모리 | 워커 1개. 등록 화면을 떠나거나 마지막 인식 뒤 3분이 지나면 정리 |
OCR은 완벽하지 않으므로 사용자가 결과를 점검할 수 있게 화면을 구성했습니다.
- 사용자가 입력한 값은 인식한 값으로 덮어버리지 않습니다. 사용자가 버튼을 눌러 고른 값으로만 덮습니다. 자동 값을 직접 고치면 ‘자동’ 표시가 빠지고 되돌리는 버튼이 뜹니다.
- 금액 칸은 1/N 상태를 따릅니다. 켜져 있으면 총금액을 넣고, 꺼져 있고 참여자가 혼자면 내 금액을 넣습니다. 꺼져 있는데 여러 명이면 안내만 하고, 1/N을 켜는 순간 총금액을 넣습니다.
- 한 영수증에 카드 결제가 여러 건이면 이번 결제를 버튼으로 고르게 하고, 나머지는 따로 등록하도록 안내합니다. 사진에 영수증이 여러 장이면 한 장씩 찍으라고 안내합니다.
- 카테고리는 영수증 내용으로 추정하지 않습니다. 실제 등록 데이터를 보면 편의점·카페 결제도 대부분 중식으로 등록돼 있어서, 가게 이름으로 추정하면 틀리게 됩니다. 결제 시각이 17시 이후일 때만 석식 칩을 보여 주고, 날짜는 경고만 합니다.
- 인식 도중 사진을 지우거나 바꾸면 이전 결과는 버립니다.
왜 이렇게 만들었나
엔진 두 가지 비교하기
Tesseract는 20년 넘게 이어진 오픈소스 OCR로, 브라우저 이식판(tesseract.js)이 성숙해서 저도 종종 써 왔고, 크기도 8.1MB밖에 안 됩니다. PaddleOCR은 바이두가 공개한 OCR로, 글자 영역을 찾는 모델과 글자를 읽는 모델이 따로 있고 한국어 모델이 v5부터 작아졌습니다. 인식 모델은 13.4MB이고, 실행 파일까지 합치면 32.6MB입니다.
샘플 26장으로 Tesseract 26가지 설정, Paddle 13가지 설정을 돌렸습니다. 아래 표는 엔진이 읽은 텍스트 안에 정답이 들어 있는 비율입니다.
| 엔진·설정 | 총액 | 날짜 | 시각 | 승인번호 | 상호(사용처) |
|---|---|---|---|---|---|
| Tesseract 한국어만 | 73% | 73% | 65% | 48% | 35% |
| Tesseract 한국어+영어 | 81% | 77% | 77% | 60% | 35% |
| Tesseract 한국어+영어, 흩어진 글자 모드, 그레이 변환 | 92% | 69% | 62% | 62% | 31% |
| Paddle, 축 정렬 박스 | 96% | 69% | 65% | 79% | 38% |
| Paddle, 회전 박스 | 96% | 100% | 92% | 98% | 73% |
Tesseract는 설정마다 득실이 엇갈렸습니다. 영어를 켜면 승인번호가 12%p 오르지만 시간이 1.7배 들고, 흩어진 글자 모드에 그레이 변환을 더하면 총액이 11%p 오르는 대신 시각이 15%p 떨어집니다. 이미지를 키우거나 흑백으로 바꾸면 손해였고, 빠른 모델은 시각이 35%까지 내려갔습니다. Paddle은 회전 박스 후처리를 넣자 모든 필드에서 앞섰고, 속도도 WASM 기준 중앙값 1.6초로 Tesseract의 2.0초보다 빨랐습니다. 그래서 기본은 Paddle로, 폴백은 Tesseract로 두었습니다.
Paddle 안에서도 속도를 더 줄여 보려고 글자 인식 모델에 여러 줄을 묶어 한 번에 넣어 봤지만, 줄당 70~85ms가 걸려 한 줄씩 넣을 때(23~32ms)보다 오히려 느렸기 때문에 인식은 한 줄씩 합니다.
회전 박스 한 줄이 날짜를 69%에서 100%로
검출 모델은 글자 위치를 점 뭉치로 줍니다. 이 점 뭉치를 사각형으로 잘라 인식 모델에 넘기는데, 폰 사진은 줄이 조금씩 기울어 있기 마련입니다. 화면 축에 맞춘 축 정렬 박스(axis-aligned box) 는 긴 줄에서 이웃 줄을 끌어들이거나 점수 미달로 버려지는데, 줄의 기울기에 맞춘 회전 박스(rotated box) 로 바꾸자 날짜 69%가 100%, 승인번호 79%가 98%가 됐습니다. 흔히 OpenCV로 하는 계산이지만 브라우저판이 수 MB고, 이미 Paddle이 32MB나 쓰고 있어서 JS로 직접 구현했습니다. 영수증 전체를 반듯하게 펴는 투시 보정도 OpenCV.js가 있어야 해서 넣지 않았고, 회전 박스만으로 충분했습니다.
WebGPU는 3배 빠르지만 데스크톱 Chromium에서만 먼저 쓴다
같은 모델을 CPU에서 돌리면 WASM이고, 그래픽카드에서 돌리면 WebGPU입니다. WebKit(Safari 엔진)에서 WebGPU는 0.52초 대 1.55초로 약 3배 빨랐습니다. 그런데 워커 하나로 연속 인식하면 탭이 죽는 현상이 26장 중 2번 재현됐고, iPhone은 모델 실행 라이브러리의 빌드 방식이 iOS 26 Safari 버그에 걸린다는 보고가 있는데, 직접 재현하지는 못했습니다.
그래서 모바일과 Safari는 WASM만 사용하게 하고, WebGPU는 데스크톱 Chromium 계열에서 하드웨어 GPU가 있을 때만 먼저 시도합니다. GPU를 소프트웨어로 흉내 내는 환경은 WASM보다 20배 느려서 모델을 받기 전에 거절합니다.
VLM 대신 규칙 파서를 골랐다
사진을 통째로 보고 금액이 얼마인지 답하는 시각 언어 모델(VLM) 을 쓰는 길도 있긴 합니다. 시각 언어 모델은 이미지와 질문을 함께 받아 글로 답하는 모델입니다. 그런데 크기가 문제입니다. 인식 모델이 13.4MB인 데 비해 PaddleOCR-VL은 0.9~1.9GB, Qwen2-VL은 1.9GB였고, 작은 VLM(SmolVLM, Florence)은 한국어를 읽지 못했습니다.
그래서 규칙 파서를 만들었습니다. 규칙은 언제나 같은 답을 내니, 단위 테스트를 344건 붙여 동작을 고정해 둘 수 있게 됩니다. 대신 융통성이 없어서 살짝 깨진 글자를 읽어 내기 어렵고, 영수증마다 생김새가 달라서 상호명을 골라내는 정확도가 떨어졌습니다.
엔진이 읽은 것과 폼에 채울 것은 다르다
엔진이 읽은 뒤 파서가 골라 폼에 넣은 값, 즉 사용자가 체감하는 정확도는 따로 쟀습니다.
| 엔진 | 실행 방식 | 금액(단일/분할 블록) | 일시 | 승인번호 | 카드 앞자리(분할 블록) | 사용처(1순위/후보 안) | 첫 인식 | 인식 중앙값(상위 90%) |
|---|---|---|---|---|---|---|---|---|
| Paddle | WASM, Chromium | 95% / 88% | 85% | 58% | 25% | 54% / 62% | 2.2초 | 1.58초 (1.93초) |
| Paddle | WASM, WebKit | 95% / 63% | 85% | 62% | 38% | 54% / 62% | 2.1초 | 1.55초 (1.97초) |
| Paddle | WebGPU, WebKit | WASM과 같음 | 같음 | 같음 | 같음 | 같음 | 1.1초 | 0.52초 (0.73초) |
| Tesseract | WASM | 50% / 50% | 58% | 31~35% | 0% | 12% | 4.0초 | 1.9~2.0초 (3.3초) |
분할 블록은 카드 결제가 두 건인 영수증 4장의 결제 8건을 뜻합니다.
| 필드 | 엔진이 읽은 것 | 폼에 채운 것 | 차이 |
|---|---|---|---|
| 총액 | 96% | 95% | 1%p |
| 날짜 | 100% | 85% | 15%p |
| 승인번호 | 98% | 58~62% | 36~40%p |
| 상호 | 73% | 54% | 19%p |
엔진이 읽은 것과 폼에 채운 것을 필드별로 대조해 보았습니다. 차이는 전부 규칙 파서에서 생깁니다. 엔진은 괜찮은데 파서가 병목이었습니다.
생각보다 여기저기서 막혔다
OCR만 간단히 붙이면 끝날 줄 알았는데 열한 곳에서 막혔고, 그중에 여덟 곳만 해결했습니다.
| # | 무엇 | 결과 |
|---|---|---|
| 1 | 기울어진 줄에서 날짜·승인번호가 빠짐 | 해결: 회전 박스 |
| 2 | 금액이 선명한데 금액 칸이 빔 | 해결: 반복 금액 폴백, 86% → 95% |
| 3 | 사용처에 영수증 안내 문구가 붙음 | 해결: 한 줄 안에서 조각 고르기 |
| 4 | 정상인데 엔진이 차단됨 | 해결: 카나리 판정 수정 2건 |
| 5 | tesseract.js에서 걸린 세 가지 | 해결 |
| 6 | 새로고침마다 32MB | 해결: Cache Storage |
| 7 | 다운로드 퍼센트가 뒤로 감 | 해결: 동시 다운로드 |
| 8 | 데스크톱 Safari WebGPU 탭 사망 | 미해결: 기본 WASM 사용 |
| 9 | 승인번호가 58~62%에서 멈춤 | 미해결: 폼에 쓰지 않기로 함 |
| 10 | iPhone WebGPU | 미검증: 모바일은 WASM |
| 11 | 테스트 서버에서 모델이 깨진 채 도착해 엔진이 멈춤 | 해결: sha256 검증·재시도, 60초 제한 |
1. 기울어진 줄에서 날짜·승인번호가 빠짐 (해결)
- 증상: Paddle이 날짜 69%, 승인번호 79%만 읽었습니다. 사람 눈에는 또렷한 줄인데 결과에서 통째로 빠졌습니다.
- 원인: 검출 모델이 준 점 뭉치를 축 정렬 박스로 잘랐습니다. 줄이 기울면 긴 줄일수록 사각형이 커져 이웃 줄을 끌어들이거나, 글자 비율이 낮아져 점수 미달로 버려집니다. 날짜와 승인번호는 영수증에서 가장 긴 줄이라 가장 많이 당했습니다.
- 해결: 점 뭉치를 감싸는 최소 면적 사각형을 구해 기울기대로 오려 냈습니다. OpenCV의 minAreaRect에 해당하는 계산을 JS로 짰습니다. 날짜 100%, 승인번호 98%가 됐습니다.
2. 금액이 선명한데 금액 칸이 빔 (해결)
- 증상: 한 초밥집 영수증은 금액이 크고 선명한데 금액 칸이 비었습니다.
- 원인: OCR 결과를 열어 보니 ‘합계’ 라벨이 두 줄로 쪼개지고 글자 하나가 빠져 있었습니다. 라벨 옆 숫자를 찾는 규칙으로는 후보가 0개가 됩니다.
- 해결: 라벨을 못 찾을 때만, 결제 구간(승인 정보 근처)에서 두 번 이상 반복되는 금액을 씁니다. 영수증은 합계·승인금액·받은 금액을 같은 숫자로 반복 인쇄하기 때문입니다. 영수증 전체에서 가장 많이 반복된 금액을 쓰면 품목 단가가 더 많이 반복돼 틀리므로 결제 구간으로 제한했습니다. 단일 결제 금액이 86%에서 95%가 됐고, 나빠진 영수증은 없었습니다.
3. 사용처에 안내 문구가 붙음 (해결)
- 증상: 사용처 후보가 “*최근영수증발행인쇄 CU ○○점”처럼 나왔습니다.
- 원인: 한 행에 찍힌 안내 문구와 매장명을 통째로 후보로 썼습니다. 처음 규칙은 헤더 첫 줄을 상호로 봐서 주소가 1순위로 추천되기도 했습니다.
- 해결: 사용처는 한 줄 안에 있고 특수문자나 ‘영수증’·‘발행’ 같은 단어가 들어가지 않는다는 도메인 규칙을 넣었습니다. 기호(* : / #)로 조각을 나누고, 안내 단어·라벨·숫자뿐인 조각을 버리고, ‘○○점’으로 끝나는 조각을 우선합니다. 주소·전화·대표자 줄은 후보에서 뺍니다. 1순위 정답이 12장에서 14장, 후보 3개 안 정답이 12장에서 16장이 됐고, 기호가 남은 후보는 14개에서 0개가 됐습니다.
4. 정상인데 엔진이 차단됨 (해결)
- 배경: 엔진이 중간에 죽으면 브라우저가 영원히 기다리는 것을 막으려고 카나리(canary) 를 뒀습니다. 카나리는 인식 시작 때 브라우저 저장소에 ‘시도 중’ 기록을 남기고 정상 종료 때 지우는 장치로, 다음 부팅 때 기록만 남아 있으면 탭이 죽은 것으로 보고 그 엔진을 막습니다.
- 증상 1: 정상 인식 뒤 2분 안에 새로고침하면 Paddle이 막혔습니다. 완료 시점에 시도 id를 먼저 지워서 정상 종료 기록이 남지 않았고, 유예 시간 안의 새로고침이 사고로 보였습니다. 유예 판정이 끝날 때까지 id를 유지하도록 고쳤습니다.
- 증상 2: 같은 브라우저에서 두 번째 탭을 열면 Paddle이 Tesseract로 떨어졌습니다. 탭끼리 공유하는 localStorage에서 다른 탭의 살아 있는 시도를 자기 탭의 사고로 오판했습니다. 시도에 탭 세션 id를 붙여 같은 탭 것만 판정하도록 고쳤습니다.
- 확인: 둘 다 재현 테스트를 먼저 만들어 수정 전 실패, 수정 후 통과를 확인했습니다.
5. tesseract.js에서 걸린 세 가지 (해결)
- 90° 돌아간 영수증을 절반만 읽음: 회전 옵션(rotateRadians)이 회전한 뒤 원래 캔버스 크기로 잘라 냅니다. 인식 전에 픽셀을 직접 회전합니다.
- 워커 생성 실패(“URL is invalid”): 내부 워커를 임시 주소(blob URL)로 띄워서 자체 서버 경로의 스크립트를 찾지 못합니다. 옵션(workerBlobURL=false)으로 자체 호스팅 경로에서 만듭니다.
- 줄 수 0: v6부터 텍스트 외 출력(줄·단어)이 기본으로 꺼져 있습니다. blocks 출력을 켜서 줄을 뽑습니다.
덤으로, 정밀(float) 모델은 일부 기기에서 중단돼 정수 모델만 쓰고, 빠른 모델은 시각 인식이 35%로 처참했기에 쓰지 않습니다.
6. 새로고침마다 32MB (해결)
- 증상: 등록 화면을 새로고침할 때마다 모델을 다시 받았습니다.
- 원인: 처음 설계는 브라우저의 일반 HTTP 캐시에 맡겼는데, 서버가 캐시 금지(no-cache)로 응답했습니다.
- 해결: 워커가 모델 3개와 실행 파일(ORT wasm)을 Cache Storage에 넣습니다. 페이지 밖에서 요청을 가로채는 브라우저 기능인 서비스 워커(Service Worker) 로 캐시하는 방법도 있었지만, 범위와 위험에 비해 얻는 것이 적어서 워커 안에서 직접 넣는 쪽을 골랐습니다. 캐시 이름에 모델 해시와 라이브러리 버전이 들어가서 모델이 바뀌면 자동으로 새로 받습니다. 서버도 캐시 헤더를 줍니다. 첫 방문에 32.6MB를 받고, 새로고침·새 탭에서는 0건이 됐습니다.
7. 다운로드 퍼센트가 뒤로 감 (해결)
- 증상: 느린 네트워크에서 퍼센트가 99%까지 갔다가 34%로 돌아가는 식으로 뒤로 갔습니다. 서버가 파일 크기를 알려 주지 않으면 받는 내내 100%였습니다.
- 원인: 파일 4개를 차례로 받아서 다음 파일이 시작될 때마다 전체 크기가 커졌습니다. 크기를 모르면 받은 양을 전체로 쳤습니다.
- 해결: 4개를 동시에 받고 합산 비율이 내려가지 않게 고정했습니다. 크기를 모르는 파일이 있으면 퍼센트 없이 안내만 합니다.
8. 데스크톱 Safari WebGPU 탭 사망 (미해결)
- 증상: 데스크톱 Safari에서 워커 하나로 WebGPU 연속 인식을 하면 탭이 죽는 것이 26장 중 2번 재현됐습니다.
- 원인 추정: 모델 실행 라이브러리의 WebGPU 판은 asyncify라는 변환 방식으로 빌드돼 있습니다. asyncify는 GPU 결과를 기다리려고 동기 코드를 비동기로 바꾸는 방식인데, 메모리가 누적됩니다. 데스크톱 Safari의 최대 메모리가 asyncify는 1.5~2.0GB, 순수 WASM은 0.9GB였습니다.
- 처리: Safari는 기본 WASM으로 두고, WebGPU는 시험용 URL 파라미터를 붙였을 때만 씁니다. N장마다 워커를 다시 만드는 안은 원인을 모른 채 덮는 것이라 기각했습니다.
9. 승인번호가 58~62%에서 멈춤 (미해결)
- 증상: OCR 결과 안에 승인번호가 98% 들어 있는데 파서가 58~62%만 채웁니다.
- 원인: 승인번호 라벨이 2글자 이상 깨지거나, 번호 뒤에 다른 숫자가 공백 없이 붙어 8자리 경계를 잡지 못합니다.
- 시도한 것: 글자 하나 틀린 라벨도 인정(편집 거리 1), 라벨 표기 확장, ‘승인’ 근처 8자리 폴백으로 55%에서 58~62%가 됐습니다. 글자 둘 틀린 라벨까지 인정하면 ‘결제방법’을 ‘결제액’으로 잡는 오탐이 실측됐고, 라벨 없이 8자리 숫자를 전부 후보로 삼아도 오탐이 났습니다.
- 처리: 규칙으로 더 갈지, 이 필드만 작은 모델이나 위치 기반 규칙(승인 블록 안의 숫자 배치)을 쓸지는 남겨 두고, 일단 이 필드는 폼에 쓰지 않기로 했습니다.
10. iPhone WebGPU는 시험하지 못함 (미검증)
- 상태: 시연에 쓴 iPhone(iOS 26.6.2)은 WebGPU 어댑터 생성과 GPU 연산이 정상이었습니다. 문제는 GPU가 아니라 라이브러리 쪽입니다.
- 위험: asyncify 빌드가 iOS 26 Safari의 컴파일 메모리 버그(WebKit 304810)에 걸려 탭이 죽는다는 보고가 있고, 같은 계열 재현이 iOS 26.6.2에서 4회 중 4회였습니다. 우회에 필요한 JSPI(JavaScript Promise Integration) 는 WebAssembly가 JS의 비동기 결과를 직접 기다리는 기능인데, Safari 27부터 들어옵니다.
- 처리: 모바일은 순수 WASM으로 갑니다. GPU 시험 모드는 벤치 페이지에서만 돌리고, 10회 연속 인식 + 120초 유지 + 2세션을 통과해야 허용합니다.
11. 모델 파일이 깨진 채 도착해 엔진이 멈춤 (해결)
- 증상: 테스트 서버(HTTP)에서 모델·wasm 파일이 전송 중 손상되거나 다른 파일과 섞여 도착했고, 엔진 준비가 끝나지 않은 채 멈췄습니다. 깨진 파일이 캐시에 남아 새로고침해도 같았습니다.
- 원인: 전송 중 손상 자체는 네트워크 경로 쪽 문제라 코드로 막을 수 없고, 운영 환경은 HTTPS라 암호화 계층이 걸러 냅니다. 코드 쪽 원인은 둘이었는데, 받은 파일을 검증 없이 캐시에 저장했고 엔진 준비에 시간 제한을 두지 않았습니다.
- 해결 1: 모델 3개와 wasm 2개를 받은 뒤 크기와 sha256을 확인하고, 틀리면 그 파일만 최대 3번 다시 받고, 통과한 것만 캐시에 넣습니다. 기대 해시는 저장소의 해시 목록 파일에 두고 빌드 때 설치된 onnxruntime-web과 대조합니다. 캐시 이름을 바꿔서 검증 없이 저장된 옛 캐시는 자동으로 지워집니다. HTTP에서는 브라우저 내장 해시 기능(crypto.subtle)을 쓸 수 없어 순수 JS로 계산하는데, Mac에서 14MB에 약 0.06초가 걸리고, 폰에서는 재지 않았습니다.
- 해결 2: 진행 신호 없이 60초가 지나면 그 엔진을 끝내고 다음 엔진으로 넘어갑니다. 마지막 엔진이면 직접 입력해 달라는 안내를 보여 줍니다. Tesseract는 로딩 중에 살아 있다는 신호를 보내서 느린 망에서 잘못 끊기지 않습니다.
- 확인: 한 번 깨진 경우는 다시 받아 Paddle로 성공했고, 계속 깨진 경우는 3번 뒤 Tesseract로 넘어가 완료했으며, 둘 다 깨진 경우는 61초 뒤 안내로 끝났습니다. 깨진 파일은 캐시에 남지 않았습니다. Chromium·WebKit과 HTTP 환경에서 확인했습니다.
결과
사진을 첨부하면 약 2~5초 뒤에 비어 있던 사용처와 금액이 채워집니다. 읽는 동안 밖으로 나가는 요청은 없습니다. 사진 첨부 칸 아래의 ‘영수증 자동 인식’ 체크박스로 켜고 끄며, 기본은 꺼짐이고 기기에 기억됩니다. 꺼져 있으면 화면도 전송 필드도 이전과 동일하게 두었습니다.
다만 사진 첨부 칸의 위치는 바꿨습니다. 기존에는 사진 첨부가 부가적인 항목이라 아래쪽에 있었지만, 이제는 OCR이 남은 칸을 채워 주므로 사진부터 등록하도록 위로 올렸습니다.
화면에서는 네 단계로 진행됩니다.
- 사진을 첨부하면 패널에 ‘영수증 읽는 중’이 뜹니다. 처음 한 번은 모델 다운로드 안내가 먼저 나옵니다.
- ‘OCR 완료’가 뜨면 사용처 추천·금액 추천 영역에 후보 칩이 나타납니다.
- 비어 있던 칸에 값이 들어가고 ‘자동’ 표시가 붙습니다. 직접 입력한 칸은 건드리지 않고, 버튼을 누르면 바꿉니다.
- 등록하기를 누르면 등록됩니다. 저장 방식은 이전과 같습니다.
| 항목 | 값 | 뜻 |
|---|---|---|
| 금액(단일 결제) | 95% (21/22) | 카드 결제가 한 건인 영수증 22장 중 21장에서 금액이 맞았습니다 |
| 일시 | 85% (22/26) | 26장 중 22장 |
| 사용처 | 1순위 54%, 후보 3개 안 62% | 첫 추천이 정답인 비율, 칩 3개 안에 정답이 있는 비율 |
| 승인번호 | 58~62% | 아직 낮아서 폼에 쓰지 않습니다 |
| 인식 시간 | 중앙값 1.6초, 90%가 2.0초 안 | Mac 기준이고, 모바일에서도 빠르게 동작합니다 |
| 첫 방문 다운로드 | 32.6MB 한 번 | 이후 새로고침·새 탭에서도 0건 |
| 인식 중 외부 요청 | 0건 | 사진을 읽는 동안 밖으로 나가는 요청 수 |
처음의 기준과 대조하면 이렇습니다.
| 항목 | 기준 | 결과 | 판정 |
|---|---|---|---|
| 금액 | 90% | 95%(단일 결제) | 달성 |
| 일시 | 90% | 85% | 근접 |
| 승인번호 | 85% | 58~62% | 미달 |
| 사용처 | 후보 3개 안 80% | 62%(1순위 54%) | 미달 |
| 인식 시간 | 중앙값 5초, 90%가 8초 안 | 중앙값 1.6초, 90%가 2.0초 안(Mac) | 달성 |
| 외부 요청 | 인식 중 0건 | 0건 | 달성 |
| 기존 화면 | 그대로 | 체크박스 한 줄 추가, 사진 첨부 칸 위치 이동 | 의도한 변경 |
OCR은 잘 됐는데 파서가 아쉽게 남아버렸다…
승인번호는 엔진이 98% 읽는데도 파서가 58~62%만 잡아서 폼에 쓰지 않고 있고, 사용처도 후보 3개 안 62%로 기준 80%에 못 미칩니다. 둘 다 엔진이 아니라 규칙 파서 쪽의 한계라, 승인 블록 안의 숫자 배치 같은 위치 정보를 규칙에 더 넣으면 좀 더 올라갈 여지가 있습니다. 그래도 금액과 일시는 목표를 거의 달성했고, 사진 한 장으로 사용처와 금액이 채워지는 최초의 목적은 이루었기에 실서버에 배포해서 지금도 사용되고 있습니다.
참고 자료
- PaddlePaddle. PaddleOCR. https://github.com/PaddlePaddle/PaddleOCR
- PaddleOCR Documentation. Introduction to PP-OCRv5 Multilingual Text Recognition. https://www.paddleocr.ai/latest/en/version3.x/algorithm/PP-OCRv5/PP-OCRv5_multi_languages.html
- ONNX Runtime. Using WebGPU (onnxruntime-web). https://onnxruntime.ai/docs/tutorials/web/ep-webgpu.html
- naptha/tesseract.js. API 문서. https://github.com/naptha/tesseract.js/blob/master/docs/api.md
- Emscripten. Asyncify. https://emscripten.org/docs/porting/asyncify.html
- V8. WebAssembly JavaScript Promise Integration (JSPI). https://v8.dev/blog/jspi
- WebKit Bugzilla. Bug 304810 (High memory usage in WASM compilation thread). https://bugs.webkit.org/show_bug.cgi?id=304810
- MDN. Web Workers API. https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API
- MDN. Cache. https://developer.mozilla.org/en-US/docs/Web/API/Cache
- MDN. WebGPU API. https://developer.mozilla.org/en-US/docs/Web/API/WebGPU_API
- OpenCV. Structural Analysis and Shape Descriptors (minAreaRect). https://docs.opencv.org/4.x/d3/dc0/group__imgproc__shape.html