
장애전파 방지를 위해 써킷브레이커 패턴을 적용하기전 "Circuitbreaker"에 대해 학습하고 정리하고자 합니다.
Circuitbreaker는 직역하자면 회로차단기입니다. 전기 회로에서 과부하가 걸리면 전기를 차단해 화재를 예방하듯, 소프트웨어 아키텍처에서도 서비스 간의 장애 전파를 막는 역할을 수행합니다.
분산 시스템이나 마이크로서비스 아키텍처(MSA)에서는 하나의 서비스가 다른 서비스를 호출하는 일이 빈번합니다. 이때 호출된 서비스(Callee)에 에러가 발생하거나 응답이 지연되면, 호출한 서비스(Caller)의 리소스(Thread 등)가 고갈되어 결국 전체 시스템이 마비되는 장애 전파(Cascading Failure) 현상이 발생할 수 있습니다.
장애 전파 방지: 특정 서비스의 에러가 전체 시스템으로 번지는 것을 막습니다.
빠른 실패(Fail-fast): 응답이 오지 않을 서비스에 무한정 대기하지 않고 즉시 에러를 반환하여 사용자 경험을 개선합니다.
리소스 보호: 장애가 발생한 서비스로 요청을 보내지 않음으로써 스레드, 메모리 등의 자원 고갈을 방지합니다.
서킷 브레이커는 시스템의 상태에 따라 세 가지 상태를 오가며 동작합니다.

