사진을 넣으면 블로그 초안이 나오는 로컬 LLM 파이프라인 Momently 개발기 6편.
기능이 10개 에이전트로 불어나는 동안 에러 처리는 각자 진화했고, 어느 날
사용자 화면에 Java 예외 메시지가 그대로 떴다. 이번 편은 에러 응답 표준을
정하는 것보다 훨씬 어려웠던 일 — 기존 코드와 공존시키며 점진 적용하기 —
의 기록이다.

증상: 사용자 화면의 stack trace

콘솔의 "새 글쓰기" 실패 화면에 이런 게 떴다.

작업 실패: 500 Internal Server Error on POST request for
"http://momently-style:8000/api/v1/style": [{"detail": ...

내부 컨테이너 호스트명, HTTP 클라이언트 예외 메시지, 에이전트 응답 본문 조각.
사용자가 볼 이유도 없고 봐서도 안 되는 정보들이다. 이건 표면 증상이었고, 원인을
파 보니 에러 처리 전체가 무정부 상태였다.

진단: 세 겹의 무정부 상태

에이전트 측. 10개 에이전트 중 voice_profile_agent 하나만 HTTP status를
제대로 쓰고(HTTPException + detail), 나머지 9개는 HTTP 200 + 본문
status: "error"
패턴이었다. 성공도 200, 실패도 200. 이러면 HTTP 레벨의
모니터링·알람이 전부 장님이 되고, retry 판단도 본문을 파싱해야 가능하다.
심지어 그 하나 남은 에이전트도 에러 메시지가 한국어/영어 혼용이었고, FastAPI의
validation 에러(422)는 어느 에이전트에서도 wrap되지 않아 프레임워크 기본 본문이
그대로 흘러나갔다.

orchestrator 측. 에이전트 클라이언트마다 catch 처리가 3가지 패턴으로 흩어져
있었다 — 단순 status+body 전달, 본문 truncate, 그리고 조용한 fallback(5편에서
본 그놈이다). 모두 최종적으로 IllegalStateException에 메시지를 구겨 넣었다.
에러 코드도, 사용자용 메시지 분리도 없다.

전이 경로. WorkflowController에는 @ExceptionHandler가 아예 없었고,
비동기 실행 경로의 catch는 IllegalStateException.getMessage()를 그대로
workflow.markFailed()에 넣었다. raw 응답 본문이 실패 메시지가 되어 DB에
저장되고, SSE를 타고 사용자 화면까지 배달되는 완성된 경로였다.

운영 관점의 비용도 명확했다. 같은 종류의 장애가 에이전트마다 다른 모양으로
보여서 root cause 추적이 어려웠고, 한 워크플로가 10개 에이전트를 부르는데
요청과 에러를 잇는 공통 식별자가 없어 timestamp와 에이전트 이름으로 수작업
매칭
을 해야 했다.

결정: envelope 하나로 통일 (ADR 005)

모든 에이전트와 클라이언트가 쓰는 표준 에러 응답을 정의했다.

{
  "error_code": "VOICE_PROFILE_NOT_FOUND",
  "message": "voice profile not found",
  "user_message": "말투 프로필을 찾을 수 없습니다.",
  "retryable": false,
  "retry_after_seconds": null,
  "trace_id": "wf_090d8cb4_step_meta_2026-05-23T14:03:52Z",
  "details": null
}

필드마다 존재 이유가 있다.

  • error_code (UPPER_SNAKE_CASE): 기계용 안정 식별자. 메시지 문구는 바뀌어도
    코드로 알람 룰과 분기가 유지된다.
  • message(영어) / user_message(한국어) 분리: 이 스키마의 심장이다.
    사용자 화면에는 user_message만 나간다. 스키마 차원에서 raw stack 노출
    사고를 차단
    하는 것 — 리뷰에서 "이 메시지 사용자에게 보여도 되나?"를 매번
    묻는 대신, 보여도 되는 필드를 따로 만들었다.
  • retryable: "이 요청을 그대로 다시 보내면 다른 결과가 나올 수 있는가"에
    대한 에이전트의 의도 표명. 기존에는 orchestrator가 status code로
    추측했다(5xx면 retry). 하지만 backoff 윈도를 아는 건 에이전트 쪽이다 —
    LLM cold-start 중인지, rate limit인지는 에이전트만 안다.
  • retry_after_seconds: 위와 같은 맥락. 명시되면 orchestrator의 고정
    backoff보다 우선한다.
  • trace_id: orchestrator가 X-Request-Id 헤더로 내려보내고 에이전트가
    echo. 한 워크플로의 에이전트 호출 10건을 하나로 묶는 키다. 수작업 timestamp
    매칭의 종언. (OpenTelemetry / RFC 9457의 관행과도 맞는 패턴이다.)
  • details: validation 실패의 필드 단위 정보({loc, msg, type}). 문자열
    하나로 뭉개면 "어느 필드가 왜"를 잃는다. FastAPI의
    RequestValidationError.errors() 포맷을 그대로 보존해 콘솔이 필드 강조를 할
    수 있게 했다.

HTTP 규칙은 단순하다: status code는 RFC 의미 그대로 쓴다. "200 + status:error"
패턴은 deprecated.

버린 대안 — 중앙 메시지 매핑. user_message를 에이전트가 아니라
orchestrator가 error_code → 한국어 메시지 테이블로 중앙 관리하는 안을 진지하게
검토했다. 일관성 관점에선 매력적이다. 보류한 이유: 메시지에 필요한 도메인
컨텍스트("말투 프로필을 찾을 수 없습니다"에서 뭘 못 찾았는지)는 에이전트가 가장
잘 알고, 중앙 테이블은 에이전트 추가/변경 때마다 두 곳을 고치는 중복과 번역
회귀를 만든다. 메시지는 컨텍스트가 가장 가까운 곳에서 만든다.

진짜 일: 점진 적용

표준 정의는 하루면 한다. 문제는 이미 돌아가는 에이전트 10개와 클라이언트,
테스트를 어떻게 옮기느냐다. 빅뱅 전환은 버렸다 — 한 PR로 10개 에이전트를
바꾸면 리뷰가 불가능하고, 문제가 생겼을 때 어느 변경이 원인인지 알 수 없다.

대신 단계를 이렇게 설계했다.

단계 내용
2a orchestrator 인프라만 신설 — AgentInvocationException, 파서, @ControllerAdvice. 기존 클라이언트는 아직 옛 방식. advice가 신·구 둘 다 처리
2b reference 에이전트 1개 적용 (voice_profile_agent — 이미 HTTPException을 써서 변경량 최소)
2c reference 클라이언트 1개 적용 (StyleAgentClient) + retryer가 envelope의 retryable/retry_after를 status 휴리스틱보다 우선하도록 개선
2d 비동기 경로 sanitization — raw body가 SSE/콘솔로 새는 마지막 경로 차단
2e 메트릭 — agent.invocation.error Counter (agent / error_code / status 태그)
3 나머지 에이전트 8개 + 클라이언트, 한 에이전트씩 별도 PR로 기계적 이관
4 legacy 패턴 제거

이 순서의 요점 두 가지. 첫째, reference를 먼저 만든다. 2b/2c는 기능
추가가 아니라 "나머지 8개가 베낄 모범 답안"을 만드는 단계다. 이후 단계 3은
판단이 필요 없는 기계적 작업이 된다. 둘째, 전환기에는 파서가 양쪽을 다
읽는다.
error_code 필드가 있으면 표준 envelope, 없으면 legacy로 분기하고,
legacy는 status 기반 fallback + 본문 truncate로 처리한다. 공존 비용을 지불하는
대신 어느 시점에도 시스템이 반쯤 깨진 상태가 되지 않는다.

@startuml
skinparam shadowing false
autonumber

participant "style_agent" as A
participant "AgentHttpRetryer\n+ 파서" as P
participant "AgentInvocation\nException" as E
participant "@ControllerAdvice /\n비동기 catch" as H
participant "콘솔 (SSE)" as C

A --> P : HTTP 503 + envelope\n{error_code, user_message,\nretryable: true, retry_after: 20}
P -> P : retryable=true →\nretry_after 초 후 재시도
A --> P : 재시도도 실패
P -> E : envelope → 예외 필드 1:1 매핑
E -> H : throw
H -> H : user_message만 응답/상태로,\nmessage·stack·raw body는 로그로
H --> C : "문체 적용이 지연되고 있습니다.\n잠시 후 다시 시도해 주세요."
note right of C #ddffdd
  사용자에게는 user_message만.
  trace_id로 로그에서 전체 맥락 추적
end note
@enduml

production review가 표준의 구멍을 찾다

단계 2a 인프라를 배포하기 전에 보안/운영 관점의 리뷰를 여러 축으로 돌렸는데,
"표준을 만드는 코드" 자체에서 문제가 여럿 나왔다. 골라 보면:

  • 4xx 뭉개기 회귀: status 매핑이 주요 4xx만 나열하고 나머지를 502로
    fallthrough시켜서, 408/410/451 같은 코드가 5xx로 둔갑 → CB·알람·retry 판단을
    전부 오염시킬 수 있었다. 4xx 전체 pass-through로 수정.
  • Retry-After 위생: 에이전트가 음수나 거대한 값을 보내면? 음수/0 가드 +
    상한 3600초. 표준의 필드는 신뢰가 아니라 검증의 대상이다 — envelope도 결국
    외부 입력이다.
  • 로그 인젝션: trace_id는 에이전트가 echo해 주는 값인데, 여기 CRLF를 심으면
    로그 위조가 된다. sanitizer로 traceId/errorCode의 제어문자를 차단하고, 응답에
    echo할 때 charset/길이 검증을 추가.
  • advice 공존: 기존 RestApiExceptionHandler와 새 advice의 우선순위가
    겹치면 어떤 핸들러가 잡을지 비결정적이 된다. 초안 ADR은 일반 Exception
    fallback까지 새 advice가 잡는 설계였는데, 이 리뷰에서 Spring 자체 예외를
    silencing할 위험이 드러나 AgentInvocationException만 잡는 disjoint 설계로
    좁혔다.
    ADR 본문에 "원래 결정 수정"으로 남겨 뒀다 — 결정이 바뀐 흔적을
    지우지 않는 것도 ADR의 일부다.
  • agentName 필드 추가: 구조화 로깅과 메트릭의 grouping key로 어느
    에이전트의 실패인지가 1급 정보여야 했다.

에러 처리 코드는 "가장 나쁜 날"에 실행되는 코드다. 평소엔 안 돌다가 장애
때만 도는 코드가 그 자체로 버그를 갖고 있으면 장애가 이중 장애가 된다. 표준
인프라일수록 리뷰 강도를 높인 건 옳은 투자였다.

이 결정을 다시 한다면

envelope 스키마와 점진 적용 전략은 그대로 간다. 더 일찍 했어야 하는 건
trace_id다. 이건 에이전트가 3개일 때 넣었어도 하루짜리 작업이었는데, 10개가
된 뒤에야 넣어서 그 사이의 모든 디버깅이 timestamp 고고학이었다. 관측성 장치는
"필요해진 다음"에 넣으면 이미 늦다 — 필요해졌다는 건 이미 어둠 속에서 며칠을
보냈다는 뜻이다.

그리고 이 ADR의 마지막 줄에는 다음 공사의 예고가 적혀 있었다: "단계 5(별도
ADR) — fault isolation. 한 에이전트의 5xx 폭주가 다른 에이전트 호출 thread 풀을
고갈시키는 위험." 에러의 모양은 통일했지만, 에러의 전파는 아직
막지 못한 상태였다.


다음 편이 바로 그 이야기다. LLM cold-start 60초가 톰캣 worker thread를 묶어
멀쩡한 워크플로까지 굶기는 cascade failure, 그리고 Resilience4j 기반 per-agent
Circuit Breaker + Bulkhead 설계 — "4xx로는 서킷을 열지 않는다" 같은 failure
분류의 디테일까지.