MSA에서 장애 전파를 막는 방법 - Circuit Breaker & Retry 패턴

2025년 12월 22일

devops

# MSA# Circuit Breaker# Retry# Resilience4j# 장애 대응

들어가며

MSA를 도입하면서 새로운 문제에 직면했습니다. 한 서비스의 장애가 전체 시스템을 마비시키는 상황입니다.

모놀리식 아키텍처에서는 하나의 프로세스 안에서 모든 것이 동작했지만, MSA는 네트워크를 통해 여러 서비스가 통신합니다. 네트워크는 항상 불안정하고, 서비스는 언제든 장애가 날 수 있습니다.

이런 분산 시스템 환경에서 안정적으로 운영하려면 Resilience(회복력) 패턴이 필수입니다. 이 글에서는 Circuit Breaker와 Retry 패턴의 개념과 동작 원리를 다룹니다.

MSA의 딜레마: 분산의 대가

모놀리식 vs MSA의 장애 영향도

모놀리식 아키텍처:

  • 단일 프로세스 내 메서드 호출
  • 장애 = 전체 다운
  • 하지만 예측 가능

MSA 아키텍처:

  • 네트워크를 통한 서비스 간 통신
  • 부분 장애(Partial Failure) 가능
  • 장애 전파 패턴 복잡

실제 장애 시나리오

주문 서비스가 결제 서비스와 재고 서비스를 호출하는 상황을 가정해봅시다.

Inventory ServicePayment ServiceOrder ServiceAPI GatewayUserInventory ServicePayment ServiceOrder ServiceAPI GatewayUser응답 지연 (30초)Payment 응답 대기 중스레드 고갈새로운 요청 처리 불가주문 요청POST /orders결제 요청Timeout!재고 확인

문제점:

  1. Payment Service 지연 → Order Service 스레드 고갈
  2. Order Service 다운 → 전체 주문 불가
  3. 정상 동작하는 Inventory Service도 사용 불가
  4. 카스케이딩 실패(Cascading Failure) 발생

이것이 바로 장애 전파입니다. 하나의 서비스 문제가 연쇄적으로 다른 서비스에 영향을 미칩니다.

실제 사례: 올리브영 통합 재고 API

올리브영에서도 유사한 문제를 겪었습니다. 통합 재고 API는 Redis(캐시)와 Oracle RDB를 연계합니다.

장애 상황:

정상: Redis 조회 → 캐시 히트 → 빠른 응답 (10ms)
장애: Redis 장애 → Connection Wait Timeout (3초) × Retry (3회) = 9초 대기!

문제점:

  • Redis 한 번 실패할 때마다 사용자는 9초씩 대기
  • 초당 100건 요청이면 → 900초(15분) 누적 대기
  • RDB는 정상인데 Redis 때문에 전체 서비스 마비

해결책: Circuit Breaker 도입으로 Redis 장애 감지 시 즉시 RDB로 Failover:

장애 감지 후: Redis 건너뛰기 → 직접 RDB 조회 → 빠른 응답 (100ms)

효과:

  • 응답 시간: 9초 → 0.1초 (90배 개선)
  • 사용자 영향 최소화
  • RDB로 정상 서비스 유지

출처: 올리브영 기술 블로그 - Circuit Breaker 실전 가이드

Timeout만으로 충분하지 않은 이유

Timeout의 한계

많은 개발자가 Timeout 설정만으로 충분하다고 생각합니다.

// Timeout 설정만으로는 부족하다
val response = restClient.post()
    .uri("/payment")
    .timeout(Duration.ofSeconds(3))
    .retrieve()
    .body<PaymentResponse>()

문제:

  • 이미 장애 난 서비스에 계속 요청
  • 리소스(스레드, 커넥션) 낭비
  • 복구 시간 지연
  • 사용자는 계속 에러 경험

필요한 것

  • Fail Fast: 빠른 실패로 리소스 보호
  • 자동 복구 감지: 서비스 정상화 확인
  • 재시도 전략: 일시적 장애 대응

