사진을 넣으면 블로그 초안이 나오는 로컬 LLM 파이프라인 Momently 개발기 5편.
"내 말투로 쓰인 초안"이 이 프로젝트의 정체성인데, 그 말투 기능이 몇 주간
조용히 죽어 있었다. 이번 편은 말투 학습 기능의 설계, 학습 데이터 정제,
그리고 스토리지 마이그레이션에서 읽기 경로를 빼먹은 사고의 부검이다.

말투가 없으면 AI 글은 남의 글이다

초안 품질이 아무리 좋아도 말투가 내 것이 아니면 결국 전부 고쳐 쓰게 된다.
어미("했어요" vs "했다"), 자주 쓰는 감탄사, 문단 호흡, 이모지 습관 — 이런 게
안 맞으면 글 전체가 남의 글처럼 읽힌다. 그래서 voice_profile_agent를 따로
뒀다: 내가 이미 쓴 글들을 먹여서 말투 프로필을 만들고, style/draft 단계가 그
프로필을 프롬프트에 반영한다.

학습 UX는 최대한 단순하게 갔다. 공개 네이버 블로그 URL을 붙여넣으면 끝.
본문을 추출하고, Ollama로 말투를 분석해 프로필에 누적한다. 분석 파라미터
(VOICE_ANALYSIS_TEMPERATURE, VOICE_ANALYSIS_TOP_P)는 환경변수로 빼서 분석
품질 실험을 코드 수정 없이 할 수 있게 했고, 빠른 미리보기용 fast_analysis
옵션도 뒀다(심층 분석은 Ollama를 오래 잡는다 — 이게 게이트웨이 타임아웃 600초
설정의 배경이고, 10편에서 다시 나온다).

첫 번째 문제: 잡음이 말투로 학습된다

돌려보니 프로필에 이상한 게 섞였다. 네이버 블로그 본문을 그대로 긁으면 글
사이에 이미지 URL, 클릭 추적 URL, 지도 위젯의 UI 텍스트가 끼어 들어온다. 분석
모델은 이걸 구분하지 않는다 — URL 조각과 지도 버튼 문구가 "이 사람이 자주
쓰는 단어"로 학습됐다.

그래서 학습 전 정제 단계를 넣었다: 이미지/추적 URL, 지도 UI 잡음을 본문에서
제거한 뒤에만 분석에 넘긴다. 학습 데이터 파이프라인의 오래된 격언 그대로다 —
garbage in, garbage out인데, LLM은 garbage도 그럴싸하게 소화해버려서 증상이
늦게 발견된다.

테스트도 이 지점에서 한 번 정리했다. 외부 네트워크에 의존하는 URL 추출 테스트는
CI에서 못 돌린다. VOICE_BLOG_IMPORT_FIXTURE_MAP으로 URL → 로컬 fixture HTML
매핑을 주입할 수 있게 해서, Docker smoke test가 실제 네이버에 접속하지 않고도
"URL 입력 → 본문 추출 → 말투 학습"의 전체 경로를 검증한다.

마이그레이션: 로컬 파일에서 MinIO로

프로필 저장은 처음에 로컬 파일시스템이었다(/var/lib/momently-voice 볼륨).
단일 머신에선 충분하지만 백업·격리 관점에서 오브젝트 스토리지가 낫다고 판단해
MinIO로 옮겼다. voice_profile_agent의 저장 경로를 MinIO로 바꾸고, 업로드가 잘
되는 걸 확인하고, 배포했다. 학습도 잘 됐다. 끝 — 인 줄 알았다.

"보내고 끝": 성공처럼 보이는 실패

몇 주 뒤에야 알아챈 증상: 글쓰기 결과물에 학습된 말투가 반영되지 않는다.
에러는 없다. 워크플로는 COMPLETED다. 글도 나온다. 다만 말투가 기본 톤이다.

원인을 따라가 보니 이랬다.

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

participant "WorkflowRunner" as W
participant "StyleAgentClient" as S
participant "로컬 파일시스템" as FS
participant "MinIO" as M
participant "style_agent" as SA

