|

3줄 요약
- 다른 프로젝트가 넘긴 원고 4편(43,699바이트)을 발행하며, LLM에 조판을 맡기는 대신 결정적 변환기(scripts/publish/md2bsp.py, 무LLM)를 만들어 썼다.
- 4편 변환에 쓴 LLM 토큰은 0이었고, FAQ 23문항이 자동 추출됐으며 자체 점검(스타일 블록·h1·TODO·이모지 잔존 검사)이 4편 모두 통과했다.
- 경계선은 하나다. 판단이 필요하면 LLM, 규칙이 있으면 스크립트. 이 변환에는 판단이 필요 없었다.

질문
무엇을 변환했나 다른 프로젝트가 넘긴 원고 4편, 총 43,699바이트
LLM 토큰은 얼마나 썼나 0. scripts/publish/md2bsp.py(무LLM 결정적 변환기)로 처리했다
왜 LLM에 맡기면 안 됐나 원고의 공고번호·쪽수가 이미 대조가 끝난 값이라 한 글자도 바뀌면 안 됐다
무엇으로 통과를 판정했나 변환기가 규격 위반을 세어 종료 코드로 알린다. 4편 모두 PASS
어디까지 스크립트인가 규칙이 있고, 같은 출력이 필요하고, 검사를 자동화할 수 있으면 스크립트다

이 글대로 하면, 원고를 발행 HTML로 옮기는 단계에서 LLM 호출을 0으로 줄이면서 동시에 조판 사고(전역 CSS 오염, 구조화 데이터 중복, 수치 변조)를 사람이 기억하지 않아도 막히는 구조로 바꿀 수 있다.

md→HTML 변환에 LLM을 쓰면 안 되는 이유는 무엇인가?

세 가지 문제가 생기기 때문이다. 첫째, 같은 입력을 넣어도 같은 출력이 보장되지 않는다. 둘째, 원문의 수치를 실수로 손댈 위험이 있다. 셋째, 몇 편이면 비용이 작아도 편수가 늘어나면 그만큼 커진다. 이번에 발행한 원고는 공고번호와 쪽수까지 이미 대조된 값이 들어 있어서, 조판 과정에서 숫자 하나라도 바뀌면 그 자체로 오류였다. 그래서 변환을 LLM이 아니라 규칙 기반 스크립트에게 맡겼다.

어디까지 스크립트이고 어디부터 LLM인가?

세 가지를 물어 판정한다. 하나라도 "예"이면 스크립트 쪽이다.

질문 아니오
같은 입력에 같은 출력이 필요한가 스크립트 LLM 가능
원문의 값을 한 글자도 바꾸면 안 되는가 스크립트 LLM 가능
통과·실패를 종료 코드로 자동 판정할 수 있는가 스크립트 LLM 가능

이 변환은 세 질문 모두 "예"였다. 원고가 이미 정해진 형식(제목, 3줄 요약, 표, Q/A 목록)을 따르고 있었고, 그 형식을 HTML 태그로 옮기는 규칙이 명확했으며, 결과가 규격을 지켰는지도 문자열 검사로 판정할 수 있었다. 판단이 필요한 작업 — 요약, 재구성, 어투 조정 — 이었다면 LLM이 적합했겠지만, 정해진 입력을 정해진 규칙으로 옮기는 이 작업은 스크립트의 영역이었다. 원칙은 간단하다. 판단이 필요하면 LLM, 규칙이 있으면 스크립트.

변환기는 실제로 무엇을 하는가?

md 한 편을 받아 본문 HTML과 메타 JSON을 내놓고, 규격 위반이 있으면 종료 코드 1로 끝난다. 호출은 한 줄이다.

python -X utf8 scripts/publish/md2bsp.py 원고.md out/본문.html --meta out/meta.json

표준 출력은 두 줄이다. 첫 줄이 메타 JSON, 둘째 줄이 자체 점검 결과다.

{"title": "<H1에서 뽑은 제목>", "excerpt": "<3줄 요약 첫 항목>", "faq_count": <개수>, "html_len": <길이>, "src": "원고.md"}
자체점검: PASS

faq_count가 기대와 다르면 원고의 Q/A 형식이 어긋난 것이고, html_len이 원고 대비 지나치게 작으면 본문 일부가 파싱되지 않은 것이다. 이 두 숫자만 봐도 조판 사고 대부분이 걸러진다. 변환기는 이 과정에서 제목(H1)을 본문에서 제거하고 3줄 요약의 첫 항목을 발췌로 뽑는다. 발행 플랫폼이 제목을 따로 렌더하므로 본문에 제목이 남으면 두 번 나오기 때문이다.

