23화 2026-06-20

표와 자필진술서, OCR은 어디까지 읽어낼까

지난 화에서 qwen3-vl:8b-instruct로 인쇄된 한글 본문을 빠르고 정확하게 읽었다. 피의자신문조서나 판결문처럼 문장이 줄줄이 이어지는 글, 이 글에서는 이런 페이지를 ‘줄글’이라 부르겠다. 그런데 형사 증거기록이 이런 줄글만으로 이뤄지는 것은 아니다. 전과가 빽빽한 표, 도장이 겹쳐 찍힌 페이지, 흘려 쓴 자필진술서가 섞여 있다. 이번 화의 질문은 이것이다. 줄글이 아닌 페이지에서도 이 모델이 쓸 만한가.

미리 말하면, 결과는 생각보다 좋았다. 줄글뿐만 아니라 표의 내용도 거의 다 정확하게 읽어냈다. 다만 표의 시각적 형식이 일부 흐트러졌고, 자필진술서만은 예외였다. 하나씩 보겠다.


표 페이지: 내용은 정확하다, 형식이 흐트러진다

먼저 표를 읽혔다. 21쪽 조회회보서로, 인적사항 표와 범죄경력 표가 섞인 페이지다. 전과가 행으로 빼곡히 들어가 있어 줄글보다 까다로운 페이지다. 프롬프트는 이렇게 줬다.

이 문서는 한국어 형사 기록의 한 페이지다.
페이지에 보이는 텍스트를 그대로 옮겨라.
표는 마크다운 표로 옮기되 빈 칸은 비워 두고 내용을 지어내지 마라.
보이지 않거나 읽을 수 없는 글자는 추측하지 말고 [판독불가]로 표시하라.
마크다운으로 출력하라.

줄글에 쓰던 프롬프트에 “표는 마크다운 표로 옮기되 빈 칸은 비워 두고 내용을 지어내지 마라”는 한 줄을 더한 것이다. 이 프롬프트로 21쪽(조회회보서)을 돌렸다.

ollama run qwen3-vl:8b-instruct "(프롬프트)" ./page-21.png

결과가 인상적이었다. 표 안의 내용은 거의 다 정확했다. 주민지문번호, 일련번호, 사건번호, 처분일자, 죄명, 처분결과, “여신전문금융업법위반” 같은 어려운 단어까지 제대로 읽었다. 전과 표는 숫자와 날짜가 빼곡한데, 그 숫자들을 거의 틀리지 않고 옮겨냈다. 추론을 끈 가벼운 모델로 이만큼 읽어낸 것은 줄글에 이어 또 하나의 긍정적인 신호였다.

다만 한 가지가 흐트러졌다. 표의 시각적 형식이다. 원본 전과 표는 한 항목 안에 여러 죄명이 묶여 있는 구조다. 예를 들어 1번 항목 안에 절도·여신전문금융업법위반·사기가 세트로 들어가 있고, 입건일과 처분일이 두 줄로 나뉜다. 모델은 이 묶인 칸을 평평하게 펴서, 각 죄명을 별도 행으로 늘어놨다. 글자는 다 맞게 읽었는데, 어느 죄가 어느 항목에 묶이는지를 보여주는 표의 시각적 배치가 흐트러진 것이다.

출력의 일부를 비식별화해 옮기면 이렇다. 글자는 정확한데, 원래 한 항목으로 묶여 있던 죄명들이 각각 별도 행으로 펴진 것이 보인다.

| 연번 | 입건일 | 입건관서 | 죄명 | 처분결과 |
|---|---|---|---|---|
| 1 | 2005. 5. 2. | 서울수서경찰서 | | |
|   | 2005. 6. 24. | 절도 | 서울동부지방법원 | 벌금200만원 |
|   |   | 여신전문금융업법위반 | | |
|   |   | 사기 | | |
| 2 | 2007. 10. 18. | 서울송파경찰서 | | |
...

짚어둘 것은, 표에서는 “글자를 읽는 것”과 “형식을 살리는 것”이 별개라는 점이다. 그리고 형사기록 실무에서 더 중요한 쪽은 글자다. 전과 표에서 우리가 알아야 하는 것은 결국 “언제, 무슨 죄로, 어떤 처분을 받았나”라는 내용이고, 그 내용은 다 살아 있다. 표의 줄 묶음이 흐트러진 것은 사람이 원본을 한 번 보면 금세 맞출 수 있는 수준이다. 내용이 정확하다는 것이 형식이 흐트러진 것보다 훨씬 큰 수확이다.


