22화 2026-06-17

한글 형사기록, 로컬 모델로 읽힌 첫 페이지

지난 화에서 비교군을 좁혔다. 글로벌 범용 VLM인 Qwen3-VL을 기준선으로 삼아, 실제 증거기록 한 페이지를 읽혀보기로 했다.

테스트 자료는 무전취식(사기) 사건의 증거기록이다(모두 동의받은 자료다). 다만 완전한 형사 기록 전체를 다루는 것은 무거워서, 기록 하나에서 OCR 시험에 필요한 종류만 추려 31페이지짜리 간이 기록을 만들었다. 남긴 것은 기록 표지, 증거목록, 손글씨 진술서, 조회회보서(전과 기록 표의 인식 여부를 보기 위해), 피의자신문조서, 증거에 편철된 판결문 정도다. 인쇄된 본문(조서·판결문), 표(증거목록·전과 기록), 손글씨(진술서)가 한 묶음에 다 들어가도록 구성한 것이다. OCR이 각 종류를 어떻게 처리하는지를 한 기록으로 확인하기 위함이다.


스캔본을 페이지 이미지로 쪼개기

VLM은 이미지를 입력으로 받는다. PDF를 통째로는 못 읽으니, 31페이지 PDF를 page-01.png, page-02.png … 식으로 31장의 이미지로 변환해야 한다. 스캔본은 페이지 전체가 하나의 이미지라 텍스트 레이어가 없어서, 단순 텍스트 추출이 안 되고 반드시 이미지로 변환해 OCR을 거쳐야 한다.

변환 도구는 pdftoppm을 썼다. 리눅스에서 PDF를 페이지별 이미지로 쪼개주는 표준 도구이고, 대부분의 우분투에 기본 포함돼 있다.

해상도(DPI)를 먼저 정해야 했다. 스캔본 OCR에서 해상도는 정확도에 직결된다. 너무 낮으면 글자가 뭉개지고, 너무 높으면 이미지가 커져 모델이 느려진다. 한 페이지만 200 DPI와 300 DPI로 뽑아 눈으로 비교했다.

pdftoppm -png -f 1 -l 1 -r 200 2026ocr-test.PDF test_200dpi
pdftoppm -png -f 1 -l 1 -r 300 2026ocr-test.PDF test_300dpi

-png는 PNG로 저장하라는 뜻, -f 1 -l 1은 첫 페이지만 변환하라는 뜻, -r 200은 해상도 200 DPI를 뜻한다. 두 이미지를 비교하니 200 DPI에서도 글자가 충분히 읽을 만했다. 31페이지를 돌릴 것을 생각하면 가벼운 쪽이 유리하니 200 DPI로 확정하고, 전체를 변환했다.

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

페이지 범위 옵션 없이 돌리면 PDF 전체가 page-01.png부터 page-31.png까지 변환된다.


첫 페이지를 읽히다

먼저 받은 모델은 qwen3-vl:8b였다. 8쪽(피의자신문조서 첫 장, 깔끔한 타이핑 문서)에 이 프롬프트를 줬다.

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

명령은 이렇게 줬다. 모델을 부르고, 큰따옴표 안에 위 프롬프트를, 맨 끝에 읽힐 이미지 파일을 붙인다.

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

핵심은 “추측하지 말고 [판독불가]“다. 환각을 막는 최소 장치로 넣었다. Added image './page-08.png'가 떴다. 모델이 이미지를 제대로 입력받았다는 뜻이고, 이로써 비전 기능이 정상 작동한다는 것이 확인됐다.

결과는 거의 완벽했다. 제목 “피의자신문조서”, 피의자 “한종○”, 날짜 “2026. 4. 15. 08:29”, 주민등록번호 앞자리, 주소, 등록기준지까지 한 자도 틀리지 않았다. 법조문 「형사소송법」 제244조의3의 낫표까지 살렸다. 숫자·고유명사·법조문이 형사기록에서 가장 중요한데, 그것이 다 맞았다.

빈 칸도 지어내지 않았다. 원본에서 답이 비어 있던 “직장주소는”, “연락처는” 같은 항목을 멋대로 채우지 않고 빈 상태로 뒀다. 환각을 가장 걱정했는데, 적어도 이 페이지에서는 없는 내용을 만들어내지 않았다.

실제 출력의 일부를 비식별화해 옮기면 이런 식이었다.

## 피의자신문조서

피의자 : 한종○

