이 시리즈는 사진과 동영상을 넣으면 한국어 블로그 초안이 나오는 개인 프로젝트
Momently를 만들면서 했던 의사결정들과, 만들다 보니 터진 문제들, 그리고 그걸
어떻게 바꿨는지를 기록한 개발기다. 잘 만든 결과물 자랑보다는 "왜 그렇게
결정했고, 어떤 대안을 버렸고, 어디서 틀렸는지"에 초점을 둔다.

왜 만들었나

여행을 다녀오면 사진이 몇백 장씩 쌓인다. 맛집을 다녀와도 마찬가지다. 사진은
찍는 순간 끝나지만, 후기는 그렇지 않다. 사진을 고르고, 순서를 잡고, 뭘 먹었는지
기억을 더듬고, 글의 뼈대를 세우고, 내 말투로 다듬는 것까지 — 블로그 글 하나에
드는 시간의 대부분은 "쓰기"가 아니라 이 정리 과정이다.

그래서 목표를 이렇게 잡았다.

사진/동영상 폴더를 통째로 던지면, 내 말투로 쓰인 블로그 초안이 나온다.

여기서 "초안"이라는 단어가 중요하다. 처음부터 최종본 자동 발행은 목표가 아니었다.
AI가 쓴 글이 무검토로 쏟아지는 물건은 만들고 싶지 않았고, 마지막 검토와 발행
버튼은 사람이 누르는 구조를 처음부터 전제로 깔았다. 이 원칙은 프로젝트 막바지에
네이버 발행 기능을 설계하면서 법적 리스크 판단과 정확히 다시 만나게 되는데
(9편), 초기에 별생각 없이 세운 원칙이 나중에 발목을 잡는 게 아니라 오히려
방어선이 되어준 드문 사례였다.

첫 번째 결정: 로컬 LLM — 데이터가 어디로 가는가

가장 먼저 정한 것은 의외로 모델이 아니라 데이터의 이동 경로였다.

입력이 여행 사진이다. 가족 얼굴, 집 근처 위치, 숙소, 며칠간의 동선이 전부 들어
있다. 검토한 선택지는 셋이었다.

대안 A — 상용 API (GPT/Claude 계열). 품질은 압도적으로 좋다. 하지만 사진
원본을 외부로 업로드해야 한다. 편하자고 만든 도구가 개인정보를 가장 민감한
형태로 외부에 흘리는 통로가 된다. 탈락.

대안 B — 하이브리드. 비전 분석(사진 → 텍스트 요약)만 로컬에서 하고, 글
생성은 상용 API로 보내는 절충안. 꽤 오래 고민했다. 하지만 사진을 요약한 텍스트
자체가 이미 "5월 12일 오후, 아이와 함께 ○○해수욕장" 같은 개인정보의 텍스트
버전이다. 원본만 안 보낸다고 경계가 지켜지는 게 아니었다. 경계를 "원본 사진"이
아니라 "사진에서 파생된 모든 것"으로 정의하는 순간 이 대안도 무너졌다.

선택 — 전부 로컬 (Ollama). 비전 분석과 글 생성 모두 Ollama 기반 로컬 모델로
결정했다. 대가는 명확했고, 미리 알고 지불했다.

  • 품질: 로컬에서 돌릴 수 있는 14b급 모델은 상용 최상위 모델보다 확실히 약하다.
    이 격차를 메우는 것이 이후 프롬프트 설계와 후처리 방어(4편), 모델 비교
    하네스(4편)의 존재 이유가 된다.
  • 속도: LLM cold-start 한 번에 30~60초가 날아간다. 이 숫자는 나중에
    서킷브레이커 도입(7편)이라는 큰 공사의 직접적 원인이 된다.

대신 "사진이 내 머신 밖으로 나가지 않는다"는 성질은 설계 전체에서 흔들리지 않는
불변 조건이 됐다. 이후의 많은 결정 — 동영상 프레임 샘플링 수 조정, 기본 모델
교체, review 단계 LLM 교정 기본 OFF 같은 성능 튜닝 — 이 전부 이 제약 안에서
이뤄진다.

두 번째 결정: 오케스트레이션을 누가 하는가

로컬 LLM만큼 중요했던 결정이 하나 더 있다. "다음에 뭘 할지"를 누가 정하는가.
후보는 셋이었다.