추론을 켜면 형식도 살아난다, 다만 느리다

흐트러진 표 형식을 추론 버전이라면 살릴 수 있을지 궁금했다. 같은 21쪽(조회회보서)을 추론 버전(qwen3-vl:8b)으로 돌렸다.

추론 버전은 표 구조를 먼저 따져보았다. 추론 과정을 보면, 원본 표에 병합된 칸이 있다는 것을 알아채고 그것을 어떻게 표현할지 고민하고 있었다. Instruct가 그냥 펴버렸던 그 지점을, 추론 버전은 “여기는 칸이 묶여 있다”고 인식한 것이다.

그리고 실제로 형식을 살려냈다. 범죄경력 표에서 한 항목 안에 여러 죄명이 묶인 구조를, 빈 칸을 활용해 원본에 가깝게 표현했다. Instruct가 펴버린 것을 추론 버전은 묶어낸 것이다. 내용은 물론이고 표의 시각적 형식까지 정확하게 재현한 셈이다.

같은 부분을 추론 버전 출력으로 옮기면 이렇다. 앞서 Instruct가 죄명마다 행을 나눴던 1번 항목이, 여기서는 한 칸 안에 묶여 있다.

| 연번 | 입건일 | 입건관서 | 죄명 | 처분결과 |
|---|---|---|---|---|
| 1 | 2005. 5. 2. | 서울수서경찰서 | 절도<br>여신전문금융업법위반<br>사기 | 2005. 6. 24.<br>서울동부지방법원<br>벌금200만원 |
| 2 | 2007. 10. 18. | 서울송파경찰서 | ... | ... |

한 항목 안의 여러 죄명을 <br>로 한 칸에 담아, 어느 죄가 어느 항목에 묶이는지가 원본처럼 살아 있다. Instruct 출력에서 빈 칸으로 흐트러졌던 그 묶음이, 추론 버전에서는 제자리를 찾은 것이다. (참고로 <br>은 마크다운 표의 한 칸 안에서 줄을 바꾸는 표시다. 칸 안에 그냥 엔터를 넣으면 표가 깨지므로, 한 칸에 여러 줄을 담을 때 이 표시를 쓴다.)

다만 대가는 시간이었다.

항목qwen3-vl:8b-instructqwen3-vl:8b (추론)
소요 시간(real)20.8초3분 55초
출력 토큰(eval count)7177772
표 형식 재현흐트러짐살림

추론 버전은 형식을 살렸지만 한 페이지에 3분 55초가 걸렸다. Instruct의 20.8초와 비교하면 11배다. 토큰도 10배 넘게 썼다. 표 형식을 살리는 값으로 페이지당 4분을 치러야 한다면, 표가 여러 장인 기록에서는 부담이 크다. 형식이 흐트러진 것을 사람이 손으로 맞추는 편이 빠를 수 있다.

여기서 한 가지 더 확인된 것이 있다. 추론 버전도 작은 글자의 이름 오독(“한종○“의 가운데 글자를 비슷한 글자로 읽은 것)은 Instruct와 똑같이 냈다. 3분 55초를 더 들였는데도 그 오독은 못 고쳤다. 이름 오독은 “더 고민해서” 풀리는 문제가 아니라 “작은 글자가 시각적으로 어떻게 보이느냐”의 문제이기 때문이다. 이 점은 뒤에서 다시 짚는다.


파라미터가 크면 OCR이 나아지나

여기서 21화에 걸어둔 의문에 답이 나온다. 추론을 켠다는 것은 모델이 더 많이 사고하게 만드는 것인데, 그렇게 해도 작은 글자 오독은 안 고쳐졌다. 사고를 늘려도 안 보이는 획이 보이지는 않는다. 이것은 파라미터를 키우는 것으로도 마찬가지일 가능성이 높다.

OCR에서 정확도를 좌우하는 우선순위를 정리하면 이렇게 된다. 이미지 해상도, 그다음 OCR 특화 여부, 그다음 추론 여부, 그리고 맨 뒤가 파라미터 크기다. 8B Instruct가 인쇄 텍스트와 표 내용을 거의 정확하게 읽은 것이 그 증거다. 8B로 충분했다.

