Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 4편: 설정, 사용, Fallback

2025. 11. 21. 10:40·서버
※ 시리즈 글
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 1편: 이해와 설계
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 2편: 상태 머신, 상태 전이
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 3편: 슬라이딩 윈도우
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 4편: 설정, 사용, Fallback (현재 글)

🔷 들어가며

이번 글에서는 직접 구현한 CircuitBreaker를 Spring 환경에서 편리하게 사용할 수 있게 하는 방법을 살펴보겠습니다. 이 글에서 다룰 내용은 다음과 같습니다.

  • YML 기반으로 CircuitBreaker를 설정하고 생성하는 방법
  • @CircuitBreaker 어노테이션을 사용해 AOP로 메서드 호출을 감싸는 방식
  • 호출 실패 시 동작하는 Fallback 메서드 구조와 매칭 규칙

구현된 코드는 Spring Boot 3.5.7, Kotlin으로 작성되어 있습니다. 이 글에서는 spring 패키지를 중심으로 설명하겠습니다.

 

spring-lab/resilience4j-circuitbreaker at main · alstn113/spring-lab

Spring에 대한 다양한 실험. Contribute to alstn113/spring-lab development by creating an account on GitHub.

github.com

 

🔷 YML을 통한 CircuitBreaker 설정과 생성

Resilience4j는 Spring Boot 환경에서 YML 설정만으로 CircuitBreaker를 생성할 수 있습니다.

package io.github.alstn113.resilience4j.spring

import io.github.alstn113.resilience4j.core.CircuitBreakerConfig
import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties("resilience4j.circuitbreaker")
data class CircuitBreakerProperties(
    val instances: Map<String, InstanceProperties> = emptyMap(),
) {

    data class InstanceProperties(
        val failureRateThreshold: Float = 50F,
        val permittedNumberOfCallsInHalfOpenState: Int = 10,
        val slidingWindow: SlidingWindowProperties = SlidingWindowProperties(),
        val waitDurationInOpenState: Long = 60000,
    )

    data class SlidingWindowProperties(
        val type: CircuitBreakerConfig.SlidingWindowType = CircuitBreakerConfig.SlidingWindowType.COUNT_BASED,
        val size: Int = 100,
        val minimumNumberOfCalls: Int = 100,
    )
}
# application.yml

resilience4j:
  circuitbreaker:
    instances:
      myCircuitBreaker:
        failureRateThreshold: 50
        permittedNumberOfCallsInHalfOpenState: 5
        waitDurationInOpenState: 5000 # 5s
        slidingWindow:
          type: COUNT_BASED
          size: 10
          minimumNumberOfCalls: 5

resilience4j.circuitbreaker.instances 아래에 이름과 옵션을 정의하면, Spring Boot가 해당 정보를 바탕으로 CircuitBreakerRegistry 내부에 인스턴스를 자동 생성합니다.

 

 

package io.github.alstn113.resilience4j.spring

import io.github.alstn113.resilience4j.core.CircuitBreakerConfig
import io.github.alstn113.resilience4j.core.CircuitBreakerRegistry
import io.github.alstn113.resilience4j.spring.aop.CircuitBreakerAspect
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
import java.time.Duration

@Configuration
@EnableConfigurationProperties(CircuitBreakerProperties::class)
class CircuitBreakerAutoConfiguration {

    @Bean
    fun circuitBreakerRegistry(
        properties: CircuitBreakerProperties,
        clock: Clock,
    ): CircuitBreakerRegistry {

        if (properties.instances.isEmpty()) {
            return CircuitBreakerRegistry(
                clock = clock,
                defaultConfig = CircuitBreakerConfig.custom().build()
            )
        }

        val map = properties.instances.mapValues { (_, p) ->
            CircuitBreakerConfig.custom()
                .failureRateThreshold(p.failureRateThreshold)
                .permittedNumberOfCallsInHalfOpenState(p.permittedNumberOfCallsInHalfOpenState)
                .slidingWindow(
                    p.slidingWindow.type,
                    p.slidingWindow.size,
                    p.slidingWindow.minimumNumberOfCalls
                )
                .waitDurationInOpenState(Duration.ofMillis(p.waitDurationInOpenState))
                .build()
        }

        return CircuitBreakerRegistry(configMap = map, clock = clock)
    }

