서론
현재 프로젝트에서는 Gateway 서버를 통해 각 서비스에 요청을 하는 구조이다. 내가 현재 개발하고 있는 배송 서비스는 외부에서도 요청을 받아서 응답을 주고, 내부 서비스 간을 통해서도 요청/응답을 주고 받는 역할을 하는데 여기서 Delivery-Service가 느려지거나 멈추면 다른 MSA 서비스에도 장애를 전파할 위험이 높다.
현재 배송 서비스에서 장애가 날 법한 상황들은 다음과 같다.
- 데이터베이스 연결 지연/실패: 배송 정보 조회 시, DB 응답이 느려지거나 연결이 끊기는 경우이다. 서비스는 DB 응답을 기다리느라 쓰레드를 붙잡아 두게 되고, 이렇게 되면 Delivery-Service의 쓰레드가 모두 소진되어 서비스 전체의 장애로 이어질 수 있다.
- 다른 마이크로 서비스 장애: 예를 들어, Delivery-Service가 응답 지연 상태에 빠지면, 이를 호출하는 API Gateway의 쓰레드도 함께 대기 지연 상태에 빠진다. 다른 서비스에서(Order-Service) delivery-service를 호출할 때에도 마찬가지다. 결국 배송 서비스와 연결된 모든 호출자의 쓰레드 풀이 소진되게 되고, 연쇄적인 장애가 발생하게 된다.
- 사용자가 몰려서 응답이 지연되는 경우: 현재는 개별 서비스 간의 모든 통신이 OpenFeign으로 연결되어 있기에 (동기 방식으로 처리됨) 사용자가 몰릴수록 지연 시간도 눈덩이 마냥 증가될 것이다.
본론
위와 같은 상황들을 방지하고자 Circuit Breaker를 도입하게 되었다.
Circuit Breaker 차단기가 열리면(OPEN) Gateway는 요청을 delivery-service로 보내지 않고 즉시 Fallback 응답을 반환할 것이다. 이로써 Gateway의 쓰레드 풀이 보호되고, 시스템의 다른 부분은 정상적으로 작동될 수 있다.
간단하게 Circuit Breaker 상태를 확인해보도록 하자.
| 상태 | 역할 | 시스템 영향 |
| CLOSED | 요청 정상 전달 | 서비스 장애 시 연쇄 장애 위험 |
| OPEN | 요청 즉시 차단 (Fallback) | 연쇄 장애 방지 |
| HALF_OPEN | 소수의 요청으로 복구 시도 | 안전한 서비스 복구 경로 제공 |
아니면 사용자 경험을 좀 더 개선시킬 때에도 쓰일 수 있다. 예를 들어, 지연 시간이 오래 걸리게 되어 사용자가 로딩 화면이라거나, 에러 표시 화면을 계속적으로 보게 하는 대신, "현재 서비스가 점검/복구 중입니다. 잠시 후에 다시 시도해주세요..."와 같은 좀 더 완곡한 응답을 표시해줄 수 있다.
application.yml 설정
# gateway-service/src/main/resources/application.yml
# Gateway 라우팅 및 Circuit Breaker 설정
spring:
cloud:
gateway:
routes:
- id: delivery-service # 라우트 식별자 수정 (하이픈 사용 권장)
uri: lb://DELIVERY-SERVICE # Eureka에 등록된 서비스 ID는 대문자 (DELIVERY-SERVICE)
predicates:
- Path=/open-api/v1/deliveries/**, /api/v1/deliveries/**
filters:
- StripPrefix=0 # 요청 경로에서 접두사를 제거하지 않음 (Path 필터의 복잡성 때문에)
- name: CircuitBreaker
args:
name: deliveryCircuitBreaker # 인스턴스 이름
fallbackUri: forward:/delivery-fallback # 대체 URL 설정
- id: delivery-fallback-route # Fallback을 처리할 라우트
uri: forward:/delivery-fallback
predicates:
- Path=/delivery-fallback
# --------------------------------------------------------------------------
# Resilience4j (서킷 브레이커) 상세 설정
resilience4j:
circuitbreaker:
configs:
default:
failureRateThreshold: 50 # 실패율 50%를 초과 시 OPEN(차단)
waitDurationInOpenState: 10s # OPEN 상태를 10초 유지 후 HALF_OPEN으로 전환
permittedNumberOfCallsInHalfOpenState: 3 # Half-Open 상태에서 허용되는 요청 수
# Half-Open 상태에서 Open/Closed 상태 전환을 판단할 요청의 개수
# 3개 요청 전부 성공 -> Closed 상태로 전환
# 3개 요청 중 1개라도 실패 -> Open 상태로 전환
slidingWindowType: COUNT_BASED
slidingWindowSize: 20 # 최근 20개 호출을 기준으로 실패율 계산
minimumNumberOfCalls: 10 # 10개 이상 호출이 있어야 실패율 계산 시작 (안정성 강화)
slowCallRateThreshold: 50 # Slow 콜 비율이 50% 초과 시 차단
slowCallDurationThreshold: 5000ms # 5초 이상 응답 시 Slow 콜로 간주 (물류 API 응답 시간 고려)
instances:
deliveryCircuitBreaker: # delivery 회로 차단기 이름
baseConfig: default # default 설정을 상속받아 사용
timelimiter:
configs:
default:
# 타임아웃 발생 시 Circuit Breaker에 실패 신호 보냄
timeoutDuration: 5s # 5초 안에 응답이 없으면 타임아웃 발생 (슬로우 콜 시간 고려)
# --------------------------------------------------------------------------
# Actuator 및 모니터링 설정
management:
endpoint:
health:
show-details: always
endpoints:
web:
exposure:
include: '*' # 모든 Actuator 엔드포인트 노출 (모니터링을 위해)
metrics:
enable:
resilience4j:
circuitbreaker:
calls: true
FallbackController
게이트웨이에서 delivery-service를 호출하고, 서킷 브레이커 차단(OPEN) 상태가 되었을 때의 Fallback 메서드를 아래와 같이 구성해주었다.
@RestController
public class GatewayFallbackController {
public static final String CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR = "CircuitBreakerExecutionException";
@RequestMapping("/delivery-fallback")
public ResponseEntity<Mono<ApiResponse<FallbackResponse>>> fallback(ServerWebExchange exchange) {
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
Exception exception = exchange.getAttribute(CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR);
String routeId = route != null ? route.getId() : "unknown-route";
String errorMessage = exception != null ? exception.getMessage() : "No error info";
FallbackResponse response = FallbackResponse.builder()
.statusCode(503)
.error(routeId + " : " + errorMessage)
.message("서비스 중단")
.build();
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(Mono.just(ApiResponse.error(response)));
}
}
Circuit Breaker 동작 테스트
Eureka 서버, Gateway 서비스, 인증/인가를 위한 User 서비스, 그리고 Delivery 서비스를 모두 실행한 상태에서 진행된다.
1. 요청 실패 유도 및 차단기 열기
요청 실패를 유도하려면 먼저 delivery-service가 실패하여 Gateway의 실패율 임계치(failureRateThreshold: 50)를 초과하게 만들어야 한다.
- Postman을 사용하여 Gateway 엔드포인트에 요청을 보낸다.
- 회로 차단기를 OPEN하기 위해 앞서 application.yml에서 설정한 minimumNumberOfCalls: 10 이상, slidingWindowSize: 20 내에서 연속으로 10회 이상 요청을 보낸다. (첫 10회 요청 동안은 연결 실패 발생)
- 위 요청의 실패율이 50%를 초과하면, Gateway가 Circuit Breaker를 연다.
- 11번째 요청부터는 Gateway가 delivery-service로 요청을 아예 보내지 않고, 즉시 설정한 Fallback URL로 라우팅한다.
- Actuator Health Check(http://localhost:{실행 중인 서버 포트번호}/actuator/health)를 통해 deliveryCircuitBreaker의 상태가 OPEN으로 바뀌었는지 확인
2. 서비스 복구 및 재시도
차단기가 OPEN 상태로 10초(waitDurationInOpenState: 10s)가 지나면, Gateway는 자동으로 HALF_OPEN 상태로 전환되어 서비스 복구를 시도한다.
- 서비스 복구: 정지했던 delivery-service를 다시 실행한다.
- HALF_OPEN 상태 진입: 10초가 지난 후 Actuator Health Check를 확인하면 상태가 HALF_OPEN 으로 변경되는 것을 확인할 수 있다. (Gateway가 복구를 시도할 준비가 된 상태)
- Postman으로 앞서 설정한 permittedNumberOfCallsInHalfOpenState: 3만큼 요청을 보낸다.
- 이 3개의 요청이 delivery-service로 전달되고, 정상적으로 응답한다면 Circuit Breaker는 다시 CLOSED 상태로 돌아간다.
- 3개의 요청 중 하나라도 실패하면 Circuit Breaker는 다시 OPEN 상태로 돌아가 10초를 기다린다.

결론
위 테스트 과정을 통해 Circuit Breaker가 시스템 장애를 감지하고, 차단하고, 그리고 복구하는 전체적인 흐름을 파악할 수 있었다. 그러나 현재는 외부 클라이언트로부터의 (Gateway를 통해서 들어오는 요청) 요청만 차단하고 있고, MSA 서비스 간 내부 통신(OpenFeign 통신)에 대해서는 Fallback 처리를 하지 못하였다. 이 부분은 시간적 여유가 생기는대로 팀원들과 수정해나가는 걸로..
참고
서킷 브레이커 설정 관련 공식문서
https://resilience4j.readme.io/docs/circuitbreaker
CircuitBreaker
Getting started with resilience4j-circuitbreaker
resilience4j.readme.io
'Project' 카테고리의 다른 글
| [RUSH_CREW] 2. DDD에서의 VO(Value Object) (0) | 2025.12.02 |
|---|---|
| [RUSH_CREW] 1. 타임딜 서비스 개요 (feat. DDD Bounded Context) (0) | 2025.11.26 |
| [DELIVERY_SIGNAL] 배송 담당자 순번 할당 설계/구현 (0) | 2025.11.07 |
| [DELIVERY_SIGNAL] DDD 계층 구조 적용 (0) | 2025.11.06 |
| [SURFING THE GANGWON] 기상청 API 호출 성능 최적화 (0) | 2025.10.01 |