| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 트랜잭션
- mysql
- redis
- NIO
- 데이터베이스
- GitOps
- selector
- spring boot
- Kubernetes
- docker
- grafana
- monitoring
- RDBMS
- 백엔드개발
- JPA
- 성능최적화
- netty
- Kotlin
- jvm
- helm
- DevOps
- 동시성제어
- prometheus
- CloudNative
- webflux
- kafka
- SpringBoot
- Java
- 성능 최적화
- 백엔드
- Today
- Total
유성
서버의 생존 전략: Circuit Breaker로 장애 전파 방지하기 본문
서버 운영 중에는 서버가 요청을 처리할 수 있는 여력이 있음에도, 의도적으로 요청을 차단해야 하는 순간이 있다.
이는 특정 서비스의 문제가 전체 시스템으로 번지는 '장애 전파'를 막가 위한 필수적인 선택이다.
1. 왜 요청을 차단해야 할까? (장애 전파의 위험성)
다음과 같은 서비스 구조를 가정해보자.
- A 서비스: 사용자 요청을 직접 받는 게이트웨이 역할 (리소스 풍부)
- B 서비스: 비즈니스 로직 처리 및 DB와 연결된 서비스
- DB: 현재 리소스 과부하 상태
정상적인 요청 흐름: 사용자 -> A 서비스 -> B 서비스 -> DB
장애 발생 시나리오:
- DB 부하 발생: DB응답이 느려진다.
- B 서비스 병목: B 서비스는 DB응답을 기다리다 설정된 타임아웃(Timeout)에 걸리거나, 커넥션 풀이 고갈된다.
- A 서비스 장애 전파: A 서비스 역시 B 서비스의 응답을 기다리며 스레드가 점유되고, 결국 전체 시스템이 마비된다.
- 악순환: 사용자는 응답이 없으니 계속 새로고침을 누르고, 부하는 눈덩이처럼 불어난다.
이때 가장 현명한 방법은 A서비스에서 요청을 미리 차단하여 DB가 회복할 시간을 벌어다 주는것이다.
2. Spring Boot 3.5 & Resilience4j를 이용한 구현
Spring Boot 환경에서 가장 대중적으로 사용되는 Resilience4j를 사용하여 서킷 브레이커를 구현해보자.
의존성 설정
build.gradle 설정
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
implementation 'org.springframework.boot:spring-boot-starter-aop'
서킷 브레이커의 동작 방식을 결정하는 설정
application.yml 설정
resilience4j:
circuitbreaker:
instances:
orderServiceCircuitBreaker:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED # 최근 N개의 호출을 기준으로 판단
slidingWindowSize: 4 # 4개의 호출을 통계에 사용
failureRateThreshold: 50 # 에러율이 50% 이상이면 서킷 Open
minimumNumberOfCalls: 3 # 최소 3번은 호출한 뒤에 통계 계산 시작
waitDurationInOpenState: 5000ms # Open 상태에서 5초 대기 후 Half-open으로 전환
permittedNumberOfCallsInHalfOpenState: 2 # Half-open 상태에서 시도할 호출 횟수
slowCallRateThreshold: 50 # 느린 호출 비율 임계치
slowCallDurationThreshold: 2000ms # 2초 이상 걸리면 느린 호출로 간주
@CircuitBreaker 어노테이션을 사용하여 장애 발생 시 fallback 메서드가 실행되도록 구성
// B 서비스로 요청을 가정한 클래스
@Slf4j
@Component
public class OrderClient {
int i = 0;
@CircuitBreaker(name = "orderServiceCircuitBreaker", fallbackMethod = "fallback")
public String order() {
log.info("[Request] 주문 시도");
if (i++ > 3) {
throw new RuntimeException("주문 시스템 장애 발생");
}
log.info("주문 성공");
return "주문 완료";
}
public String fallback(Throwable t) {
if (t instanceof CallNotPermittedException) {
log.error("서킷 브레이커가 열려서 호출이 차단된 경우");
} else if (t instanceof RuntimeException) {
log.error("주문 에러가 발생한 경우");
}
return "주문 시스템에 접속할 수 없습니다. 잠시 후 다시 시도해 주세요.";
}
}
3. 동작 원리 상세 설명
설정된 서킷 브레이커는 다음과 같은 상태 변화를 거치며 시스템을 보호한다.
- CLOSED (정상)
- 모든 요청이 정상적으로 전달된다.
- slidingWindowSize 만큼의 호출을 지켜보며 실패율을 계산한다.
- 위 설정에서는 4번의 호출 중 50%(2번) 이상 실패하면 서킷이 열린다.
- OPEN (차단)
- 실패율이 임계치를 넘으면 즉시 OPEN 상태가 된다.
- 이 상태에서는 원래의 order() 메서드를 호출하지 않고, 즉시 fallback 메서드를 실행한다.(Fail-Fast)
- 부하가 걸린 하위 시스템(B 서비스, DB)은 이 시간 동안 추가 요청을 받지 않고 회복할 시간을 갖는다.
- HALF-OPEN (시도)
- waitDurationInOpenState 5초가 지나면 HALF-OPEN 상태로 전환된다.
- 다시 요청을 조금씩 보내보며 시스템이 정상화 되었는지 확인한다.
- 이때 요청이 성공하면 다시 CLOSED로, 여전히 실패하면 다시 OPEN 상태로 돌아간다.
4. 처리 결과
로직은 4회 성공 이후 실패가 계속되고, 2번 실패 이후 CircuitBreaker가 가동되도록 세팅되어있다.