Circuit Breaker 패턴

전기 회로 차단기 비유

Circuit Breaker는 전기 회로 차단기에서 아이디어를 얻었습니다.

  • 전기 과부하 → 차단기 작동 → 전류 차단 → 안전 보호
  • 네트워크 장애 → Circuit Breaker → 요청 차단 → 시스템 보호

3가지 상태

Circuit Breaker는 3가지 상태를 가집니다.

실패율 임계값 초과대기 시간 경과성공실패

Closed

Open

HalfOpen

정상 동작

모든 요청 허용

실패율 모니터링

요청 차단

즉시 예외 발생 (Fail Fast)

리소스 보호

제한적 요청 허용

복구 여부 테스트

Closed (정상)

  • 모든 요청 통과
  • 실패율 측정 중
  • 임계값 초과 시 → Open

Open (차단)

  • 모든 요청 즉시 차단
  • Fallback 응답 반환
  • 대기 시간 후 → Half-Open

Half-Open (반개방)

  • 제한된 요청만 허용
  • 성공하면 → Closed
  • 실패하면 → Open

동작 예시 (Resilience4j 기준)

resilience4j.circuitbreaker:
  instances:
    paymentService:
      failureRateThreshold: 50          # 실패율 50% 초과 시
      slidingWindowSize: 10             # 최근 10개 요청 기준
      waitDurationInOpenState: 10s      # Open 상태 10초 유지
      permittedNumberOfCallsInHalfOpenState: 3  # Half-Open에서 3개 테스트

시나리오:

  1. 최근 10개 요청 중 6개 실패 → Open
  2. 10초 대기 → Half-Open
  3. 3개 요청 허용
    • 3개 모두 성공 → Closed
    • 1개라도 실패 → Open (다시 10초 대기)

Fallback 전략

Circuit Open 시 응답 전략:

@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
fun processPayment(orderId: Long): PaymentResult {
    return paymentClient.pay(orderId)
}

// Fallback 메서드
fun paymentFallback(orderId: Long, ex: Exception): PaymentResult {
    logger.warn("Payment service unavailable for order $orderId", ex)
    return PaymentResult.pending(orderId, "결제 대기 중입니다")
}

Fallback 옵션:

  • 캐시된 데이터 반환
  • 기본값 반환
  • 대체 서비스 호출
  • 사용자에게 친절한 에러 메시지

Retry 패턴

언제 사용하는가?

일시적 장애(Transient Fault):

  • 네트워크 순간 지연
  • DB 연결 풀 일시적 고갈
  • 서비스 재시작 중

재시도하면 안 되는 경우:

  • 4xx 클라이언트 에러 (잘못된 요청)
  • 멱등성 없는 작업 (중복 결제 위험)
  • 이미 영구적인 장애

Retry 전략

Fixed Delay

resilience4j.retry:
  instances:
    inventoryService:
      maxAttempts: 3
      waitDuration: 1s
시도 1: 실패 → 1초 대기
시도 2: 실패 → 1초 대기
시도 3: 실패 → 포기

단점: 서비스가 회복 중일 때 계속 요청 → 부담 가중

Exponential Backoff

resilience4j.retry:
  instances:
    inventoryService:
      maxAttempts: 4
      waitDuration: 1s
      exponentialBackoffMultiplier: 2
시도 1: 실패 → 1초 대기
시도 2: 실패 → 2초 대기
시도 3: 실패 → 4초 대기
시도 4: 실패 → 포기

장점: 서비스에 회복 시간 제공

Jitter (선택적)

randomizedWaitFactor: 0.5  # 50% 랜덤 변동
2초 대기 → 1~3초 사이 랜덤

목적: 다수의 클라이언트가 동시에 재시도 → Thundering Herd 방지

멱등성 보장

재시도는 멱등성이 전제되어야 합니다.

// ❌ 위험: 중복 결제 가능
@Retry(name = "paymentService")
fun charge(amount: Int) {
    paymentGateway.charge(amount)
}