    @Bean
    @ConditionalOnMissingBean
    fun circuitBreakerAspect(registry: CircuitBreakerRegistry): CircuitBreakerAspect {
        return CircuitBreakerAspect(registry)
    }

    @Bean
    @ConditionalOnMissingBean
    fun clock(): Clock {
        return Clock.systemDefaultZone()
    }
}

 

🔷 어노테이션 기반 CircuitBreaker 사용

Spring에서는 보통 메서드에 @CircuitBreaker를 붙여 AOP로 호출을 감싸는 방식을 사용합니다.

package io.github.alstn113.resilience4j.spring.annotation

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class CircuitBreaker(
    val name: String,
    val fallbackMethod: String = "",
)
package io.github.alstn113.resilience4j.spring.aop

import io.github.alstn113.resilience4j.core.CircuitBreakerRegistry
import io.github.alstn113.resilience4j.core.execute
import io.github.alstn113.resilience4j.spring.annotation.CircuitBreaker
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.aspectj.lang.reflect.MethodSignature
import java.lang.reflect.Method
import java.util.concurrent.ConcurrentHashMap

@Aspect
class CircuitBreakerAspect(
    private val registry: CircuitBreakerRegistry,
) {

    // 키: FallbackCacheKey, 값: 예외 타입 -> 메서드 맵
    private val fallbackMethodCache = ConcurrentHashMap<FallbackCacheKey, Map<Class<*>, Method>>()

    @Around("@annotation(circuitBreaker)")
    fun proceed(
        joinPoint: ProceedingJoinPoint,
        circuitBreaker: CircuitBreaker,
    ): Any? {
        val cb = registry.circuitBreaker(circuitBreaker.name)

        return try {
            cb.execute { joinPoint.proceed() }
        } catch (e: Exception) {
            executeFallback(joinPoint, circuitBreaker.fallbackMethod, e)
        }
    }

    private fun executeFallback(
        joinPoint: ProceedingJoinPoint,
        fallbackMethodName: String,
        originalException: Exception,
    ): Any? {
        if (fallbackMethodName.isBlank()) {
            throw originalException
        }

        val signature = joinPoint.signature as MethodSignature
        val cacheKey = FallbackCacheKey(
            fallbackMethodName,
            signature.method.parameterTypes.toList(),
            signature.method.returnType,
            joinPoint.target::class.java
        )

        val fallbackMap = getOrBuildFallbackMap(joinPoint.target::class.java, cacheKey)
        val fallbackMethod = findFallbackMethod(fallbackMap, originalException)
            ?: throw originalException

        return invokeFallback(fallbackMethod, joinPoint.args, originalException, joinPoint.target)
    }

    private fun getOrBuildFallbackMap(targetClass: Class<*>, cacheKey: FallbackCacheKey): Map<Class<*>, Method> {
        return fallbackMethodCache.computeIfAbsent(cacheKey) { key ->
            targetClass.declaredMethods // private 포함 모든 메서드 조회
                .filter { it.name == key.fallbackMethodName && it.parameterCount == key.params.size + 1 } // 이름과 파라미터 개수로 필터링
                .filter { Throwable::class.java.isAssignableFrom(it.parameterTypes.last()) } // 마지막 파라미터가 예외 타입인지 확인
                .associateBy { it.parameterTypes.last() } // 마지막 파라미터(예외 타입) 기준으로 매핑
                .mapValues { (_, method) -> method.apply { isAccessible = true } } // private 메서드 접근 허용
        }
    }


    private fun findFallbackMethod(fallbackMap: Map<Class<*>, Method>, exception: Exception): Method? {
        var exClass: Class<*>? = exception::class.java
        while (exClass != null && exClass != Any::class.java) {
            fallbackMap[exClass]?.let { return it }
            exClass = exClass.superclass // 상위 클래스 탐색
        }
        return null
    }

    private fun invokeFallback(
        method: Method,
        args: Array<Any>,
        exception: Exception,
        target: Any,
    ): Any? {
        val newArgs = args.plus(exception)
        return method.invoke(target, *newArgs)
    }

    private data class FallbackCacheKey(
        val fallbackMethodName: String,
        val params: List<Class<*>>,
        val returnType: Class<*>,
        val targetClass: Class<*>,
    )
}

