사진을 넣으면 블로그 초안이 나오는 로컬 LLM 파이프라인 Momently 개발기 2편.
이번 편은 코드보다 먼저 그은 두 개의 선 — 모듈 경계(ADR 001)와 공개 API
경계(ADR 002) — 이야기다. 지루해 보이는 결정이지만, 이후 모든 편에서 다룰
작업들이 이 두 선 위에서 굴러간다.

경계 질문이 처음 나온 순간

첫 모듈 photo_exif_llm_pipeline(EXIF 추출 + 사진별 LLM 요약)을 만들 때는 구조
고민이 없었다. 폴더 하나, 파이썬 코드, 테스트. 문제는 두 번째 기능인 사진
그룹화를 붙이려던 순간에 왔다.

같은 파이썬 프로젝트 안에 grouping/ 폴더를 파면 그만인가? 아니면 별도
프로젝트로 나눠야 하나? 나눈다면 레포까지 나눠야 하나? 이 시점에 답해야 했던
진짜 질문은 폴더 구조가 아니라 이것이었다.

"이 파이프라인의 단계들은 앞으로 얼마나 자주, 얼마나 독립적으로 바뀔 것인가?"

LLM 파이프라인의 단계는 일반적인 웹 앱의 레이어와 성격이 다르다. 그룹화 전략은
계속 실험 대상이고(4편에서 다룰 모델 비교), 프롬프트는 수시로 바뀌고, 단계
자체가 추가된다(실제로 2개로 시작해 10개가 됐다). 바뀌는 단위가 "단계"라면,
격리 단위도 "단계"여야 한다는 게 결론이었다.

검토한 세 가지 구조

대안 A — 단일 앱 (모놀리스). 파이썬 프로젝트 하나에 모든 단계를 패키지로.
초기 개발은 가장 빠르다. 버린 이유:

  • 단계별로 의존성이 다르다. EXIF/ffmpeg 처리와 LLM 프롬프트 로직과 웹 서버가
    한 의존성 트리에 묶이면, 한 단계의 라이브러리 업그레이드가 전체를 흔든다.
  • 오케스트레이션을 Spring으로 가기로 한 이상(1편) 어차피 폴리글랏이다. ML
    생태계는 Python이 압도적이고, 상태 관리·트랜잭션·운영은 Spring이 익숙하고
    강하다. 언어 경계가 이미 있는데 모듈 경계만 뭉갤 이유가 없다.
  • 단계 하나만 따로 실행/테스트하는 "단위 하네스"가 어려워진다. LLM 단계는
    느리기 때문에, 그룹화만 빠르게 반복 실험하는 루프가 필수였다.

대안 B — 완전 멀티레포. 단계마다 Git 레포 분리. 마이크로서비스 정석처럼
보이지만 버렸다:

  • 혼자 개발한다. 레포 10개의 PR/버전/CI를 혼자 관리하는 건 격리가 아니라 형벌이다.
  • 초기에는 모듈 간 계약(입출력 JSON 스키마)이 매일 바뀐다. 계약 변경 하나에
    레포 3개를 건드리는 원자적 변경이 불가능해진다.

선택 — 워크스페이스 하나 + 에이전트별 독립 모듈 (ADR 001). 중간값이다.
상위 워크스페이스는 하나로 유지하되, 각 에이전트는 독립 모듈로 분리하고 모듈마다
다음을 강제했다.

  • 자체 README, 입출력 JSON 스키마, 테스트, API 명세(OpenAPI)
  • 전체 호출 순서와 상태 관리는 Spring 오케스트레이터만 담당
@startuml
skinparam shadowing false
skinparam componentStyle rectangle

package "workspace (모노 워크스페이스)" {
  [spring_orchestrator] as Orch

  package "photo_grouping_agent" {
    [FastAPI 서버] as GA
    file "schema/ (입출력 계약)" as GS
    file "docs/api-spec, openapi.yaml" as GD
    file "tests/" as GT
  }

  package "photo_exif_llm_pipeline" {
    [CLI / 파이프라인] as EA
    file "schema + tests" as ES
  }

  package "draft_agent · style_agent · ..." {
    [FastAPI 서버] as DA
    file "schema + tests" as DS
  }
}

Orch -down-> GA : HTTP (계약 = 스키마)
Orch -down-> EA
Orch -down-> DA

note bottom of Orch
  순서·상태·재시도는 여기만 안다.
  에이전트는 자기 단계의
  입력 → 출력만 책임진다.
end note
@enduml

이 구조의 실질적 이득은 예상보다 컸다. 패턴의 재사용 비용이 0에 가까웠다.
새 단계(대표 사진, 개요, 메타…)가 필요할 때마다 "모듈 하나 복사 → 스키마 정의 →
FastAPI 진입점 → 오케스트레이터에 클라이언트 추가"라는 같은 틀을 반복했고,
2개였던 에이전트가 10개가 되는 동안 구조 논쟁을 다시 한 적이 없다.

훗날 워크스페이스는 컴포넌트별 독립 레포로 분리됐다. 계약이 안정된 뒤에는
멀티레포의 단점(원자적 변경 불가)이 거의 사라지고 장점(독립 배포·독립 이력)만
남는다. 처음부터 멀티레포였으면 초기 계약 변경 비용 때문에 고생했을 것이고,
끝까지 모노였으면 배포 단위가 계속 뭉쳐 있었을 것이다.
구조는 시점의 함수였다.

두 번째 선: 공개 API에서 인프라를 걷어내다

그룹화 에이전트의 첫 API 요청 스키마에는 이런 필드가 있었다.

