27화 2026-06-29

송달문서(SCAN파일) 정리하기

[변호사의 로컬llm 세팅기] 27화: 송달문서(SCAN파일) 정리하기

26화에서 기일지정서 한 장으로 구글 드라이브에서 받아, 모델로 읽히고, 캘린더에 일정을 만드는 흐름이 작동하는 것을 봤다. 이번에는 그 흐름을 스크립트로 묶으려 한다.

먼저 무엇을 만들려는지 실제 사무실 풍경으로 그려본다.

법원·검찰·경찰에서 사무실로 송달 온 서류들은 일괄로 스캔되어 구글 드라이브의 한 폴더에 쌓인다. 스캐너가 붙이는 이름은 scan0001.pdf, scan0002.pdf … 식이라, 파일명만으로는 무엇이 누구 사건의 어떤 서류인지 알 수 없다. 지금까지는 이걸 사람이 하나씩 열어보고, 피고인 폴더로 옮기고, 공판기일이 있으면 캘린더에 손으로 입력했다.

내가 만들려는 것은 이 일을 로컬 LLM에 맡기는 것이다. scan0001.pdf 형식으로 쌓인 스캔 파일들을 모델이 읽어 내용을 파악하고, 그에 따라 필요한 일 — 사건 폴더로 분류, 일정 등록, 이력 기록, 통보 — 을 알아서 처리하게 하는 것이다.

한 문장으로 줄이면 이렇다.

스캔 폴더에 scan0001.pdf 형식으로 쌓인 서류를 로컬 모델로 읽어, 누구 사건인지 가려 그 사람 폴더로 분류하고, 출석할 일정이 있으면 캘린더에 넣은 후, 처리 결과를 텔레그램으로 알려준다.

이 한 문장을 다시 단계로 펼치면, 다음 순서로 처리된다.

단계하는 일쓰는 도구
1. 다운로드SCAN 폴더의 PDF를 ATOM에 내려받기gog drive download
2. 이미지 변환앞 3쪽을 PNG로 쪼개기pdftoppm
3. 추출당사자·출석일정·일시 등을 JSON으로 뽑기qwen3-vl:8b-instruct
4. 후처리깨진 JSON 정리(따옴표·키·이름)sed, jq
5. 폴더 분류당사자로 폴더를 찾거나 만들어 이동gog drive ls/mkdir/move
6. 이름 변경피고인\_서류이름\_월일.pdfgog drive rename
7. 캘린더출석 일정이 있으면 등록gog calendar create
8. 사건 일지피고인 폴더 독스에 한 줄 누적gog docs create/insert
9. 통보텔레그램으로 결과·링크 전송curl

처리 결과가 쌓이는 구글 드라이브 폴더 구조는 이렇게 잡았다.

Ai Test/
├── SCAN/          ← 스캐너가 서류를 떨구는 입력함 (미처리 파일만 남는다)
├── INBOX/         ← 당사자를 못 가려낸 미분류 서류
└── 사건/          ← 피고인 폴더들이 모이는 곳
    ├── 김OO/
    │   ├── 김OO\_공판기일통지서\_0629.pdf
    │   └── 김OO\_사건요약          (구글 독스, 이력 누적)
    ├── 오OO\_이OO/  (공동피고인은 가나다순으로 이름을 잇는다)
    └── 한OO/

SCAN폴더에 들어온 서류들은 모델이 해석하여 사건 폴더 내의 피고인 이름 폴더로 들어간다. 피고인이 특정되지 않은, 혹은 분류할 수 없는 파일은 INBOX폴더 안으로 들어간다.

이번 화는 그중 추출 — 위 표의 3·4단계, PDF를 받아 필요한 값을 JSON으로 뽑는 일 — 만 다룬다.

일단은 SCAN 폴더 안에 6종류의 문서를 넣어 테스트해보기로 했다.

  1. 피고인 작성의 타이핑된 반성문
  2. 공소장(공동피고인)
  3. 공판기일지정통지서
  4. 형사사건 기록
  5. 불송치이유서
  6. 판결문

