최근 Kotlin과 SpringBoot, MSA를 활용한 프로젝트를 진행하고 있다.
MSA는 장점이 많은 아키텍쳐이지만, 해결해야 할 문제도 있다.
그 중 이번 글에선 장애 전파를 막는 방법, 그 중에서도 서킷 브레이커에 대한 실습을 정리해본다.
(코드는 https://github.com/HoyeongJeon/spring-circuit 에서 볼 수 있다.)
개념 정리
1. 장애 전파를 막는 것이 왜 중요할까?
MSA 환경에선 각 서비스가 독립적으로 운영된다.
그러다 보니 다른 서비스에 필요한 데이터가 있을 경우, 요청을 보내 데이터를 얻어온다.
여기서 문제는 다른 서비스에 장애가 생겼을 경우다.
이런 경우, 정상 서비스는 지속적인 에러 응답을 받게 된다.
이러면, B 서비스에서 발생한 장애가 A 서비스에도 전파 된다.
서비스의 안정성을 높이기 위해선 장애가 전파되는 것을 막아야 한다.
MSA 환경에서 장애 전파를 막기 위한 방법으로 크게 다음 3가지가 존재한다.
- Circuit Breaker
- Bulkhead
- Timeout
이번 글에선 위 방법 중 Circuit Breaker 패턴을 사용하여 장애 전파를 막는 방법에 대해 알아보도록 한다.
2. Circuit Breaker 패턴이란?
- Circuit Breaker 패턴은 장애 전파를 막기 위한 패턴 중 하나로, 장애가 발생한 서비스에 대한 요청을 일정 시간 동안 차단하는 방식을 말한다.

- Circuit Breaker 패턴은 다음과 같은 3가지 상태를 가진다.
- Closed: 서비스가 정상적으로 동작하는 상태
- Open: Circuit Breaker가 열린 상태로, 서비스가 장애 상태인 경우. 이 상태에서는 요청을 차단한다.
- Half-Open: Circuit Breaker가 열린 상태에서 일정 시간이 지나면 Half-Open 상태로 전환된다. 이 상태에서는 일부 요청을 허용하여 서비스가 정상적으로 동작하는지 확인한다.

Circuit Breaker에 관한 자세한 설명은 공식 문서에 잘 설명이 되어 있으므로, 링크를 보면 된다.
https://resilience4j.readme.io/docs/circuitbreaker
CircuitBreaker
Getting started with resilience4j-circuitbreaker
resilience4j.readme.io
실습
1. Circuit Breaker 패턴 구현하기
Circuit Breaker 패턴을 구현하기 위해 다음과 같은 라이브러리를 사용했다.
- Spring Cloud Circuit Breaker
- Resilience4j
Circuit Breaker 패턴을 구현하는 방법으로 AOP, Registry Pattern, Proxy Pattern 등이 있다. 이번 실습에서는 AOP와 Registry Pattern을 사용하여 Circuit Breaker 패턴을 구현해보도록 한다.
Circuit Breaker 패턴을 구현하기 위해 다음과 같은 라이브러리를 사용했다.
- Spring Cloud Circuit Breaker
- Resilience4j
Circuit Breaker 패턴을 구현하는 방법으로 AOP, Registry Pattern, Proxy Pattern 등이 있다. 이번 실습에서는 AOP와 Registry Pattern을 사용하여 Circuit Breaker 패턴을 구현해보도록 한다
AOP 방식을 사용하기
application.yml
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED
failureRateThreshold: 15 // Circuit Breaker 가 열리는 기준
minimumNumberOfCalls: 5 // Circuit Breaker 가 열리기 전 최소 호출 횟수
slidingWindowSize: 50 // Circuit Breaker 가 열리기 전 호출 횟수
permittedNumberOfCallsInHalfOpenState: 5
recordExceptions:
- org.springframework.web.client.HttpServerErrorException // Circuit Breaker 가 열리는 예외(500 에러)
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException // Circuit Breaker 가 열리지 않는 예외(400 에러)
instances:
circuitBreaker:
baseConfig: default
이번 실습에선 500 에러를 Circuit Breaker가 열리는 기준으로 삼았기에, 500 대 에러를 기록하고, 그 외 에러는 Circuit Breaker 가 열리는 기준에서 제외했다.
CircuitBreakerService.kt
@Service
class CircuitBreakerService(
private val webClient: WebClient,
) {
companion object {
private val logger = LoggerFactory.getLogger(this::class.java)
}
var successCount = AtomicInteger(0)
var fourxxCount = AtomicInteger(0)
var fivexxCount = AtomicInteger(0)
var callNotPermittedCount = AtomicInteger(0)
@CircuitBreaker(name = "circuitBreaker", fallbackMethod = "fallback")
fun call(): Mono<String> {
return webClient.get()
.uri("/api/random-error")
.retrieve()
.onStatus({ it.is2xxSuccessful }) {
successCount.incrementAndGet()
logger.info("정상 응답")
Mono.empty()
}
.onStatus({ it.is4xxClientError }) { response ->
Mono.error(HttpClientErrorException(response.statusCode()))
}
.onStatus({ it.is5xxServerError }) { response ->
Mono.error(HttpServerErrorException(response.statusCode()))
}
.bodyToMono(String::class.java)
.defaultIfEmpty("Request가 정상적으로 처리되었습니다.")
}
fun fallback(e: Throwable): Mono<String> {
return when (e) {
is CallNotPermittedException -> {
callNotPermittedCount.incrementAndGet()
logger.info("CircuitBreaker가 열렸습니다.")
Mono.just("CircuitBreaker가 열렸습니다.")
}
is HttpClientErrorException -> {
fourxxCount.incrementAndGet()
logger.warn("4xx 에러")
Mono.just("4xx 에러")
}
is HttpServerErrorException -> {
fivexxCount.incrementAndGet()
logger.error("5xx 에러")
Mono.just("5xx 에러")
}
else -> Mono.just("기타 에러")
}
}
}
failureRateThreshold에 따른 Circuit Breaker 동작
15%


(Circuit Breaker 가 열리기 전 50개의 로그를 검사한 결과, 8번의 500 에러가 발생했으므로 실패율은 16%이다.)
임계점을 넘기니 Circuit Breaker가 열리고, 응답이 Circuit Breaker가 열렸다는 메시지로 변경된다.
(CallNotPermittedException이 발생)
20%

25%

Registry 방식을 사용하기
CircuitBreakerConfiguration.kt
@Configuration
class CircuitBreakerConfiguration() {
private fun circuitBreakerConfig(): CircuitBreakerConfig {
return CircuitBreakerConfig.custom()
.failureRateThreshold(15.0f) // circuit 작동 임계값 : 15%, 20%, 25%
.minimumNumberOfCalls(10) // circuit 작동 최소 호출 횟수
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // circuit 작동 윈도우 타입
.slidingWindowSize(1000) // circuit 작동 윈도우 크기
.recordExceptions(HttpServerErrorException::class.java) // 500 에러는 Circuit Breaker로 처리
.ignoreExceptions(
HttpClientErrorException::class.java, // 400 에러는 Circuit Breaker로 처리하지 않음
)
.build()
}
@Bean
fun circuitBreakerRegistry(): CircuitBreakerRegistry {
return CircuitBreakerRegistry.of(circuitBreakerConfig())
}
}
CircuitBreakerService.kt
@Service
class CircuitBreakerService(
private val webClient: WebClient,
private val circuitBreakerRegistry: CircuitBreakerRegistry
) {
var successCount = AtomicInteger(0)
var fourxxCount = AtomicInteger(0)
var fivexxCount = AtomicInteger(0)
var callNotPermittedCount = AtomicInteger(0)
fun call(): Mono<String> {
val circuitBreaker = circuitBreakerRegistry.circuitBreaker("circuit-breaker")
return webClient.get()
.uri("/api/random-error")
.retrieve()
.onStatus({ it.is2xxSuccessful }) {
successCount.incrementAndGet()
Mono.empty()
}
.onStatus({ it.is4xxClientError }) { response ->
fourxxCount.incrementAndGet()
Mono.error(HttpClientErrorException(response.statusCode()))
}
.onStatus({ it.is5xxServerError }) { response ->
fivexxCount.incrementAndGet()
Mono.error(HttpServerErrorException(response.statusCode()))
}
.bodyToMono(String::class.java)
.transform(CircuitBreakerOperator.of(circuitBreaker))
.onErrorResume(this::fallback)
}
fun fallback(e: Throwable): Mono<String> {
return when (e) {
is CallNotPermittedException -> {
callNotPermittedCount.incrementAndGet()
Mono.just("CircuitBreaker가 열렸습니다.")
}
is HttpServerErrorException -> Mono.just("5xx 에러")
is HttpClientErrorException -> Mono.just("4xx 에러")
else -> Mono.just("기타 에러")
}
}
}
failureRateThreshold에 따른 Circuit Breaker 동작
15%


(Circuit Breaker 가 열리기 전 52개의 로그를 검사한 결과, 8번의 500 에러가 발생했으므로 실패율은 15.38%이다.)
임계점을 넘기니 Circuit Breaker가 열리고, 응답이 Circuit Breaker가 열렸다는 메시지로 변경된다.
(CallNotPermittedException이 발생)
20%

실패율이 20%를 넘기지 않아 Circuit Breaker가 열리지 않았다.
25%

실패율이 25%를 넘겨 Circuit Breaker가 열렸다.
최근 Kotlin과 SpringBoot, MSA를 활용한 프로젝트를 진행하고 있다.
MSA는 장점이 많은 아키텍쳐이지만, 해결해야 할 문제도 있다.
그 중 이번 글에선 장애 전파를 막는 방법, 그 중에서도 서킷 브레이커에 대한 실습을 정리해본다.
(코드는 https://github.com/HoyeongJeon/spring-circuit 에서 볼 수 있다.)
개념 정리
1. 장애 전파를 막는 것이 왜 중요할까?
MSA 환경에선 각 서비스가 독립적으로 운영된다.
그러다 보니 다른 서비스에 필요한 데이터가 있을 경우, 요청을 보내 데이터를 얻어온다.
여기서 문제는 다른 서비스에 장애가 생겼을 경우다.
이런 경우, 정상 서비스는 지속적인 에러 응답을 받게 된다.
이러면, B 서비스에서 발생한 장애가 A 서비스에도 전파 된다.
서비스의 안정성을 높이기 위해선 장애가 전파되는 것을 막아야 한다.
MSA 환경에서 장애 전파를 막기 위한 방법으로 크게 다음 3가지가 존재한다.
- Circuit Breaker
- Bulkhead
- Timeout
이번 글에선 위 방법 중 Circuit Breaker 패턴을 사용하여 장애 전파를 막는 방법에 대해 알아보도록 한다.
2. Circuit Breaker 패턴이란?
- Circuit Breaker 패턴은 장애 전파를 막기 위한 패턴 중 하나로, 장애가 발생한 서비스에 대한 요청을 일정 시간 동안 차단하는 방식을 말한다.

- Circuit Breaker 패턴은 다음과 같은 3가지 상태를 가진다.
- Closed: 서비스가 정상적으로 동작하는 상태
- Open: Circuit Breaker가 열린 상태로, 서비스가 장애 상태인 경우. 이 상태에서는 요청을 차단한다.
- Half-Open: Circuit Breaker가 열린 상태에서 일정 시간이 지나면 Half-Open 상태로 전환된다. 이 상태에서는 일부 요청을 허용하여 서비스가 정상적으로 동작하는지 확인한다.

Circuit Breaker에 관한 자세한 설명은 공식 문서에 잘 설명이 되어 있으므로, 링크를 보면 된다.
https://resilience4j.readme.io/docs/circuitbreaker
CircuitBreaker
Getting started with resilience4j-circuitbreaker
resilience4j.readme.io
실습
1. Circuit Breaker 패턴 구현하기
Circuit Breaker 패턴을 구현하기 위해 다음과 같은 라이브러리를 사용했다.
- Spring Cloud Circuit Breaker
- Resilience4j
Circuit Breaker 패턴을 구현하는 방법으로 AOP, Registry Pattern, Proxy Pattern 등이 있다. 이번 실습에서는 AOP와 Registry Pattern을 사용하여 Circuit Breaker 패턴을 구현해보도록 한다.
Circuit Breaker 패턴을 구현하기 위해 다음과 같은 라이브러리를 사용했다.
- Spring Cloud Circuit Breaker
- Resilience4j
Circuit Breaker 패턴을 구현하는 방법으로 AOP, Registry Pattern, Proxy Pattern 등이 있다. 이번 실습에서는 AOP와 Registry Pattern을 사용하여 Circuit Breaker 패턴을 구현해보도록 한다
AOP 방식을 사용하기
application.yml
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowType: COUNT_BASED
failureRateThreshold: 15 // Circuit Breaker 가 열리는 기준
minimumNumberOfCalls: 5 // Circuit Breaker 가 열리기 전 최소 호출 횟수
slidingWindowSize: 50 // Circuit Breaker 가 열리기 전 호출 횟수
permittedNumberOfCallsInHalfOpenState: 5
recordExceptions:
- org.springframework.web.client.HttpServerErrorException // Circuit Breaker 가 열리는 예외(500 에러)
ignoreExceptions:
- org.springframework.web.client.HttpClientErrorException // Circuit Breaker 가 열리지 않는 예외(400 에러)
instances:
circuitBreaker:
baseConfig: default
이번 실습에선 500 에러를 Circuit Breaker가 열리는 기준으로 삼았기에, 500 대 에러를 기록하고, 그 외 에러는 Circuit Breaker 가 열리는 기준에서 제외했다.
CircuitBreakerService.kt
@Service
class CircuitBreakerService(
private val webClient: WebClient,
) {
companion object {
private val logger = LoggerFactory.getLogger(this::class.java)
}
var successCount = AtomicInteger(0)
var fourxxCount = AtomicInteger(0)
var fivexxCount = AtomicInteger(0)
var callNotPermittedCount = AtomicInteger(0)
@CircuitBreaker(name = "circuitBreaker", fallbackMethod = "fallback")
fun call(): Mono<String> {
return webClient.get()
.uri("/api/random-error")
.retrieve()
.onStatus({ it.is2xxSuccessful }) {
successCount.incrementAndGet()
logger.info("정상 응답")
Mono.empty()
}
.onStatus({ it.is4xxClientError }) { response ->
Mono.error(HttpClientErrorException(response.statusCode()))
}
.onStatus({ it.is5xxServerError }) { response ->
Mono.error(HttpServerErrorException(response.statusCode()))
}
.bodyToMono(String::class.java)
.defaultIfEmpty("Request가 정상적으로 처리되었습니다.")
}
fun fallback(e: Throwable): Mono<String> {
return when (e) {
is CallNotPermittedException -> {
callNotPermittedCount.incrementAndGet()
logger.info("CircuitBreaker가 열렸습니다.")
Mono.just("CircuitBreaker가 열렸습니다.")
}
is HttpClientErrorException -> {
fourxxCount.incrementAndGet()
logger.warn("4xx 에러")
Mono.just("4xx 에러")
}
is HttpServerErrorException -> {
fivexxCount.incrementAndGet()
logger.error("5xx 에러")
Mono.just("5xx 에러")
}
else -> Mono.just("기타 에러")
}
}
}
failureRateThreshold에 따른 Circuit Breaker 동작
15%


(Circuit Breaker 가 열리기 전 50개의 로그를 검사한 결과, 8번의 500 에러가 발생했으므로 실패율은 16%이다.)
임계점을 넘기니 Circuit Breaker가 열리고, 응답이 Circuit Breaker가 열렸다는 메시지로 변경된다.
(CallNotPermittedException이 발생)
20%

25%

Registry 방식을 사용하기
CircuitBreakerConfiguration.kt
@Configuration
class CircuitBreakerConfiguration() {
private fun circuitBreakerConfig(): CircuitBreakerConfig {
return CircuitBreakerConfig.custom()
.failureRateThreshold(15.0f) // circuit 작동 임계값 : 15%, 20%, 25%
.minimumNumberOfCalls(10) // circuit 작동 최소 호출 횟수
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED) // circuit 작동 윈도우 타입
.slidingWindowSize(1000) // circuit 작동 윈도우 크기
.recordExceptions(HttpServerErrorException::class.java) // 500 에러는 Circuit Breaker로 처리
.ignoreExceptions(
HttpClientErrorException::class.java, // 400 에러는 Circuit Breaker로 처리하지 않음
)
.build()
}
@Bean
fun circuitBreakerRegistry(): CircuitBreakerRegistry {
return CircuitBreakerRegistry.of(circuitBreakerConfig())
}
}
CircuitBreakerService.kt
@Service
class CircuitBreakerService(
private val webClient: WebClient,
private val circuitBreakerRegistry: CircuitBreakerRegistry
) {
var successCount = AtomicInteger(0)
var fourxxCount = AtomicInteger(0)
var fivexxCount = AtomicInteger(0)
var callNotPermittedCount = AtomicInteger(0)
fun call(): Mono<String> {
val circuitBreaker = circuitBreakerRegistry.circuitBreaker("circuit-breaker")
return webClient.get()
.uri("/api/random-error")
.retrieve()
.onStatus({ it.is2xxSuccessful }) {
successCount.incrementAndGet()
Mono.empty()
}
.onStatus({ it.is4xxClientError }) { response ->
fourxxCount.incrementAndGet()
Mono.error(HttpClientErrorException(response.statusCode()))
}
.onStatus({ it.is5xxServerError }) { response ->
fivexxCount.incrementAndGet()
Mono.error(HttpServerErrorException(response.statusCode()))
}
.bodyToMono(String::class.java)
.transform(CircuitBreakerOperator.of(circuitBreaker))
.onErrorResume(this::fallback)
}
fun fallback(e: Throwable): Mono<String> {
return when (e) {
is CallNotPermittedException -> {
callNotPermittedCount.incrementAndGet()
Mono.just("CircuitBreaker가 열렸습니다.")
}
is HttpServerErrorException -> Mono.just("5xx 에러")
is HttpClientErrorException -> Mono.just("4xx 에러")
else -> Mono.just("기타 에러")
}
}
}
failureRateThreshold에 따른 Circuit Breaker 동작
15%


(Circuit Breaker 가 열리기 전 52개의 로그를 검사한 결과, 8번의 500 에러가 발생했으므로 실패율은 15.38%이다.)
임계점을 넘기니 Circuit Breaker가 열리고, 응답이 Circuit Breaker가 열렸다는 메시지로 변경된다.
(CallNotPermittedException이 발생)
20%

실패율이 20%를 넘기지 않아 Circuit Breaker가 열리지 않았다.
25%

실패율이 25%를 넘겨 Circuit Breaker가 열렸다.