들어가며
MSA를 도입하면서 새로운 문제에 직면했습니다. 한 서비스의 장애가 전체 시스템을 마비시키는 상황입니다.
모놀리식 아키텍처에서는 하나의 프로세스 안에서 모든 것이 동작했지만, MSA는 네트워크를 통해 여러 서비스가 통신합니다. 네트워크는 항상 불안정하고, 서비스는 언제든 장애가 날 수 있습니다.
이런 분산 시스템 환경에서 안정적으로 운영하려면 Resilience(회복력) 패턴이 필수입니다. 이 글에서는 Circuit Breaker와 Retry 패턴의 개념과 동작 원리를 다룹니다.
MSA의 딜레마: 분산의 대가
모놀리식 vs MSA의 장애 영향도
모놀리식 아키텍처:
- 단일 프로세스 내 메서드 호출
- 장애 = 전체 다운
- 하지만 예측 가능
MSA 아키텍처:
- 네트워크를 통한 서비스 간 통신
- 부분 장애(Partial Failure) 가능
- 장애 전파 패턴 복잡
실제 장애 시나리오
주문 서비스가 결제 서비스와 재고 서비스를 호출하는 상황을 가정해봅시다.
문제점:
- Payment Service 지연 → Order Service 스레드 고갈
- Order Service 다운 → 전체 주문 불가
- 정상 동작하는 Inventory Service도 사용 불가
- 카스케이딩 실패(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로 정상 서비스 유지
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
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개 테스트시나리오:
- 최근 10개 요청 중 6개 실패 → Open
- 10초 대기 → Half-Open
- 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() { ... }동작:
- Retry가 재시도 (일시적 장애 대응)
- Circuit Breaker가 실패율 측정
- 임계값 초과 시 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 적용이 매우 중요합니다.
각 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 전략:
- 캐시 활용: 과거 데이터라도 제공
- 기본값 반환: 인기 상품, 베스트셀러 등
- 기능 숨김: UI에서 추천 섹션 제거
- 대체 알고리즘: 단순한 추천 로직 사용
모니터링 및 알림
Circuit Breaker를 적용했다면 모니터링은 필수입니다.
주요 메트릭
-
Circuit Breaker 상태
- CLOSED, OPEN, HALF_OPEN 카운트
- 상태 전환 이벤트
-
실패율
- 서비스별 실패율 추이
- 임계값 대비 현재 상태
-
차단된 요청 수
- Circuit Open으로 인한 차단 횟수
- 사용자 영향도 파악
알림 전략
# 알림 우선순위
Critical: Circuit Open (즉시 알림)
Warning: 실패율 40% 이상 (3분 지속 시 알림)
Info: Circuit Closed (복구 알림)자세한 모니터링 구현은 2편에서 다룹니다:
- Prometheus + Grafana 대시보드
- Slack 알림 연동
- 실시간 이벤트 스트림
시리즈 구성
이 시리즈에서는 Kotlin + Spring Boot로 Resilience4j를 실전 적용합니다.
- MSA에서 장애 전파를 막는 방법 (현재 글)
- Resilience4j 실전 적용 - Kotlin + Spring Boot 구현
- Resilience4j 운영하기 - Actuator + Prometheus + Grafana
다음 글
다음 글에서는 Kotlin과 Spring Boot로 Resilience4j를 프로젝트에 통합하고, Circuit Breaker와 Retry를 실제로 구현합니다.