오히려 큰 모델이 OCR에서 불리할 수도 있다. 큰 모델은 “맥락상 이 글자는 X일 것”이라는 보정을 잘하는데, 그 보정이 과하면 원본에 없는 것을 그럴듯하게 채우는 쪽으로 기운다. OCR은 똑똑한 것보다 원본에 충실한 것이 미덕인데, 큰 모델은 똑똑한 쪽으로 기울 위험이 있다. 정확도를 올리고 싶으면 모델을 키우기보다 해상도를 올리는 것이 먼저다.


자필진술서는 어떨까

결론부터 말하면, 자필진술서는 OCR이 제대로 되지 않았다. 4쪽(피해자 작성의 진술서)을 OCR 돌려보았다. 프롬프트는 손글씨 처리 지시를 담아 이렇게 줬다.

이 문서는 한국어 형사 기록의 한 페이지다.
페이지에 보이는 텍스트를 그대로 옮겨라.
손으로 쓴 글씨는 내용을 추측하지 말고 [손글씨]로 표시하라.
보이지 않거나 읽을 수 없는 글자는 추측하지 말고 [판독불가]로 표시하라.
마크다운으로 출력하라.

핵심은 “손으로 쓴 글씨는 추측하지 말고 [손글씨]로 표시하라”는 지시다. 흘려 쓴 글씨를 억지로 읽어 지어내지 말고, 손글씨라는 사실만 표시하고 넘어가라는 뜻이다. 못 읽는 글자는 [판독불가]로 두라고도 했다.

결과를 보면, 인적사항 칸(주민번호 마스킹 부분)은 지시대로 [판독불가]로 정직하게 뒀다. 그런데 정작 진술 본문의 손글씨는 [손글씨]로 판단치 못하고 OCR이 진행되었다. 원본과 모델이 읽은 것을 나란히 놓으면 이렇다(피해자 인적사항은 제외했다).

