왜 장애 격리가 핵심이었나
이전 글에서 소개한 이커머스 자동화 플랫폼은 외부 API에 대한 의존도가 극도로 높다. 도매몰 API, 오픈마켓 API, AI API, 택배사 API까지 — 하나라도 장애가 나면 연쇄적으로 전체 시스템이 멈출 수 있는 구조다.
실제로 설계 초기에 프로토타입을 만들어 테스트했을 때, 하나의 마켓 API가 30초간 응답하지 않자 스레드 풀이 고갈되어 다른 정상 마켓의 주문 처리까지 멈추는 현상을 경험했다. 이때부터 장애 격리를 아키텍처의 1순위 요구사항으로 설정했다.
적용한 5가지 패턴
| 패턴 | 적용 대상 | 목적 |
|---|---|---|
| Circuit Breaker | 외부 API 호출 | 장애 서비스 빠른 차단 |
| Bulkhead | 서비스 간 통신 | 리소스 격리 |
| Timeout | 모든 외부 호출 | 무한 대기 방지 |
| Retry with Backoff | 일시적 장애 | 자동 복구 |
| Dead Letter Queue | 처리 실패 메시지 | 실패 메시지 보존 |
1. Circuit Breaker — 외부 API 차단기
외부 마켓 API를 호출하는 서비스에 Circuit Breaker를 적용했다. Go 서비스에서는 직접 구현했고, 상태 전이는 다음과 같다.
CLOSED (정상) → 연속 실패 N회 → OPEN (차단)
OPEN → 대기 시간 경과 → HALF-OPEN (시험)
HALF-OPEN → 성공 → CLOSED
HALF-OPEN → 실패 → OPEN
핵심 설정값
type CircuitBreakerConfig struct {
FailureThreshold int // 연속 실패 허용 횟수: 5
SuccessThreshold int // 복구 판단 성공 횟수: 3
OpenTimeout time.Duration // OPEN 유지 시간: 30초
HalfOpenMaxCalls int // HALF-OPEN 시 허용 호출 수: 1
}
설정값은 마켓별로 다르게 적용했다. 응답이 느린 마켓은 OpenTimeout을 60초로 늘리고, 자주 장애가 발생하는 마켓은 FailureThreshold를 3으로 낮췄다.
실전에서 중요했던 포인트
Circuit Breaker가 OPEN 상태일 때 단순히 에러를 반환하면 안 된다. 해당 마켓의 대기 작업을 별도 큐에 보관하고, Circuit이 CLOSED로 복구되면 보관된 작업을 순차 처리하도록 했다. 그렇지 않으면 장애 복구 후 대량 재요청이 한꺼번에 발생해 또다시 장애를 유발한다.
2. Bulkhead — 격벽 패턴
Bulkhead는 선박의 격벽에서 따온 패턴이다. 한 구획에 물이 차도 다른 구획은 안전하도록 리소스를 격리한다.
이 플랫폼에서는 두 가지 수준으로 Bulkhead를 적용했다.
서비스 수준 격리
각 마이크로서비스는 독립적인 Docker 컨테이너로 실행된다. 컨테이너별 CPU와 메모리 제한을 설정하여, 하나의 서비스가 리소스를 독점하지 못하도록 했다.
services:
product-collector:
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
product-analyzer:
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G # AI 처리로 메모리 더 할당
커넥션 풀 격리
하나의 MariaDB를 공유하지만, 서비스별로 커넥션 풀 크기를 분리했다. 주문 처리기가 커넥션을 모두 점유해서 정산 관리기가 DB에 접근하지 못하는 상황을 방지한다.
// 서비스별 커넥션 풀 설정
db.SetMaxOpenConns(20) // 서비스마다 다른 값
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
3. Timeout — 계층별 타임아웃
타임아웃은 단순해 보이지만, 계층별로 정확하게 설정하지 않으면 의미가 없다.
API Gateway (Nginx) : 60초
서비스 HTTP 핸들러 : 55초
외부 API 호출 (HTTP) : 30초
DB 쿼리 : 10초
Redis 연산 : 5초
핵심 원칙은 외부에서 내부로 갈수록 타임아웃이 짧아지는 것이다. 내부 타임아웃이 외부보다 길면, 이미 클라이언트가 포기한 요청을 서버가 계속 처리하게 된다.
Go에서는 context.WithTimeout을 사용하여 타임아웃을 전파한다.
func (s *OrderService) ProcessOrder(ctx context.Context, orderID string) error {
// 외부 API 호출에 개별 타임아웃 적용
apiCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, err := s.marketClient.GetOrderDetail(apiCtx, orderID)
if err != nil {
// context.DeadlineExceeded 체크로 타임아웃 구분
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("market API timeout: %w", err)
}
return err
}
// ...
}
Python 서비스에서는 asyncio.wait_for로 동일한 패턴을 적용한다.
async def analyze_product(self, product_id: str) -> AnalysisResult:
try:
result = await asyncio.wait_for(
self.ai_client.analyze(product_id),
timeout=30.0
)
return result
except asyncio.TimeoutError:
logger.warning(f"AI analysis timeout: {product_id}")
await self.mark_as_failed(product_id, "AI_TIMEOUT")
raise
4. Retry with Exponential Backoff — 지수 백오프 재시도
일시적 장애(네트워크 순단, API rate limit 등)에 대응하기 위해 지수 백오프를 적용했다.
func RetryWithBackoff(ctx context.Context, maxRetries int, fn func() error) error {
baseDelay := 1 * time.Second
maxDelay := 60 * time.Second
for attempt := 0; attempt <= maxRetries; attempt++ {
err := fn()
if err == nil {
return nil
}
if attempt == maxRetries {
return fmt.Errorf("max retries exceeded: %w", err)
}
// 지수 백오프 + 지터
delay := baseDelay * time.Duration(1<<uint(attempt))
if delay > maxDelay {
delay = maxDelay
}
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
delay = delay + jitter
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return nil
}
지터(jitter)를 반드시 추가해야 한다. 여러 서비스가 동시에 실패하면 동일한 타이밍에 재시도하게 되어 thundering herd 문제가 발생한다. 랜덤 지터가 이를 분산시킨다.
재시도하면 안 되는 경우
모든 에러에 재시도를 적용하면 안 된다. 다음 경우는 즉시 실패 처리한다.
- 4xx 에러 (클라이언트 오류): 재시도해도 결과가 같다
- 인증/인가 실패: 토큰 갱신이 필요한 별도 흐름이다
- 데이터 검증 실패: 입력 데이터 자체의 문제다
- 비멱등 연산의 부분 성공: 중복 처리 위험이 있다
5. Dead Letter Queue — 실패 메시지 보존
Redis Streams에서 처리에 실패한 메시지는 Dead Letter Queue(DLQ)로 이동한다.
[정상 스트림] → 처리 시도 → 성공 → ACK
→ 실패 (재시도 N회 초과) → [DLQ 스트림]
DLQ에 보관된 메시지는 다음과 같이 활용한다.
- 모니터링: DLQ 메시지 수를 감시하여 장애 조기 탐지
- 분석: 실패 원인별 분류로 시스템 개선 포인트 도출
- 수동 재처리: 원인 해결 후 DLQ 메시지를 원래 스트림으로 재투입
- 알림: DLQ 적재량이 임계치를 넘으면 알림 봇이 메시지 발송
AI 실패의 특수 처리
AI API 호출은 일반적인 외부 API보다 실패 패턴이 다양하다. 응답 시간의 분산이 크고, rate limit에 걸리기 쉬우며, 응답 품질이 일정하지 않다.
이를 위해 AI 실패만의 별도 흐름을 설계했다.
AI 요청 → 성공 → 다음 단계 진행
→ 실패 → 실패 상태 마킹 → 다른 작업 계속 처리
→ 재시도 큐 등록
→ 자동/수동 재처리
핵심은 AI 실패가 전체 파이프라인을 멈추지 않는 것이다. 상품 100개 중 3개의 AI 분석이 실패해도, 나머지 97개는 정상적으로 마켓에 등록된다. 실패한 3개는 재시도 큐에서 별도로 처리한다.
패턴 조합의 실제 흐름
실제 외부 API 호출 시 이 패턴들이 어떻게 조합되는지 정리하면 다음과 같다.
요청 발생
→ Bulkhead: 전용 커넥션 풀/goroutine 풀에서 실행
→ Circuit Breaker 상태 확인
→ OPEN이면 즉시 실패 반환 (빠른 실패)
→ CLOSED/HALF-OPEN이면 진행
→ Timeout 설정
→ 외부 API 호출
→ 성공: Circuit Breaker 성공 카운트 증가
→ 실패: Retry with Backoff
→ 재시도 성공: 정상 처리
→ 재시도 모두 실패: Circuit Breaker 실패 카운트 증가
→ 임계치 초과 시 Circuit OPEN
→ 메시지를 DLQ로 이동
교훈
장애 격리 패턴을 적용하면서 얻은 교훈이다.
-
패턴을 아는 것과 적절히 적용하는 것은 다르다. Circuit Breaker의 임계값 하나를 잘못 설정하면 정상적인 API도 차단된다. 프로덕션 트래픽을 관찰하며 지속적으로 튜닝해야 한다.
-
Go와 Python에서 동일한 패턴의 구현 방식이 다르다. Go는 goroutine과 channel로 자연스럽게 구현되지만, Python은 asyncio 기반으로 접근해야 한다. 패턴의 의미론은 같지만 구현 관용구는 언어에 맞춰야 한다.
-
모니터링 없는 장애 격리는 의미가 없다. Circuit이 OPEN으로 바뀌었는데 아무도 모르면, 장애를 격리한 것이 아니라 숨긴 것이다. 모든 패턴의 상태 변화를 로깅하고 알림으로 연결해야 한다.
-
단순함을 유지해야 한다. 패턴을 과도하게 적용하면 디버깅이 극도로 어려워진다. 꼭 필요한 지점에만 적용하고, 각 패턴의 설정값과 동작을 문서화하는 것이 중요하다.
참고 자료
- sony/gobreaker — Go Circuit Breaker 라이브러리
- Resilient Microservices Design Patterns — Circuit Breaker, Bulkhead, Retries 패턴 종합