위의 사람에 대한 사기 피의사건에 관하여 2026. 4. 15. 08:29 서울성북경찰서
유치장 조사실에서 사법경찰리 경장 이○○은 사법경찰관 경감 정○○를 참여하게 하고,
아래와 같이 피의자임에 틀림없음을 확인한다.

문 : 피의자의 성명, 주민등록번호, 직업, 주거, 등록기준지 등을 말하시오.
답 : 성명은 한종○
    주민등록번호는 (앞자리)-*******
    직업은 미상
    주거는 서울 송파구 (이하 생략)
    등록기준지는 서울 성북구 (이하 생략)
...
사법경찰관은 ... 「형사소송법」 제244조의3에 따라 진술을 거부할 수 있는 권리 및
변호인의 참여 등 조력을 받을 권리가 있음을 ...

미세한 오독은 두 군데 있었다. “피의자임에”를 “피의자인에”로, “행사할 것인지”를 “행사를 행할 것인지”로 읽었다. 의미를 바꿀 정도는 아니지만, 정확한 워딩이 중요한 형사기록에서는 짚어둘 오독이다.


한 장에 1분 11초, 대부분이 혼잣말이었다

정확도는 좋았는데, 한 가지가 걸렸다. 시간이었다. 수십, 수백 페이지짜리 증거기록을 실제로 돌리려면 한 장에 몇 초 걸리는지가 곧 쓸 수 있느냐 없느냐를 가른다. 그래서 시간을 제대로 재서 다시 돌렸다.

time ollama run qwen3-vl:8b --verbose "...(같은 프롬프트)..." ./page-08.png

time은 명령 전체의 소요 시간을, --verbose는 모델 자체의 처리 통계(토큰 수, 생성 속도)를 출력 끝에 찍어준다.

결과는 이랬다.

total duration:    1m11s
load duration:     147ms
prompt eval count: 2093 token(s)
eval count:        2645 token(s)
eval duration:     1m10s
eval rate:         37.64 tokens/s

한 페이지를 OCR하는데 무려 1분 11초가 걸렸다. 출력 토큰도 2645토큰이나 됐다. 이 페이지의 실제 본문은 그리 길지 않은데 왜 이렇게 많을까. 이유는 모델이 답을 내기 전에 추론(Thinking)을 길게 했기 때문이다. 추론 내용을 들여다보면 정작 글자를 읽는 추론이 아니었다. “이걸 마크다운으로 옮기라고 했는데, 줄바꿈을 어떻게 살리지”, “표가 아니니까 그냥 줄바꿈으로 두면 되나”, “빈 칸은 어떻게 표시해야 하나” 같은, 출력 형식을 어떻게 맞출지를 두고 따지는 추론이었다. 한국어를 읽어내는 데 쓴 게 아니라 “마크다운으로 출력하라”는 지시를 어떻게 해석할지 고민하느라 길어진 것이다. 그 추론이 전부 토큰으로 계산됐고, 정작 필요한 한국어 OCR은 얼마 되지 않았다.

단순히 글자를 옮기는 작업에 깊은 사고는 필요 없다. 적어도 이 작업에서는 추론이 시간만 잡아먹고 있었다. 추론을 끄면 출력이 본문만 남아 시간도 짧아질 것이라 보고, 추론을 끄는 방법을 찾기로 했다.


빈손으로도 형사기록을 지어내는 모델

추론을 끄는 방법은 의외로 간단했다. Qwen3-VL에는 추론이 아예 꺼진 별도 모델, Instruct 버전이 있었다. 그건 잠시 뒤에 보기로 하고, 그 전에 재미 삼아 한 가지를 시험해봤다.

VLM은 못 보는 부분을 만나면 그럴듯하게 지어낸다고 했다. 그렇다면 아무것도 안 보여주면 어떻게 될까. 이미지 자리에 빈 파일을 넣고, 똑같이 “형사 기록 한 페이지를 옮겨라”라는 프롬프트를 줘봤다. 결과가 이랬다.

# 형사사건 기록
**사건번호**: 2023-10-001
**작성일**: 2023년 10월 5일
**피고인**: 김철수
**변호사**: [판독불가]

볼 이미지가 없는데도, 모델은 사건번호·작성일·피고인까지 갖춘 형사기록 한 장을 통째로 지어냈다. 2023년에 “김철수”라는 가공의 인물이 피고인인, 어디에도 없는 문서다. 그림 없이 “형사 기록 한 페이지를 옮겨라”라는 글자만 받고서, 형사기록이라는 게 대충 이렇게 생겼겠거니 하고 형식을 흉내 낸 것이다. 형식만큼은 제법 그럴듯해서, 보고 있자니 좀 귀엽기도 했다.

