들어가며
개념편에서 Circuit Breaker와 Retry 패턴을 배웠습니다. 이제 실제 코드로 구현해봅시다.
이 글에서는 주문 서비스가 결제 서비스 API를 호출하는 시나리오를 예제로, Kotlin과 Spring Boot 환경에서 Resilience4j를 적용하는 방법을 다룹니다.
실습 환경
의존성 설정
// build.gradle.kts
plugins {
kotlin("jvm") version "1.9.21"
kotlin("plugin.spring") version "1.9.21"
id("org.springframework.boot") version "3.2.1"
}
dependencies {
// Spring Boot
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
// Resilience4j
implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0")
implementation("io.github.resilience4j:resilience4j-kotlin:2.2.0")
implementation("io.github.resilience4j:resilience4j-micrometer:2.2.0")
// HTTP Client
implementation("org.springframework.boot:spring-boot-starter-webflux")
// Monitoring
implementation("io.micrometer:micrometer-registry-prometheus")
// Test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0")
}버전:
- Kotlin: 1.9.21
- Spring Boot: 3.2.1
- Resilience4j: 2.2.0
- Java: 17+
프로젝트 구조
src/main/kotlin/com/example/order
├── OrderApplication.kt
├── config
│ └── WebClientConfig.kt
├── client
│ ├── PaymentClient.kt
│ └── InventoryClient.kt
├── service
│ └── OrderService.kt
└── controller
└── OrderController.kt
src/main/resources
├── application.yml
└── application-resilience.ymlCircuit Breaker 구현
1. application.yml 설정
# application-resilience.yml
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
# 응답 지연도 실패로 간주
slowCallDurationThreshold: 500ms
slowCallRateThreshold: 50
recordExceptions:
- org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError
- java.io.IOException
- java.util.concurrent.TimeoutException
instances:
paymentService:
baseConfig: default
failureRateThreshold: 60
waitDurationInOpenState: 30s
slowCallDurationThreshold: 1s # 결제는 1초 이상이면 느림
inventoryService:
baseConfig: default
failureRateThreshold: 40
slowCallDurationThreshold: 300ms # 재고 조회는 300ms 이상이면 느림설정 설명:
| 속성 | 값 | 의미 |
|---|---|---|
slidingWindowType |
COUNT_BASED | 카운트 기반 (또는 TIME_BASED) |
slidingWindowSize |
10 | 최근 10개 요청 기준 |
minimumNumberOfCalls |
5 | 최소 5개 호출 후 통계 시작 |
failureRateThreshold |
50 | 실패율 50% 초과 시 Open |
waitDurationInOpenState |
10s | Open 상태 10초 유지 |
permittedNumberOfCallsInHalfOpenState |
3 | Half-Open에서 3개 테스트 |
automaticTransitionFromOpenToHalfOpenEnabled |
true | 자동 전환 활성화 |
slowCallDurationThreshold |
500ms | 500ms 이상 응답을 “느린 호출”로 판정 |
slowCallRateThreshold |
50 | 느린 호출 비율 50% 초과 시 Open |
Slow Call 감지 (응답 지연 대응)
실패하지 않아도 응답이 느리면 Circuit을 Open할 수 있습니다.
시나리오:
요청 10개:
- 5개: 성공 (100ms) ✅
- 5개: 성공 (600ms) ⚠️ Slow Call
실패율: 0%
느린 호출 비율: 50% → Circuit OPEN!언제 사용?
- DB 커넥션 풀 고갈로 응답 지연
- 외부 API가 느려지는 경우
- 네트워크 병목
올리브영 사례:
# Redis 장애 시 Connection Wait Timeout으로 느린 호출 급증
redis-inventory:
slowCallDurationThreshold: 500ms
slowCallRateThreshold: 10 # 10%만 느려져도 차단2. WebClient 설정
// WebClientConfig.kt
@Configuration
class WebClientConfig {
@Bean
fun paymentWebClient(): WebClient {
return WebClient.builder()
.baseUrl("http://payment-service:8080")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build()
}
@Bean
fun inventoryWebClient(): WebClient {
return WebClient.builder()
.baseUrl("http://inventory-service:8080")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build()
}
}3. Annotation 기반 Circuit Breaker
// PaymentClient.kt
@Component
class PaymentClient(
private val webClient: WebClient
) {
private val logger = KotlinLogging.logger {}
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
@Retry(name = "paymentService")
fun processPayment(orderId: Long, amount: BigDecimal): PaymentResponse {
logger.info { "Calling payment service for order $orderId" }
return webClient.post()
.uri("/api/payment")
.bodyValue(PaymentRequest(orderId, amount))
.retrieve()
.bodyToMono(PaymentResponse::class.java)
.timeout(Duration.ofSeconds(3))
.block() ?: throw PaymentException("Payment service returned null")
}
// Fallback 메서드: 시그니처가 원본과 동일 + Exception 파라미터
private fun paymentFallback(orderId: Long, amount: BigDecimal, ex: Exception): PaymentResponse {
logger.warn { "Payment fallback for order $orderId: ${ex.message}" }
return when (ex) {
is CallNotPermittedException -> {
// Circuit Breaker가 Open 상태
PaymentResponse(
orderId = orderId,
status = PaymentStatus.PENDING,
message = "결제 서비스가 일시적으로 불가능합니다. 잠시 후 다시 시도해주세요."
)
}
else -> {
// 기타 예외
PaymentResponse(
orderId = orderId,
status = PaymentStatus.FAILED,
message = "결제 처리 중 오류가 발생했습니다."
)
}
}
}
}
data class PaymentRequest(
val orderId: Long,
val amount: BigDecimal
)
data class PaymentResponse(
val orderId: Long,
val status: PaymentStatus,
val message: String
)
enum class PaymentStatus {
SUCCESS, PENDING, FAILED
}
class PaymentException(message: String) : RuntimeException(message)핵심 포인트:
@CircuitBreaker위에@Retry배치 (실행은 역순)- Fallback 메서드는 원본과 동일한 파라미터 +
Exception CallNotPermittedException: Circuit Open 시 발생하는 예외
4. 프로그래밍 방식 구현
Annotation이 불편한 경우 직접 제어:
@Component
class PaymentClient(
private val webClient: WebClient,
private val circuitBreakerRegistry: CircuitBreakerRegistry,
private val retryRegistry: RetryRegistry
) {
private val logger = KotlinLogging.logger {}
private val paymentCircuitBreaker = circuitBreakerRegistry.circuitBreaker("paymentService")
private val paymentRetry = retryRegistry.retry("paymentService")
fun processPayment(orderId: Long, amount: BigDecimal): PaymentResponse {
val decoratedSupplier = Decorators.ofSupplier {
callPaymentApi(orderId, amount)
}
.withRetry(paymentRetry)
.withCircuitBreaker(paymentCircuitBreaker)
.withFallback(listOf(Exception::class.java)) { ex ->
paymentFallback(orderId, amount, ex)
}
.decorate()
return Try.ofSupplier(decoratedSupplier).get()
}
private fun callPaymentApi(orderId: Long, amount: BigDecimal): PaymentResponse {
logger.info { "Calling payment API for order $orderId" }
return webClient.post()
.uri("/api/payment")
.bodyValue(PaymentRequest(orderId, amount))
.retrieve()
.bodyToMono(PaymentResponse::class.java)
.timeout(Duration.ofSeconds(3))
.block() ?: throw PaymentException("Null response")
}
private fun paymentFallback(orderId: Long, amount: BigDecimal, ex: Throwable): PaymentResponse {
logger.warn { "Payment fallback: ${ex.message}" }
return PaymentResponse(orderId, PaymentStatus.PENDING, "결제 대기 중")
}
}Retry 구현
1. Retry 설정
# application-resilience.yml
resilience4j:
retry:
configs:
default:
maxAttempts: 3
waitDuration: 500ms
retryExceptions:
- org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError
- java.io.IOException
ignoreExceptions:
- org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest
instances:
paymentService:
baseConfig: default
maxAttempts: 3
waitDuration: 1s
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
enableRandomizedWait: true
randomizedWaitFactor: 0.5
inventoryService:
baseConfig: default
maxAttempts: 2Exponential Backoff 동작:
시도 1: 실패 → 1초 대기
시도 2: 실패 → 2초 대기 (1 * 2)
시도 3: 실패 → 포기Randomized Wait (Jitter) 추가:
2초 대기 → 1~3초 사이 랜덤 (2초 ± 50%)2. Annotation 기반 Retry
@Component
class InventoryClient(
private val webClient: WebClient
) {
private val logger = KotlinLogging.logger {}
@Retry(name = "inventoryService", fallbackMethod = "checkStockFallback")
fun checkStock(productId: Long, quantity: Int): StockResponse {
logger.info { "Checking stock for product $productId" }
return webClient.get()
.uri("/api/inventory/{productId}/stock?quantity={quantity}", productId, quantity)
.retrieve()
.bodyToMono(StockResponse::class.java)
.block() ?: throw InventoryException("Stock check failed")
}
private fun checkStockFallback(productId: Long, quantity: Int, ex: Exception): StockResponse {
logger.warn { "Stock check fallback for product $productId: ${ex.message}" }
return StockResponse(available = false, message = "재고 확인 불가")
}
}
data class StockResponse(
val available: Boolean,
val message: String
)
class InventoryException(message: String) : RuntimeException(message)3. 멱등성 보장 - Idempotency Key
@Retry(name = "paymentService")
fun processPayment(orderId: Long, amount: BigDecimal): PaymentResponse {
val idempotencyKey = "order-$orderId-${System.currentTimeMillis()}"
return webClient.post()
.uri("/api/payment")
.header("Idempotency-Key", idempotencyKey)
.bodyValue(PaymentRequest(orderId, amount))
.retrieve()
.bodyToMono(PaymentResponse::class.java)
.block()!!
}서버측 구현 예시:
@RestController
class PaymentController {
private val processedKeys = ConcurrentHashMap<String, PaymentResponse>()
@PostMapping("/api/payment")
fun pay(
@RequestHeader("Idempotency-Key") key: String,
@RequestBody request: PaymentRequest
): PaymentResponse {
// 이미 처리된 요청인지 확인
return processedKeys.computeIfAbsent(key) {
processPaymentInternal(request)
}
}
private fun processPaymentInternal(request: PaymentRequest): PaymentResponse {
// 실제 결제 처리 로직
return PaymentResponse(
orderId = request.orderId,
status = PaymentStatus.SUCCESS,
message = "결제 완료"
)
}
}Circuit Breaker + Retry 조합
Annotation 순서가 중요
// ✅ 올바른 순서
@Retry(name = "paymentService")
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
fun processPayment(): PaymentResponse { ... }
// ❌ 잘못된 순서
@CircuitBreaker(name = "paymentService")
@Retry(name = "paymentService") // Circuit Open 시에도 Retry 시도
fun processPayment(): PaymentResponse { ... }실행 순서 (AOP는 역순):
요청 → Retry → CircuitBreaker → 실제 메서드설정 값 튜닝 가이드
resilience4j:
retry:
instances:
paymentService:
maxAttempts: 3 # Retry 3번
waitDuration: 1s
circuitbreaker:
instances:
paymentService:
minimumNumberOfCalls: 5 # 최소 5번 호출 후 통계 시작
failureRateThreshold: 50 # 실패율 50% 초과 시 Open시나리오 분석:
- 5번 호출: 모두 실패 (Retry로 각각 3번씩 시도)
- 실패율 100% → Circuit Open
- 6번째 호출:
CallNotPermittedException즉시 발생 (Retry 안 함)
Aspect Order 명시적 제어
기본적으로 Resilience4j는 다음 순서로 실행됩니다:
Retry (-3) → CircuitBreaker (-4) → RateLimiter (-5)하지만 특정 상황에서는 순서를 변경해야 할 수 있습니다.
문제 상황
@Retry(name = "payment", maxAttempts = 3)
@CircuitBreaker(name = "payment")
fun processPayment() { ... }예상치 못한 동작:
- Retry 3번 → 각 시도마다 Circuit Breaker 실패로 기록
- 실제 1번 실패했지만 Circuit Breaker는 3번 실패로 인식
- 더 빨리 Circuit이 Open될 수 있음
해결 방법 1: Aspect Order 변경
@Configuration
class Resilience4jAspectConfig {
@Bean
fun retryAspect(
retryRegistry: RetryRegistry,
fbdRegistry: FallbackDecoratorsRegistry
): RetryAspect {
return RetryAspect(retryRegistry, fbdRegistry).apply {
order = 1 // 기본 -3에서 1로 변경
}
}
@Bean
fun circuitBreakerAspect(
cbRegistry: CircuitBreakerRegistry,
fbdRegistry: FallbackDecoratorsRegistry
): CircuitBreakerAspect {
return CircuitBreakerAspect(cbRegistry, fbdRegistry).apply {
order = 2 // 기본 -4에서 2로 변경
}
}
}실행 순서:
요청 → CircuitBreaker (Order 2) → Retry (Order 1) → 메서드효과:
- Circuit이 Open이면 Retry 하지 않음 (즉시 차단)
- 실패 카운트가 정확히 기록됨
해결 방법 2: 프로그래밍 방식
Annotation 대신 프로그래밍 방식으로 순서를 명확히:
fun <T> executeWithResilience(
retryName: String,
circuitBreakerName: String,
fallback: (Throwable) -> T,
action: () -> T
): T {
val retry = retryRegistry.retry(retryName)
val circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName)
// 명확한 순서: CircuitBreaker → Retry
return Decorators.ofSupplier(action)
.withRetry(retry)
.withCircuitBreaker(circuitBreaker)
.withFallback(listOf(Exception::class.java), fallback)
.decorate()
.get()
}올리브영 사례
올리브영에서는 Retry 횟수가 Circuit Breaker 실패 카운트에 누적되는 문제를 해결하기 위해 Aspect Order를 조정했습니다.
// application.yml
resilience4j:
retry:
configs:
default:
aspect-order: 1 # CircuitBreaker보다 먼저 실행
circuitbreaker:
configs:
default:
aspect-order: 2결과:
- Circuit Open 시 Retry 하지 않음
- 실패 카운트 정확성 향상
- 불필요한 재시도 방지
실전 예제: Order Service
// OrderService.kt
@Service
class OrderService(
private val paymentClient: PaymentClient,
private val inventoryClient: InventoryClient
) {
private val logger = KotlinLogging.logger {}
fun createOrder(request: CreateOrderRequest): OrderResponse {
// 1. 재고 확인
val stockResult = inventoryClient.checkStock(request.productId, request.quantity)
if (!stockResult.available) {
throw OrderException("재고 부족")
}
// 2. 결제 처리
val paymentResult = paymentClient.processPayment(
orderId = generateOrderId(),
amount = request.amount
)
return when (paymentResult.status) {
PaymentStatus.SUCCESS -> {
OrderResponse(status = "COMPLETED", message = "주문 완료")
}
PaymentStatus.PENDING -> {
OrderResponse(status = "PENDING", message = paymentResult.message)
}
PaymentStatus.FAILED -> {
throw OrderException("결제 실패: ${paymentResult.message}")
}
}
}
private fun generateOrderId(): Long = System.currentTimeMillis()
}
data class CreateOrderRequest(
val productId: Long,
val quantity: Int,
val amount: BigDecimal
)
data class OrderResponse(
val status: String,
val message: String
)
class OrderException(message: String) : RuntimeException(message)
// OrderController.kt
@RestController
@RequestMapping("/api/orders")
class OrderController(private val orderService: OrderService) {
@PostMapping
fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<OrderResponse> {
return try {
val response = orderService.createOrder(request)
ResponseEntity.ok(response)
} catch (ex: OrderException) {
ResponseEntity.badRequest().body(
OrderResponse(status = "FAILED", message = ex.message ?: "주문 실패")
)
}
}
}이벤트 리스너로 상태 모니터링
@Configuration
class CircuitBreakerEventConfig(
private val circuitBreakerRegistry: CircuitBreakerRegistry
) {
private val logger = KotlinLogging.logger {}
@PostConstruct
fun registerEventListener() {
circuitBreakerRegistry.allCircuitBreakers.forEach { cb ->
cb.eventPublisher
.onStateTransition { event ->
logger.warn {
"CircuitBreaker ${cb.name}: ${event.stateTransition.fromState} → ${event.stateTransition.toState}"
}
}
.onFailureRateExceeded { event ->
logger.error {
"CircuitBreaker ${cb.name}: Failure rate ${event.failureRate}% exceeded threshold"
}
}
}
}
}출력 예시:
CircuitBreaker paymentService: CLOSED → OPEN
CircuitBreaker paymentService: Failure rate 60.0% exceeded threshold로컬 테스트: Wiremock으로 장애 시뮬레이션
1. Wiremock 설정
// build.gradle.kts
testImplementation("com.github.tomakehurst:wiremock-jre8:2.35.0")2. Circuit Breaker Open 테스트
@SpringBootTest
@AutoConfigureWireMock(port = 8081)
class OrderServiceTest {
@Autowired
lateinit var orderService: OrderService
@Test
fun `Circuit Breaker Open 테스트`() {
// Given: Payment API가 5번 연속 실패하도록 설정
stubFor(
post(urlEqualTo("/api/payment"))
.willReturn(
aResponse()
.withStatus(500)
.withFixedDelay(500)
)
)
// When: 5번 호출하여 Circuit Open 유발
repeat(5) {
assertThrows<OrderException> {
orderService.createOrder(CreateOrderRequest(1L, 1, BigDecimal(10000)))
}
}
// Then: 6번째 호출은 즉시 실패 (CallNotPermittedException)
val start = System.currentTimeMillis()
val result = orderService.createOrder(CreateOrderRequest(1L, 1, BigDecimal(10000)))
val elapsed = System.currentTimeMillis() - start
assertThat(result.status).isEqualTo("PENDING") // Fallback 응답
assertThat(elapsed).isLessThan(100) // 즉시 응답 (Circuit Open)
}
@Test
fun `Retry 동작 확인`() {
// Given: 처음 2번 실패, 3번째 성공
stubFor(
post(urlEqualTo("/api/payment"))
.inScenario("Retry")
.whenScenarioStateIs(STARTED)
.willReturn(aResponse().withStatus(500))
.willSetStateTo("SECOND_TRY")
)
stubFor(
post(urlEqualTo("/api/payment"))
.inScenario("Retry")
.whenScenarioStateIs("SECOND_TRY")
.willReturn(aResponse().withStatus(500))
.willSetStateTo("THIRD_TRY")
)
stubFor(
post(urlEqualTo("/api/payment"))
.inScenario("Retry")
.whenScenarioStateIs("THIRD_TRY")
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""{"orderId": 1, "status": "SUCCESS", "message": "OK"}""")
)
)
// When
val result = orderService.createOrder(CreateOrderRequest(1L, 1, BigDecimal(10000)))
// Then
assertThat(result.status).isEqualTo("COMPLETED")
verify(exactly(3), postRequestedFor(urlEqualTo("/api/payment")))
}
}3. 로그로 상태 확인
# application-test.yml
logging:
level:
io.github.resilience4j: DEBUG예상 로그:
2025-12-22 10:00:01 INFO PaymentClient - Calling payment service for order 1
2025-12-22 10:00:02 WARN PaymentClient - Payment fallback for order 1: 500 Internal Server Error
2025-12-22 10:00:02 DEBUG CircuitBreaker - paymentService: Recorded failure (failureRate=20.0%)
...
2025-12-22 10:00:10 DEBUG CircuitBreaker - paymentService: Changed state from CLOSED to OPEN
2025-12-22 10:00:11 WARN PaymentClient - Payment fallback for order 6: CircuitBreaker 'paymentService' is OPEN프로덕션 고려사항
1. 설정 값 가이드라인
| 시나리오 | minimumNumberOfCalls | failureRateThreshold | waitDurationInOpenState |
|---|---|---|---|
| 고가용성 우선 | 10 | 30% | 60s |
| 밸런스 | 5 | 50% | 30s |
| 빠른 차단 우선 | 3 | 60% | 10s |
2. Circuit Breaker별 독립 설정
resilience4j:
circuitbreaker:
instances:
paymentService:
failureRateThreshold: 60 # 결제는 여유롭게
waitDurationInOpenState: 30s
inventoryService:
failureRateThreshold: 40 # 재고는 엄격하게
waitDurationInOpenState: 10s
notificationService:
failureRateThreshold: 70 # 알림은 실패해도 OK
waitDurationInOpenState: 60s3. Timeout 설정
webClient.post()
.uri("/api/payment")
.timeout(Duration.ofSeconds(3)) // ✅ 반드시 설정
.retrieve()
.bodyToMono(PaymentResponse::class.java)
.block()권장 값:
- Timeout < waitDuration (Retry)
- 예: Timeout 3초, Retry waitDuration 1초, maxAttempts 3 → 최대 9초
분산 환경에서의 Circuit Breaker
Redis 기반 상태 공유 구현
여러 인스턴스가 Circuit Breaker 상태를 공유해야 하는 경우 Redis를 사용할 수 있습니다.
1. 의존성 추가
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("io.lettuce:lettuce-core")
}2. Redis 설정
# application.yml
spring:
redis:
host: localhost
port: 6379
timeout: 2000ms3. RedisCircuitBreakerStateStore 구현
@Component
class RedisCircuitBreakerStateStore(
private val redisTemplate: RedisTemplate<String, String>
) {
private val logger = KotlinLogging.logger {}
fun getState(circuitBreakerName: String): CircuitBreakerState? {
val key = "circuit-breaker:$circuitBreakerName:state"
val state = redisTemplate.opsForValue().get(key)
return state?.let { CircuitBreakerState.valueOf(it) }
}
fun setState(circuitBreakerName: String, state: CircuitBreakerState, ttl: Duration) {
val key = "circuit-breaker:$circuitBreakerName:state"
redisTemplate.opsForValue().set(key, state.name, ttl)
logger.info { "Circuit Breaker $circuitBreakerName state set to $state in Redis" }
}
fun incrementFailureCount(circuitBreakerName: String): Long {
val key = "circuit-breaker:$circuitBreakerName:failures"
return redisTemplate.opsForValue().increment(key) ?: 0
}
fun resetFailureCount(circuitBreakerName: String) {
val key = "circuit-breaker:$circuitBreakerName:failures"
redisTemplate.delete(key)
}
fun getFailureCount(circuitBreakerName: String): Long {
val key = "circuit-breaker:$circuitBreakerName:failures"
return redisTemplate.opsForValue().get(key)?.toLong() ?: 0
}
}
enum class CircuitBreakerState {
CLOSED, OPEN, HALF_OPEN
}4. Redis 기반 Circuit Breaker Wrapper
@Component
class SharedCircuitBreakerService(
private val stateStore: RedisCircuitBreakerStateStore,
private val circuitBreakerRegistry: CircuitBreakerRegistry
) {
private val logger = KotlinLogging.logger {}
fun <T> executeWithSharedCircuit(
circuitBreakerName: String,
fallback: (Throwable) -> T,
action: () -> T
): T {
// 1. Redis에서 현재 상태 확인
val sharedState = stateStore.getState(circuitBreakerName)
// 2. OPEN 상태면 즉시 fallback
if (sharedState == CircuitBreakerState.OPEN) {
logger.warn { "Circuit $circuitBreakerName is OPEN (shared state)" }
return fallback(CallNotPermittedException.createCallNotPermittedException(
circuitBreakerRegistry.circuitBreaker(circuitBreakerName)
))
}
// 3. 실행 시도
return try {
val result = action()
// 성공 시 실패 카운트 리셋
stateStore.resetFailureCount(circuitBreakerName)
// HALF_OPEN에서 성공하면 CLOSED로
if (sharedState == CircuitBreakerState.HALF_OPEN) {
stateStore.setState(circuitBreakerName, CircuitBreakerState.CLOSED, Duration.ZERO)
}
result
} catch (ex: Exception) {
handleFailure(circuitBreakerName, sharedState, ex, fallback)
}
}
private fun <T> handleFailure(
circuitBreakerName: String,
currentState: CircuitBreakerState?,
ex: Exception,
fallback: (Throwable) -> T
): T {
// 실패 카운트 증가
val failureCount = stateStore.incrementFailureCount(circuitBreakerName)
logger.warn { "Circuit $circuitBreakerName failure count: $failureCount" }
// 임계값 확인 (설정에서 가져와야 하지만 여기서는 하드코딩)
val threshold = 5
if (failureCount >= threshold) {
// Circuit OPEN
stateStore.setState(
circuitBreakerName,
CircuitBreakerState.OPEN,
Duration.ofSeconds(30) // waitDurationInOpenState
)
logger.error { "Circuit $circuitBreakerName transitioned to OPEN" }
}
return fallback(ex)
}
}5. 사용 예제
@Component
class PaymentClient(
private val webClient: WebClient,
private val sharedCircuitBreakerService: SharedCircuitBreakerService
) {
fun processPayment(orderId: Long, amount: BigDecimal): PaymentResponse {
return sharedCircuitBreakerService.executeWithSharedCircuit(
circuitBreakerName = "paymentService",
fallback = { ex -> paymentFallback(orderId, amount, ex) },
action = { callPaymentApi(orderId, amount) }
)
}
private fun callPaymentApi(orderId: Long, amount: BigDecimal): PaymentResponse {
return webClient.post()
.uri("/api/payment")
.bodyValue(PaymentRequest(orderId, amount))
.retrieve()
.bodyToMono(PaymentResponse::class.java)
.timeout(Duration.ofSeconds(3))
.block() ?: throw PaymentException("Null response")
}
private fun paymentFallback(orderId: Long, amount: BigDecimal, ex: Throwable): PaymentResponse {
return PaymentResponse(orderId, PaymentStatus.PENDING, "결제 대기 중")
}
}트레이드오프 고려사항
Redis 기반 공유 Circuit Breaker:
✅ 장점:
- 모든 인스턴스가 일관된 상태 유지
- 효과적인 장애 전파 차단
❌ 단점:
- Redis 의존성 추가
- 네트워크 레이턴시 (매 요청마다 Redis 조회)
- Redis 장애 시 Circuit Breaker 동작 불가
권장:
- 소규모/중규모: 각 인스턴스별 Circuit Breaker (기본)
- 대규모: Redis 기반 공유 (필요시)
API Gateway 통합
Spring Cloud Gateway에서의 Circuit Breaker
API Gateway는 모든 요청의 진입점이므로 Circuit Breaker 적용이 매우 중요합니다.
1. 의존성 추가
// build.gradle.kts
dependencies {
implementation("org.springframework.cloud:spring-cloud-starter-gateway")
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j")
}2. Gateway 설정
# application.yml
spring:
cloud:
gateway:
routes:
- id: payment-service
uri: http://payment-service:8080
predicates:
- Path=/api/payment/**
filters:
- name: CircuitBreaker
args:
name: paymentServiceCB
fallbackUri: forward:/fallback/payment
- name: Retry
args:
retries: 3
statuses: BAD_GATEWAY, SERVICE_UNAVAILABLE
backoff:
firstBackoff: 1s
maxBackoff: 5s
factor: 2
- id: order-service
uri: http://order-service:8080
predicates:
- Path=/api/orders/**
filters:
- name: CircuitBreaker
args:
name: orderServiceCB
fallbackUri: forward:/fallback/order
resilience4j:
circuitbreaker:
instances:
paymentServiceCB:
slidingWindowSize: 10
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3
orderServiceCB:
slidingWindowSize: 10
failureRateThreshold: 60
waitDurationInOpenState: 20s3. Fallback 컨트롤러
@RestController
@RequestMapping("/fallback")
class FallbackController {
@PostMapping("/payment")
fun paymentFallback(
@RequestHeader headers: HttpHeaders,
@RequestBody(required = false) body: String?
): ResponseEntity<Map<String, Any>> {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(
mapOf(
"status" to "UNAVAILABLE",
"message" to "결제 서비스가 일시적으로 사용 불가능합니다.",
"userMessage" to "잠시 후 다시 시도해주세요. (예상 복구 시간: 30초)",
"retryAfter" to 30,
"timestamp" to System.currentTimeMillis()
)
)
}
@GetMapping("/order")
@PostMapping("/order")
fun orderFallback(): ResponseEntity<Map<String, Any>> {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(
mapOf(
"status" to "UNAVAILABLE",
"message" to "주문 서비스가 일시적으로 사용 불가능합니다.",
"userMessage" to "잠시 후 다시 시도해주세요.",
"timestamp" to System.currentTimeMillis()
)
)
}
}4. Rate Limiting 추가
spring:
cloud:
gateway:
routes:
- id: payment-service
uri: http://payment-service:8080
predicates:
- Path=/api/payment/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 초당 10개
redis-rate-limiter.burstCapacity: 20 # 최대 20개
- name: CircuitBreaker
args:
name: paymentServiceCB
fallbackUri: forward:/fallback/payment필터 실행 순서:
Request → RateLimiter → Retry → CircuitBreaker → Downstream Service5. 커스텀 Predicate로 조건부 Circuit Breaker
특정 조건에서만 Circuit Breaker 적용:
@Configuration
class GatewayConfig {
@Bean
fun customRouteLocator(builder: RouteLocatorBuilder): RouteLocator {
return builder.routes()
.route("payment-with-circuit-breaker") { r ->
r.path("/api/payment/**")
.and()
.header("X-Enable-Circuit-Breaker", "true") // 헤더가 있을 때만
.filters { f ->
f.circuitBreaker { config ->
config.setName("paymentServiceCB")
config.setFallbackUri("forward:/fallback/payment")
}
}
.uri("http://payment-service:8080")
}
.build()
}
}실전 시나리오: 서비스별 다른 Circuit Breaker 전략
resilience4j:
circuitbreaker:
instances:
# 결제: 매우 중요, 보수적
paymentServiceCB:
failureRateThreshold: 40
waitDurationInOpenState: 60s
minimumNumberOfCalls: 10
# 주문: 중요, 밸런스
orderServiceCB:
failureRateThreshold: 50
waitDurationInOpenState: 30s
minimumNumberOfCalls: 5
# 추천: 부가 기능, 공격적
recommendationServiceCB:
failureRateThreshold: 70
waitDurationInOpenState: 10s
minimumNumberOfCalls: 3전략:
- Payment: 실패율 40% 초과 시 차단, 60초 대기 (엄격)
- Order: 실패율 50% 초과 시 차단, 30초 대기 (중간)
- Recommendation: 실패율 70% 초과 시 차단, 10초 대기 (여유)
다음 글
다음 글에서는 Spring Boot Actuator와 Prometheus로 Resilience4j 메트릭을 수집하고, Grafana 대시보드로 Circuit Breaker 상태를 실시간 모니터링합니다.