Develop/Java & Spring

[MSA] MSA 전환 프로젝트 - 7. 서비스 장애 처리(Circuit Breaker) with Resilience4J

jjh0119 2025. 3. 26. 13:31

1. 서비스 장애 처리는 왜 필요한가?

이전 포스트에서 MSA의 마이크로 서비스 간 통신에 대해 알아보았는데 이렇게 현재 외부에서 필요한 데이터를 요청하는 구조를 가진 MSA에서 만약 데이터를 요청해야 하는 해당 서버에 장애가 생긴다면 어떤 일이 일어날까?

이전 포스트의 예시처럼 user-service와 order-service 간 통신을 예로 들어 설명해보겠다.

  1. 클라이언트 측에서 user의 정보 API 요청
  2. API Gateway에서 user-service로 요청 라우팅
  3. user-service에서 userDto에 포함되어야 하는 orderList를 받아오기 위해 order-service로 OpenFeign을 이용해 요청
  4. order-service의 orders DB에서 해당 user의 userID와 일치하는 orderListg를 user-service로 응답
  5. OpenFeign의 응답 결과를 userDto에 setOrders()
  6. userDto객체를 클라이언트에게 응답

이런 Flow로 이루어진 user 정보 확인 기능에서 만약 order-service에 장애가 발생한다면 어떻게 될까?
장애 상황을 크게 두가지 경우로 나누어보자

  • order-service로의 요청이 아예 실패하는 경우
  • order-service로의 요청이 전달은 되지만 응답이 지연되는 경우

이 두가지의 경우는 다른 상황이지만 서비스에 미치는 악영향은 둘 다 비슷하다.

결과적으로 이 장애가 발생하고 있는 시간동안 응답을 기다리면서 메모리, 스레드 등 리소스를 계속 소모하게 된다는 것이다.
요청이 아예 실패하는 경우는 Timeout 시간만큼, 응답이 지연되는 경우는 Latency 시간만큼 스레드를 점유하게 될 것이다.

이렇게 장애가 지속된다면 이러한 불필요한 리소스 소비는 다른 서비스의 성능에도 악영향을 미쳐 전체 시스템의 리소스가 고갈되거나, 심한 경우 전체 시스템이 중단될 수도 있다.

예를 들어 order-service로부터 결과를 받아야만 user-service가 정상적으로 동작할 수 있다면 user-service의 요청도 지연되거나 실패할 수 있고 더 나아가 지금처럼 user-service - order-service의 단순한 관계가 아닌 더 복잡한 관계일 경우 연결된 다른 서비스에도 연쇄적으로 악영향(지연, 실패)이 확산될 수 있다.

이러한 상황은 사용자에게 엄청난 대기시간과 함께 요청 실패라는 굉장히 부정적인 경험을 주게 될 것이고 혹여나 이것이 결제 같이 민감한 서비스와 엮여들어간다면 굉장히 피곤한 상황이 될 것이 분명하다.

따라서 우리는 다음과 같은 필요성을 느끼게 된다.

요청을 발송할 서비스가 이미 장애가 발생한 상태라면 요청을 보내지 않는다.

서비스 호출에서 장애가 발생하더라도 사용자에게는 정상적인 응답을 보내고 싶다.

호출할 서비스에 연결이 지연되고 있어서 리소스를 점유하고 있다면 우선 다른 요청을 먼저 처리하도록 만들고 싶다.

이러한 필요성을 충족시켜주는 것이 Circuit Breaker 패턴이다.

물론 현재도 마이크로 서비스간 동기 호출 시 Circuit Breaker 패턴을 구현하여 개별 서비스 수준에서 장애 전파를 방지할 수 있지만 전체 시스템 차원의 중앙 집중식 장애 전파 방지책을 마련할 필요가 있다고 생각해
Resilience4j 라이브러리를 활용해 Circuit Breaker를 API Gateway에 적용함으로써 중앙 집중식 Circuit Breaker 패턴을 구현해보려 한다.


2. Circuit Breaker 패턴의 원리

Resilience4j 라이브러리를 사용해보기에 앞서 Circuit Breaker가 어떤 방식으로 작동하는지 그 원리에 대해 먼저 알아보려고 한다.

Circuit Breaker 패턴은 앞서 간략하게 말했듯이,
장애를 방지하기 위한 패턴으로 장애 발생 지점을 감지하고 실패하는 요청을 계속해서 보내지 않도록 방지하는 패턴이다.

즉,

요청을 발송할 서비스가 이미 장애가 발생한 상태라면 요청을 보내지 않는다.

이 필요성을 충족시키기 위한 패턴인 것이다.

