Resilience4j 실전 적용 - Kotlin + Spring Boot 구현

2025년 12월 22일

devops

# Resilience4j# Spring Boot# Kotlin# Circuit Breaker# Retry

들어가며

개념편에서 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.yml

Circuit 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)

핵심 포인트:

  1. @CircuitBreaker 위에 @Retry 배치 (실행은 역순)
  2. Fallback 메서드는 원본과 동일한 파라미터 + Exception
  3. 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: 2

Exponential 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

시나리오 분석:

  1. 5번 호출: 모두 실패 (Retry로 각각 3번씩 시도)
  2. 실패율 100% → Circuit Open
  3. 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: 60s

3. 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: 2000ms

3. 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: 20s

3. 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 Service

5. 커스텀 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 상태를 실시간 모니터링합니다.


참고 자료

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