시작하며
안녕하세요. 개발자 stark입니다. 오늘은 서킷 브레이커를 적용해 봅시다.
마이크로서비스 아키텍처(MSA)에서는 외부 API 호출 시 장애에 대한 대응이 매우 중요합니다. Spring Boot 애플리케이션에서 Feign 클라이언트를 사용할 때 Resilience4j의 CircuitBreaker를 적용하여 원격 호출 실패 시 Fallback 로직을 실행하는 방법을 소개하고자 합니다.
최근에 제가 LinkedIn을 보다가 Resilience4J에 대한 글을 보고 크게 감명을 받았습니다. 저도 MSA 프로젝트를 하면서 서킷 브레이커는 반드시 적용시켜야 한다는 것은 알고 있었습니다. 그러나 이것을 어떻게 설명할지에 대해서 고민만 하고 있었는데 요즘 수많은 장애들을 겪으며 조금 더 회복 탄력성이 필요하다는 것을 느꼈고 서버 간 장애에 대한 예외전파를 처리하는 것이 정말 중요하다는 것을 더 깊이 깨달았습니다. 그래서 이건 공부하면서 무조건 포스팅을 적어야겠다는 생각이 들었습니다.
사람이 잠도 안 자고 생각지도 못한 순간에 일시적으로 발생한 장애를 바로바로 1초 만에 확인해서 처리할 수는 없습니다. 그러니 우리는 이런 것도 개발적으로 해결할 필요성이 있습니다. 가장 좋은 건 라이브러리의 도움을 받는 것입니다. 그래서 이걸 꼭 해야 하나? 이런 생각이 들 수도 있지만 다들 하라는 건 일단 그냥 해보면 됩니다. 그러면서 공부하면 왜 해야 하는지를 알 수 있게 됩니다. 이것을 적용한 결과는 추후 장애가 발생했을 때 본인에게 평가될 것입니다. (엄청난 칭찬을 받을 수도..?)
여하튼 저는 이것을 사람과의 관계랑 비슷하게 생각했는데 우리가 친구랑 싸운 후에는 다시 관계를 회복하기 위해 탄력성이 필요합니다. 누군가 삐져서 대답을 안 해주면 일부로 안 건드리고 한동안 조용히 있기도 하고 시간이 조금 지나서 괜찮은지 보려고 슬쩍 말도 걸어보고 합니다. 서버도 똑같습니다. 다른 서버에게 회복 탄력성을 줘야 합니다. 그러니 지금부터 서킷 브레이커를 적용시켜서 서버끼리도 화해할 수 있는 기회를 줍시다!
조금 재미있게 설명하고 싶어서 그림을 그려봤습니다 ㅎㅎ
제 글보다 좋은 것은 공식 문서를 읽어보는 것입니다. 공식 설명보다 좋은 것은 없습니다. 응용하는 것은 자유지만 기본 설정은 설명서(매뉴얼)에 가장 잘 적혀있습니다. 그러니 하단의 걸어둔 서킷 브레이커 공식 README에 들어가서 내용을 확인해 보시는 것을 추천합니다. 이번 포스팅의 많은 설명들은 하단의 resilience4J 공식 README에 작성되어 있습니다. (꼭 확인해 주세요!)
CircuitBreaker
Getting started with resilience4j-circuitbreaker
resilience4j.readme.io
서킷 브레이커란?
서킷 브레이커는 분산 시스템에서 한 서비스의 장애가 전체 시스템으로 확산되는 것을 방지하는 보호장치입니다.
예를 들어, 마이크로서비스(MSA) 환경에서 특정 서비스가 응답하지 않거나 실패가 잦을 경우, 해당 서비스로의 요청을 계속 보내면 전체 시스템의 성능이 저하되거나 장애가 확산될 수 있습니다. 서킷 브레이커는 이런 상황에서 일정 기준 이상의 실패가 감지되면 'OPEN' 상태로 전환되어 추가 요청을 차단하고, 일정 시간 후에 'HALF_OPEN' 상태에서 테스트 호출을 통해 서비스 복구 여부를 판단합니다. 이를 통해 장애가 발생한 서비스에 대한 불필요한 호출을 막아 시스템 전체의 안정성을 유지하며, 장애 복구가 완료되면 다시 정상적인 호출이 이루어지도록 도와줍니다.
MSA에서 왜 서킷 브레이커를 적용해야 할까?
저희가 MSA 프로젝트를 구성하면 아래와 같은 모양이 될 것입니다. 각 서버는 별도의 도메인을 가지게 설계되고 가끔 도메인은 상대 도메인의 데이터가 필요한 경우가 있습니다. 그런 상황에는 다른 서버의 api를 호출해서 필요한 데이터를 받아가거나 Kafka, gRPC 등을 사용해서 데이터를 받기도 합니다.
아래 그림을 봅시다. 서버 간 호출이 수없이 많이 이루어지는 MSA 프로젝트에서 제일 요청을 많이 받는 한 서버에 장애가 발생했습니다. 그럼 이 상황에는 어떻게 될까요? 조금 쉽게 이해하기 위해 장애가 발생한 서버를 회원 서버라고 가정해 봅시다. 여러 서버에서 회원 정보가 필요해서 회원 서버에 api 요청을 보냅니다. 근데 회원 서버가 고장 나버린 것입니다. 그러니 요청을 한 모든 서버들이 예외를 받게 됩니다. 이것을 MSA 서버 간의 예외 전파라고 합니다. 만약 회원 서비스가 빨리 고쳐지지 않으면 서비스 자체가 마비될 것입니다.
아래 그림을 봅시다. 결국 예외가 전파되어 모든 서버가 마비되었습니다.
저 눈물이 글썽거리는 개발자 stark가 보이시나요? 이렇게 한 서버에 강한 결합을 가지도록 MSA 프로젝트를 구성하면 이 서버가 터지면서 발생한 예외가 전파되어 요청하는 모든 서버들이 하나씩 연쇄적으로 터지면서 눈물의 야근을 하게 됩니다. (단일 장애 지점) 이것이 MSA를 구성하기 쉽지 않은 이유 중 하나입니다. 느슨한 결합을 하도록 MQ를 사용했다면 이렇게까지 예외가 전파되지는 않았을 것입니다. 그렇다 하더라도 이런 상황에 대한 처리는 꼭 필요합니다.
장애 전파 및 회복력(Resilience)을 위해 서킷 브레이커를 사용한다.
자 그럼 본론입니다. 서버가 터졌을 때 회복력을 보장하기 위해 사용하는 Resilience4j 라이브러리는 시스템이 장애 상황에서도 안정성을 유지하고 신속하게 복구할 수 있는 능력을 부여합니다. 서킷 브레이커 패턴은 장애가 있는 서비스로부터의 장애 전파를 효과적으로 차단하여, 문제의 확산을 방지하고 전체 시스템의 안정성을 높여줍니다. 이를 통해 사용자는 부분적인 장애에도 불구하고 시스템의 핵심 기능을 지속적으로 이용할 수 있으며, 개발자와 운영자는 장애 상황에서 문제를 점진적으로 해결할 수 있는 여유를 확보하게 됩니다. 결국, 서킷 브레이커는 장애 복구 시간 단축과 시스템 전체의 견고한 운영을 가능하게 하여, 보다 높은 회복력을 제공하는 중요한 패턴입니다.
그럼 서킷 브레이커를 적용하면 어떻게 될까요?
아래 그림에서는 서킷 브레이커를 자물쇠로 그려두었습니다. 서킷 브레이커는 설정한 횟수만큼 상대방 서버에 요청을 보내고 받은 예외를 측정합니다. 그래서 특정 순간이 되면 상대 서버에 api 요청 보내는 것 자체를 막아버립니다. 이것을 서킷 OPEN상태라고 합니다. 그리고 OPEN 상태에서 설정한 초가 지나면 HALF_OPEN 상태로 바꿔서 몇 번 테스트 요청을 보내서 상대방 서버가 고쳐졌는지 확인할 수 있게 합니다. 즉, 응답도 안 하는 서버에 요청을 보내서 예외가 전파되는 것을 막고 스레드 낭비를 하는 것도 막아줍니다. (정말 중요합니다. 그러니 꼭 적용해야겠죠?)
서킷 브레이커의 동작 원리
서킷 브레이커의 기본 동작을 설명드리겠습니다.
서킷 브레이커를 적용시킨 메서드는 실행 중 예외가 발생하면 기본적으로 fallback 메서드가 호출됩니다. 동시에, CircuitBreaker는 해당 예외들을 감지하여 내부 상태(예: CLOSED, OPEN, HALF_OPEN)를 업데이트하게 됩니다. 조금 자세히 살펴볼까요?
1. 예외 발생 및 Fallback 처리
서킷 브레이커가 적용된 메서드에서 호출 도중 예외가 발생하면, Resilience4j는 즉시 해당 예외를 감지하고 사전에 정의된 fallback 메서드를 호출합니다. 이 fallback 메서드는 원래 메서드와 동일한 반환 타입을 가지며, 예외가 발생한 상황에 대해 안전하게 대체 응답을 제공함으로써 장애가 발생하더라도 사용자에게 일관된 결과를 전달할 수 있도록 설계되어 있습니다. 예외가 발생하면 실제 원격 호출을 중단하고 fallback 로직으로 빠르게 전환되기 때문에, 서비스의 응답 지연이나 과부하를 방지할 수 있습니다. 아래 그림과 같이 예외가 발생했음에도 사용자에게는 fallback 메서드로 처리된 정상 응답을 내보낼 수도 있습니다. (캐싱 활용 등)
2. CircuitBreaker의 상태 관리
동시에, 발생한 예외들은 서킷 브레이커 내부에 누적되어 실패율을 계산하는 데 사용됩니다. CircuitBreaker는 최근 일정 횟수(슬라이딩 윈도우)를 기준으로 전체 호출 중 실패한 요청의 비율을 측정하며, 이 실패율이 미리 설정된 임계치를 초과하면 OPEN 상태로 전환됩니다. OPEN 상태에서는 추가적인 원격 호출이 아예 시도되지 않고, 즉각적으로 fallback 메서드가 호출되어 장애가 있는 서비스로의 불필요한 요청을 막습니다. 이러한 방식은 시스템 전체에 장애가 전파되는 것을 방지하고, 자원을 보호하며, 장애가 발생한 서비스가 복구될 때까지 호출을 차단하는 역할을 합니다.
3. Fallback과 CircuitBreaker의 상호 작용
결과적으로, fallback은 예외 발생 시의 즉각적인 예외 처리 로직으로서 장애 상황에서 대체 결과를 제공하는 역할을 수행하며, CircuitBreaker는 이러한 예외들을 기반으로 호출 실패율을 누적하고 상태를 관리합니다. OPEN 상태가 되면 새로운 호출은 모두 자동으로 차단되며, 이후 일정 시간이 지난 후 호출이 재시도되면서 HALF_OPEN 상태로 전환되고, 시스템의 복구 여부를 판단하게 됩니다. 이와 같이, fallback과 CircuitBreaker는 각각 예외 처리와 상태 관리라는 별도의 역할을 수행하면서도 상호 보완적으로 작동하여 시스템의 회복력(resilience)을 크게 향상시킵니다.
4. 참고사항
서킷 브레이커가 OPEN 되었을 때만 fallback 메서드가 실행되는 것이 아닙니다. 저는 이 부분이 헷갈렸는데 서킷이 적용된 로직에서 예외가 발생하면 무조건 fallback 메서드가 실행됩니다. 다만 정해진 횟수가 지나면 feign 요청을 보내지 않는 것뿐입니다.
- 예외 발생 시 fallback: 예외가 발생하면, Resilience4j는 즉시 fallback 메서드를 호출해서 대체 응답을 반환합니다.
- CircuitBreaker 동작: 발생한 예외들은 CircuitBreaker에 기록되어, 실패율이 설정된 임계치를 넘으면 CircuitBreaker가 OPEN 상태로 전환됩니다. OPEN 상태에서는 추가 호출이 차단되고, 바로 fallback이 호출됩니다.
Resilience4J 의존성 추가
아래와 같이 의존성을 추가해 줍시다.
- 제가 일부로 아무것도 추가하지 않은 상태에서 CircuitBreaker를 적용하기 위해 필요한 dependency만 넣어두었습니다. 스프링에서 기본적으로 사용되는 web, jpa, db 등 필요한 것은 직접 추가해 주시면 됩니다. 그리고 제일 중요한 게 있는데 AOP 의존성을 꼭 추가해주셔야 합니다. 그래야만 어노테이션 기반 CircuitBreaker가 적용됩니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.3.8'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
ext {
set('springCloudVersion', "2023.0.5")
}
dependencies {
// aop 추가
implementation 'org.springframework.boot:spring-boot-starter-aop:3.4.2'
// feign 추가
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
// resilience4j 추가
implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
tasks.named('test') {
useJUnitPlatform()
}
이 gradle 설정을 그대로 사용하시려는 분도 계실 텐데 중요한 게 있습니다. 바로 스프링부트 버전마다 적절한 Spring Cloud의 버전이 존재합니다. 그러니 꼭 자신의 스프링부트 버전과 일치하는 Spring Cloud 버전을 찾아서 ext에서 세팅해 주세요! 이게 안 맞아서 서버를 실행하면 버전 관련 문제가 발생하는 경우가 꽤 있습니다.
Resilience4j 기본 설정 이해하기
우리가 라이브러리를 추가할 때는 대부분 기본 설정값 또는 application.yml에 작성한 설정이 적용됩니다. 근데 Resilience4J는 아무런 커스텀 설정이나 YAML 설정이 없으면, 어떻게 동작할까요? 예를 들어, Resilience4j의 기본 CircuitBreakerConfig는 보통 다음과 같이 정의되어 있습니다.
제가 공식 사이트에 있는 설명을 번역해 봤습니다. (설명은 그대로 번역한 것은 아닙니다 ㅎㅎ)
Config property | 기본값 | 설명 |
failureRateThreshold | 50 | 서비스 호출 실패율이 이 값(%)을 초과하면 회로 차단기가 OPEN 상태로 전환됩니다. |
slowCallRateThreshold | 100 | 호출 중 느린 응답의 비율이 이 임계치(%)를 넘으면, 전체 실패율에 반영됩니다. |
slowCallDurationThreshold | 60000 [ms] | 호출 응답 시간이 이 시간(예: 2초)보다 길면 느린 호출로 간주됩니다. |
permittedNumberOfCalls InHalfOpenState |
10 | HALF_OPEN 상태에서 테스트를 위해 허용되는 최대 호출 수입니다. |
maxWaitDurationInHalfOpenState | 0 [ms] | HALF_OPEN 상태에서 테스트 호출 대기 시 허용되는 최대 대기 시간을 지정합니다. |
slidingWindowType | COUNT_BASED | 통계 집계를 COUNT_BASED(호출 횟수 기반) 또는 TIME_BASED(시간 기반) 중 하나로 설정합니다. |
slidingWindowSize | 100 | 통계 집계에 사용할 호출 횟수(또는 시간 간격)를 지정합니다. |
minimumNumberOfCalls | 100 | 실패율 계산을 시작하기 위한 최소 호출 횟수입니다. |
waitDurationInOpenState | 60000 [ms] | 회로 차단기가 OPEN 상태에서 호출을 차단하는 기간입니다. |
automaticTransition FromOpenToHalfOpenEnabled |
false | OPEN 상태 후, 자동으로 HALF_OPEN 상태로 전환할지 여부를 결정합니다. |
recordExceptions | empty | 실패로 기록할 예외 클래스들을 지정합니다. |
ignoreExceptions | empty | 실패로 간주하지 않고 무시할 예외 클래스들을 지정합니다. |
recordFailurePredicate | throwable -> true By default all exceptions are recorded as failures. |
사용자 정의 조건으로, 특정 예외가 실패로 기록될지 여부를 판단합니다. |
ignoreExceptionPredicate | throwable -> false By default no exception is ignored. |
사용자 정의 조건으로, 특정 예외를 무시할지 여부를 판단합니다. |
즉, 아무런 설정을 제공하지 않으면, CircuitBreakerConfig.defaults() 메서드에서 위와 같은 기본 값들이 사용됩니다. Spring Cloud CircuitBreaker(Resilience4J) 자동 구성 시에도 별도의 설정이 없으면 이러한 기본값이 적용됩니다.
따라서, YAML이나 커스텀 Java Config를 선언하지 않으면 위와 같은 기본 설정이 적용되어 동작하게 됩니다. 이 기본 설정은 많은 경우에 적절할 수 있지만, 실제 애플리케이션 상황에 맞게 조정하는 것이 좋습니다. 예를 들어, 호출 횟수가 적은 서비스라면 slidingWindowSize를 줄이거나, 장애 회복 시간을 조정하기 위해 waitDurationInOpenState 값을 변경하는 식으로 수정할 수 있습니다.
자 그럼 이 설정이 맞을까요? 테스트 코드를 실행해 봅시다.
@DisplayName("Resilience4j CircuitBreaker 기본 설정 테스트")
class CircuitBreakerConfigTest {
@DisplayName("기본 CircuitBreakerConfig 값 검증")
@Test
void testDefaultConfigValues() {
// given: 기본 설정 인스턴스 생성 (별도 설정이 없으면 기본값 사용)
CircuitBreakerConfig defaultConfig = CircuitBreakerConfig.ofDefaults();
// when: 각 설정 값 출력 (로그 확인)
System.out.println("Failure Rate Threshold: " + defaultConfig.getFailureRateThreshold());
System.out.println("Slow Call Rate Threshold: " + defaultConfig.getSlowCallRateThreshold());
System.out.println("Slow Call Duration Threshold: " + defaultConfig.getSlowCallDurationThreshold().getSeconds());
System.out.println("Permitted Number Of Calls In Half Open State: " + defaultConfig.getPermittedNumberOfCallsInHalfOpenState());
System.out.println("Max Wait Duration In Half Open State: " + defaultConfig.getMaxWaitDurationInHalfOpenState().getSeconds());
System.out.println("Sliding Window Type: " + defaultConfig.getSlidingWindowType());
System.out.println("Sliding Window Size: " + defaultConfig.getSlidingWindowSize());
System.out.println("Minimum Number Of Calls: " + defaultConfig.getMinimumNumberOfCalls());
System.out.println("Wait Duration In Open State: " + defaultConfig.getWaitIntervalFunctionInOpenState().apply(1));
System.out.println("Automatic Transition From Open To Half Open Enabled: " + defaultConfig.isAutomaticTransitionFromOpenToHalfOpenEnabled());
System.out.println("Record Failure Predicate: " + defaultConfig.getRecordExceptionPredicate().test(new RuntimeException()));
System.out.println("Ignore Exception Predicate: " + defaultConfig.getIgnoreExceptionPredicate().test(new RuntimeException()));
}
}
테스트를 실행하면 아래와 같은 결과를 얻게 됩니다.
- 천천히 비교해 보시면 공식 사이트에 적혀있는 정보와 완전히 동일한 설정이 적용되어 있습니다.
Failure Rate Threshold: 50.0
Slow Call Rate Threshold: 100.0
Slow Call Duration Threshold: 60 (s)
Permitted Number Of Calls In Half Open State: 10
Max Wait Duration In Half Open State: 0 (s)
Sliding Window Type: COUNT_BASED
Sliding Window Size: 100
Minimum Number Of Calls: 100
Wait Duration In Open State: 60000 (ms)
Automatic Transition From Open To Half Open Enabled: false
Record Failure Predicate: true
Ignore Exception Predicate: false
이것을 한 문단으로 정리해 보면 기본 설정일 때 서킷 브레이커는 다음과 같이 동작합니다.
Resilience4j의 기본 설정을 사용하면, CircuitBreaker는 호출 횟수 기반(COUNT_BASED)의 슬라이딩 윈도우를 사용하여 최근 100회의 호출을 기준으로 실패율을 계산합니다. 이때 실패율이 50%를 초과하면 CircuitBreaker는 OPEN 상태로 전환되어 추가 호출을 차단합니다. OPEN 상태에서는 60초(60000ms) 동안 호출이 차단되며, 자동으로 HALF_OPEN 상태로 전환되지 않고(automaticTransitionFromOpenToHalfOpenEnabled는 false), 다음 호출 시점에 상태 전환 여부를 결정합니다.
HALF_OPEN 상태에서는 최대 10회의 테스트 호출이 허용되며, 응답 시간이 60초 이상 걸리는 호출은 느린 호출로 간주되어, 느린 호출 비율이 100%가 되면 실패로 판단됩니다. 또한, 최소 100회의 호출이 있어야 통계 집계가 시작되며, FeignException, ConnectException, RuntimeException 등의 예외가 실패로 기록되고, 별도의 예외 무시 조건은 적용되지 않습니다.
기본 설정일 때 성능 확인하기
저는 서킷 브레이커가 제대로 적용되었는지를 확인해 볼 수 있는 전용 api를 만들었습니다. 하단의 목차에서 설명드릴 텐데 지금은 서킷 브레이커의 동작을 확인하기 위해서 단순히 호출 결과만 확인하도록 하겠습니다. (결과 확인)
간단히 설명드리자면 아래의 api응답 json구조를 보시면 member-service라는 이름으로 서킷 브레이커가 등록되어 있다는 것이고 그 내부에서 어떤 상태를 가지는지 기록하고 있습니다. 기본 설정을 적용시켰기에 30번, 70, 99번 각각 요청된 순간에도 아직 서킷이 동작하지 않았고 state가 CLOSED인 것을 확인하실 수 있습니다.
// 30번째 호출에서 확인한 결과
"member-service": {
"failureRate": -1,
"failedCalls": 30,
"state": "CLOSED",
"bufferedCalls": 30
}
// 70번째 호출에서 확인한 결과
"member-service": {
"failureRate": -1,
"failedCalls": 70,
"state": "CLOSED",
"bufferedCalls": 70
}
// 99번 호출후 다시 확인한 결과
"member-service": {
"failureRate": -1,
"failedCalls": 99,
"state": "CLOSED",
"bufferedCalls": 99
}
그럼 100번 요청을 해봅시다.
자 100번째 요청이 되니까 서킷의 상태가 OPEN으로 바뀐 것을 확인할 수 있습니다. 그렇다는 것은 기본 설정이 매우 잘 적용된 것입니다. 이것을 확인한 후 저는 60초를 기다렸습니다. 그 후에 요청을 보내면 어떻게 될까요? 기본 설정에서는 HALF_OPEN으로의 자동 전환 설정이 false로 되어있어서 1분이든 3분이든 5분이든 계속해서 OPEN으로 보입니다.
"member-service": {
"failureRate": 100,
"failedCalls": 100,
"state": "OPEN",
"bufferedCalls": 100
}
그럼 어떻게 이 상태가 변경될까요? 답은 간단합니다. 60초가 지난 후 요청이 1번 더 들어오면 그때 바로 HALF_OPEN 상태로 변경됩니다. 그러니 아무도 요청하지 않으면 계속 OPEN일 것입니다. (사실상 그런 일은 없겠죠? ㅎㅎ)
"member-service": {
"failureRate": -1,
"failedCalls": 1,
"state": "HALF_OPEN",
"bufferedCalls": 1
}
결론은 기본 설정이 잘 적용되어 동작하고 있다는 것입니다. 그럼 이제 커스텀 설정을 입혀봅시다.
Config 클래스로 서킷 브레이커 설정 커스텀하기
서킷 브레이커 설정을 커스텀하기 위해 CircuitBreakerConfig와 CircuitBreakerRegistry 빈을 생성합니다.
- CircuitBreakerConfig: CircuitBreaker의 동작(예: 슬라이딩 윈도우, 실패 임계치 등)을 정의합니다.
- CircuitBreakerRegistry: 위에서 정의한 설정을 기반으로 CircuitBreaker 인스턴스를 직접 생성하거나 관리할 때 사용합니다.
- 이 방식은 코드에서 circuitBreakerRegistry.circuitBreaker("이름")을 통해 직접 CircuitBreaker를 가져와 사용할 때 필요합니다.
@Configuration
public class Resilience4jConfig {
@Bean
public CircuitBreakerConfig circuitBreakerConfig() {
return CircuitBreakerConfig.custom()
// 호출 횟수 기반의 슬라이딩 윈도우 사용 (최근 10번 호출 기준)
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
// 실패율이 50% 이상이면 CircuitBreaker가 OPEN 상태로 전환
.failureRateThreshold(50)
// OPEN 상태에서 5초 동안 호출 차단 후, HALF_OPEN 상태로 전환
.waitDurationInOpenState(java.time.Duration.ofSeconds(5))
// HALF_OPEN 상태에서 최대 5개의 호출 허용
.permittedNumberOfCallsInHalfOpenState(5)
// 최근 10번의 호출을 기준으로 통계 집계
.slidingWindowSize(10)
// OPEN 상태에서 HALF_OPEN으로 자동 전환
.automaticTransitionFromOpenToHalfOpenEnabled(true)
// FeignException, ConnectException, RuntimeException을 실패 예외로 기록
.recordExceptions(FeignException.class, ConnectException.class, RuntimeException.class)
.build();
}
// 위에서 정의한 설정으로 CircuitBreakerRegistry를 생성
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry(CircuitBreakerConfig config) {
return CircuitBreakerRegistry.of(config);
}
}
커스텀 설정 이후 테스트코드를 실행해 봅시다.
- 이번에는 커스텀한 Config 클래스가 스프링에 적용된 것을 확인해야 하기 때문에 @SpringBootTest를 클래스에 적어줬습니다. 그럼 실제로 스프링 서버가 실행됩니다. 이렇게 하면 등록된 CircuitBreakerConfig 빈을 주입받아서 확인할 수 있습니다. 바로 실행해 봅시다.
@DisplayName("Resilience4j CircuitBreaker 커스텀 설정 테스트")
@SpringBootTest
class FindMemberServiceTest {
@Autowired
private CircuitBreakerConfig circuitBreakerConfig;
@DisplayName("커스텀 CircuitBreakerConfig 값 검증")
@Test
void testDefaultConfigValues() {
// when: 각 설정 값 출력 (로그 확인)
System.out.println("Failure Rate Threshold: " + circuitBreakerConfig.getFailureRateThreshold());
System.out.println("Slow Call Rate Threshold: " + circuitBreakerConfig.getSlowCallRateThreshold());
System.out.println("Slow Call Duration Threshold: " + circuitBreakerConfig.getSlowCallDurationThreshold().getSeconds());
System.out.println("Permitted Number Of Calls In Half Open State: " + circuitBreakerConfig.getPermittedNumberOfCallsInHalfOpenState());
System.out.println("Max Wait Duration In Half Open State: " + circuitBreakerConfig.getMaxWaitDurationInHalfOpenState().getSeconds());
System.out.println("Sliding Window Type: " + circuitBreakerConfig.getSlidingWindowType());
System.out.println("Sliding Window Size: " + circuitBreakerConfig.getSlidingWindowSize());
System.out.println("Minimum Number Of Calls: " + circuitBreakerConfig.getMinimumNumberOfCalls());
System.out.println("Wait Duration In Open State: " + circuitBreakerConfig.getWaitIntervalFunctionInOpenState().apply(1));
System.out.println("Automatic Transition From Open To Half Open Enabled: " + circuitBreakerConfig.isAutomaticTransitionFromOpenToHalfOpenEnabled());
System.out.println("Record Failure Predicate: " + circuitBreakerConfig.getRecordExceptionPredicate().test(new RuntimeException()));
System.out.println("Ignore Exception Predicate: " + circuitBreakerConfig.getIgnoreExceptionPredicate().test(new RuntimeException()));
}
}
테스트 실행 결과는 다음과 같습니다.
Failure Rate Threshold: 50.0
Slow Call Rate Threshold: 100.0
Slow Call Duration Threshold: 60
Permitted Number Of Calls In Half Open State: 5
Max Wait Duration In Half Open State: 0
Sliding Window Type: COUNT_BASED
Sliding Window Size: 10
Minimum Number Of Calls: 100
Wait Duration In Open State: 5000
Automatic Transition From Open To Half Open Enabled: true
Record Failure Predicate: true
Ignore Exception Predicate: false
음.. 뭐가 다를까요? 맨 위로 가서 기본 설정의 결과와 비교해 보시는 것도 좋습니다. 그러나 위의 설정 코드만 봐도 제가 작성한 대로 설정이 적용되어 있는 것을 확인할 수 있습니다. 즉, 커스텀 설정이 잘 적용되었습니다. (개인적으로는 이렇게 항상 기본값을 알아보고 테스트를 해보면서 적용을 해보시는 것을 추천드립니다. 지식의 폭이 달라집니다 ㅎㅎ)
YAML 방식으로 서킷 브레이커 설정 커스텀하기
만약 YAML 파일을 통해 설정을 관리하고 싶다면, application.yml 파일에 아래와 같이 작성할 수 있습니다.
spring:
application:
name: circuit-test-server
resilience4j:
circuitbreaker:
instances:
member-service:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
failureRateThreshold: 50
waitDurationInOpenState: 5s
permittedNumberOfCallsInHalfOpenState: 5
automaticTransitionFromOpenToHalfOpenEnabled: true
recordExceptions:
- feign.FeignException
- java.net.ConnectException
- java.lang.RuntimeException
- 이렇게 YAML 방식으로 설정하면, Resilience4j는 자동 구성되어 해당 설정을 사용하게 됩니다. 음.. 매우 간단하죠? 테스트도 진행해 봤는데 자바 코드 방식과 yaml방식 모두 잘 동작합니다. 선택은 상황에 따라 달라질 것 같습니다.
서킷 브레이커는 동시성 문제에 안전하다.
이번 목차는 조금 많이 재미없고 어려운 내용입니다. 혹시 서킷 브레이커가 어떻게 호출에 대한 기록을 하는지 궁금하실 분들을 위해 준비해 봤습니다. 공식 문서에도 적혀있는데 제가 조금 MSG를 뿌려봤습니다. 내용이 조금 어려우니 관심 없으시다면 PASS 해주세요 ㅎㅎ
서킷 브레이커의 동시성 안전성
Resilience4j의 CircuitBreaker는 내부 상태를 관리할 때 AtomicReference를 사용합니다. 이는 여러 스레드가 동시에 상태 업데이트를 요청하더라도, 원자적(atomic)으로 처리되어 한 번에 한 스레드만 상태나 슬라이딩 윈도우에 접근할 수 있음을 보장합니다. 또한, 호출 기록과 슬라이딩 윈도우의 스냅샷을 읽을 때도 동기화(synchronized)가 되어 있어, 데이터의 일관성과 원자성이 유지됩니다. 그러나 실제로 외부 함수를 호출하는 부분은 동기화되지 않습니다. 이는 함수 호출 자체를 동기화하면 모든 호출이 직렬화되어 성능 저하와 병목 현상이 발생하기 때문입니다.
예를 들어, 만약 20개의 스레드가 동시에 함수 실행 권한을 요청한다면, CircuitBreaker의 상태가 CLOSED(호출 가능한 정상 상태)인 경우 모든 스레드가 동시에 함수를 호출할 수 있습니다. 슬라이딩 윈도우는 단지 통계 집계 시 고려하는 호출의 범위를 의미하며, 동시 실행되는 호출의 수를 제한하지 않습니다. 동시 호출 제한이 필요하면 Bulkhead 패턴을 함께 사용해야 합니다.
흠.. 어렵죠? 조금 스레드적인 관점에서 이해해 봅시다.
각 요청은 별도의 비즈니스 스레드에서 실행되며, 각 스레드는 외부 함수(FeignClient)를 호출하여 결과(성공 또는 실패)를 받습니다. 이때, 각 요청이 완료되는 시점에 그 결과는 CircuitBreaker의 내부 슬라이딩 윈도우에 기록됩니다. 이 기록 과정은 AtomicReference나 synchronized 블록과 같은 동기화 메커니즘을 사용하여 안전하게 처리되며, 동기화는 결과를 기록하는 매우 짧은 시간 동안만 이루어집니다. 중요한 점은 실제 외부 함수 호출 자체는 동기화되지 않기 때문에, 여러 요청이 동시에 실행될 수 있어 높은 처리량을 유지할 수 있다는 것입니다.
예를 들어, 130개의 api 요청이 거의 동시에 들어와 각각 다른 스레드에서 처리된다면, 각 스레드는 자신의 로직 내부에서 서킷 브레이커가 적용된 (Feign) 요청이 끝나는 순간 그 결과를 안전하게 슬라이딩 윈도우에 기록합니다. 만약 이 중 100개의 요청이 완료되었는데 모두 실패로 기록되었다면, 아직 진행 중인 나머지 30개 요청이 있다 하더라도 슬라이딩 윈도우에는 이미 100개의 실패 기록이 반영됩니다. 이에 따라 CircuitBreaker는 전체 실패율을 정확히 계산하여 설정된 임계치(예: 50% 이상)를 초과하면 이후의 새로운 호출은 차단하고 즉시 fallback 로직을 실행합니다.
즉, 각 요청이 완료되면 해당 스레드가 자신의 결과를 동기화된 방식으로 기록하므로, 동시 호출 상황에서도 모든 호출 결과가 1:1로 정확하게 업데이트됩니다. 별도의 '서킷 전용' 스레드 없이 각 비즈니스 스레드가 결과 기록을 담당하기 때문에, 외부 함수 호출은 병렬로 실행되어 성능 저하 없이 높은 처리량을 유지할 수 있습니다. 이와 같은 구조 덕분에 동시에 많은 요청이 들어와도 내부 상태 업데이트는 정확하게 이루어지고, CircuitBreaker는 실제 호출 결과에 기반해 적절하게 상태를 전환하며 시스템 안정성을 보장합니다.
CircuitBreakerRegistry의 역할
Resilience4j는 내부적으로 ConcurrentHashMap을 기반으로 한 인메모리 CircuitBreakerRegistry를 제공합니다. 이 Registry는 스레드 안전성과 원자성을 보장하며, CircuitBreaker 인스턴스를 생성하고 관리하는 데 사용됩니다. Registry를 통해 이름(key)으로 CircuitBreaker 인스턴스를 생성하거나 검색할 수 있으며, 이를 통해 전체 애플리케이션에서 일관된 설정과 상태로 CircuitBreaker를 사용할 수 있습니다. 예를 들어, 별도의 설정 없이도 Registry는 기본 설정 값을 사용해 자동으로 CircuitBreaker 인스턴스를 생성합니다.
이것을 확인할 수 있는 코드는 다음과 같습니다.
우리가 서킷 브레이커 설정 클래스를 작성하면서 빈 등록한 CircuitBreakerRegistry 인터페이스에 들어가 줍니다. 그럼 아래와 같은 코드가 나올 것입니다. 이 인터페이스를 구현하는 구현체로는 InMemoryCircuitBreakerRegistry 클래스가 존재합니다.
// 서킷 브레이커 Registry 인터페이스입니다.
public interface CircuitBreakerRegistry extends Registry<CircuitBreaker, CircuitBreakerConfig> {
static CircuitBreakerRegistry of(CircuitBreakerConfig circuitBreakerConfig) {
return new InMemoryCircuitBreakerRegistry(circuitBreakerConfig);
}
}
// 위의 인터페이스를 구현하는 구현체입니다.
public final class InMemoryCircuitBreakerRegistry
extends AbstractRegistry<CircuitBreaker, CircuitBreakerConfig>
implements CircuitBreakerRegistry {
public InMemoryCircuitBreakerRegistry(Map<String, CircuitBreakerConfig> configs, Map<String, String> tags) {
this((CircuitBreakerConfig)configs.getOrDefault("default", CircuitBreakerConfig.ofDefaults()), tags);
this.configurations.putAll(configs);
}
}
위의 코드의 생성자를 보면 this.configurations.putAll(configs); 메서드를 통해 configurations라는 곳에 Map객체를 putAll() 하고 있습니다. 즉 이게 제가 찾고 있는 ConcurrentHashMap이겠죠? 그래서 로직을 타고 들어가 보면 아래의 AbstractRegistry 클래스로 이동되고 필드에는 ConcurrentMap으로 configurations가 선언되어 있습니다. 제가 생성자 한 개를 같이 적어놨는데 이 생성자의 첫 번째 라인을 보시면 인스턴스로 ConcurrentHashMap을 생성해서 넣어주고 있습니다.
public class AbstractRegistry<E, C> implements Registry<E, C> {
protected static final String DEFAULT_CONFIG = "default";
protected static final String CONFIG_MUST_NOT_BE_NULL = "Config must not be null";
protected static final String CONSUMER_MUST_NOT_BE_NULL = "EventConsumers must not be null";
protected static final String SUPPLIER_MUST_NOT_BE_NULL = "Supplier must not be null";
protected static final String TAGS_MUST_NOT_BE_NULL = "Tags must not be null";
private static final String NAME_MUST_NOT_BE_NULL = "Name must not be null";
private static final String REGISTRY_STORE_MUST_NOT_BE_NULL = "Registry Store must not be null";
protected final RegistryStore<E> entryMap;
// 이 코드입니다.
protected final ConcurrentMap<String, C> configurations;
protected final Map<String, String> registryTags;
private final AbstractRegistry<E, C>.RegistryEventProcessor eventProcessor;
// 생성자 1개만 봅시다.
public AbstractRegistry(C defaultConfig, List<RegistryEventConsumer<E>> registryEventConsumers, Map<String, String> tags) {
this.configurations = new ConcurrentHashMap();
this.entryMap = new InMemoryRegistryStore();
this.eventProcessor = new RegistryEventProcessor((List)Objects.requireNonNull(registryEventConsumers, "EventConsumers must not be null"));
this.registryTags = (Map)Objects.requireNonNull(tags, "Tags must not be null");
this.configurations.put("default", Objects.requireNonNull(defaultConfig, "Config must not be null"));
}
// 나머지 코드 생략
}
즉, Resilience4j의 CircuitBreaker는 내부 상태와 슬라이딩 윈도우 업데이트를 원자적이고 동기화된 방식으로 처리하여 스레드 안전성을 보장하면서도, 실제 함수 호출은 동시 실행되도록 하여 높은 처리량을 유지합니다. 동시에 CircuitBreakerRegistry를 사용하면 이름 기반으로 인스턴스를 손쉽게 생성하고 관리할 수 있으므로, 기본 설정 없이도 자동으로 구성된 인스턴스를 사용할 수 있습니다. 동시 호출 제한이 필요한 경우에는 Bulkhead 패턴과 함께 사용하면 됩니다.
Feign 클라이언트와 서비스 레이어 구현
이제 어떤 식으로 서킷 브레이커를 코드에 적용하는지 알아봅시다. (단순 예시입니다)
저는 FeignClient 인터페이스를 선언하고 이 인터페이스를 호출하는 서비스 코드와 컨트롤러를 작성했습니다. 정말 단순하게 이게 끝입니다! 직접 코드를 작성하실 때도 이런 구조로 흘러갈 것입니다. (물론 아키텍처에 따라 흐름은 달라질 수 있습니다)
Feign 클라이언트 인터페이스
- 먼저, Feign 클라이언트 인터페이스를 아래와 같이 선언했습니다. 이 예제에서는 member-service라는 이름으로 원격 API의 /api/members/{memberId} 엔드포인트를 호출합니다. (MSA 기준입니다)
@FeignClient(name = "member-service", url = "http://localhost:8090")
public interface MemberFeignClient {
@GetMapping("/api/members/{memberId}")
ResponseEntity<ResponseMemberDTO> getMemberById(@PathVariable("memberId") Long memberId);
}
서비스 레이어 – CircuitBreaker 적용 및 Fallback 처리
- 아래 코드는 Feign 클라이언트 호출을 Resilience4j의 CircuitBreaker로 감싼 서비스 구현 예제입니다. 여기서는 CircuitBreakerRegistry를 통해 "member-service"라는 이름의 CircuitBreaker를 생성하고, executeSupplier() 메서드를 사용하여 원격 호출을 감싸주도록 했습니다. (어노테이션을 사용하지 않는 방식입니다)
- 이 로직에서는 만약 CircuitBreaker가 OPEN 상태이면 CallNotPermittedException이 발생하고, 로직에서는 이 예외를 캐치하여 fallbackMemberInfo() 메서드에서 대체 데이터를 반환하도록 했습니다. (fallback의 개념은 하단의 목차에서 설명드리겠습니다.)
@Slf4j
@RequiredArgsConstructor
@Service
public class FindMemberService {
private final MemberFeignClient memberFeignClient;
private final CircuitBreakerRegistry circuitBreakerRegistry;
private CircuitBreaker feignCircuitBreaker;
@PostConstruct
public void init() {
// "member-service"라는 이름의 CircuitBreaker 생성
feignCircuitBreaker = circuitBreakerRegistry.circuitBreaker("member-service");
}
/**
* 회원 ID로 회원 조회 (CircuitBreaker 적용 + Fallback 포함)
*
* @param memberId 조회할 회원 ID
* @return 조회된 회원 정보 DTO
*/
public ResponseMemberDTO findMemberInformation(Long memberId) {
try {
return feignCircuitBreaker.executeSupplier(() -> fetchMemberInfo(memberId));
} catch (CallNotPermittedException e) {
log.warn("[CIRCUITBREAKER OPEN] 요청 차단됨 - memberId={}, 원인={}", memberId, e.getMessage());
return fallbackMemberInfo(memberId, e);
}
}
/**
* FeignClient를 통해 회원 정보를 가져옴
*
* @param memberId 조회할 회원 ID
* @return 회원 정보 DTO
*/
private ResponseMemberDTO fetchMemberInfo(Long memberId) {
ResponseEntity<ResponseMemberDTO> response = memberFeignClient.getMemberById(memberId);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("Feign call failed with status: " + response.getStatusCode());
}
return response.getBody();
}
/**
* Fallback 메서드: FeignClient 호출 실패 시 대체 응답 반환
*
* @param memberId 조회할 회원 ID
* @param t 실패 원인
* @return 기본 회원 정보 DTO
*/
private ResponseMemberDTO fallbackMemberInfo(Long memberId, Throwable t) {
log.error("[FEIGN FALLBACK] CircuitBreaker 발동 - memberId={}, 원인={}", memberId, t.getMessage());
return ResponseMemberDTO.builder()
.id(-1L)
.name("Fallback User")
.email("fallback@example.com")
.build();
}
}
또 다른 적용 방법도 확인해 봅시다. 바로 @CircuitBreaker 어노테이션을 사용하는 것입니다.
- 어노테이션 기반의 로직은 아래와 같이 조금 더 간결하고 쉽습니다. 역시 뭐든 자동으로 해주는 게 최곱니다 ㅎㅎ 참고로 여기서도 fallbackMethod라는 개념이 나옵니다. 즉, 그만큼 서킷 브레이커를 적용할 때 fallbackMethod를 지정해 주는 것이 중요하다는 의미입니다. (하단에서 알아봅시다)
@Slf4j
@RequiredArgsConstructor
@Service
public class FindMemberServiceAnnotation {
private final MemberFeignClient memberFeignClient;
@CircuitBreaker(name = "member-service", fallbackMethod = "fallbackMemberInfo")
public ResponseMemberDTO findMemberInformation(Long memberId) {
ResponseEntity<ResponseMemberDTO> response = memberFeignClient.getMemberById(memberId);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("Feign call failed with status: " + response.getStatusCode());
}
return response.getBody();
}
public ResponseMemberDTO fallbackMemberInfo(Long memberId, Throwable t) {
// fallback 메서드 내에서 로그 출력
log.error("[FEIGN FALLBACK] CircuitBreaker 발동 - memberId={}, 원인={}", memberId, t.getMessage(), t);
return ResponseMemberDTO.builder()
.id(-1L)
.name("Fallback User")
.email("fallback@example.com")
.build();
}
}
Controller
- 서비스 레이어를 호출하는 컨트롤러는 아래와 같습니다. 여기서는 /api/test/feign 엔드포인트를 통해 Feign 클라이언트를 통한 회원 조회를 테스트합니다. 간단하게 작성하였습니다.
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/test")
public class FeignController {
private final FindMemberService findMemberService;
@GetMapping("/feign")
public ResponseEntity<ResponseMemberDTO> feignTest(@RequestParam Long memberId) {
log.trace("[FEIGN TEST] 들어온 HTTP 요청 - memberId={}", memberId);
// FeignClient를 통한 회원 정보 조회
ResponseMemberDTO response = findMemberService.findMemberInformation(memberId);
// 최종 HTTP 응답 반환
return ResponseEntity.ok(response);
}
}
서킷 브레이커의 fallback 메서드란?
자 다시 코드를 봅시다. (이번 설명은 어노테이션 기반 서킷 브레이커로 설명드리겠습니다)
- @CircuitBreaker 어노테이션을 사용할 때는 무조건 name값에 인스턴스명을 지정해줘야만 합니다. (참고로 서킷 브레이커 설정 클래스를 작성할 때 클래스 내부에 인스턴스 설정을 해준 적이 없습니다. 그러나 스프링이 알아서 어노테이션의 name에 값으로 적어준 이름대로 인스턴스를 생성해 줍니다.)
@Slf4j
@RequiredArgsConstructor
@Service
public class FindMemberServiceAnnotation {
private final MemberFeignClient memberFeignClient;
@CircuitBreaker(name = "member-service", fallbackMethod = "fallbackMemberInfo")
public ResponseMemberDTO findMemberInformation(Long memberId) {
ResponseEntity<ResponseMemberDTO> response = memberFeignClient.getMemberById(memberId);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("Feign call failed with status: " + response.getStatusCode());
}
return response.getBody();
}
public ResponseMemberDTO fallbackMemberInfo(Long memberId, Throwable t) {
// fallback 메서드 내에서 로그 출력
log.error("[FEIGN FALLBACK] CircuitBreaker 발동 - memberId={}, 원인={}", memberId, t.getMessage(), t);
return ResponseMemberDTO.builder()
.id(-1L)
.name("Fallback User")
.email("fallback@example.com")
.build();
}
}
자 그럼 이제 @CircuitBreaker 어노테이션의 두 번째 인자인 fallbackMethod를 봅시다.
- 여기에는 문자열로 fallback 메서드의 이름을 적어주면 됩니다. 그리고 이 fallback 메서드는 서킷 브레이커가 존재하는 클래스 내부에 함께 선언되어있어야만 합니다. 그래서 저는 클래스 하단에 "fallbackMemberInfo"라는 서킷 fallback 전용 메서드를 선언해 줬고 @CircuitBreaker의 fallbackMethod에도 문자열로 메서드명을 지정해 줬습니다. (이름이 꼭 동일해야 합니다)
public ResponseMemberDTO fallbackMemberInfo(Long memberId, Throwable t) {
// fallback 메서드 내에서 로그 출력
log.error("[FEIGN FALLBACK] CircuitBreaker 발동 - memberId={}, 원인={}", memberId, t.getMessage(), t);
return ResponseMemberDTO.builder()
.id(-1L)
.name("Fallback User")
.email("fallback@example.com")
.build();
}
fallbackMethod를 작성할 때는 규칙이 존재합니다. 그 규칙은 다음과 같습니다.
- fallback 메서드는 기본 메서드와 반드시 동일한 반환 타입을 가져야 하며, 기본 메서드와 동일한 매개변수를 사용하되 마지막 인자로 Throwable 타입을 추가해야 합니다. 예를 들어, 기본 메서드가 아래와 같다고 가정해 봅시다.
@CircuitBreaker(name = "member-service", fallbackMethod = "fallbackMemberInfo")
public ResponseMemberDTO findMemberInformation(Long memberId) {
// ...
}
- 이 상황에 fallback 메서드는 아래와 같이 작성해야 합니다. 맨 뒤에 매개변수로 Throwable이 추가된 것을 확인할 수 있습니다.
public ResponseMemberDTO fallbackMemberInfo(Long memberId, Throwable t) {
// 로직 작성
}
- 또한, fallback 메서드는 AOP 프록시를 통해 호출되므로 public(또는 접근 가능한) 접근 제어자를 사용해야 하며, 서킷 브레이커가 적용된 기본 메서드와 같은 클래스 내 또는 동일한 빈에서 정의되어야 합니다.
마지막으로, 장애 상황 발생 시 대체 로직을 수행할 수 있도록 fallback 메서드 내부에는 적절한 기본 값을 반환하고, 필요하다면 장애 원인을 파악할 수 있도록 로그를 남기는 것이 중요합니다. 이러한 규칙을 준수하면, 기본 메서드에서 예외가 발생할 경우 Resilience4j가 자동으로 fallback 메서드를 호출하여 안전하게 대체 결과를 반환하게 됩니다.
서킷 브레이커를 검사할 수 있는 api 만들기
서킷 브레이커의 상태를 확인할 수 있는 컨트롤러(api)를 만들어봅시다.
- 등록된 서킷 브레이커를 확인해야 하므로 CircuitBreakerRegistry를 주입받아서 사용합니다. 코드를 보면 알 수 있듯이 매우 쉽게 서킷 관련 정보를 확인할 수 있도록 기본적으로 이와 관련된 메서드를 지원합니다.
@RequestMapping("/api/circuit-breaker")
@RequiredArgsConstructor
@RestController
public class CircuitBreakerController {
private final CircuitBreakerRegistry circuitBreakerRegistry;
@GetMapping
public ResponseEntity<Map<String, Object>> getCircuitBreakerInfo() {
Map<String, Object> info = new HashMap<>();
// 등록된 모든 서킷 브레이커 정보 조회
for (CircuitBreaker cb : circuitBreakerRegistry.getAllCircuitBreakers()) {
Map<String, Object> cbInfo = new HashMap<>();
cbInfo.put("state", cb.getState().toString());
cbInfo.put("failureRate", cb.getMetrics().getFailureRate());
cbInfo.put("bufferedCalls", cb.getMetrics().getNumberOfBufferedCalls());
cbInfo.put("failedCalls", cb.getMetrics().getNumberOfFailedCalls());
info.put(cb.getName(), cbInfo);
}
return ResponseEntity.ok(info);
}
}
이 api가 바로 맨 처음 기본 설정 테스트를 할 때 사용한 api입니다.
- 호출 결과로는 아래와 같이 실제 서버에 등록된 서킷 브레이커의 상태가 표시됩니다. 여기서 바로 보이지 않더라도 당황하지 마세요! 서킷 브레이커를 포함하고 있는 메서드가 실행되어야 생기는 경우도 많습니다. (특히 어노테이션을 적용한 경우)
{
"grpcCircuitBreaker": {
"failureRate": -1,
"failedCalls": 0,
"state": "CLOSED",
"bufferedCalls": 0
},
"feignCircuitBreaker": {
"failureRate": -1,
"failedCalls": 0,
"state": "CLOSED",
"bufferedCalls": 0
},
"member-service": {
"failureRate": -1,
"failedCalls": 0,
"state": "HALF_OPEN",
"bufferedCalls": 0
}
}
서킷 브레이커 테스트 진행하기 (잘 적용되었는가?)
단순히 서킷 브레이커 자체의 성능을 테스트하기 위함이라면 설정 코드를 커스텀해서 매우 적은 요청만 받아도 서킷이 OPEN 되도록 만들고 테스트를 하셔도 됩니다. 저는 제가 설정코드에 지정한 대로 테스트를 진행하도록 하겠습니다.
이제 제가 Config 클래스에 설정한 내용을 분석해 봅시다. 제가 작성한 코드가 적용되었다면 이렇게 동작해야 합니다.
최근 10회의 호출을 기준으로 실패율을 계산하며, 만약 이 중 50% 이상의 호출이 실패하면 CircuitBreaker는 OPEN 상태로 전환되어 5초 동안 모든 호출을 차단합니다. 이후 자동으로 HALF_OPEN 상태로 전환되며, HALF_OPEN 상태에서는 최대 5회의 테스트 호출을 허용하여 시스템의 회복 여부를 판단합니다. 또한, FeignException, ConnectException, RuntimeException과 같은 예외를 실패로 기록함으로써, 이러한 예외가 발생할 경우 실패율에 반영되어 CircuitBreaker의 상태에 영향을 주게 됩니다.
그럼 테스트를 해봅시다. 먼저 서킷을 적용한 api에 요청을 9번 보내보겠습니다.
- 요청을 보낸 다음 서킷 상태확인 api를 호출하면 다음과 같은 결과를 얻게 됩니다.
"member-service": {
"failureRate": -1,
"failedCalls": 9,
"state": "CLOSED",
"bufferedCalls": 9
}
자 그럼 제가 설정한대로면 1번 더 호출하면 OPEN이 되어야 합니다. 보내봅시다!
"member-service": {
"failureRate": 100,
"failedCalls": 10,
"state": "OPEN",
"bufferedCalls": 10
}
그리고 5초가 지난 후 아래와 같이 자동으로 HALF_OPEN으로 변경되었습니다.
"member-service": {
"failureRate": -1,
"failedCalls": 0,
"state": "HALF_OPEN",
"bufferedCalls": 0
}
완벽합니다! 서킷 브레이커가 잘 적용된 것을 알 수 있습니다. 자 그럼 서킷이 잘 적용되었는지는 확인이 되었습니다.
마무리하며
서킷 브레이커는 MSA에서 굉장히 중요한 역할을 합니다. 왜냐하면 1개의 거대한 비즈니스 도메인을 모놀리식 서버로 구성했던 예전의 방식과는 달리 MSA에서는 거대한 비즈니스 도메인을 여러 개의 하위 도메인으로 분리해서 각 서버를 구성하고 이 서버들이 서로 유연하고 긴밀하게 통신을 하며 데이터를 가공하기 때문입니다. 만약 한 서버에서 장애가 발생했을 때 다른 서버들은 어떻게 될까요? 별로 상상하고 싶지도 않습니다. 만약 관련된 서버가 20개면 최악의 상황에는 21개의 서버가 모두 터질 것입니다. 그러니 서킷 브레이커를 사용해서 이런 상황에 대해 회복 탄력성을 가지도록 해줘야만 합니다.
생각해 보면 사용자는 우리가 만든 서버가 어떻게 구성되었는지는 전혀 신경 쓰지 않습니다. 어디 가서 사용자에게 저희가 MSA로 만들어서 이런 오류가 발생해요!!라고 해봤자 "그래서 나한테 어쩌라는 거지? 네가 잘 만들었어야지." 이런 생각을 할 것입니다. 그러니 저희 백엔드 개발자는 좋은 서비스를 제공하기 위해서 정말 끝없이 고민해야만 합니다. 그렇지 않으면 성격 급한 한국인은 1초만 느려도 이걸 왜 쓰는 거지? 하고 떠날 수도 있습니다. (고객 한 명 한 명이 소중합니다..)
그런데 생각해 보면 이게 간단한 게 아닙니다. MSA로 부드럽고 유연하며 회복도 잘되고 성능도 좋은 서비스를 만들어야 한다. 일반적인 기업에서 이걸 가능하게 하려면 어떤 노력이 필요할까요? 상상하기도 힘듭니다.. 이건 정말 엄청난 미션입니다. 장애가 발생하면 가능한 빠르게 복구해야 하고 그렇지 않으면 수많은 사용자는 떠납니다. 그런데 과연 이런 서버 간의 통신에서만 장애가 발생할까요? 그것도 아닙니다. 비즈니스 로직에서도 수많은 장애가 발생할 것입니다.
그리고 서버 간 장애보다는 내부의 장애가 압도적으로 많다 보니 우리가 집중하는 부분은 프로젝트 내부의 아키텍처가 주가 되었지만 (저도 그렇기는 합니다) 최근 느끼는 것은 아무리 내부 구성이 멋지고 견고해도 외부 저항이 없다면 쉽게 무너집니다. 죽어라 멋지게 만들어봐야 써먹을 수 없는 상황이 되면 의미가 없어지는 것입니다. 그러니 수많은 동기화, 데이터 요청, 다른 서버에 응답을 하면서 발생하는 예외를 우리는 탄력적으로 관리하기 위해 CircuitBreaker를 꼭 적용합시다.
긴 글 읽어주셔서 감사합니다. :)
'MSA' 카테고리의 다른 글
이벤트 소싱과 CQRS, 넌 대체 정체가 뭐냐? (0) | 2024.11.23 |
---|---|
[MSA] Transactional Outbox Pattern (1) | 2024.10.13 |
[Spring] 헥사고날 아키텍처 (2) | 2024.08.10 |
MSA 서버 간 통신: SNS의 MessageAttributes로 완벽한 Zeropayload 전략 구현하기 (1) | 2023.12.01 |
MSA 환경에서 SNS 메시지 재발행을 위한 스프링 배치 및 스케쥴러 구현 (1) | 2023.11.28 |