서킷 브레이커 적용기: Resilience4j 상태 전이를 테스트로 확인하기

anlee·2025년 5월 21일

매일매일 블로그

목록 보기
16/49

외부 API가 느려지거나 실패하면 호출하는 서비스의 스레드와 연결도 함께 묶일 수 있다. 서킷 브레이커는 실패가 누적된 의존성으로 향하는 새 호출을 잠시 차단해 장애가 확산되는 것을 줄인다.

적용할 때는 성공 응답만 반환하는 예제로 끝내지 않고, 실패율이 쌓이고 호출이 차단됐다가 복구되는 과정을 확인해야 한다. 이 글에서는 Java 17·Resilience4j 2.2.0을 기준으로 상태 전이를 재현한다. 테스트를 위한 작은 설정이며 그대로 운영 기본값으로 권하는 수치는 아니다.

1. 상태마다 허용하는 호출이 다르다

서킷 브레이커 상태 전이

상태동작
CLOSED호출을 허용하고 성공·실패·느린 호출을 기록
OPEN새 호출을 거절하고 CallNotPermittedException 발생
HALF_OPEN제한된 시험 호출로 회복 여부를 판단

실패율은 최소 호출 수가 쌓인 뒤 평가된다. 창 크기를 100으로 두고 몇 번 실패했다고 바로 열린다고 기대하면 설정을 잘못 읽은 것이다. 느린 호출 비율도 설정에 따라 개방 조건이 될 수 있다.

OPEN의 대기 시간이 지났을 때의 전이도 구분한다. 자동 전이 옵션을 켜지 않은 기본 동작에서는 이후 호출이 들어올 때 HALF_OPEN으로 전이할 수 있다. “설정한 시간이 지나면 반드시 즉시 CLOSED가 된다”는 뜻이 아니다. Resilience4j CircuitBreaker

2. 실패를 주입하는 실행 가능한 테스트

테스트 의존성은 다음과 같다. Spring을 띄우지 않고 서킷 브레이커 자체의 동작을 확인한다.

implementation 'io.github.resilience4j:resilience4j-circuitbreaker:2.2.0'
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.3'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.3'
test { useJUnitPlatform() }
// CircuitBreakerExampleTest.java — src/test/java/example
package example;

import io.github.resilience4j.circuitbreaker.CallNotPermittedException;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig;
import java.time.Duration;
import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CircuitBreakerExampleTest {
    private CircuitBreaker create() {
        var config = CircuitBreakerConfig.custom()
            .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
            .slidingWindowSize(4)
            .minimumNumberOfCalls(4)
            .failureRateThreshold(50)
            .waitDurationInOpenState(Duration.ofSeconds(60))
            .permittedNumberOfCallsInHalfOpenState(2)
            .build();
        return CircuitBreaker.of("inventory", config);
    }

    private void fail(CircuitBreaker breaker) {
        assertThrows(IllegalStateException.class, () ->
            breaker.executeSupplier(() -> {
                throw new IllegalStateException("injected downstream failure");
            })
        );
    }

    private void open(CircuitBreaker breaker) {
        breaker.executeSupplier(() -> "ok");
        breaker.executeSupplier(() -> "ok");
        fail(breaker);
        assertEquals(CircuitBreaker.State.CLOSED, breaker.getState());
        fail(breaker);
        assertEquals(CircuitBreaker.State.OPEN, breaker.getState());
    }

    @Test
    void opensAndRejectsWithoutCallingDownstream() {
        var breaker = create();
        var transitions = new ArrayList<CircuitBreaker.StateTransition>();
        breaker.getEventPublisher().onStateTransition(
            event -> transitions.add(event.getStateTransition())
        );
        open(breaker);
        var downstreamCalls = new AtomicInteger();
        assertThrows(CallNotPermittedException.class, () ->
            breaker.executeSupplier(downstreamCalls::incrementAndGet)
        );
        assertEquals(0, downstreamCalls.get());
        assertTrue(transitions.contains(CircuitBreaker.StateTransition.CLOSED_TO_OPEN));
    }

    @Test
    void closesAfterSuccessfulHalfOpenProbes() {
        var breaker = create();
        open(breaker);
        // 테스트에서는 60초를 기다리는 대신 상태를 명시적으로 전이한다.
        breaker.transitionToHalfOpenState();
        breaker.executeSupplier(() -> "ok");
        assertEquals(CircuitBreaker.State.HALF_OPEN, breaker.getState());
        breaker.executeSupplier(() -> "ok");
        assertEquals(CircuitBreaker.State.CLOSED, breaker.getState());
    }

    @Test
    void reopensWhenHalfOpenProbesFail() {
        var breaker = create();
        open(breaker);
        breaker.transitionToHalfOpenState();
        fail(breaker);
        fail(breaker);
        assertEquals(CircuitBreaker.State.OPEN, breaker.getState());
    }
}

