사진을 넣으면 블로그 초안이 나오는 로컬 LLM 파이프라인 Momently 개발기 8편.
이번 편은 인프라가 아니라 프롬프트 파이프라인의 사고다. 사진 OCR에 섞인
흔한 단어 하나가 글의 장르를 통째로 바꿔버린 사건, 그 구조적 원인, 그리고
"누가 결정권자인가"를 계약으로 명시한 수리 과정.

사건: 여행 후기를 시켰는데 퀴즈 정답 공유 글이 나왔다

콘솔에서 일반 후기를 요청했는데, 나온 초안이 이상했다. 제목부터 "○○ 퀴즈 정답"
류였고, 본문 구조도 앱테크 퀴즈 정답 공유 글의 전형(정답 먼저, 참여 방법,
포인트 안내)이었다. 올린 사진은 그냥 일상 사진이었는데.

원인은 사진 한 장이었다. 사진 속 텍스트(OCR)에 "포인트"라는 단어가 있었다.

배경 설명이 필요하다. Momently에는 앱테크 퀴즈 정답 공유 글이라는 특수 장르
지원이 있었다. 퀴즈 스크린샷을 올리면 정답 공유 글 구조로 outline을 잡는
기능인데, 이 장르의 트리거가 OCR 키워드 매칭이었고 그 키워드 목록에 "포인트"가
들어 있었다. "포인트"는 퀴즈 글에만 나오는 단어가 아니다. 카페 적립 포인트,
관광 포인트, 촬영 포인트 — 일상 사진 어디에나 있다.

진짜 원인: 의도가 전달되지 않는 구간

키워드 오탐은 표면 원인이다. 파 보니 구조 문제가 아래에 있었다.

사용자는 워크플로를 만들 때 콘텐츠 유형(content_type)과 작성
방향(writing_instructions)을 입력한다. 이 의도는 draft 단계에는 전달되고
있었다. 그런데 글의 장르와 구조를 결정하는 건 draft가 아니라 그 앞의 outline
단계
였고, outline 에이전트는 사용자 의도를 전달받지 못하고 있었다.

@startuml
skinparam shadowing false
title 사고 당시의 의도 전달 경로 (before)

rectangle "사용자 의도\ncontent_type\nwriting_instructions" as Intent #ddffdd
rectangle "WorkflowRunner" as W
rectangle "outline_agent\n(글 구조 결정)" as O #ffdddd
rectangle "draft_agent\n(본문 작성)" as D
rectangle "사진 OCR 키워드" as OCR #ffffcc

Intent --> W
W --> O : 그룹/사진 정보만 전달\n(의도 미전달!)
W --> D : 의도 전달됨
OCR --> O : "포인트" 발견 →\n퀴즈 정답 공유 구조 발동
O --> D : 퀴즈 구조의 outline
note bottom of D
  draft는 의도를 받지만
  이미 구조가 퀴즈 글로 잡힌 뒤.
  outline을 따라 쓸 수밖에 없다
end note
@enduml

outline 입장에서 보면, 판단에 쓸 수 있는 신호가 사진 정보와 OCR뿐이었다. 그
중에 가장 구체적인 신호(특정 키워드 매칭)가 하드코딩된 분기까지 갖고 있으니,
사실상 OCR 키워드가 글 구조의 단독 결정권자였던 셈이다. 여기서 얻은 일반화
하나:

파이프라인의 어느 단계에 상위 의도가 전달되지 않으면, 그 단계에서는 그
시점에 가장 구체적인 신호가 결정권을 가진다.

의도적으로 설계한 우선순위가 아니라, 전달 누락이 만든 사실상의 우선순위다.
1편에서 "제어 흐름은 코드가 가진다"고 정리했지만, 콘텐츠 결정권은 프롬프트에
무엇을 넣느냐의 문제라 상태 머신으로는 안 잡힌다. 결정적 파이프라인에도 이런
고장 모드가 있다.

수리: 결정권의 서열을 계약으로

고친 것은 세 겹이다.

1) 의도를 outline까지 배관. OutlineAgentPort에 사용자 의도를 포함하는
오버로드를 추가하고, WorkflowRunnercontent_type/writing_instructions
outline 요청 payload에 포함하게 했다. 여기서 쓴 하위호환 기법이 소소하지만
유용했다 — 인터페이스에 default 메서드 오버로드를 추가해서(이미
DraftAgentPort가 쓰던 패턴) 기존 구현체와 테스트 스텁을 하나도 안 건드리고
확장했다.

2) 결정권 서열을 프롬프트에 명시. outline/draft 에이전트의 프롬프트 규칙을
이렇게 바꿨다: 사용자 의도가 있으면 그것이 장르·구조를 최우선으로 결정하고,
사진/OCR은 재료로만 쓴다.
퀴즈 정답 공유 구조와 퀴즈형 제목 fallback은
사용자 의도가 비어 있을 때만 동작하도록 강등했다.

3) 오탐 원흉 제거. "포인트"를 퀴즈 트리거 키워드에서 뺐다. 다만 이건
치료가 아니라 소독이다 — 다음 오탐 단어는 반드시 또 나온다. 그래서 1·2번이
본체다: 트리거가 오탐해도 사용자 의도가 있으면 무해하도록 구조를 바꾼 것.