대안 1 — 멀티모달 모델 한 방 프롬프트. 사진을 전부 넣고 "블로그 글 써줘"
한 번으로 끝내는 방식. 가장 단순하지만 바로 탈락했다. 로컬 모델의 컨텍스트로
사진 수백 장을 감당할 수 없고, 무엇보다 실패의 단위가 전체다. 마지막에
말투가 이상하면 처음부터 다시 돌려야 하고, 중간 결과(그룹핑이 잘 됐는지, 개요가
괜찮은지)에 개입할 지점이 없다.

대안 2 — LLM 에이전트 오케스트레이션. 요즘 유행하는 방식이다. LLM에게
tool-calling으로 각 에이전트를 도구로 쥐여주고, "다음 단계"를 LLM이 판단하게
하는 것. 진지하게 고민했지만 이것도 버렸다. 이유는 네 가지다.

  1. 이 도메인은 순서가 고정이다. 후기 작성은 "분석 → 그룹화 → 개요 → 초안 →
    문체 → 검수"라는 순서가 사실상 정해져 있다. 동적으로 판단할 게 없는 곳에
    LLM 판단을 넣으면, 얻는 유연성은 0인데 비결정성이라는 비용만 생긴다.
  2. 실패한 단계부터 재시작하려면 상태가 명시적이어야 한다. 60초짜리 LLM
    단계가 실패했을 때 앞 단계를 다시 돌지 않으려면 "지금 어디까지 성공했는지"가
    DB에 상태값으로 남아 있어야 한다. LLM의 판단 흐름은 이런 재진입이 안 된다.
  3. 로컬 모델은 planner로 못 믿는다. qwen2.5:14b급 모델은 글은 곧잘 쓰지만,
    제어 흐름을 맡기기엔 신뢰도가 부족하다. 게다가 판단 한 번마다 LLM 호출이
    끼어들면 그 자체로 지연이 쌓인다.
  4. 운영이 안 보인다. 상태 머신은 메트릭, 재시도 정책, DB 제약과 자연스럽게
    붙는다. "지금 어느 단계인가"가 곧 상태값이라 대시보드도, 알람도, 디버깅도
    상태 하나로 통한다. LLM이 경로를 정하면 이 모든 게 로그 고고학이 된다.

선택 — 결정적 상태 머신 파이프라인. 이 결정은 프로젝트 초기 문서에 원칙
그대로 박아뒀다.

  • 에이전트는 자율적으로 다음 단계를 결정하지 않는다.
  • 순서와 재시도 정책은 Spring 오케스트레이터가 코드로 보장한다.

— 초기 오케스트레이션 설계 메모에서

요약하면 이렇다. LLM은 각 단계 안에서 콘텐츠를 만들 때만 쓴다. 제어 흐름은
결정적 코드가 가진다.
LLM은 데이터 평면(data plane)이고 오케스트레이션은 제어
평면(control plane)이며, 제어 평면에 비결정성을 넣지 않는다.

유연성이 필요한 지점이 없지는 않다. 다만 그건 "사용자 의도"라는 형태로 들어오고
(그룹화 전략, 글 방향), LLM의 즉흥 판단이 아니라 enum과 요청 필드로 코드에
표현했다(2편). 이 결정 덕분에 뒤에서 다룰 것들 — 실패 단계 재시작(3편), 단계별
타이밍 로그, 서킷브레이커(7편), DB CHECK 제약(3편의 사고 포함) — 이 전부
자연스럽게 얹힐 자리가 생겼다. 반대로 이 결정이 만든 숙제도 있다. 단계가
고정이니 에이전트 하나가 아프면 거기서 전체가 막히고, 그걸 격리하는 일이 나중에
큰 공사가 된다.

그리고 8편에서 다루겠지만, "제어 흐름은 코드가 갖는다"는 원칙을 세웠어도
프롬프트 파이프라인 안에서의 결정권은 또 다른 문제였다. 사진 OCR에 섞인
단어 하나가 글 전체의 장르를 납치하는 사건이 터진다.

전체 구조

그래서 Momently는 "하나의 큰 AI"가 아니라 단계별 에이전트들의 파이프라인이다.
사람이 후기를 쓰는 과정을 그대로 단계로 쪼갰다.

@startuml
skinparam componentStyle rectangle
skinparam shadowing false

actor 사용자 as User