출처: https://martinfowler.com/bliki/CircuitBreaker.html
| 상태 | 명칭 | 설명 |
|---|---|---|
| Closed | 닫힘 | 정상 상태입니다. 모든 요청이 서비스로 전달됩니다. |
| Open | 열림 | 장애 임계치를 초과한 상태입니다. 요청을 보내지 않고 즉시 에러를 반환합니다. |
| Half-Open | 반열림 | 일정 시간이 지난 후, 장애 해결 여부를 확인하기 위해 일부 요청만 보내보는 상태입니다. |
서킷 브레이커가 Open 상태가 되어 요청이 차단되었을 때, 사용자에게 단순히 에러 페이지를 보여주는 것은 좋은 경험이 아닙니다.
이때 미리 준비한 대체 로직을 실행하는 것을 Fallback이라고 합니다.
과거에는 Netflix에서 만든 Hystrix가 주로 쓰였지만, 현재는 유지보수가 종료되어 Java 진영에서는 Resilience4j를 표준으로 사용합니다. Spring Boot 환경에서 다음과 같은 핵심 파라미터들을 설정하여 시스템을 제어합니다.
먼저 build.gradle에 아래 의존성을 추가해 줍니다.
Resilience4j의 @CircuitBreaker 어노테이션은 AOP(Aspect Oriented Programming)를 기반으로 동작하기 때문에, 비즈니스 로직에 영향을 주지 않고 장애를 감지하기 위해 AOP 의존성이 반드시 필요합니다.
dependencies {
// Resilience4j Starter
implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.3.0'
// AOP 처리를 위한 의존성 - 프로젝트에 이미 포함되어 있다면 생략 가능합니다.
implementation 'org.springframework.boot:spring-boot-starter-aop'
}
프로젝트 성격에 따라 호출 횟수(slidingWindowSize)나 실패 임계치(failureRateThreshold)를 유연하게 조절하여 사용하시면 됩니다.
resilience4j:
circuitbreaker:
instances:
paymentApi: # 결제 API 호출을 위한 인스턴스 이름
# 1. 상태 결정을 위한 설정
slidingWindowSize: 5 # 최근 5개의 호출을 기반으로 실패율 계산
failureRateThreshold: 50 # 실패율이 50% 이상일 때 서킷 Open
# 2. 상태 유지 및 전환 설정
waitDurationInOpenState: 10000ms # Open 상태를 유지할 시간 (10초 후 Half-Open 전환)
permittedNumberOfCallsInHalfOpenState: 3 # Half-Open 상태에서 허용할 테스트 호출 횟수
# 3. 측정 방식
slidingWindowType: COUNT_BASED # 호출 횟수 기반으로 측정 (TIME_BASED도 가능)
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final PaymentApiClient paymentApiClient;
/**
* @CircuitBreaker: yml에 설정한 'paymentApi' 인스턴스 설정을 적용합니다. fallbackMethod: 서킷이 Open되었거나 에러 발생 시 실행될 메서드입니다.
*/
@CircuitBreaker(name = "paymentApi", fallbackMethod = "fallbackPayment")
public String processOrder(Long orderId, int amount) {
log.info(">> 결제 API 호출 시도 (주문번호: {})", orderId);
return paymentApiClient.requestPayment(orderId, amount);
}
/**
* Fallback 메서드 - 원본 메서드와 파라미터 타입 및 순서가 동일해야 합니다. - 마지막 파라미터로 Throwable을 추가하면 발생한 에러의 원인을 파악할 수 있습니다. -
* Resilience4j는 서킷이 OPEN되어 요청이 차단될 때 'CallNotPermittedException'을 던집니다.
*/
public String fallbackPayment(Long orderId, int amount, Throwable t) {
// 서킷 브레이커에 의해 요청이 직접 차단된 경우
if (t instanceof CallNotPermittedException) {
return "[차단 상태] 시스템 보호 중입니다.";
}
// 외부 서비스의 실제 예외(RuntimeException 등)가 발생한 경우
return "[연결 에러] 외부 서비스 장애입니다. 사유: " + t.getMessage();
}
}
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
@SpringBootTest
@Slf4j
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@MockitoBean
private PaymentApiClient paymentApiClient;
@BeforeEach
void setUp() {
// 테스트 실행 전마다 paymentApi 서킷 브레이커의 상태를 초기화
circuitBreakerRegistry.circuitBreaker("paymentApi").reset();
}
private final int SLIDING_WINDOW_SIZE = 5;
private final int HALF_OPEN_LIMIT = 3;
@Test
@DisplayName("설정된 실패율을 초과하면 서킷이 OPEN되고 이후 호출을 즉시 차단한다")
void should_OpenCircuit_When_FailureRateExceedsThreshold() {
// Given: 연속적인 API 장애 설정
given(paymentApiClient.requestPayment(anyLong(), anyInt()))
.willThrow(new RuntimeException("API 장애 발생"));
// When: 윈도우 사이즈만큼 호출하여 서킷 오픈 유도
for (int count = 1; count <= SLIDING_WINDOW_SIZE; count++) {
String response = orderService.processOrder((long) count, 1000);
assertThat(response).contains("[연결 에러]");
}
// Then: 6번째 호출 시 실제 로직 진입 없이 즉시 차단(Fail-fast) 검증
String blockedResponse = orderService.processOrder(99L, 1000);
assertThat(blockedResponse).isEqualTo("[차단 상태] 시스템 보호 중입니다.");
// 실제 API 호출은 딱 5번만 발생했는지 검증
verify(paymentApiClient, times(SLIDING_WINDOW_SIZE)).requestPayment(anyLong(), anyInt());
}
@Test
@DisplayName("OPEN 상태에서 대기 시간이 지나면 HALF_OPEN 상태가 되어 복구를 시도하고, 성공 시 CLOSED 상태로 돌아온다")
void should_RecoverToClosed_After_WaitDuration() throws InterruptedException {
// [Step 1] Given: 서킷을 OPEN 상태로 전환
given(paymentApiClient.requestPayment(anyLong(), anyInt()))
.willThrow(new RuntimeException("API 장애 발생"));
for (int count = 1; count <= SLIDING_WINDOW_SIZE; count++) {
orderService.processOrder((long) count, 1000);
}
// [Step 2] When: Open 상태 유지 시간(10초) 이후 상황 시뮬레이션
log.info("--- 10.5초 대기 후 복구 시도 (Half-Open 전환 대기) ---");
Thread.sleep(10500);
// 기존 호출 기록 초기화 및 정상 응답 모킹
reset(paymentApiClient);
given(paymentApiClient.requestPayment(anyLong(), anyInt())).willReturn("결제 성공");
// [Step 3] Then: Half-Open 상태에서 성공 시나리오 검증
for (int count = 1; count <= HALF_OPEN_LIMIT; count++) {
String recoveryResponse = orderService.processOrder((long) count, 1000);
assertThat(recoveryResponse).isEqualTo("결제 성공");
log.info("Half-Open 단계 복구 호출 {}회차 성공", count);
}
// [Step 4] 최종 확인: 서킷이 완전히 CLOSED되어 일반 호출이 정상 처리되는지 확인
String finalResponse = orderService.processOrder(100L, 1000);
assertThat(finalResponse).isEqualTo("결제 성공");
// 실제 API 호출 횟수 검증 (Half-Open 3번 + 정상 1번)
verify(paymentApiClient, times(HALF_OPEN_LIMIT + 1)).requestPayment(anyLong(), anyInt());
log.info("검증 완료: 서킷이 완전히 CLOSED 상태로 복구됨");
}
}

