Notice
Recent Posts
Recent Comments
Link
«   2026/09   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

Kuma's Curious Paradise

CircuitBreaker 1 : 내부 구조 살펴보기 본문

스프링

CircuitBreaker 1 : 내부 구조 살펴보기

쿠마냥 2025. 8. 2. 04:28

1. 들어가며

외부 API를 호출하며 장애 전파를 막기 위해 CircuitBreaker를 사용할 일이 생겼다. 

적용 자체는 매우 쉽다.

스프링 부트에서 제공하는 어노테이션 덕분이다. 

 

예를 들면 서킷 브레이커를 적용하는 코드는 이렇게 생겼다. 

@CircuitBreaker(name = "externalApi", fallbackMethod = "fallback")
public String getResult() {
    return restTemplate.getForObject("https://externalapi.com/data", String.class);
}

이렇게 어노테이션을 붙이면...

 

  • 외부 API 실패율이 일정 수준 이상이면 호출 자체를 차단하고
  • 일정 시간이 지나면 일부 요청만 다시 시도해보고
  • 여전히 실패하면 지정된 fallbackMethod를 자동으로 호출한다

2. Spring AOP + Resilience4j 로 이해하는 CircuitBreaker

이것이 어떻게 가능한지 이해하기 위해서

Spring AOP와 Resilience4j가 제공하는 프록시 기반의 데코레이터 패턴을 살펴보자.

 

먼저, 전반적인 실행 흐름은 이렇다. 

 

🌟 애플리케이션을 실행하면...

@SpringBootApplication 실행
        ↓
Spring이 모든 Bean을 생성하면서
        ↓
@CircuitBreaker가 붙은 메서드를 감지 (AOP 기반)
        ↓
해당 메서드를 감싸는 프록시 객체를 동적으로 생성
        ↓
프록시 객체를 실제 Bean으로 등록

🌟 사용자가 이후 getResult() 메서드를 호출하면...

externalApiService.getResult() 호출
        ↓
프록시 객체가 메서드 호출을 가로챔
        ↓
리턴 타입 확인 → 적절한 데코레이터 선택
        ↓
CircuitBreaker.decorateXXX()로 감싼 후 실행
        ↓
외부 API 호출 → 성공/실패 기록
        ↓
실패 시 fallbackMethod 자동 호출

부연 설명을 하자면, Spring은 애플리케이션 시작 시, @Configuration, @Service, @Component, @RestController 등 등록된 많은 어노테이션들을 감지하고 @bean을 초기화한다.

 

이때 내부적으로 다음과 같은 작업도 함께한다. 

 

  • 어떤 메서드에 AOP 관련 어노테이션이 붙었는지 감지한다. (@Transactional, @Async, @CircuitBreaker...)
  • 해당 메서드를 감싸기 위해 프록시 객체를 생성한다. 
  • 프록시로 감싸진 객체를 실제 bean으로 등록한다.

이후 사용자가 externalApiService.getResult()를 호출하면 실제 호출되는 건 프록시 객체의 invoke() 메서드이다. 

 

만약 @CircuitBreaker가 붙은 메서드라면, 이런 형태가 될 것이다. 

Object invoke(Method method, Object[] args) {
    if (method has @CircuitBreaker) {
        CircuitBreaker cb = registry.get("api");

        Supplier<String> decorated = CircuitBreaker.decorateSupplier(cb, () -> actualMethod(args));

        return Try.ofSupplier(decorated)
                  .recover(throwable -> fallback(args))
                  .get();
    } else {
        return actualMethod(args);
    }
}

3. CircuitBreaker는 데코레이터로 동작한다

다시 말하면, CircuitBreaker는 실행하려는 로직을 감싸는 데코레이터 함수를 제공한다. 

Resilience4j github에서 CircuitBreaker 인터페이스 코드를 볼 수 있으며, 궁금하다면 이곳을 확인해 보자.

 

코드를 보면 다음과 같은 데코레이터 메서드들을 볼 수 있다. 

static <T> Supplier<T> decorateSupplier(CircuitBreaker cb, Supplier<T> supplier)
static <T> Callable<T> decorateCallable(CircuitBreaker cb, Callable<T> callable)
static Runnable decorateRunnable(CircuitBreaker cb, Runnable runnable)
static <T> CompletionStage<T> decorateCompletionStage(CircuitBreaker cb, Supplier<CompletionStage<T>> supplier)
static <T, R> Function<T, R> decorateFunction(CircuitBreaker cb, Function<T, R> function)

 

Spring은 반환 타입과 파라미터 시그니처에 따라 자동으로(!) 적절한 데코레이터를 선택해 준다. 

따라서 우리는 어떤 데코레이터를 선택해야 할지 고민할 필요가 없어진 것이다. (고마워 스프링...☘️)

예시로 쓰인 getResult()는 반환 타입이 String이고 파라미터로 아무것도 받지 않으므로, decorateSupplier가 호출된다. 