Circuit Breaker는 이름에서도 알 수 있듯이 전기 회로의 차단기와 유사한 원리로 작동한다.

누전 차단기가 전기 회로에서 과부하, 누전, 단락등의 문제가 발생하면 이를 감지하고 회를 차단해 사고를 방지하는 것과 동일하게, 서비스 간 통신에서 특정 서비스가 과부하, 장애, 성능 저하 상태일 때 이를 감지하고 해당 서비스로의 호출을 일시적으로 차단하여 전체 서비스가 지연되거나 실패하는 것을 미리 방지하는 것이다.

Circuit Breaker는 크게 3가지 상태를 가지는데 이 역시도 전기 회로와 비슷한 방식의 상태를 가진다.


전기 회로를 생각해보면 스위치가 Closed 상태일 때 전류가 흐르고 Open 상태일 때는 전류가 흐르지 못한다.


Circuit Breaker도 마찬가의 원리로 동작하는데 Circuit Breaker에는 실패 임계치라는 것이 존재한다.
이 실패 임계치는 Circuit Breaker를 구현할 때 설정할 수 있으며 크게 3가지로 나뉜다.

1. Failure Rate Threshold(실패율 임계치)

  • 전체 호출 중에서 실패한 호출의 비율을 기준으로 설정하는 방식

2. Successive Failures Threshold(연속 실패 횟수 임계치)

  • 연속으로 실패한 요청의 횟수를 기준으로 설정하는 방식

3. Response Time Threshold(응답 시간 임계치)

  • 서비스의 응답 시간이 특정 임계치를 초과할 경우 실패로 간주하는 방식

이 실패 임계치를 기준으로 Circuit Breaker는 3가지 상태를 가지게 된다.

1. Closed

  • 정상 상태
  • 요청의 실패율이 설정한 실패 임계치보다 낮은 상태
  • 모든 요청이 대상 서비스로 전달됨

2. Open

  • 장애 상태
  • 요청의 실패율이 실패 임계치보다 높은 상태
  • 모든 요청이 즉시 차단되어 대상 서비스로 전달되지 않음

3. Half-Open

  • 회복 테스트 상태
  • Open 상태였던 Circuit Breaker가 사전에 설정해 놓은 시간이 지난 후의 상태
  • 제한된 수의 요청만 허용하여 서비스 상태를 확인
  • 요청의 성공/실패에 따라 Closed/Open 상태로 전환

3. Resilience4j의 원리

Circuit Breaker의 원리에 대해 알아보았으니 이제 본격적으로 Resilience4j는 어떤 원리로 동작하는지 알아보자.

Resilience4j는 호출 결과를 저장하고 분석하는 수단으로 '슬라이딩 윈도우'라는 것을 사용한다.
이 슬라이딩 윈도우는 개수/시간 2가지 기준으로 다음과 같이 나뉘는데

  • Count-based Sliding Window : 요청의 개수 단위로 요청을 저장 및 집계하는 슬라이딩 윈도우
  • Time-based Sliding Window : 요청시간 단위로 요청을 저장 및 집계하는 슬라이딩 윈도우

이러한 슬라이딩 윈도우를 기반으로 Circuit Breaker의 상태를 업데이트 하게 된다.

슬라이딩 윈도우를 통해 Open 상태가 된 Resilience4j는 장애 상태를 처리하기 위한 기능으로 'Fallback' 'BulkHead' 기능을 가지고 있다.

3-1. Fallback (대체 동작)

Fallback은 Circuit Breaker가 Open 상태가 되었을 때 서비스가 대신 수행할 수 있는 대체 동작을 정의하는 기능을 의미한다.
이를 통해 서비스는 완전한 장애 대신 제한된 기능이나 기본값을 반환하여 사용자 경험을 최소한으로 유지할 수 있고
앞서 설명한 Circuit Breaker의 필요성 중

서비스 호출에서 장애가 발생하더라도 사용자에게는 정상적인 응답을 보내고 싶다.

이것을 충족하는 방식인 것이다.

캐시된 데이터나 이전 데이터 같은 대체 데이터 제공해 사용자가 기다리지 않도록 하거나 사전에 설정한 기본값을 반환할 수도 있다.
아니면 장애가 난 서비스와 유사한 기능을 수행하는 다른 서비스나 외부 API를 호출하여 결과를 제공할 수도 있다.

이런 Fallback 기능을 통해 장애 상태에서도 시스템의 부분적인 기능을 유지해 사용자 경험을 최소한으로 유지할 수 있고 부분 서비스의 장애로 인한 전체 서비스의 중단을 방지하고 장애 회복 시까지 시스템을 안정적으로 유지할 수 있다.

3-2. BulkHead (격벽)