111.webp

통계를 보면 빈손이었다는 게 드러난다. prompt eval count가 87토큰이었다. 이미지가 제대로 들어갔으면 이미지만으로 수천 토큰을 차지해 2000토큰대가 됐어야 하는데, 87토큰은 글자(프롬프트)만 들어갔다는 뜻이다. 21화에서 머리에 둬야겠다고 했던 그 환각을, 일부러 빈 파일을 넣어 눈으로 확인한 셈이다.

물론 실제 OCR에서는 이미지를 제대로 넣으니 이런 일이 없다. 다만 출력만 보면 멀쩡한 형사기록처럼 생겨서, 입력이 제대로 들어갔는지 통계(prompt eval count)로 한 번 확인하는 습관 정도는 들여둘 만하다.


Instruct 버전이라는 해답

다시 추론 끄기로 돌아가자. 앞서 말한 Instruct 버전이 그 답이다. Instruct 버전은 같은 모델에서 추론(Thinking) 기능만 뺀 것이다. 매번 옵션으로 추론을 끄는 것이 아니라, 처음부터 추론을 안 하는 모델을 받는 셈이다.

ollama pull qwen3-vl:8b-instruct

같은 8B 체급에 양자화도 같은 구성(Q4_K_M)이다. 받아서 같은 8쪽, 같은 프롬프트로 돌렸다. 통계는 이랬다.

total duration:    37.13s
load duration:     18.20s
prompt eval count: 2091 token(s)
eval count:        351 token(s)
eval duration:     8.70s
eval rate:         40.30 tokens/s

여기서 total duration 37초는 한 가지를 구분해서 봐야 한다. 이 안에는 모델을 처음 메모리에 올리는 시간(load duration, cold start) 18.20초가 섞여 있다. 실제로 글자를 생성한 eval duration은 8.70초다. 모델이 메모리에 올라간 뒤로는 한 페이지에 8.7초라는 뜻이고, 31페이지를 연달아 돌리면 모델은 한 번만 올리면 되니 이 8.7초가 실제 페이지당 처리 시간에 가깝다. 앞서 추론 버전이 한 페이지에 1분 10초가 걸린 것과 비교하면, 같은 8B 모델로 같은 정확도를 내면서 시간이 8분의 1로 줄어든 셈이다.

속도가 이만큼 빨라졌는데도 내용의 정확성은 추론 버전만큼 훌륭했다(이름·주민번호·법조문 모두 정확). 두 버전을 나란히 놓으니 차이가 분명했다.

항목qwen3-vl:8b (추론)qwen3-vl:8b-instruct
출력 토큰(eval count)2645351
생성 속도(eval rate)37.64 토큰/초40.30 토큰/초
순수 생성 시간(eval duration)1분 10초8.7초
정확도(이름·주민번호·법조문)정확정확
Thinking 출력있음(마크다운 형식 등 OCR과 무관한 추론)없음

핵심은 출력 토큰이다. 2645에서 351로, 7분의 1로 줄었다. 추론 버전은 OCR과 무관한 추론에 2300토큰을 쏟았다. 정작 OCR 결과물은 351토큰에 불과했는데도 말이다. Instruct는 그 군더더기 없이 351토큰만으로 OCR을 완료했다. 생성 속도(eval rate)가 비슷함에도, 만들어내는 출력 토큰 수가 다르다 보니 Instruct 버전이 결과를 훨씬 빨리 낼 수 있었던 것이다.

결국 단순 OCR에서는 추론이 정확도에 보탬이 되기는커녕 시간이라는 비용만 더한다는 것이, 같은 8쪽을 두 버전으로 돌려본 숫자로 확인된 셈이다.


오늘의 정리

피의자신문조서나 수사보고처럼 글자로 이뤄진 증거기록은 Instruct 모델만으로도 충분했다. 불필요한 추론을 덜어 속도를 확보하면서도 정확도가 훌륭했기 때문이다. 다만 조회회보서처럼 표로 이뤄진 증거기록이나 손글씨로 작성된 진술서는 어떨지 아직 모른다. 어려운 페이지에서도 이 모델이 버텨주는지는 다음 화에서 따져본다.

#법률실무#OCR