FAQ는 본문의 "자주 묻는 질문" 절에서만 뽑는다. 본문에 없는 질문을 구조화 데이터에 넣지 않기 위해서다. 본문과 구조화 데이터가 어긋나면 검색엔진 쪽에서 경고가 난다.

def extract_faq(md):
    """'자주 묻는 질문' 절의 Q/A 쌍을 순서대로 뽑는다."""
    m = re.search(r"\n##\s*자주 묻는 질문\s*\n(.*?)(?=\n##\s|\Z)", md, re.S)
    if not m:
        return []
    qs = re.findall(r"\*\*Q\.\s*(.+?)\*\*\s*\n\s*A\.\s*(.+?)(?=\n\s*\n|\Z)", m.group(1), re.S)

이 정규식이 형식을 강제한다. H2 제목이 "자주 묻는 질문"이 아니거나, 질문 줄의 표기가 다르거나, 답이 A.로 시작하지 않으면 그 쌍은 잡히지 않는다. 원고 규격이 곧 파서의 입력 명세가 되는 셈이다. 이번 4편에서는 이 방식으로 23문항이 자동 추출됐다.

검사는 사람이 하는가 스크립트가 하는가?

스크립트가 한다. 변환기 마지막에 넘어가면 안 되는 것 네 가지 — 스타일 블록 잔존, h1 태그 잔존, TODO 문자열 잔존, 이모지 잔존 — 를 세고, 하나라도 걸리면 종료 코드 1을 낸다. 아래는 뒤쪽 절반이다.

if "TODO(BSP)" in html:
    bad.append("TODO 잔존")
if re.search("[\U0001F300-\U0001FAFF]", html):
    bad.append("이모지 잔존")
if not title:
    bad.append("H1 제목 없음")
print("자체점검:", "PASS" if not bad else "FAIL " + " / ".join(bad))
return 0 if not bad else 1

이 몇 줄이 사람의 기억을 대체한다. LLM에게 조판을 맡기면 "스타일 블록을 넣지 마라"를 매번 프롬프트에 적고 매번 결과를 눈으로 확인해야 하지만, 스크립트는 규칙을 코드로 갖고 있으므로 잊지 않는다. 종료 코드가 0인지만 보면 되기 때문에 발행 파이프라인에 그대로 연결된다. 이번 4편은 모두 PASS였다.

스크립트가 부수적으로 막아준 함정은 무엇인가?

두 개가 막혔다. 하나는 스타일 블록이 페이지 문서 전체를 오염시키는 문제다. 우리 블로그 렌더러는 본문 안의 스타일 정의를 페이지 전역 head로 끌어올리기 때문에, 글 하나에 넣은 CSS가 다른 글까지 바꿔버린다. 변환기는 별도 스타일 블록을 쓰지 않고 태그별 스타일 사전을 갖고 인라인으로만 주입한다.

S = {
    "h2": "font-size:1.5rem;font-weight:700;margin:2.4rem 0 .9rem;",
    "p":  "line-height:1.85;margin:0 0 1.05rem;",
    "td": "border:1px solid #e2e8f0;padding:.62rem .7rem;vertical-align:top;",
}

for tag, css in S.items():
    html = re.sub(rf"<{tag}(?=[ >])", f'<{tag} style="{css}"', html)
    html = html.replace(f"<{tag}>", f'<{tag} style="{css}">')

같은 자리에서 표를 overflow-x:auto 컨테이너로 감싸는 규칙도 함께 적용한다. 표가 넓으면 모바일에서 본문 전체가 가로로 밀리는데, 이 규칙을 사람이 매번 기억하는 대신 변환기가 항상 적용한다.

다른 하나는 구조화 데이터(JSON-LD) 중복이고, 이쪽은 오진 사슬이 있었다.

단계 내용
1. 인계 명세 "BlogPosting JSON-LD를 본문에 넣어라"
2. 그대로 했다면 프론트엔드가 이미 head에 BlogPosting을 생성하고 있어 중복 구조화 데이터가 된다
3. 확인 변환기를 만들며 받는 쪽 코드를 먼저 읽었다
4. 결론 본문에 넣어야 했던 것은 FAQPage 하나뿐이었다

교훈은 명세가 아니라 순서에 있다. 인계 문서를 실행하기 전에 받는 쪽 코드를 먼저 읽는다. 명세는 작성 시점의 사실이고, 받는 쪽은 그 사이에 바뀌어 있을 수 있다. 문서에 적힌 것을 검증 없이 실행해 비용을 치른 다른 사례는 이미지 생성에 25,200원을 태운 사고에 정리해 두었다.

발행 전 무엇을 확인하는가?

원고를 넘기기 전과 변환 후에 볼 항목이다. 그대로 복사해 쓰는 체크리스트다.

