[Momently 개발기 #7] 에이전트 하나가 느려지면 전체가 죽는다 — Circuit Breaker & Bulkhead
사진을 넣으면 블로그 초안이 나오는 로컬 LLM 파이프라인 Momently 개발기 7편.
6편에서 에러의 "모양"을 통일했다. 이번 편은 에러의 "전파"를 막는 이야기 —
LLM cold-start가 시스템 전체를 인질로 잡는 cascade failure를 Resilience4j
기반 per-agent Circuit Breaker + Bulkhead로 격리한 과정이다.
문제: 한 에이전트의 감기가 전체의 몸살로
Ollama 기반 에이전트(style, draft, review, meta)는 cold-start 시 호출 하나가
30~60초를 잡아먹는다. 이때 orchestrator 내부에서 벌어지는 일:
- 동기 HTTP 호출이 read-timeout(60초 근처)까지 톰캣 worker thread를 hold한다.
- 에이전트가 envelope으로
retryable: true를 보내면(6편의 성실한 표준!)
retryer가 maxAttempts까지 추가로 재시도한다. 성실함이 hold 시간을 늘린다. - 그 사이 같은 에이전트로 향하는 다른 워크플로 요청들도 같은 운명으로 thread를
하나씩 잡는다. - worker pool이 마르면, 아픈 에이전트와 무관한 요청(다른 에이전트 호출,
심지어 단순 조회 API)까지 응답이 늦어진다.
한 에이전트의 부분 장애가 시스템 전체의 응답성 저하로 번지는 전형적인 cascade
failure다. 더 나쁜 건 구조적으로 학습이 없다는 점이었다. retryer는 호출
단위로만 동작해서, 직전 호출 10개가 전부 타임아웃으로 죽었어도 11번째 호출은
"이 에이전트는 지금 아픔"이라는 사실을 모른 채 같은 retry 비용을 처음부터 다시
치른다.
그리고 6편에서 만든 메트릭으로 "지금 quality_score_agent가 50% 실패 중"이라는
관측은 되는데, orchestrator가 그 정보로 아무것도 하지 않는다는 문제도
있었다. 대시보드를 보고 사람이 재시작해야 다음 요청이 살아났다.
결정: per-agent Circuit Breaker + Bulkhead (ADR 006)
Resilience4j로 에이전트마다 독립된 서킷브레이커(CB)와 벌크헤드를 두고, 호출
진입점(AgentHttpRetryer.executeAndParse)이 두 관문을 통과한 후에만 실제 HTTP
호출을 하도록 했다.
@startuml
skinparam shadowing false
hide empty description
state CLOSED : 정상. 호출 통과\n최근 20회의 실패율/슬로우율 집계
state OPEN : 차단. 호출 즉시 거부\n(AGENT_CIRCUIT_OPEN, 503)
state HALF_OPEN : 시험. trial 호출 5개만 통과
[*] --> CLOSED
CLOSED --> OPEN : 실패율 ≥ 50%\n또는 슬로우율 ≥ 70%\n(최소 10회 호출 후)
OPEN --> HALF_OPEN : 30초 경과\n(자동 전이)
HALF_OPEN --> CLOSED : trial 성공
HALF_OPEN --> OPEN : trial 실패
@enduml
왜 per-agent인가 (글로벌 1개가 아니라). Ollama가 죽으면 style/draft/meta/
review가 같이 아프지만, voice_profile_agent는 Ollama와 무관하다. 글로벌 CB
하나면 Ollama 장애가 멀쩡한 에이전트 호출까지 차단한다. 격리 단위는 장애
도메인 단위여야 하고, 이 시스템의 장애 도메인은 에이전트다(2편의 모듈 경계가
여기서 또 배당금을 준다). 에이전트가 10개라 cardinality 부담도 없다.
의존성 선택의 디테일. resilience4j-spring-boot3 starter 대신 core 모듈
3개(circuitbreaker, bulkhead, micrometer)만 넣고 fluent API로 Bean을 직접
구성했다. starter는 @CircuitBreaker 류 AOP 어노테이션을 classpath에 끌어와서,
의존성을 추가했다는 사실만으로 기존 spring-web bean에 의도치 않은 자동
적용이 일어날 수 있다. "의존성 추가 = 동작 변화 0"을 보장하고 싶었고, 실제로
도입 단계(6a)의 검증 기준에 "기존 코드에 Resilience4j 어노테이션 부재를 grep으로
확인"과 "도입 전후 p99 latency 차이 ±5% 이내"를 넣었다.
디테일 1: 무엇을 '실패'로 셀 것인가
CB의 품질은 failure 정의의 품질이다. 6편의 envelope이 여기서 진가를 발휘한다 —
예외 클래스가 아니라 envelope의 필드로 실패를 분류할 수 있다.
| 우선순위 | 조건 | 판정 |
|---|---|---|
| 1 | AGENT_NETWORK_ERROR |
FAILURE |
| 2 | 5xx (500/502/503/504) | FAILURE |
| 3 | 429 | FAILURE |
| 4 | 4xx + retryable=false |
IGNORE |
| 5 | 4xx + retryable=true |
FAILURE |
| 6 | 성공 응답 | SUCCESS |
핵심은 4행이다. 404나 422는 에이전트 건강 신호가 아니다. 사용자가 없는
프로필을 조회하거나 잘못된 입력을 보낸 것뿐인데, 이런 요청이 몰렸다고 CB가
열리면 사용자 입력 오류가 시스템 장애로 승격된다 — 멀쩡한 에이전트가
차단되고 정상 워크플로까지 막힌다. 반대로 5행처럼 에이전트가 일시 장애를 4xx로
표현하며 retryable=true를 명시한 드문 경우는 실패로 센다. 이 구분이 가능한 건
envelope에 retryable이라는 의도 필드가 있기 때문이고, 그래서 이 분류는
Resilience4j의 예외 클래스 기반 설정(recordExceptions)이 아니라 recordResult
Predicate로 구현했다 — errorCode와 httpStatus까지 보고 판정해야 하니까.
슬로우 콜은 별도 축이다. 30초(LLM 평균 응답 10~20초보다 크게) 넘는 호출은
성공했어도 slow로 세고, 슬로우율 70%면 서킷이 열린다. 단순 5xx만 보던 기존
retry 정책의 사각 — "죽진 않았는데 다 느림" — 을 여기서 잡는다.
디테일 2: 감싸는 순서
요청 → CircuitBreaker → Bulkhead → Retryer(envelope retry) → 실제 HTTP 호출
CB가 최외곽, retryer가 최내부다. 순서가 뒤집히면 의미가 달라진다.
- retryer가 안쪽이므로, CB는 retry를 전부 소진한 최종 결과만 본다. 일시적
5xx가 재시도로 회복되면 CB 입장에선 SUCCESS다. retry가 흡수할 수 있는 잔파도로
서킷이 열리는 false-open을 줄인다. - Bulkhead가 retryer 바깥이므로, 한 호출의 retry 시도 전체가 1 slot이다.
재시도 중에 다른 호출이 slot을 뺏어 들어오지 않는다. - CB가 OPEN이면 retryer까지 가지도 않고 즉시 차단 — 아픈 에이전트에 retry
폭탄을 계속 던지는 일 자체가 사라진다.
디테일 3: Bulkhead — 열리기 전의 방어선
CB에는 사각이 있다. OPEN이 되려면 실패/슬로우 통계가 쌓여야 하는데, 그 통계가
쌓이는 동안에도 worker thread는 슬로우 호출에 묶인다. 그래서 동시 호출 수
자체를 cap하는 bulkhead를 같이 넣었다.
- semaphore 방식 (thread-pool 방식이 아니라): thread-pool bulkhead는 비동기
wrapper가 필요해서 현재의 동기 호출 구조와 안 맞고, semaphore는 추가 thread나
context switch 비용이 없다. - 에이전트당 최대 동시 10 : 톰캣 기본 200 thread ÷ 10 에이전트 = 20의
절반. 한 에이전트가 미쳐도 thread의 5%만 잃는다. - maxWaitDuration 0: 자리가 없으면 기다리지 않고 즉시 거부. 대기를 허용하면
우리가 피하려던 thread hold가 대기열이라는 이름으로 부활한다.
거부와 차단은 6편의 envelope로 응답한다. Bulkhead 거부는AGENT_BULKHEAD_FULL(retryable=true, retry_after 1~10초 jitter), 서킷 OPEN은AGENT_CIRCUIT_OPEN(retry_after = 서킷의 남은 대기시간)으로. 6편의 advice가
이 envelope을 그대로 사용자 화면과 SSE에 전달하므로 신규 UI 코드가 0이다 —
표준을 먼저 깔아둔 것의 복리 이자다.
ADR이 코드에 진 빚: 가정했던 메트릭이 없었다
관측 설계에서 한 번 겸손해질 일이 있었다. ADR 초안은agent.circuit_breaker.transition 같은 커스텀 카운터를 가정하고 알람 룰까지
스케치했는데, 구현하며 확인해 보니 micrometer-resilience4j 바인더가 자동
노출하는 실제 메트릭은 이름도 라벨도 달랐다(resilience4j_circuitbreaker_state
게이지, 라벨은 agent가 아니라 name, 상태 전이는 별도 카운터가 아니라 state
게이지의 변화로 관측). ADR을 실측에 맞게 고쳐 썼다 — 상태 전이 알람은
"state가 open/half-open으로 2분 지속 → warning, 15분 → critical"로, bulkhead
포화는 available_concurrent_calls == 0으로. 문서가 코드를 이기게 두면 안 된다.
버린 대안 정리
- Hystrix: 2019년부터 maintenance mode. 신규 도입 부적합.
- RetryTemplate + 수동 게이트: CB의 핵심 가치는 열리는 게 아니라 자동으로
다시 닫히는 것(HALF_OPEN trial)이다. 이걸 직접 구현하면 재발명. - Istio 등 service mesh의 outlier detection: 단일 docker-compose 환경에
메시 인프라는 비용/복잡도 대비 효익이 없다. - 글로벌 1 CB / 1 Bulkhead: 장애 도메인과 격리 단위의 불일치. 위에서 설명.
- spring-boot starter: AOP 자동 적용의 부수효과 위험. core 모듈 + 명시적
Bean 구성으로.
정직한 비용 목록
도입하면서 ADR의 Cons 섹션에 적어둔 위험들 — 감춰서 좋을 게 없다.
- 저트래픽의 사각: COUNT_BASED 윈도(20회)는 호출이 뜸하면 수십 분에 걸쳐
있을 수 있다. 오래된 성공이 윈도에 남아 실제 장애의 반영이 늦어진다. 호출
빈도 데이터가 쌓이면 TIME_BASED 전환을 검토하기로. - 균등 분포 가정: "200 ÷ 10 에이전트"는 호출이 균등하다는 가정인데, 실제론
draft/style이 훨씬 자주 불린다. bulkhead 10이 이들에겐 false rejection을 만들
수 있다 — 운영 데이터 기반 재조정을 후속 과제로. - envelope 강결합: failure Predicate가 envelope의 세 시그니처(errorCode,
httpStatus, retryable)에 의존한다. ADR 005 쪽이 바뀌면 여기도 같이 바뀌어야
한다는 cross-reference를 양쪽 문서에 남겼다. - 인스턴스 로컬: orchestrator가 다중 인스턴스가 되면 CB 학습은 인스턴스별로
독립이다. 현재는 단일 인스턴스라 문제없지만 알람 룰은 인스턴스별로 봐야 한다.
적용은 3단계로 나눴다: 6a(의존성+Bean만, 동작 변화 0 증명) → 6b(retryer 통합 +
failure Predicate, "4xx만으론 안 열리고 5xx 50%면 30초 열린다"를 통합 테스트로
증명) → 6c(운영 알람/대시보드, 7일 false-open 0 + 실제 장애 시 수동 재시작 없이
자동 회복 확인). 세밀 튜닝(6d의 per-workflow degradation 포함)은 관측 데이터가
쌓인 후로 미뤘다 — 지금 조이는 나사는 감으로 조이는 나사다.
이 결정을 다시 한다면
구성은 그대로 간다. 다시 한다면 순서를 바꾸고 싶다 — bulkhead를 먼저,
CB를 나중에. 돌아보면 급했던 건 "아픈 에이전트 차단"보다 "아픈 에이전트가
thread를 다 먹는 것"이었고, semaphore bulkhead는 CB보다 훨씬 단순해서(상태도
통계도 없다) 먼저 넣기 쉬웠다. 화려한 장치(CB)가 수수한 장치(semaphore)보다
먼저 떠오르는 건 엔지니어의 직업병이다.
다음 편은 인프라에서 프롬프트로 돌아간다. 사진 OCR에 섞인 "포인트"라는 단어
하나 때문에 여행 후기가 퀴즈 정답 공유 글로 납치되던 사건 — 프롬프트
파이프라인에서 "누가 결정권자인가"를 명시하지 않으면 무슨 일이 벌어지는지에
대한 이야기다.
'AI블로그 자동 포스팅 - Momently' 카테고리의 다른 글
| Momently 개발기 #9] 자동 발행은 왜 안 되는가 — 네이버 발행과 브라우저 확장 (0) | 2026.07.19 |
|---|---|
| [Momently 개발기 #8] 사진 속 단어 하나에 글 전체가 납치당했다 — 사용자 의도와 프롬프트 우선순위 (0) | 2026.07.19 |
| [Momently 개발기 #6] 에이전트 10개의 에러가 제각각이던 날 (0) | 2026.07.19 |
| [Momently 개발기 #5] 내 말투를 학습시키기 — voice profile과 "보내고 끝" 버그 (0) | 2026.07.19 |
| [Momently 개발기 #4] LLM한테 사진 그룹화를 시켜보니 — 규칙 + LLM 보정, 그리고 모델 비교 하네스 (0) | 2026.07.19 |