1,2,3으로 먼저 파이프라인을 만들어보고, 4,5,6은 다음날 스캔 된 파일을 가정하여 4,5,6을 돌릴때 1,2,3과 충돌이 없는지 테스트 해보기로 했다. ---

스캔 파일들을 어떻게 분류할 것인가

분류의 출발점은 “이 서류가 누구 것이고, 무엇을 해야 하는가”다. “공판기일통지서인가, 반성문인가, 증거기록인가”를 어떻게 인식하고 분류할 것인가.

OCR모델을 사용하여 문서를 인식하고, 그 내용을 파악하여 각 항목으로 이뤄진 JSON으로 정리한다. 당사자가 누군지 파악할때는, 상대방 이름이랑 헷갈리면 안된다. 동명이인이 있을 경우 어떻게 처리할지, 공동피고인 사건은 어떻게 처리할지 등이 문제다. 기일도 출석일자인지, 발송일인지 등 세심하게 살펴야 할 부분이 있는데 이는 모델에 들어갈 프롬프트에서 정리해보기로 한다.

---

모델은 무엇으로, 몇 쪽을 보는가

22화에서 검증한 qwen3-vl:8b-instruct를 그대로 쓴다. 이미지를 직접 보고 글자를 읽어내는 비전 모델이다.

페이지는 앞 3쪽만 본다. 분류에 필요한 당사자·사건번호·일시는 거의 다 문서 앞머리에 있다. 수백쪽짜리 증거기록이라도 표지 한 장에 피고인과 사건번호가 박혀 있다. 두꺼운 문서를 통째로 모델에 넣으면 느리고 출력도 흔들리니, 앞 3쪽으로 고정한다.

추출 스크립트의 뼈대는 이렇다. PDF를 받아 pdftoppm으로 앞 3쪽을 200 DPI PNG로 변환하고, 그 이미지들을 프롬프트와 함께 모델에 넣어 JSON 한 덩어리를 받는다. 프롬프트는 별도 파일(prompt.txt)로 빼뒀다. 처음 짠 프롬프트는 이랬다.

이 이미지들은 한국어 형사 관련 문서 한 건의 앞부분 페이지다.
모든 페이지를 종합해서 보고, 아래 형식의 JSON만 출력해라. 다른 말은 절대 하지 마라.

{
  "당사자": \[],
  "출석일정있음": false,
  "일시": null,
  "장소": null,
  "사건번호": null,
  "서류이름": "",
  "서류요약": ""
}

판단 기준:
- "당사자"는 이 사건에서 수사나 재판을 받는 사람의 이름이다. "피고인",
  "피의자", "대상자" 등으로 적힌 사람을 모두 배열에 넣어라. 서류를 보내거나
  받는 법원·검찰·경찰·변호사·법무법인의 이름은 당사자가 아니다.
- "출석일정있음"은 본인이 특정 일시에 직접 출석하거나 참석해야 하는 날짜와
  시각이 문서에 적혀 있으면 true다. 서류 작성일, 발송일, 구속일, 제출 기한은
  출석 일정이 아니다.
- "일시"는 출석일정있음이 true일 때만 "YYYY-MM-DD HH:MM" 형식으로 채워라.
- "서류이름"은 이 문서가 무엇인지 짧게.
- "서류요약"은 이 문서의 핵심을 한 문장으로.

핵심은 두 가지다. 당사자를 직함(피고인·피의자)에 매이지 않고 사람 이름으로 뽑게 한 것, 그리고 출석 일시를 작성일·구속일·제출 기한과 구분하게 한 것이다. 모델에게 종류를 묻지 않고 “당사자가 누구이고 출석 일시가 있는가”라는 사실만 묻는다는 앞의 원칙이 그대로 들어가 있다. 이 프롬프트를 파일에서 읽어 이미지와 함께 모델에 넘긴다.