로그를 확인해보면 정확히 4번 성공 후, 2번은 RuntimeException이 발생하고
이후 요청은 메서드 실행을 하기 전에 CallNotPermittedException이 발생하는 것을 확인할 수 있다.
서킷 브레이커가 가동되는 순간에 주문 로직 자체를 시도하지 않음으로써 장애 전파을 앞단에서 막아버리는 것이다.

서킷 브레이커가 처음 가동된 후 액츄에이터를 보면 OPEN 상태로 요청을 모두 차단하도록 세팅되어있는 것을 볼 수 있다.
잠시 기다렸다가 요청을 다시 보내보면

HALF_OPEN 상태로 전환이 되고, 해당 시점은 요청을 보내도 괜찮은지 눈치를 보는 과정이다.
이 때 요청이 실패하면 다시 OPEN으로, 성공하면 CLOSED로 전환된다.
5. 모니터링
resilience4j는 actuator에서 항목을 확인할 수 있고, 시간 기반 DB에 값을 저장해 사용해볼 수 있다.
resilience4j_circuitbreaker_state{application="${spring.application.name}",group="none",name="orderServiceCircuitBreaker",state="open"} 1.0
6. 마치며
서비스 최적화 이후에도 예상치 못한 트래픽 폭주로 부하가 발생하는 경우는 언제든 존재한다.
물론 스케일 업이나 스케일 아웃을 통해 물리적인 자원을 늘려 장애를 미리 방지하는 것이 가장 이상적이다.
하지만 실제 운영 환경에서는 다음과 같은 이유로 서킷 브레이커와 같은 소프트웨어 차원의 방어 기제가 반드시 병행되어야 한다.
인프라 확장은 만능이 아니다.
- 반응 속도의 한계: 트래픽이 급증하는 순간 서버가 새로 뜨고 로드밸런서에 붙기까지는 물리적인 시간이 소요된다. 서킷 브레이커는 그 찰나의 순간에 시스템이 완전히 무너지는 것을 막아주는 최후의 보루 역할을 한다.
- 비용 효율성: 무한정 자원을 늘리는 것은 막대한 비용을 발생시킨다. 적절한 수준의 인프라를 유지하면서, 임계치를 넘는 요청에 대해서는 안전하게 차단하는것이 경제적이다.
우아한 성능 저하
요청을 단순히 막는다는 것을 넘어, fallback을 통해 "지연되고있습니다."와 같은 메시지를 전달하거나, 대체 데이터를 보여줌으로써 사용자 경험의 완전한 단절을 막는 것에 있다.
복원력을 갖춘 아키텍처로 진화
결국 견고한 시스템이란 '장애가 나지 않는 시스템' 이 아니라 '장애 상황에서도 빠르게 회복할 수 있는 시스템' 이다.
서버가 요청을 수용할 수 있는지만 고민할 것이 아니라, 어떻게 하면 시스템을 품격 있게 실패하게 할것인가를 끊임없이 고민해야 한다.
'Spring' 카테고리의 다른 글
| JPA인 줄 알았는데 MyBatis였다? R2DBC 도입 시 겪게 될 당혹감 (0) | 2025.12.24 |
|---|---|
| 왜 Request Body는 컨트롤러에 도달하지 못했나? (feat. Sitemesh & Multipart) (0) | 2025.12.19 |
| WebFlux의 기본 개념 (Event Loop, Lazy Evaluation, publish-subscribe) (3) | 2025.07.01 |
| [중요!] @Transactional(AOP)가 동작하지 않는 이유 this.update(); (0) | 2024.10.31 |
| 스프링 의존성 주입(DI) (2) | 2024.10.30 |