본문 바로가기

Spring boot

[Spring boot] - 서킷브레이커 도입하기

728x90

서킷브레이커 도입 배경

보통 공통 플랫폼에서 제공하는 기능들은 실시간성을 보장하기 위해 동기 방식으로 연동됩니다.

고객 상태 정보, 서비스 사용 현황 등 사용자가 요청하는 즉시 조회하거나, 데이터가 확보되지 않으면 그 다음 api를 호출하지 못하는 경우 비동기로 처리하기엔 제약이 많기 때문입니다.

 

동기식 연동은 구현이 직관적이고 실시간 데이터를 즉시 확보할 수 있다는 장점이 있지만, 서비스와 외부 시스템 간의 강력한 결합이라는 대가를 치러야 합니다. 호출한 외부 시스템이 느려지면 호출한 쪽의 쓰레드가 응답을 기다리며 묶이게 되고, 이는 곧 시스템 전체의 장애 전파로 이어집니다.

물론 MSA 형태로 분리했다고 하더라도, 외부 호출을 담당하는 어플리케이션은 하나의 플랫폼이 아니라 여러 플랫폼을 호출하는 기능을 가지고 있기 때문에 전체적으로 중단되는 것을 막을 순 없습니다.

카프카와 같은 메시지 큐(MQ)를 활용한 비동기 전환이 근본적인 해결책이 될 수 있지만, 앞서 언급한 실시간성 보장이나 코드 복잡도 증가, 개발 우선순위 등의 현실적인 문제로 인해 전환이 어려운 경우가 많습니다.

 

최근 운영 중인 MSA 환경에서 외부 시스템(A) 의 장애가 우리 시스템에 그대로 영향을 미쳤습니다.

A의 여러 엔드포인트(a,b,c) 중 a 서버의 장애로 인해 타임아웃이 발생하면서 쓰레드 풀이 고갈되었고 장애와 관계없는 b,c 뿐만 아니라 다른 외부 시스템들 (B,C) 기능까지 호출이 불가능한 상황으로 번졌습니다.

해당 시스템 가이드라인에 따라 길게 설정된 타임아웃 시간도 문제가 되었지만, 장애 상황에서 다른 시스템들 까지 장애 전파가 되지 않도록 제어하는 것이 필요했습니다.

 

바로 이런 상황에서, 서킷브레이커가 동기 통신의 장점을 유지하면서도 장애 전파를 확실히 끊어줄 역할을 할 수 있습니다.

 

 

서킷브레이커란?

 

서킷브레이커(Circuit Breaker) 란 이름 그대로 전기 회로 차단기와 같습니다.

과전류에 의해 가전제품이 타버리는걸 방지하듯 소프트웨어에서도 특정 서비스(외부 시스템)에 장애가 발생하면 호출을 차단하여 우리 시스템의 자원(Thread)을 보호하는 역할을 합니다.

 

현재 Spring Boot 생태계에서 사실상 표준 라이브러리는 Resilience4j입니다. 과거의 Netflix Hystrix보다 가볍고, 함수형 프로그래밍에 최적화되어 있어 세밀한 제어가 가능하다는 장점이 있습니다. Hystrix는 현재 유지보수가 되고있지 않습니다.

 

서킷브레이커는 단순히 열고 닫는게 아니라 스스로 상태를 관리하며 복구를 시도하는 로직으로 동작합니다.

  1. CLOSED (정상):
    • 외부 시스템이 멀쩡한 상태입니다. 모든 요청은 정상적으로 전달됩니다.
    • 하지만 서킷브레이커는 뒤에서 실패율이나 지연시간 등을 계산하고 있습니다.
  2. OPEN (장애/차단):
    • 실패율이 우리가 정한 임계치(예: 50%)를 넘거나, 너무 느린 호출(Slow Call)이 많아지면 서킷이 내려갑니다.
    • 이 상태가 되면 외부 시스템을 아예 호출하지 않습니다. 요청이 들어오자마자 즉시 에러(CallNotPermittedException)를 던지거나, 미리 준비한 Fallback을 실행합니다.
  3. HALF-OPEN (복구 시도):
    • 서킷이 열리고 일정 시간이 지나면, 외부 시스템이 괜찮아졌는지 판단하기 시작합니다.
    • 설정해놓은 일부의 요청만 외부 시스템으로 보내보고, 이 요청들이 성공하면 다시 CLOSED로 돌아가고, 또 실패하면 다시 OPEN으로 변경합니다.