원본(손글씨)            →  모델이 읽은 것
조니블랙(니)를 시키셨고   →  과목카볼렉(니
탕수육                  →  항속육
2병                    →  (엉뚱한 숫자)
980,000원 나왔습니다     →  (오독)

술 종류와 수량, 금액이 다 틀어졌다. 무전취식 사건에서 “무엇을 얼마에 시켰나”가 피해액의 핵심인데, 모델이 그 부분을 제대로 OCR하지 못했다.

반면 추론 버전은 손글씨임을 대번에 알아챘다. 읽을 수 없음을 인정하더니, 진술을 통째로 [손글씨]로 비워뒀다. 못 읽는 것을 정직하게 비워둔 것이다.

정리하면, 자필진술서는 Instruct 버전이든 추론 버전이든 쉽지 않았다. 자필진술서까지 OCR할 수 있는 모델이 나오는지는 관심을 갖고 지켜봐야겠지만, 당분간 자필진술서 OCR은 어려울 것 같다.


기록 전체를 한 번에 OCR해보기

이제 31페이지 전체를 Instruct로 돌려, 대량 처리가 실제로 쓸 만한지 봤다. 먼저 원본 PDF를 페이지별 이미지로 쪼개야 한다. pdftoppm으로 PDF를 페이지마다 PNG로 변환했다.

pdftoppm -png -r 200 2026ocr-test.PDF page

-png는 PNG로 저장하라는 뜻이고, -r 200은 해상도를 200dpi로 준 것이다. 앞에서 본 대로 OCR 정확도는 모델 크기보다 해상도가 먼저이니, 너무 낮게 잡지 않았다. 명령을 돌리면 page-01.png부터 page-31.png까지 31장이 만들어진다.

31페이지에는 인쇄·표·자필이 다 섞여 있으니, 종류별 지시를 하나로 합친 프롬프트를 썼다.

이 문서는 한국어 형사 기록의 한 페이지다.
페이지에 보이는 인쇄된 텍스트를 그대로 옮겨라.
손으로 쓴 글씨는 내용을 추측하지 말고 [손글씨]로 표시하라.
표는 마크다운 표로 옮기되 빈 칸은 비워 두고 내용을 지어내지 마라.
보이지 않거나 읽을 수 없는 글자는 추측하지 말고 [판독불가]로 표시하라.
마크다운으로 출력하라.

페이지마다 손으로 명령을 치는 대신, 위 프롬프트를 PROMPT 변수에 담아 반복문 스크립트로 묶었다.

for img in page-*.png; do
  name=$(basename "$img" .png)
  echo "=== $name 처리 중 ($(date +%H:%M:%S)) ==="
  ollama run qwen3-vl:8b-instruct "$PROMPT" "./$img" > "out/$name.md" 2>/dev/null
done

for 반복문이 page-01.png부터 page-31.png까지 하나씩 모델에 넣고, 각 결과를 out/page-01.md 식으로 저장한다. 페이지마다 처리 시작 시각을 찍어 진행을 보이게 했다. 2>/dev/null은 잡다한 진행 표시를 버려 결과 파일에 순수 텍스트만 담기게 하는 부분이다.

실행하면 페이지마다 처리 시작 시각이 찍히며 진행된다.

=== page-01 처리 중 (14:24:39) ===
=== page-02 처리 중 (14:24:56) ===
=== page-03 처리 중 (14:25:18) ===
...
=== page-22 처리 중 (14:28:39) ===
=== page-23 처리 중 (14:29:25) ===
...
=== page-31 처리 중 (14:32:16) ===
=== 전체 완료: 468초 ===

전체 완료까지 7분 47초가 걸렸다. 페이지별 시작 시각의 차이를 보면, 인쇄 페이지는 1012초씩으로 빠르고 일정한 반면, 전과가 빽빽한 표 페이지(2224쪽)는 한 장에 45초씩 걸렸다. 페이지 종류에 따라 처리 시간이 4배 차이 났다. 31페이지를 8분이 안 되는 시간에, 의뢰인 정보가 한 바이트도 밖으로 나가지 않고 처리한 것이다.


1차 처리를 비전 모델로 할지, Instruct로 할지

전체를 돌려보니 한 가지 방향이 보였다. 페이지 종류에 따라 처리 방식을 달리하면 더 효율적이라는 점이다.

지금까지 본 것을 정리하면 이렇다. 줄글은 Instruct로 빠르고 정확하게 처리된다. 표는 내용은 Instruct로 충분하고, 형식까지 정확히 살리려면 추론 버전이 낫지만 시간이 11배 든다.

그렇다면 한 기록을 통째로 한 모델에 맡기는 대신, 페이지를 먼저 분류해 종류별로 다른 처리를 거는 방법을 생각해볼 수 있다. 줄글이 대부분인 인쇄 페이지는 빠른 Instruct로 한 번에 돌리고, 표의 형식이 중요한 페이지는 추론 버전으로 돌리는 식이다.

어떻게 분류하느냐가 문제인데, 증거목록을 참조하는 것도 방법이다. 형사 증거기록의 앞머리에는 증거목록이 붙고, 거기에 서류명과 쪽수가 정리돼 있다. 증거목록만으로도 진술서인지 진술조서인지 등을 1차적으로 구별할 수 있다. 그러면 이 분류를 기준으로, 페이지마다 어떤 모델로 돌릴지를 스크립트가 정하게 만들 수 있다.

물론 증거목록만으로 모든 게 갈리지는 않는다. 같은 “진술서”라도 인쇄된 것과 자필인 것이 섞일 수 있고, 조서 중간에 손으로 쓴 페이지가 끼기도 한다. 그래서 증거목록 기반 분류를 1차로 깔고, 결과물에 [손글씨]나 [판독불가]가 많이 섞인 페이지를 2차로 걸러 사람에게 넘기는 식으로 보완할 수 있다. 어쨌든 출발점은 사람의 눈이 아니라 기록이 이미 갖고 있는 증거목록이다. 이것을 어떻게 스크립트로 묶을지는 다음 과제로 남겨둔다.


오늘의 정리

qwen3-vl:8b-instruct로 31페이지를 8분 안에 완주했고, 줄글과 표 내용까지 정확하게 읽힌다는 것을 확인했다. 원래는 다음 화에서 한국어 특화 모델인 VARCO-VISION 2.0과 비교해볼 생각이었다. 그런데 구독자 한 분이 한국딥러닝의 KDL-Frontier-Parser-nano를 소개한 기사를 공유해주셨다. 12억(1.2B) 매개변수의 초경량 문서 파싱 특화 모델인데, 글로벌 문서 파싱 벤치마크에서 1위를 기록했고 외부 서버 없이 로컬에 설치해 쓸 수 있다고 한다. 이 글이 추구하는 완전 로컬 처리와 결이 맞아, 다음 화에서는 이 모델로 같은 기록을 돌려 표 형식이나 작은 글자 오독이 나아지는지 보기로 했다.

#법률실무#OCR