기대 결과는 네 번째 완료 호출에서 실패율 50%로 OPEN이 되고, 그 다음 호출은 내부 로직을 실행하지 않은 채 거절되는 것이다. HALF_OPEN에서는 두 번의 시험 결과로 회복 또는 재개방을 확인한다.

transitionToHalfOpenState()테스트를 빠르고 결정적으로 만들기 위한 수동 전이다. 이 테스트가 실제 60초 대기나 자동 전이 스케줄러까지 검증한 것은 아니다. 운영 설정에서는 시간 경과와 호출 유입도 별도로 확인한다.

3. 이벤트 리스너는 실제 등록된 인스턴스에 연결하기

위 테스트는 서킷 브레이커의 getEventPublisher()에 상태 전이 리스너를 붙인다. Spring에서도 레지스트리에서 실제 사용하는 이름의 인스턴스를 얻어 등록할 수 있다.

// CircuitEventConfig.java
package example;

import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CircuitEventConfig {
    @Bean
    ApplicationRunner registerCircuitEvents(CircuitBreakerRegistry registry) {
        return args -> registry.circuitBreaker("inventory")
            .getEventPublisher()
            .onStateTransition(event -> LoggerFactory.getLogger(getClass())
                .info("inventory circuit transition={}", event.getStateTransition()));
    }
}

이 Spring 예제는 resilience4j-spring-boot3:2.2.0이 레지스트리를 구성하는 프로젝트를 전제로 한다. 애너테이션 방식을 사용하면 spring-boot-starter-aop 등 필요한 의존성과 프록시 적용 여부도 확인한다. 자기 객체 내부 호출은 프록시를 통과하지 않을 수 있다. 이름을 다르게 쓰면 서로 다른 서킷 브레이커의 상태를 보게 된다.

레지스트리 전체에 추가·교체·삭제 이벤트를 등록하려면 해당 인터페이스의 필수 메서드를 모두 구현해야 한다. 필요한 동작이 특정 인스턴스의 상태 로깅이라면 위처럼 좁은 리스너로 목적을 분명히 할 수 있다. 같은 리스너를 반복 등록하면 로그와 처리도 중복되므로 등록 생명주기를 관리한다.

4. Timeout·Retry·Bulkhead와의 역할 구분

서킷 브레이커는 실행 중인 HTTP 요청을 알아서 취소하는 타임아웃 기능이 아니다. 느린 호출은 완료된 뒤 통계에 반영될 수 있으므로 HTTP 클라이언트의 연결·응답 시간 제한이 따로 필요하다.

장치제어하는 대상
Timeout한 호출을 얼마나 기다릴지
Circuit Breaker실패가 누적된 대상에 새 호출을 허용할지
Retry실패한 작업을 어떤 조건으로 몇 번 더 시도할지
Bulkhead의존성별 동시 실행·자원 사용을 얼마나 허용할지

Retry와 Circuit Breaker를 중첩하는 순서에 따라 실패 통계에 시도별 결과가 들어가는지 최종 결과가 들어가는지가 달라진다. 재시도 횟수는 전체 시간 제한과 멱등성을 함께 고려한다. 사용자 입력 오류까지 외부 시스템 장애로 세면 서킷이 불필요하게 열릴 수 있어 예외 분류도 필요하다.

Fallback은 업무상 허용되는 대체 동작이어야 한다. 조회에서 캐시된 정보를 보여 줄 수는 있지만, 결제 실패를 성공 DTO로 바꾸면 안 된다. 대체 데이터의 최신성이나 기능 제한이 중요하면 사용자에게 드러내고, 모니터링에는 원래 실패도 남긴다.

5. 상태와 사용자 영향을 함께 관찰하기

Resilience4j 상태와 호출 지표 대시보드

이 화면은 상태·호출 지표를 보는 예다. 한 시점의 CLOSED 화면만으로 장애 대응이 검증됐다고 결론 내릴 수는 없다. 거절된 호출 수, 지연, fallback 사용량, 실제 오류 응답을 함께 본다. 최소 표본이 쌓이지 않은 실패율 표시도 0%와 구분한다.

여러 애플리케이션 인스턴스는 일반적으로 각자의 서킷 상태를 가진다. 한 서버의 OPEN을 서비스 전체의 동일한 상태로 간주하지 않는다. 설정값을 바꿀 때는 정상 복구 후 시험 호출이 통과하는지, 장기 장애에서 자원이 버티는지까지 확인해야 한다.

0개의 댓글