여러 메서드가 있지만 제일 중요한 proceed 메서드를 보겠습니다. 이 메서드는 다음과 같이 동작합니다.

  1. @CircuitBreaker 감지
    AOP가 어노테이션을 감지해 메서드 호출을 가로챕니다.
  2. CircuitBreaker 조회
    name으로 지정된 CircuitBreaker 인스턴스를 Registry에서 가져옵니다.
  3. 메서드 실행을 CircuitBreaker로 감싸기
    joinPoint.proceed()를 CircuitBreaker.execute로 감싸 실행합니다.
    예외 발생 시 Fallback 로직으로 넘어갑니다.

 

🔷 Fallback 메서드

🔶 Fallback 메서드가 호출되는 상황

아래 예시는 @CircuitBreaker와 여러 종류의 Fallback을 함께 사용한 예시입니다.

@RestController
class TestController {

    private val random = Random()

    // http://127.0.0.1:8080/dns?prob=0.4 로 반복 호출 시도
    @GetMapping("/dns")
    @CircuitBreaker(name = "myCircuitBreaker", fallbackMethod = "fallback")
    fun queryDns(@RequestParam prob: Double): String {

        return if (random.nextDouble() < prob) {
            "Cloudflare DNS 응답 성공 (1.1.1.1)"
        } else {
            throw IllegalArgumentException()
        }
    }

    private fun fallback(prob: Double, e: CallNotPermittedException): String {
        return "Cloudflare DNS 차단됨 → Google DNS(8.8.8.8)로 우회"
    }

    private fun fallback(prob: Double, e: IllegalArgumentException): String {
        return "Cloudflare DNS 실패 → 요청 실패 응답"
    }
}

Fallback 메서드는 다음 두 경우 모두 호출됩니다.

  • CircuitBreaker가 Open 상태여서 CallNotPermittedException이 발생한 경우
  • CircuitBreaker는 정상(Closed)이지만 원본 메서드 자체에서 예외가 발생한 경우

또한 Fallback 메서드는 원본 메서드 파라미터 + 예외 객체를 함께 받아야 합니다.
예외 타입이 정확히 일치하는 Fallback이 없다면, 상위 타입을 순서대로 탐색해 가장 적합한 메서드를 선택합니다.
(IllegalArgumentException → RuntimeException → Exception 순으로 검사)

🔶 Fallback 메서드 내부 동작

executeFallback 메서드가 어떻게 동작하는지 처리 흐름에 따라 살펴보겠습니다.

 

1. 예외 발생 시 Fallback 호출
CircuitBreaker가 감싼 메서드에서 예외가 발생하면, AOP에서 executeFallback() 메서드가 호출됩니다.

executeFallback(joinPoint, circuitBreaker.fallbackMethod, e)

여기서 fallbackMethod는 어노테이션 @CircuitBreaker(fallbackMethod = "fallbackHandler")로 지정한 메서드 이름입니다.

 

2. Fallback 메서드 이름과 시그니처 확인
AOP는 joinPoint를 통해 호출된 원본 메서드의 시그니처를 가져옵니다.

val signature = joinPoint.signature as MethodSignature

이 정보로 원본 메서드의 파라미터 타입, 반환 타입, 소유 클래스를 기반으로 캐시 키(FallbackCacheKey)를 생성합니다.

 

3. Fallback 메서드 캐싱
같은 클래스와 메서드에 대해 반복 호출 시 Reflection 비용을 줄이기 위해 fallbackMethodCache에 캐싱합니다.

private val fallbackMethodCache = ConcurrentHashMap<FallbackCacheKey, Map<Class<*>, Method>>()

캐시에 없으면, 클래스 내 모든 메서드를 검사하여 이름이 일치하고, 파라미터 개수가 원본 + Exception 한 개인지, 마지막 파라미터가 Throwable 타입인지의 조건을 만족하는 메서드를 찾아 매핑합니다.

getOrBuildFallbackMap 메서드에서 사용하는 declaredMethods는 public 뿐만 아니라 private, procted들에 접근하기 위해서 사용합니다. 또한 isAccessible = true는 private 메서드라도 호출할 수 있도록 허용합니다.

 

4. 예외별 Fallback 메서드 선택
단일 Fallback 메서드가 여러 예외를 처리하도록 만들 수 있습니다.