원고 쪽:

  • [ ] # 제목 H1이 정확히 1개 있는가 (없으면 변환기가 FAIL을 낸다)
  • [ ] **3줄 요약** 블록의 첫 항목이 그대로 발췌로 쓸 만한 한 문장인가
  • [ ] FAQ 절의 H2 제목이 ## 자주 묻는 질문인가
  • [ ] 질문이 한 줄, 답이 다음 줄 A. … 형식인가
  • [ ] Q/A 쌍 사이가 빈 줄로 구분돼 있는가 (붙어 있으면 한 쌍으로 잡힌다)
  • [ ] 본문에 스타일 블록이나 수동 JSON-LD를 넣지 않았는가

변환 후:

  • [ ] 표준 출력 둘째 줄이 자체점검: PASS인가
  • [ ] faq_count가 원고의 Q/A 개수와 같은가
  • [ ] html_len이 원고 크기에 비해 지나치게 작지 않은가
  • [ ] titleexcerpt가 비어 있지 않은가

이 방식이 통하지 않는 경우는 무엇인가?

결정적 변환기는 원고가 규격을 지킬 때만 결정적이다. 규격을 벗어난 원고를 넣으면 조용히 일부를 흘린다.

실패 조건 증상 대응
FAQ 절 제목이 다르다 faq_count: 0 원고의 H2를 자주 묻는 질문으로 맞춘다
Q/A 쌍이 빈 줄 없이 붙어 있다 여러 쌍이 하나로 합쳐짐 쌍 사이에 빈 줄을 넣는다
H1이 없다 종료 코드 1, FAIL H1 제목 없음 원고 첫 줄에 # 제목을 넣는다
본문에 의미 있는 기호 문자를 썼다 변환기가 제거한다 그 기호를 텍스트로 풀어 쓴다
원고가 매번 다른 형식으로 온다 규칙이 성립하지 않는다 형식을 먼저 합의하거나 이 단계는 사람이 맡는다

한계도 적어 둔다. 이 변환기는 우리 블로그 플랫폼 전용이다. 태그별 스타일 사전과 "본문에 스타일 정의를 넣지 않는다"는 규칙은 우리 렌더러가 본문의 스타일을 전역으로 올리기 때문에 필요한 것이고, 다른 플랫폼이면 이 제약 자체가 없을 수 있다. 마크다운 파싱은 외부 라이브러리와 그 확장(tables, sane_lists, nl2br)에 의존하므로 버전이 바뀌면 출력이 달라질 수 있다. 그리고 이 방식은 판단이 필요 없는 단계에만 유효하다. 원고 자체를 쓰는 일은 여전히 LLM의 몫이고, 같은 분업을 발행 파이프라인 전체에 적용하면 근거 수집·기계 검증·조판은 스크립트로, 초안 집필은 LLM으로 갈린다. 자체 콘텐츠 감사에서 확인된 결함들의 상당수도 판단이 필요 없는 단계에서 LLM이 매번 조금씩 다르게 처리하며 쌓인 것이었다.

자주 묻는 질문

Q. md를 HTML로 변환할 때 LLM을 쓰면 안 되는 이유는 무엇인가?
A. 같은 입력에 같은 출력이 보장되지 않고, 원문 수치를 실수로 바꿀 위험이 있으며, 편수가 늘어날수록 비용이 커지기 때문이다.

Q. 이번에 변환한 원고는 어떤 것이었나?
A. 다른 프로젝트가 넘긴 원고 4편, 총 43,699바이트였다. 공고번호·쪽수까지 이미 대조된 값이 들어 있었다.

Q. 변환에 LLM 토큰을 얼마나 썼는가?
A. 0이었다. scripts/publish/md2bsp.py라는 무LLM 결정적 변환기로 처리했고, 본문의 Q/A 23문항이 FAQ로 자동 추출됐다.

Q. 어떤 작업을 LLM에, 어떤 작업을 스크립트에 맡겨야 하는가?
A. 같은 입력에 같은 출력이 필요한가, 원문 값을 한 글자도 바꾸면 안 되는가, 통과 여부를 종료 코드로 판정할 수 있는가를 묻는다. 하나라도 "예"이면 스크립트다. 요약이나 재구성처럼 판단이 들어가는 일은 LLM의 영역이다.

Q. 결정적 변환기를 쓰면서 얻은 부수 효과는 무엇인가?
A. 본문 스타일이 사이트 전역을 오염시키는 문제와 JSON-LD가 중복 생성되는 문제를 설계 단계에서 원천적으로 피할 수 있었고, 규격 위반을 종료 코드로 잡게 되면서 발행 파이프라인에 그대로 연결됐다.

Comments

Comments (0)

Leave a Comment

← Back to List