BulkHead는 응답시 지연되는 서비스에 자원을 모두 소진하지 않도록 자원 할당을 격리하여 하나의 서비스에서 발생한 문제로 인해 다른 서비스들이 영향을 받지 않도록 설계하는 방식이다.
해양 선박의 격벽 구조에서 유래된 개념으로 배에 여러 격실을 만들어 물이 한 격실에 유입되어도 나머지 격실까지 물이 넘치지 않도록 만든 것과 비슷한 원리다.
이는 앞서 설명한 Circuit Breaker의 필요성 중

호출할 서비스에 연결이 지연되고 있어서 리소스를 점유하고 있다면 우선 다른 요청을 먼저 처리하도록 만들고 싶다.

이것을 충족하기 위한 방식이다.

각 서비스 호출마다 별도의 쓰레드 풀을 사용하여 하나의 서비스가 과부하에 걸리더라도 다른 서비스들이 독립적으로 작동할 수 있도록 하거나 각 마이크로서비스 간의 연결 수를 제한하여, 특정 서비스에 장애가 발생해도 다른 서비스에 영향을 주지 않도록 한다.

Bulkhead 방식을 통해 하나의 서비스에서 발생한 장애가 다른 서비스에 영향을 주지 않도록 격리할 수 있어 전체 시스템의 안정성을 높이고 서비스의 리소스를 개별적으로 관리함으로써 예기치 않은 대기 시간이나 장애 전파를 방지할 수 있다.


4. Resilience4j 적용

원리에 대해 공부해 보았으니 이제 본격적으로 Resilience4j를 적용해볼 시간이다.

4-1. Dependency 추가

dependencies {
  ...
  implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j'
}

4-2. applicaion.yml

spring:
  cloud:
    gateway:
      default-filters:
        - name: CircuitBreaker
          args:
            name: defaultCircuitBreaker # Resilience4j에서 정의한 Circuit Breaker 인스턴스 이름을 지정
            fallbackUri: forward:/fallback # 요청을 차단한 경우, /fallback으로 대체 경로를 제공하도록 설정

resilience4j:
  circuitbreaker:
    configs:
      default:
        register-health-indicator: true  # Spring Actuator의 헬스 인디케이터에 Circuit Breaker 상태가 등록
        allow-health-indicator-to-fail: false  # 헬스 인디케이터에서 Circuit Breaker의 장애 상태를 반영할지 결정
        sliding-window-type: COUNT_BASED  # 슬라이딩 윈도우 타입: COUNT_BASED는 호출 수를 기준으로 함
        sliding-window-size: 10  # 슬라이딩 윈도우 크기를 설정하여 최근 10개의 호출에 대해 실패율을 측정
        minimum-number-of-calls: 10  # 서킷 브레이커가 동작하기 위한 최소 호출 수
        failure-rate-threshold: 50 #실패율 임계값 설정(%)
        slow-call-rate-threshold: 50  # 느린 호출 비율 임계값 (%)
        slow-call-duration-threshold: 10s  # 느린 호출의 기준 시간 (초)
        wait-duration-in-open-state: 10s  # 서킷 브레이커가 오픈 상태에서 유지되는 시간 (초)
        permitted-number-of-calls-in-half-open-state: 5  # 반 오픈 상태에서 허용되는 호출 수
        record-exceptions:  # 서킷 브레이커가 예외로 간주할 예외 클래스들
          - java.util.concurrent.TimeoutException  # 타임아웃 예외
          - org.springframework.cloud.gateway.support.NotFoundException  # NotFound 예외
          - io.github.resilience4j.circuitbreaker.CallNotPermittedException  # 서킷 브레이커가 호출을 허용하지 않는 예외

    instances:
      defaultCircuitBreaker:
        baseConfig: default  # 기본 설정을 상속받음

Circuit Breker의 주요 설정값은 다음과 같다.

failure-rate-threshold: 호출 실패율에 대한 임계값, 슬라이딩 윈도우 상에서 임곗값보다 실패율이 높아지면 상태가 OPEN 된다. 기본값은 50

sliding-window-type: 슬라이딩 윈도의 형태를 COUNT_BASED로 설정할지, TIME_BASED로 설정할지 결정한다. 타입에 따라 slidingWindowSize의 숫자가 의미하는 것이 호출 횟수 또는 시간이 된다. 기본값은 COUNT_BASED

sliding-window-size: 슬라이딩 윈도우의 크기를 설정한다 기본값은 100

waitDurationInOpenState : Open 상태에서 Half-Open 상태로의 전환을 기다리는 시간을 설정한다. 기본값은 60

4-3. Fallback 설정