// ✅ 안전: 멱등성 키 사용
@Retry(name = "paymentService")
fun charge(idempotencyKey: String, amount: Int) {
    paymentGateway.charge(idempotencyKey, amount)
}

Retry + Circuit Breaker 조합

잘못된 조합: 무한 재시도

// ❌ Circuit Breaker 안쪽에 Retry
@CircuitBreaker(name = "payment")
@Retry(name = "payment", maxAttempts = 5)
fun pay() { ... }

문제:

  • Circuit이 Open되어도 Retry가 계속 시도
  • Circuit Breaker 의미 없음

올바른 조합

// ✅ Retry 안쪽에 Circuit Breaker
@Retry(name = "payment", maxAttempts = 3)
@CircuitBreaker(name = "payment")
fun pay() { ... }

동작:

  1. Retry가 재시도 (일시적 장애 대응)
  2. Circuit Breaker가 실패율 측정
  3. 임계값 초과 시 Circuit Open → Retry도 중단

Annotation 순서 = 실행 역순:

  • 외부 Decorator: Retry
  • 내부 Decorator: CircuitBreaker

Resilience4j란?

Netflix Hystrix의 후계자

항목 Hystrix Resilience4j
상태 Maintenance Mode Active
Java 버전 7+ 8+ (함수형)
의존성 RxJava 필수 최소 의존성
Spring Boot 별도 통합 필요 공식 지원
패턴 Circuit Breaker Circuit Breaker, Retry, RateLimiter, Bulkhead, TimeLimiter

주요 모듈

dependencies {
    implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
    implementation("io.github.resilience4j:resilience4j-kotlin:2.2.0")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("io.micrometer:micrometer-registry-prometheus")
}
  • resilience4j-circuitbreaker: Circuit Breaker
  • resilience4j-retry: Retry
  • resilience4j-ratelimiter: API 요청 제한
  • resilience4j-bulkhead: 동시 실행 제한
  • resilience4j-timelimiter: Timeout

언제 사용해야 하는가?

Circuit Breaker 적용 대상

적용해야 할 곳:

  • 외부 API 호출 (결제, 알림, 3rd party)
  • 다른 마이크로서비스 호출
  • 외부 DB/캐시 접근
  • 신뢰할 수 없는 네트워크 구간

적용하지 말아야 할 곳:

  • 내부 메서드 호출
  • 동기화된 트랜잭션 내부 로직
  • 즉시 실패해야 하는 비즈니스 규칙

Retry 적용 대상

적용해야 할 곳:

  • 네트워크 지터가 있는 HTTP 요청
  • 일시적 DB 연결 실패
  • 클라우드 리소스 일시적 사용 불가

적용하지 말아야 할 곳:

  • 4xx 에러 (클라이언트 잘못)
  • 멱등성 없는 작업 (결제, 주문 생성)
  • 이미 타임아웃된 요청

MSA Resilience 전략 요약

패턴 목적 사용 시점
Circuit Breaker 장애 전파 차단 외부 서비스 호출
Retry 일시적 장애 복구 네트워크 지터, 순간 장애
Timeout 무한 대기 방지 모든 네트워크 호출
Bulkhead 리소스 격리 스레드 풀 분리
Fallback 사용자 경험 보호 Circuit Open 시

분산 환경에서의 고려사항

여러 인스턴스 간 Circuit Breaker 상태 공유

MSA 환경에서는 보통 서비스가 여러 인스턴스로 실행됩니다. 이때 Circuit Breaker 상태를 어떻게 관리할지 결정해야 합니다.

옵션 1: 각 인스턴스별 독립적인 Circuit Breaker

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│ Instance 1  │  │ Instance 2  │  │ Instance 3  │
│ CB: CLOSED  │  │ CB: OPEN    │  │ CB: CLOSED  │
└─────────────┘  └─────────────┘  └─────────────┘
       ↓                ↓                ↓
    Payment Service (장애 중)