4. 그럼 CircuitBreaker는 무슨 일을 하고 싶어서 메서드를 감쌌을까?

getResult()를 호출하기 전, 내부에서는 다음과 같은 코드가 실행된다.

현재 상태가 OPEN이면 CallNotPermittedException을 터뜨리고, HALF_OPEN이면 일부만 허용한다. 

if (!circuitBreaker.tryAcquirePermission()) {
    throw CallNotPermittedException;
}

 

이를 실행하는 클래스가 CircuitBreakerStateMachine라는 클래스다. 

전체 상태를 관리하는 핵심 클래스라고 봐도 좋다. 

 

로직을 살펴보자. 

public final class CircuitBreakerStateMachine implements CircuitBreaker {

    private static final Logger LOG = LoggerFactory.getLogger(CircuitBreakerStateMachine.class);

    private final String name;
    
    // 현재 상태 (Closed, Open 등)
    private final AtomicReference<CircuitBreakerState> stateReference;
    
    // 설정값 (실패율 임계치, 슬라이딩 윈도우 크기 등)
    private final CircuitBreakerConfig circuitBreakerConfig;
    private final Map<String, String> tags;
    
    // 이벤트 처리기 (성공, 실패 등 이벤트가 발생하면 기록하기 위한 용도) 
    private final CircuitBreakerEventProcessor eventProcessor;
    
    // 시간 계산용 (~만큼의 시간이 지나면 서킷 브레이커가 다시 닫히도록)
    private final Clock clock;
    private final SchedulerFactory schedulerFactory;
    private final Function<Clock, Long> currentTimestampFunction;
    private final TimeUnit timestampUnit;
    private final ReentrantLock lock = new ReentrantLock();
    
    /.../
    
    // 현재 상태를 판단해서 호출 허용을 결정하는 메서드
    @Override
    public boolean tryAcquirePermission() {
        boolean callPermitted = stateReference.get().tryAcquirePermission();
        if (!callPermitted) {
            publishCallNotPermittedEvent();
        }
        return callPermitted;
    }
}

 

이렇게 호출 여부를 판별하고 나면, 실행하고자 하는 메서드인 getResult()를 호출한다. 

호출이 성공하면 onSuccess()를, 실패하면 onError()를 통해 성공/실패 여부를 기록한다. 

 

이 로직 또한 마찬가지로 CircuitBreakerStateMachine 내부에 있다. 

// 성공했을 때
@Override
public void onSuccess(long duration, TimeUnit durationUnit) {
    LOG.debug("CircuitBreaker '{}' succeeded:", name);
    publishSuccessEvent(duration, durationUnit);
    stateReference.get().onSuccess(duration, durationUnit);
}

// CircuitBreaker 내부적으로 이벤트를 사용하고 있음을 알 수 있다
private void publishSuccessEvent(final long duration, TimeUnit durationUnit) {
    if (eventProcessor.hasConsumers()) {
        publishEvent(new CircuitBreakerOnSuccessEvent(name, elapsedDuration(duration, durationUnit)));
    }
}

// 실패했을 때
@Override
public void onError(long duration, TimeUnit durationUnit, Throwable throwable) {
    // Handle the case if the completable future throws a CompletionException wrapping the original exception
    // where original exception is the one to retry not the CompletionException.
    if (throwable instanceof CompletionException || throwable instanceof ExecutionException) {
        Throwable cause = throwable.getCause();
        handleThrowable(duration, durationUnit, cause);
    } else {
        handleThrowable(duration, durationUnit, throwable);
    }
}

private void handleThrowable(long duration, TimeUnit durationUnit, Throwable throwable) {
    if (circuitBreakerConfig.getIgnoreExceptionPredicate().test(throwable)) {
        LOG.debug("CircuitBreaker '{}' ignored an exception:", name, throwable);
        releasePermission();
        publishCircuitIgnoredErrorEvent(name, duration, durationUnit, throwable);
        return;
    }
    if (circuitBreakerConfig.getRecordExceptionPredicate().test(throwable)) {
        LOG.debug("CircuitBreaker '{}' recorded an exception as failure:", name, throwable);
        publishCircuitErrorEvent(name, duration, durationUnit, throwable);
        stateReference.get().onError(duration, durationUnit, throwable);
    } else {
        LOG.debug("CircuitBreaker '{}' recorded an exception as success:", name, throwable);
        publishSuccessEvent(duration, durationUnit);
        stateReference.get().onSuccess(duration, durationUnit);
    }
    handlePossibleTransition(Either.right(throwable));
}

// 이하 생략...

 

실패율 임계치를 넘었을 때, 사용자가 지정한 skip해도 되는 에러가 나왔을 때 등 모두 이벤트로 관리가 진행된다. 

 

이후 호출 결과는 CircuitBreakerMetrics 클래스에 기록된다.