private fun findFallbackMethod(fallbackMap: Map<Class<*>, Method>, exception: Exception)
  • 예외 클래스부터 시작해 상위 클래스까지 순차적으로 탐색
  • 가장 적합한 Fallback 메서드를 반환
findFallbackMethod 메서드의 while 내에서 exClass = exClass.superclass를 통해 예외의 상위 타입을 찾아갑니다.

 

5. Reflection을 통한 Fallback 실행
선택된 Fallback 메서드를 호출할 때는, 원본 메서드 인자에 발생한 예외를 추가해서 전달합니다.

private fun invokeFallback(method: Method, args: Array<Any>, exception: Exception, target: Any)

이렇게 하면 메서드 구현자는 예외 정보를 활용해 적절한 대체 로직을 수행할 수 있습니다.

🔷 마치며

이번 글에서는 직접 만든 CircuitBreaker를 Spring Boot 애플리케이션에 통합하는 과정을 다뤘습니다. YML 설정만으로 CircuitBreaker를 손쉽게 관리하고, AOP를 적용해 비즈니스 로직과 장애 처리 로직을 분리했습니다. 또한 리플렉션을 활용해 예외 상황에 맞는 Fallback 메서드가 실행되도록 하여 유연한 복구 메커니즘을 만들었습니다.

 

총 4편에 걸쳐 Resilience4j CircuitBreaker의 핵심 기능을 직접 구현해 보았습니다. 단순히 라이브러리를 사용법을 익히는 것을 넘어, 안정적인 장애 처리를 위해 내부적으로 어떤 고민들이 담겨 있는지 코드로 직접 확인해 볼 수 있었던 시간이었습니다.

 

구현하며 배운 핵심 포인트는 다음과 같습니다.

  • 동시성 제어와 상태 관리: AtomicReference와 상태 패턴(State Pattern)을 통해 멀티스레드 환경에서도 안전하고 명확하게 상태 전이가 일어나도록 설계했습니다.
  • 효율적인 통계 집계: 순환 배열(Ring Buffer) 구조의 슬라이딩 윈도우를 통해, 메모리 사용량을 최소화하면서도 O(1)의 성능으로 실시간 실패율을 계산했습니다.
  • 사용자 편의성: 복잡한 내부 로직은 숨기고, 개발자는 어노테이션 하나만으로 강력한 장애 허용 능력을 갖출 수 있도록 추상화했습니다.

이 시리즈가 오픈소스 라이브러리의 내부 동작 원리를 깊이 있게 이해하는데 도움이 되었기를 바랍니다. 잘못된 부분이 있다면 댓글로 남겨주시길 바랍니다. 긴 시리즈를 읽어주셔서 감사합니다.

 

🔷 참고

  • Resilience4j 깃허브
  • Resilience4j 공식문서

'서버' 카테고리의 다른 글

선착순 쿠폰 발급 시스템 개선과 정합성 문제 해결 (Redis·Kafka)  (1) 2025.12.20
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 3편: 슬라이딩 윈도우  (1) 2025.11.21
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 2편: 상태 머신, 상태 전이  (0) 2025.11.21
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 1편: 이해와 설계  (0) 2025.11.21
Redis 분산락, 직접 구현하면서 이해해보자! - 2편: Pub/Sub 방식  (0) 2025.10.31
'서버' 카테고리의 다른 글
  • 선착순 쿠폰 발급 시스템 개선과 정합성 문제 해결 (Redis·Kafka)
  • Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 3편: 슬라이딩 윈도우
  • Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 2편: 상태 머신, 상태 전이
  • Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 1편: 이해와 설계
alstn113
alstn113
웹 프론트엔드, 서버 개발에 관한 이야기를 다룹니다 :D
  • alstn113
    alstn113's devlog
    alstn113
  • 전체
    오늘
    어제
    • 분류 전체보기 (62)
      • 서버 (31)
      • 웹 프론트엔드 (5)
      • 협업 (2)
      • 우아한테크코스 6기 백엔드 (12)
      • 책, 영상, 블로그 정리 (10)
      • 회고 (1)
  • 블로그 메뉴

    • 홈
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    회고
    글쓰기
    우아한테크코스
    플러피
    굿폰
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
alstn113
Resilience4j CircuitBreaker, 직접 구현하면서 이해해보자! - 4편: 설정, 사용, Fallback
상단으로

티스토리툴바