서킷브레이커는 단순히 에러가 났다고 해서 바로 서킷을 열지는 않습니다. Resilience4j는 두 가지 윈도우 방식을 제공하며 호출 빈도에 따라 이를 다르게 적용할 수 있습니다.

  • Count-based (횟수 기반): 최근 N번의 호출 중 실패율을 계산합니다.
  • Time-based (시간 기반): 최근 N초 동안의 호출 중 실패율을 계산합니다.

 

서킷브레이커 적용하기

 

FeignClient를 사용하고 있으며 인터페이스 별로 분리해서 서킷브레이커 레지스트리에 등록하여 각각의 설정을 관리할 수 있습니다. name을 동일하게 가져간다면 bean 충돌이 일어날 수 있으니 별도의 이름을 지정하거나 contextId를 별도로 지정하여 독립적인 bean 이름을 명시하여 프록시 객체를 등록할 수 있습니다.

 

@FeignClient(name = "ext-api", contextId = "extA", url = "${external.ext.url}")
public interface externalA {
    @GetMapping("/v1/header")
    String callA();
}

@FeignClient(name = "ext-api", contextId = "extB", url = "${external.ext.url}")
public interface externalB {
    @GetMapping("/v1/profile")
    String callB();
}

 

Resilience4j는 CircuitBreakerRegistry라는 중앙 저장소를 운영합니다.

아래와 같이 enable 해준다면, 어플리케이션 구동 시점에 yaml의 instances에 등록한 항목들(extA,extB) 을 읽어서 CircuitBreaker 객체를 생성하고 CircuitBreakerRegistry에 등록해서 서킷을 관리해줍니다.
앞서 정의한 contextId에 의해 생성된 bean 정보를 자동으로 매칭하여 서킷브레이커의 아이템 이름으로 사용합니다.

 

주요 설정은 아래와 같으며 인스턴스마다 별개의 설정을 추가하는 것이 가능합니다.

예를들어 기본 설정에 의하면 최근 50번의 호출 중 50% (25번) 이상 실패했다면 장애 상황으로 인지하고 서킷을 open 하게 됩니다. 이 때는 외부 시스템을 호출하지 않고 표준 500 에러를 응답하거나 아래에서 적용할 핸들러에 의해 별도 응답 포멧을 리턴하게 됩니다.

# 1. OpenFeign에서 서킷브레이커 기능을 사용하겠다고 선언
spring:
  cloud:
    openfeign:
      circuitbreaker:
        enabled: true 

# 2. Resilience4j 서킷브레이커 상세 설정
resilience4j:
  circuitbreaker:
    configs:
      default:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 50         # 최근 50개 호출을 저장
        minimumNumberOfCalls: 10      # 최소 10번은 호출되어야 실패율 계산 시작
        failureRateThreshold: 50      # 에러 50% 이상이면 서킷 오픈

        slowCallDurationThreshold: 30000 # 지연시간 30초 기준
        slowCallRateThreshold: 80     # 느린 호출이 80% 이상이면 서킷 오픈

        waitDurationInOpenState: 30s  # OPEN 상태에서 30초 대기 후 HALF_OPEN으로 전환
        permittedNumberOfCallsInHalfOpenState: 10 # HALF_OPEN 상태에서 10번 테스트 호출
        automaticTransitionFromOpenToHalfOpenEnabled: true # 대기 시간 지나면 자동으로 HALF_OPEN 전환

        # 무시할 예외 (4xx 에러 등)
        ignoreExceptions:
          - feign.FeignException$BadRequest    # 400
          - feign.FeignException$NotFound      # 404
          - feign.FeignException$Unauthorized  # 401
          - feign.FeignException$Forbidden     # 403
          - java.lang.IllegalArgumentException

    instances:
      extA:
        baseConfig: default
        slidingWindowSize: 100 
        minimumNumberOfCalls: 20

      extB:
        baseConfig: default
        slidingWindowType: TIME_BASED
        slidingWindowSize: 60
        minimumNumberOfCalls: 5

 

 