W -> S : 문체 적용 요청
S -> FS : loadVoiceProfile()\n/var/lib/momently-voice,\n../voice_profiles, ... 탐색
FS --> S : (MinIO 전환 후) 항상 빈 결과
note right of S #ffdddd
  쓰기는 MinIO로 옮겼지만
  읽기는 여전히 로컬만 뒤진다.
  결과가 비어도 예외가 아니라
  "프로필 없음"으로 처리
end note
S -> SA : payload에 voice_profile 없음
SA -> SA : BUILTIN_VOICE_PROFILES\nfallback → 기본 톤 적용
SA --> W : 성공 응답 (COMPLETED)
M -[hidden]-> M
note over M : 학습된 프로필은\n여기 멀쩡히 저장돼 있음
@enduml
  • 쓰기 경로(voice_profile_agent → MinIO)는 옮겼다.
  • 읽기 경로는 orchestrator 쪽에 따로 있었다. StyleAgentClient
    DraftAgentClientloadVoiceProfile()이 로컬 파일시스템 경로 몇 곳을
    뒤지는 fallback 로직이었고, MinIO 전환 후 항상 빈 결과를 반환했다.
  • 빈 결과는 에러가 아니었다. style/draft payload에 voice_profile 필드가
    빠진 채 진행됐고, 에이전트는 내장 기본 프로필로 조용히 fallback했다.

이 사고에 "보내고 끝"이라는 이름을 붙였다. 학습은 성공하고(보내고), 소비는
없는(끝) 상태. 모든 단계가 각자 성공을 보고하는데 시스템 전체는 실패하는,
silent fallback이 만드는 전형적인 고장 모드다. fallback은 가용성을 위한
장치인데, 관측 없이 쓰면 실패를 숨기는 장치가 된다.

구조적 원인도 있다. 읽기 로직이 orchestrator에 있었기 때문에, 스토리지를
바꾸는 사람(voice_profile_agent 작업)의 시야에 읽기 경로가 아예 안 들어왔다.
데이터의 소유자와 읽는 자가 다른 모듈일 때, 마이그레이션 체크리스트는 소유자
모듈의 경계를 넘어야 한다.

수정: 읽기 경로를 소유자에게로

고치는 김에 구조를 바로잡았다. 핵심 결정: orchestrator가 스토리지를 직접
읽지 않는다. 프로필 조회는 voice_profile_agent의 API를 통한다.

@startuml
skinparam shadowing false
title 수리 후의 경로 (after)

participant "WorkflowRunner" as W
participant "VoiceProfileAgentClient" as V
participant "voice_profile_agent" as VA
participant "MinIO" as M
participant "style_agent" as SA

W -> V : loadVoiceProfile(id, owner)
V -> VA : GET /api/v1/internal/voice-profiles/{id}?owner=...\nX-Internal-Token 헤더
VA -> VA : 토큰 검증\n(env 비어 있으면 default-deny)
VA -> M : 프로필 조회 (owner-scoped + legacy)
M --> VA : 프로필
VA --> V : 프로필 JSON
V --> W : voice_profile
W -> SA : payload에 voice_profile 포함
SA --> W : 학습된 말투 적용됨
@enduml
  • voice_profile_agent에 내부 전용 조회 API
    GET /api/v1/internal/voice-profiles/{id}?owner=...를 추가했다. 이제 스토리지
    백엔드가 뭐든(로컬이든 MinIO든 나중에 뭐가 되든) 읽기 계약은 HTTP 하나다.
    스토리지 지식이 소유자 모듈 안으로 봉인됐다 — 2편의 "내부 구현은 내부
    설정" 원칙을 데이터 접근에도 적용한 셈이다.
  • orchestrator에는 VoiceProfileAgentClient를 신설하고 Style/Draft 클라이언트의
    파일시스템 탐색을 전부 걷어냈다. 워크플로의 ownerUsername을 함께 전달해
    owner 단위로 조회한다.