package "Momently" {
  [momently_console\n(React)] as Console
  [spring_orchestrator\n(Spring Boot)] as Orch
  database "PostgreSQL\n(워크플로 메타데이터)" as PG
  folder "Artifact 저장소\n(단계별 산출물 JSON/MD)" as Art

  package "FastAPI Agents" {
    [photo_exif_llm_pipeline\n미디어 분석] as Exif
    [photo_grouping_agent\n사진 그룹화] as Group
    [hero_photo_agent\n대표 사진] as Hero
    [outline_agent\n개요] as Outline
    [draft_agent\n초안] as Draft
    [style_agent\n문체] as Style
    [review_agent\n검수] as Review
    [meta_agent\n제목/태그] as Meta
    [voice_profile_agent\n말투 학습] as Voice
  }

  [Ollama\n(로컬 LLM/비전 모델)] as Ollama
}

User --> Console
Console --> Orch : REST + SSE
Orch --> PG
Orch --> Art
Orch --> Exif
Orch --> Group
Orch --> Hero
Orch --> Outline
Orch --> Draft
Orch --> Style
Orch --> Review
Orch --> Meta
Orch --> Voice
Exif --> Ollama
Group --> Ollama
Draft --> Ollama
Style --> Ollama
Review --> Ollama
Meta --> Ollama
Voice --> Ollama
@enduml

역할 분담의 원칙은 한 줄로 요약된다.

순서와 상태는 Spring이 알고, 각 에이전트는 자기 단계만 안다.

  • spring_orchestrator: 파이프라인 순서 결정, 상태 머신 관리, 재시도 정책,
    결과 저장. 헥사고날 아키텍처에 ArchUnit 구조 테스트로 경계를 강제하고, JaCoCo
    커버리지 검증을 빌드에 포함한다. 에이전트 호출은 outbound port/adapter를
    통해서만 한다.
  • FastAPI 에이전트들: 자기 단계의 입력을 받아 결과만 반환한다. 어떤 모델을
    쓰는지, Ollama가 어디 있는지는 각 에이전트의 내부 설정이다. 이 경계가 왜
    중요한지는 2편에서 따로 다룬다.
  • momently_console: 업로드, 진행 상태 표시, 결과 확인·편집, 말투 학습 UI.
    진행 상태는 SSE를 우선 쓰고 실패 시 폴링으로 자동 전환한다(개인 서버 + 터널
    환경이라 SSE가 끊기는 일이 실제로 있다).

에이전트가 10개나 되는 게 과해 보일 수 있는데, 처음부터 10개였던 건 아니다.
미디어 분석과 그룹화 2개로 시작했고, "단계 = 독립 모듈" 패턴을 한 번 만들어
두니 대표 사진, 개요, 초안, 문체, 검수, 메타, 말투 학습, 프라이버시 검사,
품질 점수가 같은 틀로 하나씩 추가됐다. 패턴의 재사용 비용이 낮았던 것 —
이게 모듈 경계 결정(2편)의 실질적인 성과였다.

파이프라인 한 바퀴

사용자가 사진을 올리고 "여행 후기, 시간 순으로"라고 요청하면 이런 흐름이 돈다.

@startuml
skinparam shadowing false
autonumber

actor 사용자
participant "콘솔" as C
participant "오케스트레이터" as O
participant "미디어 분석" as E
participant "그룹화" as G
participant "대표사진/개요/초안" as M
participant "문체/검수/메타" as S

사용자 -> C : 사진 업로드 + 글 방향 입력
C -> O : POST /api/v1/workflows → 즉시 응답
note right of O : 실제 실행은 백그라운드\n콘솔은 SSE로 진행 상태 수신

O -> E : EXIF 추출 + 사진별 LLM 요약
E --> O : bundle JSON
O -> G : 전략 기반 그룹화 (TIME_BASED 등)
G --> O : 그룹 + 근거(score)
O -> M : 대표 사진 → 개요 → 초안
M --> O : 초안 마크다운
O -> S : 학습된 말투 적용 → 검수 → 제목/태그
S --> O : 최종 문서 + 발행 메타
O --> C : COMPLETED
C -> 사용자 : 결과 확인 · 편집 · 발행 패키지
@enduml

