이 스킬의 아키텍처(2-pass: 라이브러리 변환 + LLM 판단 분리, 결정론적 안전망)는 한 번에 나온 게 아니라, 여러 차례 잘못된 방향을 시도하고 실패 사례를 근거로 되돌리는 과정을 거쳐 나왔다. Claude Code와 함께 반복적으로 검증하며 만들었고, 그 과정에서 나온 핵심 전환점을 여기 정리한다. (특정 문서/기관명은 일반화했다 — 실제 검증은 한국 공공기관 입찰공고문 42건으로 진행했다.)
처음 검토한 라이브러리는 헤더 검출이 14개 샘플 중 0개로 사실상 못 쓰는 수준이었다. 대안으로
hwp2md(Go, 자체 LLM 2단계 구조)를 찾아 시험했더니
13/14까지 올라갔지만, 표 병합 셀(rowspan/colspan)·읽기 순서 보존에서는 kordoc이
더 우수했다. 처음엔 "상호보완 관계"로 결론을 미뤄뒀는데, 이후 아키텍처가 "Stage1의 헤더 승격
결과에 애초에 의존하지 않는" 구조(Pass1이 원문 텍스트를 다시 스캔해서 직접 판단)로 정해지면서
hwp2md의 우위가 의미 없어졌다 — 표/순서 보존이라는, 다운스트림이 복구 불가능한 손실만 남는 kordoc의
강점이 최종 결정 기준이 됐다.
섹션 제목을 정규식/구조 규칙으로 판단하려는 시도가 최소 세 번 있었다:
- 표의 "1행 + 가운데 열 공백 + 숫자로 시작"이라는 구조 규칙 → 판단 근거를 설명하는 과정에서 "이건 기관마다 다른 표 레이아웃엔 안 통할 구조 규칙"이라는 게 스스로 드러남
- "번호가 연속되면 최상위, 끊기면 하위 중첩"이라는 번호 시퀀스 규칙 → 타 프로젝트에서 이미 똑같은 방식으로 시도했다가 헤더가 전멸했던 전례가 있다는 지적을 받고 즉시 폐기. 이후 실제로 무작위 5개 문서를 조사해보니 섹션 제목 표기 방식이 같은 문서 안에서도 최소 4가지(평문/볼드/ pseudo-table/네이티브 헤딩)로 갈렸다 — 규칙 기반이 구조적으로 안 통하는 이유가 실증됨.
- 라이브러리를 kordoc으로 바꾸면 이 애매함이 사라지는지도 확인했지만 "아니오"였다 — 원본 문서 자체의 표기 애매성 문제라 파싱 라이브러리 선택과 무관했다.
결론: "제목일 수 있는 줄" 후보를 규칙으로 넓게 뽑는 것(recall)까지는 규칙이 유효하지만, "이게 진짜 헤더인지, 어느 계층인지" 판단은 의미/문맥이 필요해서 규칙으로 안 된다. 이 구분이 지금 아키텍처의 Pass1a(규칙, 추출만)/Pass1b(LLM, 판단만) 분리로 이어졌다.
초기엔 "문서 전체를 항상 LLM에 다 보여줘야 하나? 페이지 단위로 잘라서 처리하면 안 되나"는 문제 제기로 시작했다 — 100페이지 넘는 문서가 들어올 수 있다는 워스트케이스가 제시되면서, 청킹은 "있으면 좋은 최적화"가 아니라 "없으면 파이프라인이 안 돌아가는 필수 요건"으로 확정됐다.
문제는 이 "윈도우"를 LLM이 섹션 본문을 통째로 재작성하는 단위로 쓴 것이었다. 이 설계는 재작성 과정에서 반복적으로 사고를 냈다:
- 승인된 헤더가 재작성 결과에 아예 안 나타나는 경우(문단 전체 유실)
- 윈도우 경계가 비정상적으로 커서(3만자 이상) 표 정규화 단계 버그와 겹쳐 문제가 증폭된 경우
- 윈도우가 표 중간에서 잘리는 경우
한 번은 헤더 누락을 "누락 감지 후 결과에 강제 삽입"으로, 번호 누락을 "헤더 텍스트만 보고 번호를 추측해서 복원"으로 땜질하려 한 적도 있었는데, 두 경우 모두 "데이터가 실제로 유실된 지점이 아니라 다운스트림에서 결과만 보고 추측해서 복구하려 한다"는 지적을 받고 되돌렸다 — 손실이 일어난 지점에서 고쳐야지, 이후 단계에서 추측으로 메우면 안 된다는 원칙이 이때 세워졌다.
이 실패가 반복되자 근본 질문이 나왔다: "LLM은 판단만 하고, 문서 변환은 라이브러리로 이뤄지는데, 왜 LLM한테 본문을 다시 쓰게 시키고 있지?" 이 한 문장이 지금 아키텍처로 이어지는 재설계의 시작점이다 — Pass1(판단)은 LLM이 계속 맡되, Pass2(적용)는 판단된 위치에 헤더 마크만 삽입하는 순수 문자열 조작으로 바뀌었다. LLM 호출은 "이 줄이 헤더인가?"라는 좁은 판단에만 남고, 본문을 생성/재작성하는 경로 자체가 사라지면서 문단 손실·헤더 날조 위험군이 설계적으로 제거됐다.
"원문 추출 결과와 최종 출력을 비교해서 손실이 없다"는 검증은, 추출 단계 자체가 원본에서 이미 빠뜨린 내용은 절대 못 잡는다는 지적을 받았다 — 같은 소스끼리 비교하는 순환 검증이었다. 이후 독립적인 두 번째 파서(hwp2md)와 교차검증했지만 오탐만 잔뜩 나왔고, 세 번째로 LibreOffice 직접 변환과 대조하고 나서야 실제 버그 2건(중첩 표 처리, 첫 섹션 누락)을 찾아냈다. 삼중 교차검증(원 라이브러리 / 독립 파서 / LibreOffice) 없이는 발견 못 했을 사각지대였다.
kordoc은 병합 셀 표를 raw HTML <table>로 내보내는데, markdown에서 표는 <table> 태그가 아니라
|...| 파이프 문법으로 쓰는 게 정규 표현(GFM)이다 — 미리 정규화하지 않으면 표 데이터가 markdown
렌더러나 다운스트림 도구에서 표로 인식되지 않고 깨질 수 있다. 정규화
과정에서 "colspan은 원본에 셀 자체가 없어서 복제해야 하지만, rowspan은 복제하면 안 된다"(복제하면
같은 값이 중복 텍스트로 뻥튀기됨)는 원본 구조에 대한 이해가 있어야 정확히 처리된다는 게 실측으로
드러났다. 나중에 kordoc의 JSON 출력으로 Stage1을 재구성할 때도(§6) 이 교훈이 그대로 적용됐다 — JSON
셀은 이미 colspan이 반영된 완전한 그리드라 HTML용 로직을 그대로 재사용하면 열이 배로 늘어나는 별개의
버그가 났다.
첨부 문서 내부 체크리스트가 최상위 섹션으로 반복해서 잘못 승격되는 문제가 여러 문서에서 나타났다.
원인은 원본 HWP의 "1행×1열 텍스트박스"(콜아웃)가 kordoc의 기본 markdown 출력 경로에서 표 마크업
없이 평문으로 펼쳐지면서, "이건 박스 안에 중첩된 내용"이라는 경계 정보 자체가 사라진 것이었다 —
kordoc의 raw markdown과 --format json 출력을 직접 대조해서 확인했다. JSON blocks에는
type:"table", rows==1, cols==1로 이 정보가 명확히 남아있었다.
이 발견으로 Stage1 전체를 kordoc의 markdown 출력을 그대로 쓰는 대신 JSON blocks를 직접 순회해서
markdown을 조립하는 방식으로 재구성했다 — 이번 프로젝트에서 가장 큰 구조 변경이었다. 1×1 텍스트박스는
<!--box-start/end--> 마커로 감싸 경계 정보를 markdown에 실어 보내고, 그 마커를 본 Pass1a가
in_box: true로 표시하면, Pass1b 이후의 결정론적 안전망이 "박스 안 콘텐츠는 항상 하위"로 강제
강등한다.
박스 문제를 고친 뒤에도 비슷한 증상(첨부 내부 소제목이 최상위로 과승격)이 남아있었다. 진짜 원인은 "【서식 N】 제목" 같은 줄 자체가 볼드도 번호도 아닌 평문이라 애초에 후보로 추출조차 안 되고 있던 것이었다. 이때 명시적으로 받은 설계 제약이 셋 있었다:
- 첨부 내부 사항이 상위 계층으로 오인되면 안 된다(박스와 같은 성격의 문제)
- "서식"/"붙임" 같은 특정 단어나 "【】" 괄호 하나만 하드코딩하면 안 된다 — 실제로 전체 문서를
조사해보니
【서식N】/[붙임N]/【붙임 N】/[별지 제N호 서식]등 표기가 문서마다 달랐고, 괄호가 있어도 마커가 아닌 경우(본문 중 법령명 인용 등)도 있었다 - 이건 새로운 특수 케이스가 아니라 기존에도 있었던 "헤더 후보가 과도하게 많아지는" 문제의 동일 계열이니, 기존 추출+LLM 판단+결정론적 안전망 틀 안에서 풀어야 한다
이 제약에 맞춰 "괄호류로 감싼 선두 라벨"이라는 구조만 보고 폭넓게 후보를 추출하고(어떤 괄호 조합이든, 어떤 단어든 상관없이), 그중 LLM이 실제로 확정한 것만 "첨부 스코프 앵커"로 취급해 그 구간의 다른 후보를 하위로 강등하는 안전망을 추가했다. 검증 과정에서 캡핑 로직 자체의 버그도 두 건 더 찾았다 — 같은 마커라도 볼드로 감싸져 있으면 다른 후보 타입으로 추출돼 앵커 인식이 안 되는 문제, 그리고 스코프의 끝을 "LLM이 확정한 다음 앵커"로 잡으면 LLM이 그 앵커를 하나라도 놓쳤을 때 무관한 다른 첨부들까지 전부 하위로 잘못 collapse되는 문제였다.
이 프로젝트 전체에서 반복적으로 확인된 원칙: 전역 규칙은 실패하지만, "이미 확정된 사실에만 반응하는 좁은 범위의 결정론적 후처리"는 안전하다. "번호가 이어지면 최상위"(§2) 같은 전역 규칙은 붙임에서 번호가 정당하게 리셋되는 정상 케이스를 오탐해서 실패했다. 반면 아래 안전망들은 전부 (a) LLM이나 라이브러리가 이미 확정한 구조 정보에만 반응하고 (b) 적용 전에 오탐 0건을 전수 검증한 뒤에야 코드에 반영됐다:
- 텍스트박스(콜아웃) 안 콘텐츠 → 항상 하위로 강등
- 첨부/서식 스코프 안 콘텐츠 → 항상 하위로 강등
- "~귀하"(수신처를 향한 인사말) → 헤더 아님
- "~(인)"(응찰자 본인의 서명/날인란) → 헤더 아님. 이건 "~귀하"와 비슷해 보이지만 성격이 다른 별개 패턴이라는 지적을 받고서야 전용 규칙으로 분리했다 — "귀하"는 발주기관 대표를 향한 것이고 "(인)"은 응찰자 본인의 날인란이다.
설계가 끝난 뒤, "이게 정말 기존 방식보다 나은가"를 실측했다. 같은 원본 문서 8건을 타 프로젝트에서 쓰이던 정규식 기반 헤더 승격 파이프라인과 이 스킬에 각각 통과시켜, 최상위 헤더 번호가 서로 다른 무관한 섹션끼리 충돌하는 횟수를 셌다. 정규식 기반 파이프라인은 문서당 최대 63회 충돌이 발생했고(진짜 섹션과 서약서 조항과 첨부 내부 항목이 전부 같은 레벨에서 번호가 겹침), 이 스킬은 8건 전체 0건이었다 — §7에서 고친 "첨부 내부 콘텐츠 과승격" 문제가 실제로 정규식 기반 파이프라인에도 동일하게 존재한다는 게 실측으로 확인됐다.