{
  "project_id": "...",
  "grouping_strategy": "TIME_BASED",
  "photos": [ ... ],
  "ollama_base_url": "http://127.0.0.1:11434",
  "ollama_timeout_seconds": 120
}

동작은 했다. 오케스트레이터가 Ollama 주소를 알아서 에이전트에 넘겨주는 구조.
그런데 이 요청을 보고 있으면 이상한 점이 보인다. 호출자가 왜 피호출자의 내부
구현(어떤 모델 서버를 어디서 찾을지)을 알아야 하는가?

이 상태를 방치하면 생기는 일들:

  • 오케스트레이터가 에이전트 내부 사정(모델 서버 토폴로지)에 결합된다. 에이전트가
    Ollama를 vLLM으로 바꾸면 오케스트레이터 설정도 바뀐다.
  • API 계약이 인프라 변경마다 흔들린다. 계약은 가장 바꾸기 비싼 것인데, 가장
    자주 바뀌는 것(인프라 설정)과 묶여 버린다.
  • 공개 API에 내부 주소가 노출된다. 보안 관점에서도 나쁜 냄새다.

그래서 ADR 002로 선을 그었다. 공개 API는 도메인 의도와 데이터만 받는다.

공개 API에 남긴 것 서버 내부 설정으로 옮긴 것
project_id OLLAMA_BASE_URL
grouping_strategy OLLAMA_TIMEOUT_SECONDS
time_window_minutes 전략별 기본 모델 선택
photos 내부 retry/fallback 정책

결과적으로 Spring은 에이전트의 API 주소만 알면 된다. 에이전트가 내부에서
무슨 모델을 어떻게 돌리는지는 에이전트의 사정이다. 이 원칙은 이후 문서에
"공개 API와 내부 설정의 경계를 강제한다(주소/timeout/모델명은 내부 설정)"로
명문화돼 모든 신규 에이전트 리뷰의 체크리스트가 됐다.

"알아서 잘 묶어줘"를 계약에서 추방하다

같은 시기의 세 번째 결정. 그룹화 방식을 사용자가 지정하는 인터페이스를 자유
텍스트로 받을 수도 있었다. "시간순으로 묶되 음식 사진은 따로" 같은 문장을 LLM이
해석하게 하는 것 — LLM 프로젝트니까 오히려 자연스러워 보이는 선택지다.

버렸다. 그룹화 전략은 enum 5개로 제한했다: TIME_BASED, LOCATION_BASED,
SCENE_BASED, FOOD_TYPE_BASED, STORY_FLOW_BASED.

  • 테스트 가능성. enum이면 전략별 골든 테스트를 쓸 수 있다. 자유 텍스트면
    입력 공간이 무한이라 "이 전략이 맞게 동작한다"를 증명할 방법이 없다.
  • 재현성. 같은 요청은 같은 경로를 타야 한다. 자유 텍스트 해석이 계약 안에
    들어오면 API의 의미가 모델 버전에 따라 바뀐다.
  • 1편의 원칙과 일관성. 제어 평면(어떤 전략을 탈지)은 결정적이어야 한다.
    LLM은 전략 실행의 내부(그룹 보정)에서만 쓴다.

다만 이건 "자연어 입력을 영원히 안 받는다"는 뜻이 아니었다. 나중에 글의 방향을
지정하는 writing_instructions 같은 자연어 필드가 들어오는데(8편), 그때도
원칙은 유지된다 — 자연어는 콘텐츠 생성 단계의 재료로만 들어가고, 어떤 단계를
어떤 순서로 탈지는 건드리지 못한다.

이 경계들이 나중에 갚아준 것

경계 결정의 가치는 그은 날이 아니라 한참 뒤에 드러났다.

  • 에러 표준화(6편): 에이전트 호출이 전부 오케스트레이터의 클라이언트 계층
    한 곳을 지나기 때문에, 표준 에러 envelope 도입이 "클라이언트 계층 + 에이전트
    한 개씩"의 점진 마이그레이션으로 가능했다.
  • 서킷브레이커(7편): per-agent 격리라는 설계 자체가 "에이전트 = 독립 모듈 =
    독립 장애 도메인"이라는 이 경계 위에서만 성립한다.
  • 모델 교체 실험(4편): 그룹화 에이전트 내부에서 qwen과 gemma를 비교하는
    하네스를 돌리는 동안 오케스트레이터는 아무것도 몰랐고, 알 필요도 없었다.

이 결정을 다시 한다면

모듈 경계와 공개 API 경계는 그대로 다시 긋는다. 하나 아쉬운 것은 계약
문서(contracts.md)를 더 일찍, 더 기계가 검증 가능한 형태로
만들지 못한 점이다.
초기에는 스키마가 모듈별 예제 JSON으로 흩어져 있어서, 계약 drift를 사람 눈으로
잡아야 했다. 스키마 검증을 CI에 넣은 건 한참 뒤였고, 그 사이에 "필드 이름이
문서와 코드에서 다른" 자잘한 사고가 몇 번 있었다. 경계를 긋는 것과 경계를
기계로 감시하는 것은 다른 일이고, 후자를 미루면 전자의 가치가 샌다 — 이 교훈은
3편의 DB CHECK 제약 사고에서 더 아프게 반복된다.


다음 편은 오케스트레이터의 심장인 워크플로 상태 머신이다. UUIDv7과 PostgreSQL을
고른 이유, 큰 JSON을 DB에 안 넣기로 한 결정, 그리고 enum에 상태를 추가하고 DB
CHECK 제약을 안 고쳐서 운영 워크플로가 FAILED로 빠진 사고까지.