PROMPT="$(cat "$PROMPT\_FILE")"
RAW="$(ollama run "$MODEL" "$PROMPT" "${PAGES\[@]}" 2>/dev/null)"

첫 줄은 프롬프트를 파일에서 읽어오는 부분이다. cat으로 prompt.txt 내용을 읽고, $( )로 그 결과를 변수 PROMPT에 담는다. 둘째 줄이 모델을 부르는 부분으로, ollama run에 모델 이름·프롬프트·이미지 목록(PAGES, 앞 3쪽 PNG)을 넘겨 응답을 받는다. 2>/dev/null은 진행 표시나 경고 같은 부수 출력을 버려 실제 응답만 RAW에 남기게 한다.

ollama run <모델> "<프롬프트>" <이미지1> <이미지2> <이미지3> 형식이다. 받은 출력을 정리해 JSON으로 파싱하려 했는데, 생각보다 파싱이 어려웠다.

---

출력이 깨끗한데 파싱이 안 된다

첫 시도에서 모델은 정확한 내용을 뱉었다. 공판기일통지서를 넣으니 당사자 김OO, 출석일정 있음, 일시 2026-06-11 15:00, 장소까지 다 맞았다. 출력은 이랬다.

{
  "당사자": \["김OO"],
  "출석일정있음": true,
  "일시": "2026-06-11 15:00",
  "장소": "OO지방법원 408호 법정",
  "사건번호": "2025노OOOO",
  "서류이름": "공판기일통지서",
  "서류요약": "OO지방법원이 피고인 김OO에게 2026년 6월 11일 15시에
열릴 공판기일을 통지하는 공판기일통지서."
}

내용은 한 자도 틀리지 않았다. 그런데 이 JSON을 jq로 파싱하려 하는데서 문제가 발생하기 시작했다.

(JSON 파싱 실패 — 위 원본 출력을 확인)

값 안을 자세히 보면 단서가 있다. “서류요약” 값에 줄 바뀜이 있는 것이었다. JSON 문자열 안에서 값이 끝나기 전에 실제 줄바꿈이 들어가면 규격 위반이라 jq가 거부한다. 그래서 파싱이 안 된 것이다.

프롬프트에 “값 안에 줄바꿈을 넣지 마라”를 넣고, 받은 출력의 줄바꿈을 공백으로 바꿔 넘겨봤다.