테스트 케이스1 - [상태 변화 1: CLOSED → OPEN]
설정한 slidingWindowSize인 5회까지는 실제 API 호출을 시도하지만, 임계치를 넘는 순간 차단이 시작됩니다.
// 1~5회차: 실제 호출 시도 후 에러 발생 (데이터 수집 단계)
INFO >> 결제 API 호출 시도 (주문번호: 1)
ERROR >> 결제 서비스 장애로 인한 Fallback 실행! (사유: API 장애 발생)
// 6회차: 실제 호출 없이 즉시 차단 (Fail-fast)
INFO --- 6번째 호출 시도 (차단 여부 확인) ---
ERROR >> 결제 서비스 장애로 인한 Fallback 실행! (사유: CircuitBreaker 'paymentApi' is OPEN and does not permit further calls)
💡 분석 포인트
6번째 호출 로그를 보면 결제 API 호출 시도라는 로그가 찍히지 않았습니다. 이는 Resilience4j가 프록시 단계에서 요청을 가로채 비즈니스 로직 진입을 사전에 차단했음을 의미합니다.
테스트 케이스2 - [상태 변화 2: OPEN → HALF_OPEN → CLOSED]
설정한 차단 유지 시간(waitDurationInOpenState: 10초)이 지난 후, 시스템은 HALF_OPEN 상태로 바뀌어 서비스가 정상적으로 복구되었는지 확인하는 과정을 거칩니다.
[대기 단계]
18:16:20.407 INFO --- 10.5초 대기 후 복구 시도 (Half-Open 전환 대기) ---
[복구 시도 단계] "HALF_OPEN"
18:16:30.934 INFO >> 결제 API 호출 시도 (주문번호: 1) // 다시 실제 호출 시도 시작
18:16:30.935 INFO Half-Open 단계 복구 호출 1회차 성공
18:16:30.940 INFO >> 결제 API 호출 시도 (주문번호: 2)
18:16:30.950 INFO Half-Open 단계 복구 호출 2회차 성공
18:16:30.960 INFO >> 결제 API 호출 시도 (주문번호: 3)
18:16:30.970 INFO Half-Open 단계 복구 호출 3회차 성공 (설정된 임계치 도달!)
[복구 완료 및 검증] "CLOSED"
18:16:30.996 INFO >> 결제 API 호출 시도 (주문번호: 100) // 서킷이 닫힌 후 첫 일반 호출
18:16:30.997 INFO 검증 완료: 서킷이 완전히 CLOSED 상태로 복구됨
💡 분석 포인트
10초가 지난 후 첫 요청(주문번호 1)이 들어오는 순간, 서킷 브레이커는 '정상화되었을지도 모른다'고 판단하여 다시 API 호출을 허용합니다. 설정한 3번의 테스트 호출이 모두 성공하자 서킷은 완전히 CLOSED 상태로 돌아갔으며, 이후의 일반 호출(주문번호 100)도 정상적으로 처리되는 것을 확인할 수 있습니다.
AOP 기반 동작: @CircuitBreaker는 AOP를 이용하므로, 내부 메서드 호출(self-invocation) 시에는 적용되지 않습니다. 반드시 빈으로 주입받은 외부 객체의 메서드를 호출해야 합니다.
Fallback 메서드 시그니처: 원본 메서드와 인자 구성이 동일해야 하며, 발생한 예외를 파악하고 싶다면 마지막 인자로 Throwable을 추가해야 합니다.
테스트 격리: 스프링 통합 테스트 환경에서는 서킷 브레이커의 상태가 공유될 수 있습니다. 각 테스트 시작 전 CircuitBreakerRegistry를 통해 상태를 reset() 해주는 과정이 필수적입니다.
지금까지 Resilience4j를 사용하여 서킷 브레이커 패턴을 구현하고, 테스트 코드를 통해 그 동작 원리를 직접 검증해 보았습니다.
분산 시스템을 운영하다 보면 장애는 예고 없이 찾아오기 마련입니다. 서킷 브레이커는 단순히 장애를 막는 데 그치지 않고, 시스템이 스스로를 보호하며 회복할 수 있는 자가 치유 능력을 제공합니다. 이는 서비스 전체의 안정성을 높이는 데 매우 중요한 역할을 합니다.
이번 실습을 통해 다음과 같은 핵심 가치를 배울 수 있었습니다.
시스템 가용성 확보: 외부 서비스의 장애가 우리 시스템 전체의 '스레드 고갈'이나 '연쇄 장애'로 번지는 것을 원천 차단했습니다.
사용자 경험 보호: 무한 대기 대신 미리 준비된 Fallback을 통해 사용자에게 친절하고 빠른 응답을 제공할 수 있게 되었습니다.
검증의 중요성: 통합 테스트를 통해 CLOSED → OPEN → HALF-OPEN으로 이어지는 상태 전이 과정을 직접 확인하며, 복잡한 설정값들이 의도대로 작동하는지 확신을 가질 수 있었습니다
📌 운영 환경에서의 모니터링
실무에서는 서킷 브레이커를 적용하는 것만큼이나 현재 상태를 관찰하는 것이 중요합니다.Spring Boot Actuator를 연동하면 /actuator/circuitbreakers 등의 엔드포인트를 통해 현재 서킷의 상태나 실패율을 실시간으로 확인할 수 있습니다. 수집된 데이터를 바탕으로 서비스의 트래픽 특성에 맞춰 임계치(failureRateThreshold)나 대기 시간(waitDurationInOpenState)을 세밀하게 튜닝해 나가는 것이 운영 단계의 핵심입니다
참고 자료
올리브영 테크 블로그 - Circuitbreaker를 사용한 장애 전파 방지
API 메시업과 Fault Tolerance 문제 해결 전략
https://martinfowler.com/bliki/CircuitBreaker.html