[Momently 개발기 #3] 워크플로 상태 머신과 저장 전략 — UUIDv7, PostgreSQL, 그리고 CHECK 제약 사고
사진을 넣으면 블로그 초안이 나오는 로컬 LLM 파이프라인 Momently 개발기 3편.
1편에서 "제어 흐름은 결정적 코드가 가진다"고 결정했다. 이번 편은 그 결정의
실체인 상태 머신, 그리고 그 아래를 받치는 ID 전략(ADR 003)과 저장
전략(ADR 004) 이야기다. 마지막에는 이 설계의 사본 둘이 어긋나서 운영
워크플로가 죽었던 사고를 다룬다.
상태 머신: 단계마다 ~ING과 ~ED를 쌍으로
워크플로의 상태는 이렇게 생겼다. 각 단계가 "진행 중"과 "완료" 상태를 쌍으로
갖는다.
@startuml
skinparam shadowing false
hide empty description
[*] --> CREATED
CREATED --> PHOTO_INFO_EXTRACTING
PHOTO_INFO_EXTRACTING --> PHOTO_INFO_EXTRACTED
PHOTO_INFO_EXTRACTED --> PHOTO_GROUPING
PHOTO_GROUPING --> PHOTO_GROUPED
PHOTO_GROUPED --> HERO_PHOTO_SELECTING
HERO_PHOTO_SELECTING --> HERO_PHOTO_SELECTED
HERO_PHOTO_SELECTED --> OUTLINE_CREATING
OUTLINE_CREATING --> OUTLINE_CREATED
OUTLINE_CREATED --> DRAFT_CREATING
DRAFT_CREATING --> DRAFT_CREATED
DRAFT_CREATED --> STYLE_APPLYING
STYLE_APPLYING --> STYLE_APPLIED
STYLE_APPLIED --> REVIEWING
REVIEWING --> REVIEW_COMPLETED
REVIEW_COMPLETED --> META_GENERATING
META_GENERATING --> META_GENERATED
META_GENERATED --> COMPLETED
COMPLETED --> [*]
PHOTO_INFO_EXTRACTING --> FAILED
PHOTO_GROUPING --> FAILED
DRAFT_CREATING --> FAILED
META_GENERATING --> FAILED
FAILED --> PHOTO_INFO_EXTRACTING : retry\n(실패 단계부터)
@enduml
(FAILED 화살표는 대표 몇 개만 그렸다. 실제로는 모든 ~ING 상태에서 FAILED로 갈
수 있다.)
왜 굳이 상태를 이렇게 잘게 쪼갰나. PROCESSING 하나로 뭉치고 진행률만 별도
필드로 둘 수도 있었다. 쪼갠 이유는 상태가 곧 세 가지 역할을 겸하기 때문이다.
- 재시작 지점.
DRAFT_CREATING에서 죽었으면 초안부터 다시 하면 된다.OUTLINE_CREATED까지의 산출물은 artifact로 남아 있다. - 전이 규칙의 근거. "OUTLINE_CREATING은 PHOTO_GROUPED와 HERO_PHOTO_SELECTED
이후에만 가능하다" 같은 규칙을 코드로 강제할 수 있다. 상태가 뭉쳐 있으면
이 규칙이 주석으로 전락한다. - 관측의 단위. 콘솔의 진행 표시(SSE), 단계별 타이밍 로그
(workflow_step_timing), 나중에 붙는 메트릭과 알람이 전부 이 상태값 하나를
본다.
실패 처리: 실패는 기록이고, 성공은 자산이다
실패 정책의 원칙은 네 줄이다.
- 한 단계 실패가 전체 워크플로를 즉시 파괴하지 않는다.
- 실패 상태와 실패 단계(
last_failed_step,last_error_message)는 기록된다. - 이미 성공한 단계의 산출물은 유지한다.
- 재실행은 실패 단계부터 시작한다.
여기서 파생된 디테일 몇 가지가 실전에서 중요했다.
- 재진입 시 흔적 청소: 실패했던 워크플로가 재실행으로 정상 단계에
들어서면lastFailedStep,lastErrorMessage를 지운다. 안 지우면 성공한
워크플로 상세에 옛 에러가 유령처럼 남는다. - 멱등 응답:
run/retry가 중복 호출되면(더블 클릭, 네트워크 재전송) 새
실행을 만들지 않고 멱등하게 응답한다. 이건 API 테스트로 고정해 뒀다. - API는 즉시 응답: 워크플로 생성 API는 실행을 기다리지 않고 바로 응답하고,
실제 파이프라인은 백그라운드로 돈다. LLM 단계 하나가 분 단위인 시스템에서
동기 응답은 애초에 선택지가 아니다.
ID 전략: UUIDv7 (ADR 003)
워크플로 ID, 산출물 참조, 외부 공개 식별자에 쓸 ID 전략. 후보는 네 개였다.
| 후보 | 탈락/채택 이유 |
|---|---|
UUIDv4 |
생성은 쉽지만 완전 무작위라 시간 정렬이 안 된다. 인덱스 지역성도 나쁨 |
ULID |
시간 정렬은 되지만 표준(RFC) 기반이 아니다. 장기 유지보수에서 감점 |
Snowflake |
시간 정렬 + 짧은 숫자형. 하지만 worker id 배정, 시계 역행 처리 등 운영 부담이 생긴다 |
UUIDv7 |
채택. 시간 순 정렬 + 표준 기반 + 중앙 조정 없이 분산 생성 가능 |
결정의 실질은 "지금 단계에서 무엇을 최적화할 것인가"였다. 트래픽 최적화(짧은
숫자 키)보다 안전한 기본값과 표준 친화성을 골랐고, 숫자형 내부 PK가 정말
필요해지는 시점이 오면 공개 ID와 내부 PK를 분리하는 걸로 미뤄뒀다(아직 그 시점은
안 왔다). ID가 시간 정렬되는 성질은 부수적으로 로그 상관관계 추적에서 계속
도움이 됐다 — 6편의 trace_id 설계(wf_<id>_step_<step>_<ts>)도 이 위에 얹힌다.
저장 전략: PostgreSQL, 그리고 "DB에 큰 JSON을 넣지 않는다" (ADR 004)
후보는 메모리 저장소 유지 / 관계형 DB / NoSQL 단독. PostgreSQL을 골랐다.
- 상태 전이와 재시도는 트랜잭션 일관성이 필요한 도메인이다. "상태는
DRAFT_CREATED인데 산출물 기록이 없는" 중간 상태가 보이면 안 된다. - Spring 생태계와의 궁합, 인덱스, JSONB, 운영 도구까지 균형이 좋다.
더 중요한 결정은 이것이었다. 큰 JSON 산출물(bundle, 그룹화 결과, 초안, 최종
문서)은 DB 본문에 넣지 않고 artifact 파일 저장소로 분리한다. 사진 수백 장의
분석 결과 bundle은 쉽게 수 MB가 된다. 이걸 DB 컬럼에 누적하면:
- 백업이 빠르게 비대해지고, 워크플로 목록 조회 같은 가벼운 쿼리가 무거운
테이블 위를 걷게 된다. - 산출물은 불변인데(단계 재실행 시에만 교체) 트랜잭션 저장소에 둘 이유가 없다.
그래서 DB에는 메타데이터(상태, 실패 정보, 경로 참조)만 남기고, 산출물은<projectId>/grouping/grouping-result.json 같은 예측 가능한 경로의 파일로
저장한다. 사용자가 결과를 편집하면 원본을 덮지 않고 edits/ 디렉터리에 버전을
쌓되, *-latest.md는 항상 유지하고 타임스탬프 버전은 설정된 개수만 보존한다 —
원본은 재시도의 근거라서 삭제하지 않는다는 원칙과, 디스크가 무한하지 않다는
현실의 절충이다.
스키마 관리는 Hibernate ddl-auto가 아니라 Flyway migration + validate
모드로 갔다. 엔티티에서 스키마를 자동 생성하는 방식은 편하지만, 운영 DB에서
"코드가 스키마를 몰래 바꾸는" 경로를 열어 둔다. validate 모드는 코드와 스키마가
어긋나면 부팅을 거부한다 — 어긋남을 조기에, 시끄럽게 터뜨리는 쪽을 골랐다.
…라고 정리해 놓고, 바로 그 "어긋남" 때문에 운영 사고를 냈다.
사고: enum엔 있는데 CHECK 제약엔 없는 상태
증상은 이랬다. 메타(제목/태그) 단계가 추가된 뒤, 워크플로가 메타 단계로
진입하려는 순간 DB가 update를 거부했다.
ERROR: new row for relation "workflows" violates check constraint
"workflows_status_check"그리고 워크플로는 FAILED로 빠졌다. 코드 버그도, LLM 문제도 아니었다. 원인은
마이그레이션 파일 하나였다.
- 메타 단계를 추가하면서 Java enum
WorkflowStatus에는META_GENERATING,META_GENERATED를 추가했다. - Flyway
V4마이그레이션은 메타 단계용 컬럼은 추가했지만,status컬럼의
CHECK 제약(허용 상태 목록)은 기존 enum 목록 그대로 뒀다. - 결과: 코드는 새 상태로 전이하려 하고, DB는 "그런 상태는 모른다"며 거부.
뼈아픈 점은 이거다. Flyway + validate까지 갖췄는데도 못 잡았다. validate는
"컬럼 구조"의 어긋남을 잡지, CHECK 제약 안의 값 목록까지 검증하지 않는다.
enum과 CHECK 제약은 같은 사실("허용되는 상태의 목록")의 두 사본인데, 이 사본을
동기화하는 장치가 아무것도 없었다.
수습과 재발 방지는 세 겹으로 했다.
- 핫픽스: 운영 DB에 제약 재정의 SQL을 직접 적용해 급한 불을 끔.
- 정식 마이그레이션
V5:DROP CONSTRAINT IF EXISTS+ADD CONSTRAINT로
enum 전체 목록을 다시 명시. idempotent하게 작성해서 핫픽스가 이미 적용된
운영 DB에서도, 새로 만드는 DB에서도 같은 결과가 되게 했다. - 빌드 가드 테스트
WorkflowStatusCheckConstraintDriftTest: Java enum의
상태 목록과 마이그레이션 SQL의 CHECK 목록을 비교해서, 어긋나면 빌드가
깨진다. 다음에 누가(주로 미래의 나) enum에 상태를 추가하고 CHECK를 까먹으면
운영이 아니라 CI에서 죽는다.
교훈을 한 문장으로 줄이면: 같은 사실의 사본이 둘이면, 사본 동기화를 사람의
기억에 맡기지 말고 기계에 맡겨라. 2편에서 "경계를 긋는 것과 경계를 기계로
감시하는 것은 다르다"고 했는데, 정확히 같은 교훈이 스키마에서 반복된 셈이다.
버린 대안 정리
PROCESSING하나로 뭉친 상태 + 진행률 필드: 재시작 지점·전이 규칙·관측
단위를 전부 잃는다.- UUIDv4 / ULID / Snowflake: 위 표 참고. 시간 정렬 + 표준 + 무조정 생성의
교집합은 UUIDv7뿐이었다. - 산출물 JSONB 컬럼 저장: 초기 개발은 편하지만 백업·조회 비용이 워크플로
수에 비례해 자란다. 불변 산출물은 파일이 맞다. - Hibernate ddl-auto: 운영 DB 스키마를 코드가 암묵적으로 바꾸는 경로.
validate + Flyway로 명시성에 걸었다 — 그리고 validate가 못 보는 사각(CHECK
값 목록)은 결국 커스텀 가드 테스트로 메꿨다.
이 결정을 다시 한다면
상태 머신, UUIDv7, PostgreSQL, artifact 분리 모두 그대로 간다. 바꿀 것은
하나 — "enum ↔ DB 제약" 같은 사본 쌍을 처음 만드는 날 가드 테스트도 같이
만든다. 사고 후에 만든 drift 테스트는 30분짜리 작업이었다. 그 30분을 아낀
대가가 운영 워크플로 FAILED와 핫픽스였다. 사본은 만들 때가 가장 싸고, 어긋난
뒤가 가장 비싸다.
다음 편은 파이프라인에서 LLM이 처음으로 "판단"을 맡는 단계, 사진 그룹화다.
규칙 기반과 LLM 보정을 섞은 이유, LLM이 photo_id를 슬쩍 빼먹거나 계약에 없는
필드를 지어내는 문제를 후처리로 방어한 이야기, 그리고 qwen과 gemma를 공정하게
비교하기 위한 하네스까지.
'AI블로그 자동 포스팅 - Momently' 카테고리의 다른 글
| [Momently 개발기 #6] 에이전트 10개의 에러가 제각각이던 날 (0) | 2026.07.19 |
|---|---|
| [Momently 개발기 #5] 내 말투를 학습시키기 — voice profile과 "보내고 끝" 버그 (0) | 2026.07.19 |
| [Momently 개발기 #4] LLM한테 사진 그룹화를 시켜보니 — 규칙 + LLM 보정, 그리고 모델 비교 하네스 (0) | 2026.07.19 |
| [Momently 개발기 #2] 에이전트를 어떻게 쪼갤 것인가 — 모듈 경계와 공개 API (0) | 2026.07.19 |
| [Momently 개발기 #1] 사진만 넣으면 블로그 글이 나오게 하고 싶었다 (0) | 2026.07.19 |