그래도 깨졌다. 이번엔 다른 자리였다. 혹시 눈에 보이지 않는 출력이 들어있나 싶어, 출력을 cat -A로 — 눈에 안 보이는 문자까지 드러내 보여주는 옵션이다 — 들여다보니, 멀쩡해 보이던 JSON에 ^\[\[K, ^\[\[1G 같은 것이 곳곳에 박혀 있었다. (cat -A는 한글도 M-l M-^^ 같은 바이트 표기로 풀어 보여주는데, 그 사이사이에 이 제어 문자가 섞여 있었다.)

"일시": "2026-06-11 15:00", "M-lM-^^M-%^\[\[K
M-lM-^FM-^L": "...408M-mM-^XM-8 M-kM-2M-^UM-lM- M-^U", ...

^\[\[K가 그 정체다. 이것은 터미널 제어 문자(ANSI escape)다. ollama가 출력하면서 진행 표시(스피너)와 화면 갱신을 위해 이 문자들을 같이 내보냈고, 화면에선 안 보이지만 실제 데이터에는 값 중간중간에 박혀 있었다. 그래서 jq가 “문자열 안에 이상한 게 있다”며 거부한 것이다. 줄바꿈이 문제가 아니었다.

이건 받은 출력에서 제거하면 된다. ESC 문자로 시작하는 시퀀스를 sed로 걷어낸다.

sed 's/\\x1b\\\[\[0-9;?]\*\[a-zA-Z]//g'

sed는 텍스트에서 특정 패턴을 찾아 바꾸거나 지우는 도구다. 기본 형태는 s/A/B/g로, “A에 해당하는 것을 모두 B로 바꿔라”는 뜻이다. 여기서는 바꿀 자리(B)를 비워, A에 걸리는 것을 전부 지운다. A 자리에 쓴 \x1b\[[0-9;?]*[a-zA-Z]가 지울 대상인데, \x1b가 ESC 문자고 그 뒤 [로 시작해 영문자로 끝나는 제어 시퀀스를 가리킨다. 즉 ESC로 시작하는 제어 문자를 찾아 통째로 지우는 명령이다. 이걸 넣으니 출력이 깨끗해졌다. 그런데도 파싱은 또 실패했다.

---

반복되는 두개의 따옴표

이번 깨짐은 모양이 분명했다. 전체 출력은 이랬다.

{"당사자": \["김OO"], "출석일정있음": true, "일시": "2026-06-11 15:00",
"장소": "OO지방법원 408호 법정", "사건번호": "2025노OOOO", "서류이름": "
"공판기일통지서", "서류요약": "공판기일 지정 통지"}

"서류이름": " 다음에 또 "공판기일통지서"가 와서, 따옴표가 ""로 하나 더 붙었다. 모델이 값을 쓸 때 빈 따옴표를 먼저 열고, 그 뒤에 값을 또 쓴 것이다. 이 따옴표 하나 때문에 JSON 구조가 깨진다.

처음엔 14화에서 겪은 비결정론 — 같은 입력에도 모델이 매번 조금씩 다르게 답하는 — 으로 여겼다. 그런데 같은 파일을 세 번 돌리니, 세 번 다 글자 하나까지 똑같이 깨졌다. 완전히 결정론적인 깨짐이었다. 그리고 깨지는 자리가 항상 서류이름·요약 같은 자유 텍스트 값 근처였다.

공통된 것이 서류이름이 공란이었기에 두 가지를 시도했다. 프롬프트의 형식 예시에서 빈 값("")을 없애고 강제로 채우게 한 것, 그리고 서류이름을 객관식(보기 중 택일)으로 만든 것. 둘 다 “빈 자리라서 모델이 헷갈린다”는 가설에서 나온 수정이었다. 그러나 객관식으로 바꿔도 똑같이 깨졌다. 빈 자리가 원인이 아니었다.

결정론적으로 같은 자리에서 깨진다는 것은, 프롬프트로는 이 버릇을 못 이긴다는 뜻이다. 결국 모델을 구조적으로 바꿔야 한다는 뜻인데, 이는 내가 당장 할 수 있는 일이 아니라고 판단해 그냥 후처리를 하기로 했다.

깨짐이 결정론적이라 항상 같은 모양으로 깨지니, 그 모양을 정리하는 후처리 한 줄을 넣을 수 있다. 정상 출력에는 빈 문자열("")이 나오지 않게 프롬프트를 짰으므로, 출력에 따옴표가 연속으로 두 개 나오면 그건 전부 깨짐이다. 위치를 가리지 않고, 연속된 따옴표를 하나로 합친다.

sed -E 's/"\[\[:space:]]\*"/"/g'

따옴표 두 개가 (사이에 공백이 끼어도) 붙어 나오면 하나로 만든다. """ "든 — 줄바꿈을 공백으로 바꾸는 과정에서 두 따옴표 사이에 공백이 끼는 경우도 있었다 — 모두 잡힌다.

이 한 줄을 넣으니 세 번 다 통과했다. 모델 출력을 그대로 믿지 않고, 깨진 패턴을 알고 고쳐 쓰는 것이다. 비결정론이었으면 이 방법도 안 통했겠지만, 결정론적이라 패턴이 고정돼 있어 잡을 수 있었다.

---

키 이름에 공백이 끼고, 이름에 직함이 붙는다

파싱은 통과했지만, 통과한 JSON에 두 가지가 더 보였다.

하나는 키 이름이었다. "장 소"처럼 키 글자 사이에 공백이 끼었다. 모델이 “장소” 두 글자 사이에서 줄을 바꾼 것이 공백이 된 것이다. 파싱은 되지만, 나중에 스크립트에서 .장소로 값을 꺼낼 때 키가 장 소라 못 찾는다.

다른 하나는 당사자 이름이었다. 반성문에서 당사자가 "피고인 한 종 O수"로 나왔다. “피고인”이라는 직함이 이름에 딸려 왔고, 글자 사이 공백도 있었다(이름을 띄어 쓴 양식이라 그렇다). 이대로 폴더명을 만들면 피고인 한 O 수 폴더가 된다.

이 둘도 프롬프트로 막기보다 후처리로 잡았다. 이번엔 sed가 아니라 jq로 정리했다. JSON을 다루는 도구이니 키 이름을 바꾸고 값을 다듬는 데 적합하다. 정리식은 별도 파일(clean.jq)로 뒀다.

with\_entries(.key |= (gsub("\\\\s+"; "")))
| if has("당사자") then
    .\["당사자"] |= (map(
      gsub("피고인|피의자|대상자|피해자|참고인"; "")
      | gsub("\\\\s+"; "")
    ))
  else . end

이 정리식은 두 가지 일을 한다.

첫 줄 with_entries(.key |= (gsub("\\s+"; "")))는 모든 키 이름에서 공백을 지운다. with_entries가 JSON의 각 항목을 하나씩 훑고, .key |= ...가 그 키 이름을 바꾼다. gsub("\\s+"; "")는 공백(\s)이 하나 이상 이어진 부분을 빈 문자열로 바꾸라는 뜻이라, 장 소 같은 키가 장소로 정리된다.

나머지는 당사자 이름을 다듬는 부분이다. if has("당사자") then ... else . end로 “당사자” 키가 있을 때만 처리하고, 없으면 그대로 둔다(.). .["당사자"] |= (map(...))는 당사자 배열의 각 이름에 차례로 손을 댄다는 뜻이다. 한글 키는 .당사자로 바로 못 쓰고 .["당사자"]처럼 따옴표로 감싸야 한다. map 안에서 두 가지를 한다.

이렇게 고치니 정리가 됐다.

---

세 종류 문서로 확인

이제 추출 흐름이 섰다. PDF를 받아 앞 3쪽을 모델에 넣고, ESC 제거 → 따옴표 겹침 보정 → jq로 키·이름 정리. 스캔 폴더에 든 세 종류 문서로 돌렸다.

// 공판기일통지서
{"당사자": \["김OO"], "출석일정있음": true, "일시": "2026-06-11 15:00",
 "장소": "OO지방법원 408호 법정", "사건번호": "2025노OOOO",
 "서류이름": "공판기일통지서", "서류요약": "공판기일 지정 통지"}

// 공소장 (공동피고인)
{"당사자": \["오OO", "이OO"], "출석일정있음": false, ...
 "서류이름": "공소장"}

// 반성문
{"당사자": \["한OO"], "출석일정있음": false, ...
 "서류이름": "반성문"}

세 가지가 다 확인됐다. 공판기일통지서는 당사자·일시·장소를 정확히 뽑았다. 공소장은 공동피고인 두 사람을 모두 배열에 넣었고, 출석일정은 false로 뒀다 — 공소장에는 구속일·체포일 같은 날짜가 있는데, 그것을 출석 일정으로 오해하지 않았다. 반성문은 당사자 한 사람을 뽑고 출석일정은 false였다.

---

오늘의 정리

오늘은 여러 종류의 문서를 한 파이프라인으로 다루기 위한 추출 단계를 세웠다.

추출이 단단해졌으니, 다음은 이 JSON을 받아 실제로 폴더를 나누고 파일을 옮기는 단계다. 다음 화에서 만든다.

#법률실무#자동화