버린 대안 — 블랙리스트 정교화. "포인트는 빼고, '정답'과 '퀴즈'가 동시에
있을 때만…" 식으로 키워드 조건을 조이는 방향도 있었다. 버렸다. 자연어 입력의
오탐/미탐을 키워드 규칙으로 다 막으려는 시도는 규칙만 무한히 자라게 한다.
결정권 서열이 명시돼 있으면 트리거의 정밀도는 덜 중요해진다.

확장 1: 검색 최적화 모드 — 계약 필드 없이

의도 배관을 정비하고 나니, 같은 통로로 얹을 수 있는 기능이 보였다. 블로그 글의
현실적 요구인 검색 노출(SEO)이다.

여기서 계약 설계 결정 하나: seo_mode 같은 별도 필드를 추가하지 않았다.
대신 content_type/writing_instructions 자연어에서 "검색/SEO/키워드/노출/
상위/최적화" 신호를 감지해 활성화하고, "구글/티스토리"가 언급되면 구글 SEO,
아니면 네이버 SEO(주 타겟이니 기본값)로 분기한다. 활성화되면 제목·첫 문단·
소제목에 검색어 자연 반영, 이미지 alt 텍스트 연결 같은 규칙이 프롬프트에
들어가되, 키워드 스터핑 금지와 "사실 범위 유지" 가드를 함께 건다.

DB 스키마 변경 없음, Flyway migration 없음, 기존 의도 통로 확장만. "계약은
가장 바꾸기 비싼 것"(2편)이라는 원칙의 반대편 활용이다 — 바꾸지 않고 해결할
수 있으면 그게 이긴다.

확장 2: 검색 키워드 정형 입력 — 이번엔 계약을 바꿔서

그런데 주력 검색어만큼은 자연어 추론에 맡기기 아까웠다. "제주 흑돼지 맛집"을
노리고 쓰는 글이라면 그 문자열이 정확히 제목과 본문에 박혀야 한다. 추론이
아니라 지정의 영역이다. 그래서 이번엔 정식으로 계약을 확장했다 —
targetKeywords 필드를 콘솔 입력부터 DB까지 end-to-end로.

  • 도메인 Workflow에 필드 추가. 생성자 파라미터가 늘어나는 문제는 새 9-arg
    생성자에 기존 생성자가 위임
    하는 방식으로 흡수해 기존 호출부 77곳을 한 줄도
    안 고쳤다.
  • Flyway V2target_keywords 컬럼 추가. ADD COLUMN IF NOT EXISTS,
    ddl-auto=validate 유지(3편의 규율).
  • outline/draft/review 포트에 default 오버로드 추가(위와 같은 패턴), 프롬프트에
    "주력 검색어" 라인 주입. target_keywords가 있으면 그 자체를 사용자 의도로
    간주해 퀴즈 fallback을 끈다.

재미있는 건 review_agent 쪽이다. 키워드를 넣으라고 시켰으면 넣었는지 검사도
해야 한다.
review 단계에 SEO 검수를 추가했다: 제목에 키워드 포함, 본문에
키워드 존재, 그리고 스터핑 검사. 스터핑 판정 조건을 정하는 게 의외로
설계 문제였는데 — "같은 검색어 4회 이상 이면서 본문 토큰의 15% 초과"라는
AND 조건으로 잡았다. 횟수만 보면 짧은 정상 글이 오탐되고, 비율만 보면 긴 글의
도배를 놓친다. 검사기의 오탐은 곧 파이프라인의 거짓 실패라서, 검사 기준의
정밀도가 곧 시스템 신뢰도다. 키워드 미지정 시에는 검사 자체를 생략한다.

이 결정을 다시 한다면

의도 배관과 결정권 서열 명시는 그대로 간다. 반성할 것은 특수 장르 기능을
넣던 날의 나 자신
이다. 퀴즈 정답 공유 기능을 추가할 때 "OCR 키워드로 트리거"
라는 설계를 하면서, 이 트리거가 다른 모든 글에 대해 오탐할 수 있다는 생각을
안 했다. 기능 하나를 추가할 때 그 기능의 활성화 조건이 기존 경로 전체에
미치는 영향
을 따지는 것 — 프롬프트 파이프라인에서도 feature flag 설계와 같은
규율이 필요했다. 그리고 outline에 의도가 안 가고 있다는 사실은 사고가 나기
전까지 아무도 몰랐다. 단계별 입력 계약을 문서(contracts.md)로 관리하면서도
"이 단계가 받아야 하는데 안 받고 있는 것"의 목록은 없었다 — 계약 문서는 있는
것의 기록이지, 없는 것의 탐지기가 아니었다.


다음 편은 파이프라인의 마지막 1미터, 발행이다. 다 만든 글을 네이버 블로그에
올리는 게 왜 어려운지 — 마크다운 미지원과 이미지 문제, 자동 발행을 막는 약관과
법적 리스크, 그리고 "발행 버튼은 절대 자동으로 누르지 않는다"는 안전선을 정한
브라우저 확장 설계(ADR 007)까지.