안녕하세요. 인벤토리 스쿼드 백엔드 개발자 펭귄대장입니다!
인벤토리 스쿼드에서 장애 전파 방지를 목적으로 CircuitBreaker를 도입하게 되어 이를 소개해 보고자 합니다.
"우선 CircuitBreaker에 대해 알아보겠습니다"
CircuitBreaker란?
CircuitBreaker는 직역하면 회로 차단기로,
가정집에서 쉽게 볼 수 있는 누전 차단기가 화재를 막는 것과 비슷하게 CircuitBreaker는 서비스 간의 장애 전파를 막는 역할을 한다고 이해하면 됩니다.
CircuitBreaker는 문제가 발생한 지점을 감지하고 실패하는 요청을 계속하지 않도록 방지하며,
이를 통해 시스템의 장애 확산을 막고 장애 복구를 도와주며 유저는 불필요하게 대기하지 않게 됩니다.
아래 그림과 같이 Service A 가 Service B를 호출할 때
Service B가 반복적으로 실패한다면 CircuitBreaker를 Open 하여 Service B 에 대한 흐름을 차단하는 게 CircuitBreaker의 역할입니다.
CircuitBreaker의 3가지 상태
CircuitBreaker는 아래와 같이 세 가지 State가 있습니다.
Closed | Open | Half Open | |
---|---|---|---|
상황 | 정상 | 장애 | Open 상태가 되고 일정 요청 횟수/시간이 지난 상황. Open과 Closed 중 어떤 상태로 변경할지에 대한 판단이 이루어지는 상황 |
요청에 대한 처리 | 요청에 대한 처리 수행 정해진 횟수/비율만큼 실패할 경우 Open 상태로 변경 |
외부 요청을 차단하고 에러를 뱉거나 지정한 callback 메소드를 호출 | 요청에 대한 처리를 수행하고 실패시 Open 상태로, 성공시 Close 상태로 변경 |
⚠️ CircuitBreaker에서의 장애 판단의 기준은 아래와 같습니다.
- slow call : 기준보다 오래 걸린 요청
- failure call : 실패 혹은 오류 응답을 받은 요청
⚠️ 각 상태는 지정한 속성 값을 통해 제어할 수 있으며 아래 쪽(Resilience4j Property)에서 설명하겠습니다.
CircuitBreaker의 상태 변경
CircuitBreaker의 상태는 아래와 같이 변경됩니다.
- Closed 상태에선 정상 요청 수행
- 실패 임계치(failureRateThreshold or slowCallRateThreshold) 도달시 Closed 에서 Open 으로 상태 변경
- Open 상태에서 일정 시간(waitDurationInOpenState) 소요시 Half Open 으로 상태 변경
- Half Open 상태에서의 요청 수행
a. 지정한 횟수 (permittedNumberOfCallsInHalfOpenState 횟수만큼) 수행 후 성공 시 Half Open 상태에서 Closed 상태로 변경
b. 지정한 횟수 (permittedNumberOfCallsInHalfOpenState 횟수만큼) 수행 후 실패 시 Half Open 상태에서 Open 상태로 변경
CircuitBreaker를 지원하는 라이브러리 종류
1. Netflix Hystrix
Netflix 에서 개발한 라이브러리로 MSA 환경에서 서비스 간 통신이 원활하지 않을 경우 각 서비스가 장애 내성과 지연 내성을 갖게 하는 라이브러리
현재는 deprecated 된 상태로 Resilience4j 사용 권장
2. Resilience4j
Netflix Hystrix로 부터 영감을 받아 개발된 Fault Tolerance Library
Java 전용으로 개발된 경량 라이브러리
(Netflix Hystrix 공식 doc 에서도 Resilience4j 사용을 권장하고 있으니, 고민할 거 없이 Resilience4j를 사용했습니다.)
Resilience4j 의 코어 모듈
Resilience4j 의 코어 모듈은 아래와 같으며, 필요한 모듈을 선택하여 사용할 수 있습니다.
dependencies {
// 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}")
}
Resilience4j 모듈의 우선순위
각 모듈은 다음과 같은 우선순위로 적용됩니다. (Retry 모듈이 가장 마지막에 적용됩니다.)
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( BulkHead ( TargetFunction ) ) ) ) )
이를 알아보기 위해 resilience4j의 CircuitBreakerConfigurationProperties, RetryConfigurationProperties 클래스 내부를 살펴보면,
CircuitBreaker 와 Retry 의 Order 값이 각각 -3, -4 로
별도 처리가 없으면, CircuitBreaker 가 Retry 보다 우선으로 적용된다는 것을 알 수 있습니다.
CircuitBreakerConfigurationProperties
public class CircuitBreakerConfigurationProperties extends
io.github.resilience4j.common.circuitbreaker.configuration.CircuitBreakerConfigurationProperties {
private int circuitBreakerAspectOrder = Ordered.LOWEST_PRECEDENCE - 3;
}
RetryConfigurationProperties
public class RetryConfigurationProperties extends
io.github.resilience4j.common.retry.configuration.RetryConfigurationProperties {
private int retryAspectOrder = Ordered.LOWEST_PRECEDENCE - 4;
}
CircuitBreakerAspect
@Aspect
public class CircuitBreakerAspect implements Ordered {
@Override
public int getOrder() {
return circuitBreakerProperties.getCircuitBreakerAspectOrder();
}
}
⚠️ AOP 기반하에 동작하므로 우선순위를 바꿔서 적용하고자 할 경우
annotation 방식을 사용하여 layer 를 분리하거나 aspectOrder 속성 값을 수정하여 적용할 수 있음을 확인할 수 있습니다.
Resilience4j Property
아래 테이블은 CircuitBreaker/Retry 설정에 사용되는 속성값과 그에 대한 설명입니다.
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 |
recordFailure | 어떠한 경우에 Failure Count를 증가시킬지 Predicate를 정의해 CircuitBreaker에 대한 Exception Handler를 재정의. true를 return할 경우, failure count를 증가시키게 된다 (기본값: false) |
Retry property
property | description |
---|---|
maxRetryAttempts | 최대 재시도 수(최초 호출도 포함, 기본값 3) |
waitDuration | 재시도 할 때마다 기다리는 고정시간 (1초[1000ms], 기본값: 0.5초[500ms]) |
retryOnResultPredicate | 반환되는 결과에 따라서 retry를 할지 말지 결정하는 filter true로 반환하면 retry하고 false로 반환하면 retry 하지 않는다. (기본값: (numOfAttempts,Either |
retryExceptionPredicate | 예외(Exception)에 따라 재시도 여부를 결정하기 위한 filter 만약 예외에 따라 재시도해야 한다면 true를, 그 외엔 false를 리턴해야 한다. (기본값: result -> false) |
retryExceptions | 실패로 기록되는 블랙리스트 예외. empty일 경우 모든 에러 클래스를 재시도 (기본값: empty) |
ignoreExceptions | 무시되어야 하는 예외(화이트리스트) 즉, 재시도 되지 않아야 할 에러 클래스 리스트 (기본값: empty) |
failAfterMaxRetries | 설정한 maxAttempts 만큼 재시도하고 나서도 결과가 여전히 retryOnResultPredicate를 통과하지 못했을 때 MaxRetriesExceededException 발생을 활성화/비활성화하는 boolean (기본값: false) |
⚠️ 본 프로젝트에선 Resilience4j 의 모듈 중, CircuitBreaker와 Retry 만 사용하였습니다.
다른 모듈에 대한 속성값은 공식 doc 에서 확인할 수 있습니다.
"Resilience4j, CircuitBreaker 에 대한 사전 학습을 마쳤으니, 올리브영 재고 API 에선 어떻게 적용되었는지 간단한 코드와 함께 설명드리겠습니다"
올리브영 재고 API 에서의 Resilience4j 도입 배경
올리브영의 통합 재고 API 조회 프로세스 플로우는 위와 같습니다.
AWS memoryDB(Redis) 조회 후
Hit 일 경우 조회 결과를 Return 합니다.
Miss 일 경우 Oracle RDB를 조회하여 결과를 Redis 에 저장 후 조회 결과를 Return 합니다.
1. CircuitBreaker 미적용
Redis 서버와 통신이 불가하여 예외가 발생할 경우
서버는 Redis 액션(조회/저장) 요청마다
(redis connection wait timeout 시간 * redis connection retry 횟수) 만큼 시간을 낭비하고 그만큼 latency 는 증가하게 됩니다.
유저는 늘어난 latency 만큼 의미 없는 대기를 하게 됩니다.
2. CircuitBreaker 적용
Redis 서버와 통신이 불가하다고 판단된 경우(Circuit 이 Open state 인 경우)
서버는 Redis 액션 없이 Oracle RDB 로 바로 failover 처리되어 결과를 Return 하게 됩니다.
Redis 서버에 장애가 있어도 유저는 더 이상 무의미한 대기를 하지 않아도 됩니다.
올리브영 재고 API 에서의 CircuitBreaker + Retry 적용
Gradle
implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j:2.1.6")
implementation("org.springframework.boot:spring-boot-starter-actuator")
CircuitBreaker, Retry 모듈만 사용할 예정이지만 간편하게 starter를 사용했습니다.
AOP 기반에서 동작하므로 aop 에 대한 dependency 는 필요하나,
보통 aop 는 다른 jar 를 통해 runtime 시에 주입되는 경우가 많으므로 프로젝트 dependency 를 확인하여 주입이 되지 않는 경우 추가가 필요합니다.
actuator는 circuitbreaker 상태를 쉽게 확인할 수 있도록 health check 기능을 제공합니다.
application yml 파일
resilience4j:
circuitbreaker:
failure-rate-threshold: 10 # 실패 10% 이상 시 서킷 오픈
slow-call-duration-threshold: 500 # 500ms 이상 소요 시 실패로 간주
slow-call-rate-threshold: 10 # slowCallDurationThreshold 초과 비율이 10% 이상 시 서킷 오픈
wait-duration-in-open-state: 30000 # OPEN -> HALF-OPEN 전환 전 기다리는 시간
minimum-number-of-calls: 50 # 집계에 필요한 최소 호출 수
sliding-window-size: 100 # 서킷 CLOSE 상태에서 N회 호출 도달 시 failureRateThreshold 실패 비율 계산
permitted-number-of-calls-in-half-open-state: 30 # HALFOPEN -> CLOSE or OPEN 으로 판단하기 위해 호출 횟수
retry:
wait-duration: 100 # 재시도 사이 간격
max-attempts: 2 # 재시도 횟수(최초 호출 포함)
ConfigurationProperties 사용하여 Property 관리
@Configuration
@ConfigurationProperties(prefix = "resilience4j.circuitbreaker")
class CircuitBreakerProperty {
var failureRateThreshold: Float = Float.MIN_VALUE
var slowCallDurationThreshold: Long = Long.MIN_VALUE
var slowCallRateThreshold: Float = Float.MIN_VALUE
var waitDurationInOpenState: Long = Long.MIN_VALUE
var minimumNumberOfCalls: Int = Int.MIN_VALUE
var slidingWindowSize: Int = Int.MIN_VALUE
var permittedNumberOfCallsInHalfOpenState: Int = Int.MIN_VALUE
}
CircuitBreaker Bean 선언
@Configuration
class CircuitBreakerProvider(
val circuitBreakerProperty: CircuitBreakerProperty
) {
companion object {
const val CIRCUIT_REDIS: String = "CB_REDIS"
}
@Bean
fun circuitBreakerRegistry(): CircuitBreakerRegistry {
return CircuitBreakerRegistry.ofDefaults()
}
@Bean
fun redisCircuitBreaker(circuitBreakerRegistry: CircuitBreakerRegistry): CircuitBreaker {
return circuitBreakerRegistry.circuitBreaker(
CIRCUIT_REDIS, CircuitBreakerConfig.custom()
.failureRateThreshold(circuitBreakerProperty.failureRateThreshold)
.slowCallDurationThreshold(Duration.ofMillis(circuitBreakerProperty.slowCallDurationThreshold))
.slowCallRateThreshold(circuitBreakerProperty.slowCallRateThreshold)
.waitDurationInOpenState(Duration.ofMillis(circuitBreakerProperty.waitDurationInOpenState))
.minimumNumberOfCalls(circuitBreakerProperty.minimumNumberOfCalls)
.slidingWindowSize(circuitBreakerProperty.slidingWindowSize)
.ignoreExceptions(StockManageException::class.java) // 화이트리스트로 서킷 오픈 기준 관리
.permittedNumberOfCallsInHalfOpenState(circuitBreakerProperty.permittedNumberOfCallsInHalfOpenState)
.build()
)
}
}
failover 처리(RDB 조회)를 제외한 Redis 조회에만 Retry 를 적용시키고자 우선순위를 변경했습니다.
@Configuration
class Resilience4jOrderConfig(
val circuitBreakerConfigurationProperties: CircuitBreakerConfigurationProperties,
val retryConfigurationProperties: RetryConfigurationProperties
) {
companion object {
const val PRIORITY_1 = -3
const val PRIORITY_2 = -4
}
@PostConstruct
fun setOrder() {
circuitBreakerConfigurationProperties.circuitBreakerAspectOrder = PRIORITY_2
retryConfigurationProperties.retryAspectOrder = PRIORITY_1
}
}
⚠️ 적용 순서를 바꿔 Retry 가 CircuitBreaker 보다 우선 적용될 때의 주의사항
retry 로 인한 실패 횟수 모두 CircuitBreaker에 실패로 함께 집계되어 예상보다 failure rate 에 빨리 도달할 수 있습니다.
이를 감안하여 CircuitBreaker의 failure rate 를 설정해 주어야 합니다.
StockRepository
Redis 혹은 failover 처리된 RDB 조회 결과를 반환하는 역할을 합니다.
StockService 에선 StockRepository 에만 의존하며 관심사가 분리되었습니다.
@Repository
class StockRepository(
val redisManager: RedisManager<Stock, Key>, // Redis
val integratedStockRepository: IntegratedStockRepository // RDB
) {
/** Service layer 에서 사용할 조회 메소드 */
@Retry(name = RetryProvider.RETRY_REDIS)
@CircuitBreaker(name = CircuitBreakerProvider.CIRCUIT_REDIS, fallbackMethod = "fallbackOnFind")
fun find(storeId: String, productId: String): Stock {
return redisManager.findByKey(StockKey(storeId, productId)) // REDIS 조회
}
/** StockManageException(Redis Miss) 에 대한 fallback */
private fun fallbackOnFind(storeId: String, productId: String, e: StockManageException): Stock {
return integratedStockRepository.find(storeId = storeId, productId = productId).also {
redisManager.saveByKey(StockKey(storeId, productId), it) // REDIS 조회 후 저장
}
}
/** Circuit이 Open 상태일 때 fallback */
private fun fallbackOnFind(storeId: String, productId: String, e: CallNotPermittedException): Stock {
log.warn("circuit opened!")
return integratedStockRepository.find(storeId = storeId, productId = productId) // RDB 조회
}
/** 그외 Exception 에 대한 fallback */
private fun fallbackOnFind(storeId: String, productId: String, e: Throwable): Stock {
return integratedStockRepository.find(storeId = storeId, productId = productId) // RDB 조회
}
}
Decorator pattern으로 function에 Retry / CircuitBreaker 등을 선언할 수도 있지만, 본 프로젝트에선 annotation 방식을 사용했습니다.
⚠️ fallbackmethod
fallbackmethod 는 원래 함수의 request parameter 및 return type 이 같아야 합니다.
단, Exception 파라미터를 추가적으로 받아 원래의 함수에서 어떤 Exception 이 발생했는지 구분할 수 있습니다.
fallbackmethod 선언 시 Exception 이 다른 fallbackmethod 를 여러 개 선언함으로써(오버로딩) 예외 상황에 따른 분기처리를 할 수 있습니다.
CallNotPermittedException 는 CircuitBreaker 가 Open 되었을 때 발생하는 Exception 입니다.
"테스트 코드를 작성하여 CircuitBreaker 동작을 확인해 봅니다"
CircuitBreaker 테스트
- fallback 동작 확인
@MockBean
lateinit var redisManager: RedisManager
@MockBean
lateinit var integratedStockRepository: IntegratedStockRepository
@BeforeEach
fun setup() {
circuitBreakerService.closeCircuit(CIRCUIT_REDIS)
}
@Test
fun fallbackTest() {
val requestDto: IntegratedStocksRequestDto<IntegratedStockRequestDto.Find> =
IntegratedStocksRequestDto(listOf(IntegratedStockRequestDto.Find("sid", "pid")))
`when`(
redisManager.findByKey(StockKey("sid", "pid"))
).thenThrow(StockManageException(CustomResponseCodes.StockCustomCodes(StockResponseCodes.MISSED)))
integratedStockController.findAll(requestDto)
verify(
integratedStockRepository,
atLeast(1)
).find("sid", "pid")
}
- Circuit Open 상태에서 RDB 로 failover 확인
@Test
fun circuitOpenTest() {
val requestDto: IntegratedStocksRequestDto<IntegratedStockRequestDto.Find> =
IntegratedStocksRequestDto(listOf(IntegratedStockRequestDto.Find("sid", "pid")))
`when`(
redisManager.findByKey(StockKey("sid", "pid"))
).thenReturn(
Stock.of("sid","pid")
)
circuitBreakerService.openCircuit(CIRCUIT_REDIS) // circuit state changed to OPEN
integratedStockController.findAll(requestDto)
verify(
stockManageService,
never()
).findByKey(StockKey("sid", "pid"))
}
- Circuit Closed 상태에서 Redis 에서 조회되는지 확인
@Test
fun circuitCloseTest() {
val requestDto: IntegratedStocksRequestDto<IntegratedStockRequestDto.Find> =
IntegratedStocksRequestDto(listOf(IntegratedStockRequestDto.Find("sid", "pid")))
`when`(
redisManager.findByKey(StockKey("sid", "pid"))
).thenReturn(
Stock.of("sid","pid")
)
integratedStockController.findAll(requestDto)
verify(
stockManageService,
atLeast(1)
).findByKey(StockKey("sid", "pid"))
}
테스트 결과
Circuit 이 Open 상태이거나, 예외 발생시 RDB 에서 조회 처리가 이뤄지고 있음을 확인할 수 있습니다.
CircuitBreaker 모니터링
하기와 같이 actuator 설정을 할 경우 /api/actuator/health
를 호출하여 circuitbreaker 의 상태를 확인 할 수 있습니다.
application yml
management.endpoints.web.base-path=/api/actuator
management.endpoint.health.show-details=always
management.health.circuitbreakers.enabled= true
management.health.retry.enabled= true
resilience4j.circuitbreaker.configs.default.registerHealthIndicator= true
/api/actuator/health
호출 결과
"circuitBreakers": {
"status": "UP",
"details": {
"CB_REDIS": {
"status": "UP",
"details": {
"failureRate": "-1.0%",
"failureRateThreshold": "10.0%",
"slowCallRate": "-1.0%",
"slowCallRateThreshold": "100.0%",
"bufferedCalls": 0,
"slowCalls": 0,
"slowFailedCalls": 0,
"failedCalls": 0,
"notPermittedCalls": 0,
"state": "CLOSED"
}
}
}
}
올리브영은 Datadog 사용하여 서비스 모니터링을 하고 있습니다.
Metrics 지표(resilience4j.circuitbreakers.state
)를 circuitbreaker name을 기준으로 필터링하여 확인 할 수 있습니다.
마치며..
지금까지 올리브영 재고 API 에 적용된 Resilience4j, CircuitBreaker 에 대해 알아보았습니다.
최근 각광받는 MSA 구조에선 서비스가 작은 서비스 컴포넌트 단위로 쪼개지고
각각의 서비스가 서로 호출하고 상호 호출되는 관계를 갖게 됩니다. 이로 인해 장애 전파 방지의 필요성이 대두되고 있습니다.
올리브영 역시 기존 Monolithic 에서 MSA 로 전환하는 과정 중에 놓여 있으며,
서비스 간 장애 전파를 막기 위해 재고 API와 여러 다른 서비스에서 CircuitBreaker를 적용하고 있습니다.
모든 레거시 코드가 사라지는 그날까지, 올영 세일에 대기열이 없어지는 그날까지 최선을 다하겠습니다🔥