서킷 등록/상태변화 발생 시 RegistryEventConsumer 적용

이제 여러개의 서킷브레이커를 등록했으니 상태 변화를 감지해 줄 리스너 등록이 필요합니다. 개별 서킷마다 상태변화를 감지하는 것은 비효율적이기 때문에 RegistryEventConsumer 인터페이스를 활용하면 서킷이 추가될 때마다 자동으로 리스너를 붙여줍니다.

RegistryEventConsumer가 bean으로 등록되고나면 CircuitBreakerRegistry는 이런 consumer가 있는지 확인하고 레지스트리 내부의 이벤트 감시자로 지정합니다. 따라서 extA, extB 같은 서킷브레이커 인스턴스가 생성되거나 상태가 변경될 경우 consumer 에 작성된 코드가 실행됩니다. 

@Configuration
@Slf4j
public class CircuitBreakerConfig {

    /**
     * 모든 CircuitBreaker 인스턴스에 공통 리스너를 자동으로 부착하는 빈입니다.
     */
    @Bean
    public RegistryEventConsumer<CircuitBreaker> circuitBreakerEventConsumer() {
        return new RegistryEventConsumer<CircuitBreaker>() {
            @Override
            public void onEntryAddedEvent(EntryAddedEvent<CircuitBreaker> entryAddedEvent) {
                // 1. 레지스트리에 등록된 서킷브레이커 인스턴스를 가져옵니다 (extA, extB 등)
                CircuitBreaker circuitBreaker = entryAddedEvent.getAddedEntry();
                String cbName = circuitBreaker.getName();

                circuitBreaker.getEventPublisher()
                    .onStateTransition(event -> {
                        // 실제 서킷 상태가 CLOSED -> OPEN 등으로 변할 때 실행되는 로직
                        log.warn("🚨 [서킷 상태 변화 발생] 이름: {}, 전환: {} -> {}",
                            cbName,
                            event.getStateTransition().getFromState(), // 이전 상태
                            event.getStateTransition().getToState());  // 현재 상태
                    })
                
                log.info("서킷브레이커 리스너 등록 완료: {}", cbName);
            }

            @Override
            public void onEntryRemovedEvent(EntryRemovedEvent<CircuitBreaker> entryRemovedEvent) {}
            @Override
            public void onEntryReplacedEvent(EntryReplacedEvent<CircuitBreaker> entryReplacedEvent) {}
        };
    }
}

 

위 코드에 의해 extA, extB가 생성될 때마다 .onStateTransition 콜백 함수를 연결하고  "서킷브레이커 리스너 등록 완료: extA" 로그가 발생합니다.

이후 서킷 상태가 전환되는 경우 로그를 추가로 남김으로써 모니터링 도구와 연동한다면 효과적일 것입니다.

 

CallNotPermittedException  발생 시 GlobalExceptionHandler 적용

서킷브레이커가 작동하여 호출이 차단될 때 CallNotPermittedException 을 발생시킵니다. 이 예외를 각각 exception 예외처리 할 수도 있지만, 전역적으로 낚아채서 클라이언트에게 적절한 응답 코드(보통 503 Service Unavailable)와 메시지를 전달할 수도 있습니다.

 

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    /**
     * 서킷브레이커가 OPEN 상태일 때 발생하는 CallNotPermittedException 처리
     */
    @ExceptionHandler(CallNotPermittedException.class)
    @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) // 503 에러 반환
    public ErrorResponse handleCallNotPermittedException(CallNotPermittedException e) {
        // 어떤 서킷브레이커에서 발생했는지 이름을 가져올 수 있습니다 (extA, extB 등)
        String circuitName = e.getCircuitBreakerName();
        
        log.error("🛑 서킷브레이커가 OPEN 상태입니다. 호출이 차단되었습니다. 명칭: {}", circuitName);

        // 클라이언트에게 줄 응답 객체 생성
        return new ErrorResponse(
            "SERVICE_UNAVAILABLE",
            String.format("[%s] 외부 시스템 서비스가 일시적으로 중단되었습니다. 잠시 후 다시 시도해주세요.", circuitName)
        );
    }
}

 