장점:

  • 구현 간단
  • 외부 의존성 없음
  • 각 인스턴스가 독립적으로 판단

단점:

  • 인스턴스마다 다른 상태 가능
  • 장애 서비스에 여전히 요청이 전달될 수 있음
  • 전체적인 보호 효과 감소

적합한 경우:

  • 소규모 서비스
  • 인스턴스 수가 적을 때
  • 빠른 프로토타이핑

옵션 2: 공유 Circuit Breaker (Redis 기반)

┌─────────────┐  ┌─────────────┐  ┌─────────────┐
│ Instance 1  │  │ Instance 2  │  │ Instance 3  │
└──────┬──────┘  └──────┬──────┘  └──────┬──────┘
       │                │                │
       └────────┬───────┴───────┬────────┘
                ↓               ↓
         ┌──────────────────────────┐
         │ Redis (공유 상태)        │
         │ paymentService: OPEN     │
         │ failureCount: 15         │
         └──────────────────────────┘

장점:

  • 모든 인스턴스가 일관된 상태 유지
  • 효과적인 장애 전파 차단
  • 전체 시스템 관점에서 보호

단점:

  • Redis 등 외부 저장소 필요
  • 네트워크 레이턴시 증가
  • 구현 복잡도 증가
  • Redis 장애 시 영향

적합한 경우:

  • 대규모 서비스
  • 인스턴스가 많을 때 (10개 이상)
  • 엄격한 장애 전파 차단이 필요할 때

권장 접근법

대부분의 경우: 각 인스턴스별 Circuit Breaker로 시작

  • Resilience4j 기본 설정만으로도 충분
  • 복잡도 최소화

필요한 경우에만: 공유 Circuit Breaker 도입

  • 실제 운영하면서 문제가 발생할 때
  • 다음 시리즈에서 Redis 기반 구현 예제 제공

실전 시나리오

API Gateway에서의 Circuit Breaker 적용

API Gateway는 모든 요청의 진입점이므로 Circuit Breaker 적용이 매우 중요합니다.

요청CB1CB2CB3

Client

API Gateway

User Service

Order Service

Payment Service

각 downstream 서비스별 독립적인 Circuit Breaker 필요:

# Spring Cloud Gateway 예제
resilience4j:
  circuitbreaker:
    instances:
      user-service:
        failureRateThreshold: 50
        waitDurationInOpenState: 30s
      order-service:
        failureRateThreshold: 60
        waitDurationInOpenState: 20s
      payment-service:
        failureRateThreshold: 40  # 결제는 더 엄격하게
        waitDurationInOpenState: 60s

왜 서비스별로 다른 설정?

  • User Service: 조회 위주, 여유롭게 (50%)
  • Order Service: 중요하지만 복구 빠름 (60%)
  • Payment Service: 매우 중요, 보수적으로 (40%)

Rate Limiting과의 조합

API Gateway에서는 Circuit Breaker와 Rate Limiting을 함께 사용합니다.

// 실행 순서: RateLimiter → Retry → CircuitBreaker
@RateLimiter(name = "payment")
@Retry(name = "payment")
@CircuitBreaker(name = "payment")
fun processPayment() { ... }

역할 분담:

  • Rate Limiter: 과도한 트래픽 차단 (초당 100건 제한)
  • Retry: 일시적 장애 재시도
  • Circuit Breaker: 지속적 장애 차단

Circuit Open 시 사용자 경험 개선

Circuit이 Open될 때 사용자에게 어떤 응답을 줄지가 중요합니다.

❌ 나쁜 예

{
  "error": "CircuitBreaker 'paymentService' is OPEN and does not permit further calls"
}

문제점:

  • 기술적인 용어 (사용자가 이해 불가)
  • 다음 행동 지침 없음
  • 불안감 조성

✅ 좋은 예