@RequestMapping("/fallback")
    public Mono<ResponseEntity<String>> fallback(ServerWebExchange exchange) {
        Throwable exception = exchange.getAttribute(ServerWebExchangeUtils.CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR);
        log.info("예외: {}", exception != null ? exception.getMessage() : "Unknown error");

        return Mono.just(exception)
                .map(ex -> {
                    if (ex instanceof NotFoundException) {
                        return new ResponseEntity<>(
                        "내부 서버 오류가 발생했습니다. 관리자에게 문의해 주세요.", HttpStatus.SERVICE_UNAVAILABLE);
                    } else if (ex instanceof TimeoutException) {
                        return new ResponseEntity<>(
                        "서비스 요청 시간이 초과되었습니다. 나중에 다시 시도해 주세요.", HttpStatus.GATEWAY_TIMEOUT);
                    } else if (ex instanceof CallNotPermittedException) {
                        return new ResponseEntity<>(
                        "서비스가 현재 사용 불가능합니다. 잠시 후 다시 시도해 주세요.", HttpStatus.SERVICE_UNAVAILABLE);
                    } else {
                        return new ResponseEntity<>(
                        "내부 서버 오류가 발생했습니다. 관리자에게 문의해 주세요.", HttpStatus.SERVICE_UNAVAILABLE);
                    }
                })
                .defaultIfEmpty(new ResponseEntity<>(
                "내부 서버 오류가 발생했습니다. 관리자에게 문의해 주세요.", HttpStatus.SERVICE_UNAVAILABLE));
    }

앞서 application.yml의 spring.cloud.gateway.default-filters.args.fallbackUri 속성으로 '/fallback'을 등록했었다.
이는 Circuit Breaker가 동작해 요청을 차단하게 될 경우 컨트롤러에 등록된 '/fallback' Endpoint를 가진 메서드로 라우팅 한 후 요청 실패에 대한 Exception 별로 구분해서 응답을 반환하도록 한다는 뜻이다.
ServicerWebExchange에서 CIRCUITBREAKER_EXECUTION_EXCEPTION_ATTR 속성을 가져온 예외를 통해 예외별로 다른 응답을 반환하도록 했다.

5-3. Bulkhead 설정

Resilience4j의 Bulkhead는 다음과 같은 2개 종류의 구현체로 나뉜다.

  • SemaphoreBulkhead : 동시 호출 요청 수 제한
  • FixedThreadPoolBulkhead : 스레드풀 설정 제공

기본적으로 Semaphore Bulkhead를 사용해 간단하게 동시 호출 요청 수를 제한하여 자원을 격리할 수 있다.
Fixed ThreadPool Bulkhead를 사용하면 좀 더 추가적으로 스레드를 조절해 자원을 세세하기 격리할 수 있다.

일단은 제일 기본적인 Semaphore Bulkhead를 사용해 동시 요청을 제한해 보겠다.

application.yml

resilience4j:
  bulkhead:
    configs:
      default:
        max-concurrent-calls: 5
        max-wait-duration: 0
    instances:
      defalutBulkhead:
        base-config: default

Circuit Breaker와 유사한 방식으로 설정할 수 있다.
주요 속성은 다음과 같다.

  • max-concurrent-calls : 최대 동시 요청 수를 의미
  • max-wait-duration : 최대 동시 요청 수를 초과했을 때 대기할 시간
spring:
  cloud:
    gateway:
      routes:
        - id: user-service-login
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/login
            - Method=POST
          filters:
            - RemoveRequestHeader=Cookie
            - RewritePath=/user-service/(?<segment>.*), /$\{segment}
            - name: Bulkhead
              args:
                name: defalutBulkhead
                fallbackUri: forward:/bulkhead-fallback
            - name: CircuitBreaker
              args:
                name: defaultCircuitBreaker
                fallbackUri: forward:/fallback

그리고 설정된 Bulkhead를 user-service-login에 적용하고 싶다면 이렇게 하면 된다.

다만 여기서 주의해야할 점은 아까 defaultCircuitBreaker를 defulat filter 속성에 적용했던 것을 삭제하고 각각의 Route에 다시 defaultCircuitBreaker를 filter로 추가해줘야 한다는 것이다.

Bulkhaead는 서비스에 유입되는 동시 요청 수를 제한하여 과부하가 되지 않도록 방지하는 역할이기 때문에 Circuit Breaker보다 먼저 작동해서 Circuit Breaker의 과도한 활성을 방지해야할 필요가 있다.

그런데 만약 default filter로 Circuit Breaker를 설정할 경우 defalut filter가 가장 먼저 적용되므로 Bulkhead를 적용하는 의미가 없어지게 되기 때문이다.