만약 특정 서버에서 장애가 발생했을 때, 해당 장애가 전파되고 여러 서비스에 영향을 끼친다면 어떻게 할까? 이럴 때 사용하는 것이 서킷 브레이커(CircuitBreaker)이다.
서킷 브레이커란?
단순한 개념은 다음과 같다.
서킷 브레이커는 문제가 발생할 경우 서비스의 호출을 차단하고 fallback 메서드를 이용할 수 있도록 하는 패턴으로 서비스 호출이 실패하거나 지정된 임계값을 초과할 때, 서킷 브레이커는 OPEN 상태가 되어 후속 호출을 차단하고, 시스템이 회복될 때까지 대기한다.
서킷 브레이커의 상태는 3가지로 분류 된다. (이미지 출처)
- 닫힘(Closed) 상태:
- 서킷 브레이커는 기본적으로 닫힌 상태에서 시작.(정상 상태)
- 실패율이 정해진 값을 초과하면 서킷 브레이커는 OPEN 상태로 전환된다.
- 열림(Open) 상태:
- 서킷 브레이커가 열린 상태가 되면, 모든 요청은 차단되고 즉시 실패로 처리된다.
- 반열림(Half-Open) 상태:
- 이 상태에서는 제한된 수의 요청만 허용한다.
- 실패율이 정해진 값을 넘어가면 다시 OPEN 상태로 전환된다. 반대의 경우에는 CLOSED로 전환
서킷 브레이커는 위 3가지 상태를 통해 대상이 되는 서비스 호출 결과를 Sliding Window 형태로 저장한다.
이때 방법은 두 가지가 있다. Count-based sliding window와 Time-based sliding window가 존재한다.
- Count-based sliding window
- N개만큼의 측정 결과를 저장
- 1개의 요청마다 실패율 계산 필요
- Time-based sliding window
- 최근 N초의 실패율을 계산한다.
- Count-based sliding window의 성능 문제를 개선
- 전체 대비 실패 비율을 failure rate라고 한다.
- Failure rate가 설정한 임계치에 도달하는 순간 Open 상태로 변경된다.
구현은?
크게 두 가지 정도가 있다.
1. Netflix Hystrix : Netflix 에서 개발한 라이브러리로 MSA 환경에서 서비스 간 통신이 원활하지 않을 경우 각 서비스가 장애 내성과 지연 내성을 갖게 하는 라이브러리. 현재는 deprecated 된 상태로 Resilience4j 사용 권장
2. Resilience4j : Netflix Hystrix로 부터 영감을 받아 개발된 Fault Tolerance LibraryJava 전용으로 개발된 경량 라이브러리
(Netflix Hystrix 공식 docs에서도 Resilience4j 사용을 권장하고 있다.)
Spring Cloud Circuit Breaker
Spring Cloud is released under the non-restrictive Apache 2.0 license, and follows a very standard Github development process, using Github tracker for issues and merging pull requests into master. If you want to contribute even something trivial please do
docs.spring.io
Spring cloud에서 resilience4j 를 제공한다. resilience4j는 사용하기 쉽고 가볍게 만들어진 장애허용(fault tolerance) 라이브러리이다. 내부적으로 있는 모듈은 다음과 같다.
// 1. CircuitBreaker : 장애 전파 방지 기능 제공
implementation("io.github.resilience4j:resilience4j-circuitbreaker:${resilience4jVersion}")
// 2. Retry : 요청 실패 시 재시도 처리 기능 제공
implementation("io.github.resilience4j:resilience4j-retry:${resilience4jVersion}")
// 3. RateLimiter : 제한치를 넘어서 요청을 거부하거나 Queue 생성하여 처리하는 기능 제공
implementation("io.github.resilience4j:resilience4j-ratelimiter:${resilience4jVersion}")
// 4. TimeLimiter : 실행 시간제한 설정 기능 제공
implementation("io.github.resilience4j:resilience4j-timelimiter:${resilience4jVersion}")
// 5. Bulkhead : 동시 실행 횟수 제한 기능 제공
implementation("io.github.resilience4j:resilience4j-bulkhead:${resilience4jVersion}")
// 6. Cache : 결과 캐싱 기능 제공
implementation("io.github.resilience4j:resilience4j-cache:${resilience4jVersion}")
각 모듈은 다음과 같은 우선순위로 적용됩니다. (Retry 모듈이 가장 마지막에 적용된다.)
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( BulkHead ( TargetFunction ) ) ) ) )
아래는 서킷 브레이커와 리트라이의 우선순위 Order이다. (참고)
- CircuitBreakerConfigurationProperties **: **2147483643
- RetryConfigurationProperties: 2147483642
CircuitBreaker property는 다음과 같다.
property description
failureRateThreshold | 실패 비율 임계치를 백분율로 설정 해당 값을 넘어갈 시 Circuit Breaker 는 Open 상태로 전환되며, 이때부터 호출을 차단한다 (기본값: 50) |
slowCallRateThreshold | 임계값을 백분율로 설정, CircuitBreaker는 호출에 걸리는 시간이 slowCallDurationThreshold보다 길면 느린 호출로 간주,해당 값을 넘어갈 시 Circuit Breaker 는 Open상태로 전환되며, 이때부터 호출을 차단한다 (기본값: 100) |
slowCallDurationThreshold | 호출에 소요되는 시간이 설정한 임계치보다 길면 느린 호출로 계산.응답시간이 느린 것으로 판단할 기준 시간 (60초, 1000 ms = 1 sec) (기본값: 60000[ms]) |
permittedNumberOfCallsInHalfOpenState | HALF_OPEN 상태일 때, OPEN/CLOSE 여부를 판단하기 위해 허용할 호출 횟수를 설정 수 (기본값: 10) |
maxWaitDurationInHalfOpenState | HALF_OPEN 상태로 있을 수 있는 최대 시간이다. 0일 때 허용 횟수만큼 호출을 모두 완료할 때까지 HALF_OEPN 상태로 무한정 기다린다. (기본값: 0) |
slidingWindowType | sliding window 타입을 결정한다. COUNT_BASED인 경우 slidingWindowSize 만큼의 마지막 call들이 기록되고 집계된다.TIME_BASED인 경우 마지막 slidingWindowSize초 동안의 call들이 기록되고 집계됩니다. (기본값: COUNT_BASED) |
slidingWindowSize | CLOSED 상태에서 집계되는 슬라이딩 윈도우 크기를 설정한다. (기본값: 100) |
minimumNumberOfCalls | minimumNumberOfCalls 이상의 요청이 있을 때부터 faiure/slowCall rate를 계산.예를 들어, 해당 값이 10이라면 최소한 호출을 10번을 기록해야 실패 비율을 계산할 수 있다.기록한 호출 횟수가 9번뿐이라면 9번 모두 실패했더라도 circuitbreaker는 열리지 않는다. (기본값: 100) |
waitDurationInOpenState | OPEN에서 HALF_OPEN 상태로 전환하기 전 기다리는 시간 (60초, 1000 ms = 1 sec) (기본값: 60000[ms]) |
recordExceptions | 실패로 기록할 Exception 리스트 (기본값: empty) |
ignoreExceptions | 실패나 성공으로 기록하지 않을 Exception 리스트 (기본값: empty) |
ignoreException | 기록하지 않을 Exception을 판단하는 Predicate을 설정 (커스터마이징, 기본값: throwable -> true) |
recordFailure | 어떠한 경우에 Failure Count를 증가시킬지 Predicate를 정의해 CircuitBreaker에 대한 Exception Handler를 재정의.true를 return할 경우, failure count를 증가시키게 된다 (기본값: false) |
다른 속성이나 모듈에 대한 값들은 아래 Docs에서 확인 가능하다. (필자는 가정 사항을 예시로 들어 서킷 브레이커만 사용)
resilience4j
resilience4j.readme.io
실제 구현 및 사용 하는 이유
간단하게 예시 상황을 가정해서 실제 resilience4j를 사용해 보았다.
설정은 아래와 같다. 슬라이딩 윈도우의 크기를 10개로 설정하고 CountBase로 설정했다. 실패율 임계값은 50으로 잡았으며, half-open 일 때 허용되는 호출 수는 5개로 잡았다.
resilience4j:
circuitbreaker:
configs:
default:
slidingWindowType: COUNT_BASED # 작동 방식
slidingWindowSize: 10 # 슬라이딩 윈도우 크기
failureRateThreshold: 50 # 실패율 임계값
permittedNumberOfCallsInHalfOpenState: 5 # half-open 상태에서 허용되는 호출 수
registerHealthIndicator: true # health indicator 등록 여부
management:
endpoints:
web:
exposure:
include:
- "*" # 테스트를 위해 actuator 전체 노출
health:
circuitbreakers:
enabled: true # circuitbreakers 정보 노출
간단하게 생각하여 자동차 서비스가 있다고 가정하자. 이는 특정 자동차의 정보를 제공해 주는 것이며, 서비스 로직은 특정 Inmemory DB에서 자동차의 정보를 가져온다고 가정한 상황이다. Inmemory DB가 다운되면, failover 되는 시간을 기다리지 않고 바로 RDB로 처리하도록 한다.
// Controller
@GetMapping("/cars/{id}/info")
fun carInfo(@PathVariable id: String): String {
return carService.getCarInfo(id)
}
// In Service
@CircuitBreaker(name = "car-info-circuit-breaker", fallbackMethod = "fallbackGetCarInfo")
fun getCarInfo(id: String): String {
// Redis에서 조회한다고 가정
return when (id) {
"ABC" -> "Redis에서 가져온 $id 차량 정보"
"XYZ" -> "Redis에서 가져온 $id 차량 정보"
else -> throw RuntimeException("Redis에서 $id 차량 정보를 찾을 수 없습니다")
}
}
fun fallbackGetCarInfo(id: String, t: Throwable): String {
// RDB에서 조회한다고 가정
return "RDB에서 가져온 $id 차량의 대체 정보"
}
이제 테스트를 해보자!
curl -X GET <http://localhost:8080/cars/ABC/info>
return Response : Redis에서 가져온 ABC 차량 정보
curl -X GET <http://localhost:8080/cars/ABC/info>
return Response : RDB에서 가져온 1 차량의 대체 정보
10번의 요청 중 9번의 실패 요청을 보내고 확인 해본 결과
{
"circuitBreakers": {
"car-info-circuit-breaker": {
"failureRate": "90.0%",
"slowCallRate": "0.0%",
"failureRateThreshold": "50.0%",
"slowCallRateThreshold": "100.0%",
"bufferedCalls": 10,
"failedCalls": 9,
"slowCalls": 0,
"slowFailedCalls": 0,
"notPermittedCalls": 0,
"state": "OPEN"
}
}
전체 10회 요청 중 마지막 1회를 제외한 나머지 9회 요청이 모두 실패했기 때문에 실패율이 90% 에 달한다. 이 때문에 circuit breaker 가 OPEN 상태로 전환되었다.
여기서 정상 요청을 보내면 어떻게 될까?
curl -X GET <http://localhost:8080/cars/ABC/info>
return Response : RDB에서 가져온 ABC 차량 정보
정상적인 요청을 보냈음에도 circuit breaker는 해당 메서드가 정상적으로 요청을 처리할 수 없다고 판단하고, 무조건 fallback 메서드로 응답을 처리한다.
레디스가 복구되었다고 가정하고, 정상 응답을 보내보자.
curl -X GET <http://localhost:8080/cars/ABC/info>
return Response : Redis에서 가져온 ABC 차량 정보
{
"circuitBreakers": {
"car-info-circuit-breaker": {
"failureRate": "-1.0%",
"slowCallRate": "-1.0%",
"failureRateThreshold": "50.0%",
"slowCallRateThreshold": "100.0%",
"bufferedCalls": 3,
"failedCalls": 0,
"slowCalls": 0,
"slowFailedCalls": 0,
"notPermittedCalls": 0,
"state": "HALF_OPEN"
}
}
}
서킷 브레이커는 자동으로 Open 상태가 되면, 기본 동작에 의해 60초가 지나면 Half-Open 상태로 바뀐다. 여기서 설정한 실패 임계값보다 성공한 횟수가 많으면 다시 CLOSED 상태로 전환된다.
그럼 과연 CircuitBreaker에서 시간 기반의 자동 전환 메커니즘을 컨트롤할 순 없을까?
결론적으로는 직접적으로 제어하기는 어렵다. 하지만 모두 방법은 있다.
- Custom HealthIndicator를 사용해서 제어.
⇒ HealthIndicator를 구현하여 특정 컨디션을 체킹 하여 헬스체킹 시도. - CircuitBreakerRegistry를 활용한 제어.
⇒ 서킷 브레이커의 State를 확인하여, 특정 컨디션 체킹 로직을 통해 서킷 브레이커의 상태를 컨트롤
위 같은 방법들이 존재한다. 그렇기 때문에 잘 활용한다면 견고한 서킷 브레이커를 적절히 적용할 수 있다.
Circuit Breaker 패턴은 MSA 환경에서 시스템의 안정성과 복원력을 보장하는 핵심적인 패턴으로 사용되기도 한다. 다음과 같은 상황에서 효과적으로 활용될 수 있다
- 결제 서비스가 외부 PG사 API 호출 시 장애가 발생하면, Circuit Breaker를 통해 임시 대체 결제 프로세스로 전환
- 재고 관리 서비스가 불안정할 때, Circuit Breaker로 감지하여 RDB의 재고 정보를 제공하는 fallback 로직 실행
- 높은 부하 상황에서 특정 마이크로서비스가 응답 지연될 때, Circuit Breaker가 빠르게 대체 응답 제공
- 데이터베이스 과부하 시, Circuit Breaker를 통해 읽기 작업을 캐시 또는 레플리카로 우회
- 상품 추천 서비스 장애 시, Circuit Breaker가 미리 계산된 기본 추천 목록을 제공
- 실시간 가격 계산 서비스 장애 시, 최근 캐시된 가격 정보로 대체하여 주문 프로세스 유지
여러 블로그를 살펴보거나 고민해본다면 위 같은 예시들에서 서킷 브레이커를 사용할 수 있다.
위에서 사용한 예제 코드는 아래 레포에서 확인해볼 수 있다.
https://github.com/LeeJejune/Spring-Studying/tree/main/spring-circuitbreaker-resilience4j
Spring-Studying/spring-circuitbreaker-resilience4j at main · LeeJejune/Spring-Studying
🍃 스,,,스ㅡ,,,스프링,,! Contribute to LeeJejune/Spring-Studying development by creating an account on GitHub.
github.com
'서버 개발(생각과 구현)' 카테고리의 다른 글
외부 API와 트랜잭션의 관계를 조심하자 (0) | 2024.12.23 |
---|---|
락의 필요성과 실제 사용을 위한 분산 락, 낙관적 락, 비관적 락 탐구 (0) | 2024.12.22 |
Spring Retry(재시도)가 필요한 경우 (0) | 2024.12.20 |
외부 API 호출을 위한 OpenFeign, RestClient, WebClient들의 차이점 (0) | 2024.12.20 |
Kotest를 활용한 유닛 테스트 구성 (0) | 2024.12.19 |