{
  "status": "UNAVAILABLE",
  "message": "결제 서비스가 일시적으로 사용 불가능합니다.",
  "userMessage": "잠시 후 다시 시도해주세요. (예상 복구 시간: 30초)",
  "retryAfter": 30,
  "alternatives": [
    "장바구니에 담기",
    "나중에 결제하기"
  ]
}

개선 사항:

  • 명확한 상황 설명
  • 예상 복구 시간 제공
  • 대안 제시

Fallback 응답 구현

@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
fun processPayment(request: PaymentRequest): PaymentResponse {
    return paymentClient.pay(request)
}

private fun paymentFallback(request: PaymentRequest, ex: Exception): PaymentResponse {
    return when (ex) {
        is CallNotPermittedException -> {
            // Circuit Breaker Open
            PaymentResponse(
                status = PaymentStatus.UNAVAILABLE,
                message = "결제 서비스가 일시적으로 사용 불가능합니다.",
                userMessage = "잠시 후 다시 시도해주세요.",
                retryAfter = 30,
                alternatives = listOf("장바구니에 담기", "나중에 결제하기")
            )
        }
        is TimeoutException -> {
            // Timeout
            PaymentResponse(
                status = PaymentStatus.TIMEOUT,
                message = "결제 처리 시간이 초과되었습니다.",
                userMessage = "네트워크 상태를 확인하고 다시 시도해주세요."
            )
        }
        else -> {
            // 기타 에러
            PaymentResponse(
                status = PaymentStatus.ERROR,
                message = "결제 처리 중 오류가 발생했습니다.",
                userMessage = "고객센터로 문의해주세요."
            )
        }
    }
}

Degraded Mode (기능 축소 모드)

Circuit Open 시 완전히 차단하는 대신 제한된 기능을 제공할 수 있습니다.

@CircuitBreaker(name = "recommendationService", fallbackMethod = "recommendationFallback")
fun getRecommendations(userId: Long): List<Product> {
    return recommendationClient.getPersonalized(userId)
}

private fun recommendationFallback(userId: Long, ex: Exception): List<Product> {
    logger.warn { "Recommendation service unavailable, using cached data" }

    // 1. 캐시된 인기 상품 반환
    return cacheService.getPopularProducts()
        ?:
    // 2. 캐시도 없으면 기본 추천 상품
    defaultRecommendations
}

Degraded Mode 전략:

  1. 캐시 활용: 과거 데이터라도 제공
  2. 기본값 반환: 인기 상품, 베스트셀러 등
  3. 기능 숨김: UI에서 추천 섹션 제거
  4. 대체 알고리즘: 단순한 추천 로직 사용

모니터링 및 알림

Circuit Breaker를 적용했다면 모니터링은 필수입니다.

주요 메트릭

  1. Circuit Breaker 상태

    • CLOSED, OPEN, HALF_OPEN 카운트
    • 상태 전환 이벤트
  2. 실패율

    • 서비스별 실패율 추이
    • 임계값 대비 현재 상태
  3. 차단된 요청 수

    • Circuit Open으로 인한 차단 횟수
    • 사용자 영향도 파악

알림 전략

# 알림 우선순위
Critical: Circuit Open (즉시 알림)
Warning: 실패율 40% 이상 (3분 지속 시 알림)
Info: Circuit Closed (복구 알림)

자세한 모니터링 구현은 2편에서 다룹니다:

  • Prometheus + Grafana 대시보드
  • Slack 알림 연동
  • 실시간 이벤트 스트림

시리즈 구성

이 시리즈에서는 Kotlin + Spring Boot로 Resilience4j를 실전 적용합니다.

  1. MSA에서 장애 전파를 막는 방법 (현재 글)
  2. Resilience4j 실전 적용 - Kotlin + Spring Boot 구현
  3. Resilience4j 운영하기 - Actuator + Prometheus + Grafana

다음 글

다음 글에서는 Kotlin과 Spring Boot로 Resilience4j를 프로젝트에 통합하고, Circuit Breaker와 Retry를 실제로 구현합니다.


참고 자료

© 2025, 미나리와 함께 만들었음