이렇게 메트릭을 저장하기 때문에 상태가 바뀌어도 호출 이력을 유지할 수 있으며,

Prometheus, Grafana와 연동하여 상태 및 실패율을 실시간 추적할 수 있다. 

CircuitBreaker.Metrics {

    private final Metrics metrics;
    private final float failureRateThreshold;
    private final float slowCallRateThreshold;
    private final long slowCallDurationThresholdInNanos;
    private final LongAdder numberOfNotPermittedCalls;
    private int minimumNumberOfCalls;
    
    /.../
    
    public Result onSuccess(long duration, TimeUnit durationUnit) {
        Snapshot snapshot;
        if (durationUnit.toNanos(duration) > slowCallDurationThresholdInNanos) {
            snapshot = metrics.record(duration, durationUnit, Outcome.SLOW_SUCCESS);
        } else {
            snapshot = metrics.record(duration, durationUnit, Outcome.SUCCESS);
        }
        return checkIfThresholdsExceeded(snapshot);
    }
    
    public Result onError(long duration, TimeUnit durationUnit) {
        Snapshot snapshot;
        if (durationUnit.toNanos(duration) > slowCallDurationThresholdInNanos) {
            snapshot = metrics.record(duration, durationUnit, Outcome.SLOW_ERROR);
        } else {
            snapshot = metrics.record(duration, durationUnit, Outcome.ERROR);
        }
        return checkIfThresholdsExceeded(snapshot);
    }
    
    private Result checkIfThresholdsExceeded(Snapshot snapshot) {
        float failureRateInPercentage = getFailureRate(snapshot);
        float slowCallsInPercentage = getSlowCallRate(snapshot);

        if (failureRateInPercentage == -1 || slowCallsInPercentage == -1) {
            return Result.BELOW_MINIMUM_CALLS_THRESHOLD;
        }
        if (failureRateInPercentage >= failureRateThreshold
            && slowCallsInPercentage >= slowCallRateThreshold) {
            return Result.ABOVE_THRESHOLDS;
        }
        if (failureRateInPercentage >= failureRateThreshold) {
            return Result.FAILURE_RATE_ABOVE_THRESHOLDS;
        }

        if (slowCallsInPercentage >= slowCallRateThreshold) {
            return Result.SLOW_CALL_RATE_ABOVE_THRESHOLDS;
        }
        return Result.BELOW_THRESHOLDS;
    }

 

이렇게 성공과 실패가 발생할 때마다 상태를 Snapshot 클래스에 이를 저장하는 것을 볼 수 있다. 

이후 새로운 요청 결과가 발생하면 Snapshot.getFailureRate()과 같은 메서드를 사용하여 임계치를 초과했는지 안 했는지 여부를 반환한다. 

 

임계치를 초과하게 되면 상태가 전환된다. 

CircuitBreakerStateMachine 클래스에서 상태 전환에 대한 메서드들을 찾아볼 수 있다. 

private void stateTransition(State newState,
    UnaryOperator<CircuitBreakerState> newStateGenerator) {
    CircuitBreakerState previousState = stateReference.getAndUpdate(...);
    publishStateTransitionEvent(
        StateTransition.transitionBetween(getName(), previousState.getState(), newState));
}

@Override
public void transitionToOpenState() {
    stateTransition(OPEN,
        currentState -> new OpenState(currentState.attempts() + 1, currentState.getMetrics()));
}

 

상태는 AtomicReference(thread-safe하게 값을 읽고 바꿀 수 있게 해주는 자바 클래스)로 관리된다.

CircuitBreakerStateMachine 필드로 들어 있으니 위로 올라가서 다시 확인해 보자. 

5. 마치며

CircuitBreaker는 실행하고자 하는 메서드 앞 뒤에 붙어 일련의 일들을 수행하고 장애의 전파를 막는다. 

전체 흐름을 한번 정리해 보자면 이렇다. 

[1] 사용자가 메서드 호출 (e.g., getResult())
 ↓
[2] 프록시 객체가 메서드 호출을 가로챔 (@CircuitBreaker 감지됨)
 ↓
[3] CircuitBreaker.decorateXXX()로 메서드 감쌈
 ↓
[4] tryAcquirePermission() 호출
     - 현재 상태(Closed, Open, HalfOpen)에 따라 실행 여부 판단
     - OPEN이면 CallNotPermittedException 발생
 ↓
[5] 실제 메서드 실행
     - 성공 → onSuccess() 호출
     - 실패 → onError() 호출
 ↓
[6] CircuitBreakerMetrics에 결과 기록
     - 성공/실패/느림/허용되지 않음 등 카운트 증가
 ↓
[7] 실패율 계산 후 임계치 초과 시 상태 전이
     - 상태 변경: Closed → Open → HalfOpen → Closed

 

다음에는 실제 예시 코드를 보면서 CircuitBreaker의 적용을 살펴보자.