내부 API인 만큼 보안은 겹으로 쌓았다.

  1. X-Internal-Token 헤더 검증 — 그리고 토큰 env가 비어 있으면 허용이
    아니라 거부(default-deny). "설정 안 했으니 일단 열어두자"는 내부 API가
    외부에 새는 전형적 경로다.
  2. fail-closed 클라이언트 — orchestrator 쪽도 토큰 미설정이면 호출 자체를
    건너뛴다. 반쪽 설정 상태에서 인증 없는 호출이 나가는 걸 막는다.
  3. 배포 강제MOMENTLY_INTERNAL_TOKEN을 compose에서 :? 문법으로 양쪽
    컨테이너에 필수화했다. 빼먹으면 기동이 실패한다. 설정 누락을 런타임 버그가
    아니라 배포 에러로 앞당기는 장치다.
  4. nginx 방어선 — 게이트웨이에 ^~ /api/v1/internal/ → 404를 추가했다.
    토큰 검증이 뚫리거나 실수로 빠져도 외부에서는 경로 자체가 존재하지 않는다.
    defense in depth.

검증은 양쪽 다 돌렸다. voice_profile_agent 테스트 50/50 + 커버리지 87%,
orchestrator는 표준 검증(gradle test + JaCoCo) 통과, 그리고 docker network
내부에서 직접 호출 200 / 외부 게이트 경유 404
를 실제로 확인했다 — 이
마이그레이션의 교훈이 "읽기 경로를 검증 안 했다"였으니, 수정의 완료 조건은
읽기 경로의 실측이어야 했다.

보너스 사고: 2년 묵은 MinIO

이 사건과 별개로 MinIO 자체도 한 번 속을 썩였다. 어느 날 말투 학습 화면의
sample_count가 0으로 표시됐다 — 데이터가 사라진 것처럼 보였다. 실제로는
데이터가 멀쩡했고, 2년 묵은 구버전 MinIO 이미지의 오브젝트 list 관련 버그
목록 조회만 깨진 것이었다. 최신 이미지로 컨테이너를 재생성해서 근본 수리했다.

교훈은 소박하다. 인프라 컨테이너도 의존성이다. 애플리케이션 라이브러리는
부지런히 올리면서 minio:latest를 2년 전에 pull한 그대로 두면, 남의 버그를
내 버그처럼 디버깅하게 된다.

버린 대안 정리

  • orchestrator가 MinIO SDK로 직접 읽기: 수리는 되지만 스토리지 지식이 두
    모듈에 중복된다. 다음 스토리지 변경 때 같은 사고가 재발할 구조를 남기는 것.
  • docker network 신뢰만으로 내부 API 보호: "내부망이니까 괜찮다"는 게이트웨이
    설정 실수 한 번에 무너진다. 토큰 + default-deny + nginx 404의 다층 방어 선택.
  • fallback 유지(로컬 → MinIO 순차 탐색): 전환기 호환용으로 잠깐은 유효하지만,
    이번 사고의 원흉이 바로 "조용한 fallback"이었다. 읽기 경로를 API로 단일화하고
    fallback은 제거했다.

이 결정을 다시 한다면

마이그레이션 절차에 "읽기 경로 E2E 검증"을 완료 조건으로 명시하는 것 —
이것만 있었으면 사고 자체가 없었다. "쓰기 성공"은 마이그레이션의 절반이고,
증명은 소비자가 새 저장소의 데이터를 실제로 읽어 쓰는 장면이다.

그리고 silent fallback에 대한 태도를 바꿨다. fallback을 두려면 fallback이
발동했다는 사실이 관측돼야 한다
(로그, 메트릭, 뭐든). "프로필이 없으면 기본
톤"이라는 동작 자체는 합리적이었다. 문제는 그 동작이 몇 주간 발동 중이라는 걸
아무도 몰랐다는 것이다. 이 문제의식은 다음 편의 에러 표준화로 이어진다 —
에이전트 10개가 각자 다른 방식으로 실패를 표현하는 시스템에서, "실패가 보인다"는
것부터가 설계의 대상이었다.


다음 편은 ADR 005, 표준 에러 envelope이다. raw stack trace가 사용자 화면에
노출되던 날부터, error_code / user_message / retryable / trace_id 스키마 설계,
그리고 "표준을 정하는 것"보다 훨씬 어려웠던 "기존 코드와 공존시키며 점진
적용하기"까지.