서킷브레이커를 도입한 가장 중요한 목적 중 하나는 "장애 전파 방지" 입니다. 만약 예외처리를 제대로 하지 않고 로그만 남길 경우 클라이언트 측은 단순한 서버 오류로 인지하게 됩니다.

따라서 현재 서킷브레이커가 오픈되어 외부 시스템이 일시 중단된 상태임을 알려줄 수 있는 로그를 제대로 전달해야 하며 어떤 외부 시스템에 문제가 생겼는지 즉시 파악해야 합니다.

 

만약 글로벌 익셉션 핸들러를 적용하지 않는다면 아래와 같은 기본 포멧으로 응답해줄 것입니다.

{
    "timestamp": "2026-02-01T15:05:22.123+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "trace": "io.github.resilience4j.circuitbreaker.CallNotPermittedException: CircuitBreaker 'extA' is OPEN and does not permit further calls ... (이하 생략)",
    "message": "CircuitBreaker 'extA' is OPEN and does not permit further calls",
    "path": "/api/v1/some-data"
}

 

 

결론과 이후 적용 사항

FeignClient -> Circuit Breaker Registry (FeignClient 인터페이스를 등록하여 외부 시스템 장애 시 차단한다.)  -> Listener (서킷 등록 및 상태변화를 감지하고 로그를 남긴다) -> Global Exception (차단된 요청을 가로채서 커스텀 응답값을 제공한다)

 

팀에서 서킷브레이커를 도입 후 오픈서치 alert 기능과 연동하여 서킷 오픈 시 즉시 대응할 수 있도록 감지 체계를 적용했습니다.

추가로 spring boot actuator와 grafana를 연동하여 현재 서킷브레이커 상태를 시각화했습니다. 

 

서킷이 오픈되는 상황이 없다면 좋겠지만 그런 상황이 발생할 경우 빠르게 대비할 수 있는 체계를 마련해야 하며 세부 트리거 조건은 FeignClient 인터페이스 별로 호출량을 비교해서 튜닝하는 것이 중요합니다.

예를들어 분당 300회 호출되는 api가 있고 분당 30회 호출되는 api가 존재했기에 slidingWindowSize를 어떻게 할 것인지, Type은 어떻게 할 것인지 등 의사결정이 필요합니다.

ex) 최대 쓰레드 수가 200개이고 분당 300회(초당 5회) 일 경우 최악의 경우 40초 이내에 서버가 중단될 위험이 있기 때문에 최소한 20초 이내에는 서킷브레이커 판단이 필요하다고 보인다. 최소 표본 수를 30개로 지정한다면 6초 만에 판단을 시작하도록 할 수 있다.

 

또는 응답값이 비어있다면 이것을 내부적으로 장애로 판단할 것인지 등을 잘 고려해서 서킷 오픈 카운트에 포함시킬지도 고려해야 합니다.

또한 외부 시스템을 호출하는 인터페이스 전체에 서킷브레이커를 적용하는 것이 아닌, sms 발송 등 비동기 api를 호출하는 인터페이스의 경우 예외로 등록해주는 것도 중요합니다.

 

서킷브레이커는 다양한 장애 감지 수단 중 하나일 뿐이지만, 장애 전파 방지 및 인지 수단으로써 좋은 아이템이 될 수 있습니다.

또한 예를들어 외부 시스템이 500 Internal Server Error를 빠르게 리턴하는 상황이라면 우리 서버는 쓰레드 고갈이 없겠지만 상대방 서버의 복구를 도와주는 역할도 할 수 있습니다.

728x90

'Spring boot' 카테고리의 다른 글

Java - 불변 객체  (1) 2024.12.25
Java - Object 클래스  (0) 2024.12.24