몇 가지 포인트만 짚으면:

  • 그룹화는 자유 텍스트가 아니라 전략 enum(TIME_BASED, LOCATION_BASED,
    SCENE_BASED, FOOD_TYPE_BASED, STORY_FLOW_BASED)만 받는다. "적당히 알아서"를
    API 계약에 넣지 않기로 한 초기 결정이다(2편).
  • 각 단계 산출물은 전부 artifact로 저장되고, 중간에 실패하면 실패한 단계부터
    재시작한다. 덕분에 60초짜리 LLM 단계가 실패해도 앞 단계를 다시 돌지 않는다(3편).
  • 동영상도 입력으로 받는다. ffmpeg로 대표 프레임을 여러 장 샘플링해 비전
    모델로 각각 요약한 뒤 병합한다. 프레임 수는 설정으로 조절하는데, 체감 대기
    시간을 줄이려고 Docker 기본값을 1로 낮춘 적도 있다 — 품질과 속도의 저울질은
    로컬 LLM을 선택한 순간부터 상수처럼 따라다닌다.

기술 스택 요약

영역 선택 비고
오케스트레이션 Spring Boot 상태 머신, 헥사고날, ArchUnit, JaCoCo
에이전트 FastAPI × 10 단계별 독립 모듈, 독립 계약
모델 실행 Ollama qwen2.5:14b 기본, gemma 계열 비교 운영
DB PostgreSQL + Flyway 메타데이터만. 큰 산출물은 artifact 파일로 분리
ID UUIDv7 시간 정렬 가능한 외부 공개 식별자
프론트 React SSE 우선 + 폴링 fallback
배포 Docker Compose + nginx + Cloudflare Tunnel Mac mini 개인 서버

각 선택의 "왜"는 시리즈에서 하나씩 풀어간다. 스택 나열은 사실 이 시리즈에서
가장 재미없는 부분이고, 재미있는 건 이런 것들이다 —

  • 에이전트 10개의 에러 형식이 제각각이라 raw stack trace가 사용자 화면에
    노출되던 것을 표준 에러 envelope으로 정리한 이야기 (6편)
  • LLM cold-start가 톰캣 worker thread를 묶어 멀쩡한 워크플로까지 느려지던 것을
    서킷브레이커 + 벌크헤드로 격리한 이야기 (7편)
  • 사진 OCR에 "포인트"라는 단어 하나가 있으면 여행 후기가 퀴즈 정답 공유 글로
    납치되던 사건 (8편)
  • 말투 저장소를 MinIO로 옮겼는데 읽기 경로를 안 옮겨서, 학습한 말투가 조용히
    무시되고 있던 "보내고 끝" 버그 (5편)

이 결정들을 다시 한다면

로컬 LLM은 다시 해도 같은 결정이다. 품질 격차는 후처리와 프롬프트로 상당
부분 메울 수 있었지만, 한 번 나간 데이터는 돌아오지 않는다. 비가역적인 쪽을
불변 조건으로 잡은 건 옳았다.

결정적 파이프라인도 같은 결정이다. 다만 처음부터 알았으면 좋았을 것 하나 —
"제어 흐름을 코드가 가진다"고 해서 파이프라인이 저절로 안전해지는 건 아니었다.
상태 머신과 DB 제약이라는 두 사본이 어긋나는 사고(3편), 프롬프트 안에서의
결정권 공백(8편)처럼, 결정적 파이프라인에는 결정적 파이프라인만의 고장 모드가
있다. 비결정성을 몰아냈다고 안심한 자리에서 사고가 났다.

시리즈 목차 (예정)

  1. 사진만 넣으면 블로그 글이 나오게 하고 싶었다 — 전체 아키텍처 (이 글)
  2. 에이전트를 어떻게 쪼갤 것인가 — 모듈 경계와 공개 API
  3. 워크플로 상태 머신과 저장 전략 — UUIDv7, PostgreSQL, 그리고 CHECK 제약 사고
  4. LLM한테 사진 그룹화를 시켜보니 — 규칙 + LLM 보정, 모델 비교 하네스
  5. 내 말투를 학습시키기 — voice profile과 "보내고 끝" 버그
  6. 에이전트 10개의 에러가 제각각이던 날 — 표준 에러 envelope
  7. 에이전트 하나가 느려지면 전체가 죽는다 — Circuit Breaker & Bulkhead
  8. 사진 속 단어 하나에 글 전체가 납치당했다 — 사용자 의도와 프롬프트 우선순위
  9. 자동 발행은 왜 안 되는가 — 네이버 발행과 브라우저 확장
  10. 혼자 개발하면서 ADR을 쓰는 이유 — 운영과 회고

다음 편에서는 첫 번째 아키텍처 결정 — 에이전트 모듈 경계를 어떻게 그었는지,
그리고 공개 API에서 ollama_base_url 같은 인프라 설정을 걷어내면서 "API는
도메인 의도만 받는다"는 원칙을 세운 과정을 다룬다.