본문 바로가기
개발/Spring

[MSA] 서킷브레이커 적용 (Resilience4j)

by baau 2024. 1. 23.

작년 소프트웨어 마에스트로 14기에서 개발을 할 때, 내가 가장 많이 성장했던 시기라고 생각한다.

열정적인 동료(세무무 & 동구)와 쏘마의 지원 덕분이라고 생각이 든다. 그 중 클라우드 비용이 지원되었기 때문에 AWS의 여러 리소스를 학습하며 사용해보고, 기술적으로 MSA를 적용해면서 많이 배웠다. 현재는 그 전과 다른 기술을 바탕으로 일을 하고 있어, 이대로면 예전에 공부했던 내용을 다 잊어버릴까 시간이 날 때, 이전에 개발하고 공부했던 내용들을 기록하려고 한다.

 

먼저 MSA 환경에서 개발하면서, CircuitBreaker가 많이 신기하고 재밌게 개발한 기억이 있어, CircuitBreaker를 적용한 예시와 개념을 정리하려고 한다!

 

서킷브레이커 개념

  • 여러 서비스로 구성된 MSA 환경에서는 한 서비스에 장애가 발생했을 때, 다른 서비스가 영향을 받을 수 있다.
    = 정상적인 서비스가 장애가 발생한 서비스에 의존해서 서비스를 제공할 때, 장애가 전파될 수 있다.
  • 만약 A 서비스가 B 서비스를 호출하는 상황에서, B 서비스에서 장애가 발생하면 사용자는 A 서비스까지도 장애가 발생한 것과 같이 느껴질 수 있다.
  • 따라서, B 서비스 호출에 대한 연속 실패 횟수가 임계값을 초과한 경우 서킷 브레이커가 동작하여 B 서비스를 호출하지 못하도록 막는다.
  • 또한, 폴백(fallback) 메서드를 통해 B 서비스의 기능을 대체할 수 있는 기능도 제공한다.

 

https://martinfowler.com/bliki/CircuitBreaker.html

 

위와 같이 서킷브레이커는 3가지 상태 (Closed, Open, Half Open)를 갖는다.

  • Closed → Open : 외부 서비스의 장애 (Slow Call, Failure Call)가 기준치 이상 발생할 경우
  • Closed : 장애가 발생한 외부 서비스가 회복할 수 있도록 호출을 일정 시간동안 차단하는 상태
  • Open → Half Open : 일정 기간 이후 Half Open 상태로 변경
  • Half Open : 장애가 발생한 외부 서비스가 회복됐는지 점검하며, Closed 상태와 같이 트래픽을 흘려보내는 상태
  • Half Open → Closed : 외부 서비스가 회복되었다고 판단하면, Closed 상태로 변경하고 트래픽 다시 받을 수 있도록 한다.
  • Half Open → Open : 외부 서비스가 회복되지 않았다면, 다시 Open 상태로 변경하고 트래픽을 차단한다.

* Slow Call : 기준 시간보다 오래 걸리는 요청 / Failure Call : 실패하거나 오류를 응답하는 요청

 

 

서킷브레이커 적용 사례

"마이브러리" 서비스는 유저 서비스와 도서 서비스로 나누어져 있으며, 도서 서비스에서 도서의 리뷰를 보여줄 때 사용자의 프로필과 닉네임을 함께 보여주기 위해 유저 서비스를 호출한다. 하지만 유저 서비스에 장애가 발생한다면, 도서 서비스에서 제공하는 리뷰 기능도 동작하지 않게 되어 서킷 브레이커를 적용하여 문제를 해결하였다.

  • 유저 서비스의 트래픽을 차단하고,
  • fallback 함수를 통해 임시 닉네임과 기본 프로필 사진을 반환할 수 있도록 구현하였다.

 

의존성 추가

implementation "io.github.resilience4j:resilience4j-spring-boot3"
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-aop'

 

설정 파일

# application-resilience4j.yml

resilience4j.circuitbreaker:
  configs:
    userServiceFeignClientCircuitBreakerConfig:
      slidingWindowType: COUNT_BASED
      minimumNumberOfCalls: 10
      slidingWindowSize: 10
      waitDurationInOpenState: 10s

      failureRateThreshold: 50

      slowCallDurationThreshold: 10000
      slowCallRateThreshold: 100

      permittedNumberOfCallsInHalfOpenState: 10
      automaticTransitionFromOpenToHalfOpenEnabled: true

      eventConsumerBufferSize: 100

      recordExceptions:
        - java.net.UnknownHostException
        - java.net.SocketTimeoutException
        - java.net.ConnectException
        - feign.RetryableException
        - feign.FeignException.InternalServerError
        - feign.FeignException.BadGateway
        - feign.FeignException.GatewayTimeout
        - feign.FeignException.TooManyRequests
  instances:
    userServiceCircuitBreakerConfig:
      baseConfig: userServiceFeignClientCircuitBreakerConfig

 

recordExceptions

  • 실패라고 간주할 exception 리스트
  • 아직 어떤 예외를 실패라고 간주해야하는지는 정확하게는 잘 모르지만,,
  • 유효성 검사나 NPE와 같은 예외는 장애와 무관한 예외라 등록하면 좋지 않다.
  • Exception이나 RuntimeException 처럼 너무 높은 수준의 예외를 등록하면 안된다.
  • 보호하려는 대상에서 어떤 예외가 던져지는지를 파악하는 것이 중요하다. 예를 들어 DB에서 발생하는 예외라면, 스트레스 테스트나 부하테스트를 통해 실제 어떤 예외가 발생하는지 혹은 오픈소스나 라이브러리를 통해서 예외를 찾아보는 것이 바람직하다고 한다.

slidingWindowType

  • COUNT_BASED (default), TIME_BASED

slidingWindowSize (window size)

  • 서킷브레이커가 Closed 상태에서 실패율을 집계할 때 사용
  • COUNT_BASED : 100번 동안 몇 번의 호출이 실패했는가. / TIME_BASED : 100초 동안 몇 개가 실패했는가.
  • default 100

failureRateThreshold

  • 실패율의 임계값, 실패율이 임계값보다 크다면 서킷브레이커의 상태를 OPEN로 변경한다.
  • default 50

slowCallRateThreshold

  • 느린호출 임계값, slowCallDurationThreshold 보다 응답 시간이 긴 호출율이 임계값보다 크면 서킷브레이커의 상태를 OPEN로 변경한다.
  • default 100

minimumNumberOfCalls

  • 일정 수준부터 OPEN을 할지에 대한 판단
  • default 100, slidingWindowSize의 기본값과 동일

waitDurationInOpenStat

  • OPEN 상태에서 HALF_OPEN 상태로 변경하기까지 대기하는 시간

permittedNumberOfCallsInHalfOpenState

  • HALF_OPEN 상태에서 CLOSE로 가기 위해 허용되는 호출의 수
  • HALF_OPEN의 slidingWindowSize라고 간주해도 무방하며, permittedNumberOfCallsInHalfOpenState 횟수 내에서 failureRateThreshold 기준을 넘어가면 OPEN으로 아니면 CLOSE로 상태를 변경한다.

automaticTransitionFromOpenToHalfOpenEnabled

  • OPEN 상태에서 waitDurationInOpenState 대기 후, HALF_OPEN으로 자동으로 전환할지 말지에 대한 여부
  • True : waitDurationInOpenState 시간이 지나면 바로 HALF_OPEN으로 변경
  • False : 요청이 들어올 때, waitDurationInOpenState 시간 이후라면 그때 HALF_OPEN으로 변경

slowCallDurationThreshold

  • 몇 ms 동안 요청이 처리되지 않으면 실패로 간주할 것인가?
  • 만약 3초라고 설정시, 3초가 지났다고 하더라고 요청은 그대로 처리되지만, 서킷 브레이커에서는 실패로 간주한다.

 

코드

@FeignClient(name = "userClient")
public interface UserServiceClient {

    String DEFAULT_PROFILE_IMAGE_URL = "defaul image url";
    
    @GetMapping("/api/v1/users/info")
    @Headers("Content-Type: application/json")
    @Retry(name = "userServiceRetryConfig", fallbackMethod = "getUsersInfoFallback")
    @CircuitBreaker(name = "userServiceCircuitBreakerConfig", fallbackMethod = "getUsersInfoFallback")
    UserInfoServiceResponse getUsersInfo(@RequestParam("userId") List<String> userIds);

    default UserInfoServiceResponse getUsersInfoFallback(List<String> userIds, Exception ex) {
        return UserInfoServiceResponse.builder()
                .data(makeTemporaryResponse(userIds))
                .build();
    }

    private UserInfoList makeTemporaryResponse(List<String> userIds) {
        return UserInfoList.builder()
                .userInfoElements(userIds.stream()
                        .map(userId -> UserInfoServiceResponse.UserInfo.builder()
                                .userId(userId)
                                .nickname(makeTemporaryNickname(userId))
                                .profileImageUrl(DEFAULT_PROFILE_IMAGE_URL)
                                .build()
                        ).toList()
        ).build();
    }
    
    ...
}
  • 도서 서비스에서 유저 서비스를 호출하기 위해서 FeignClient를 사용했다.
  • @CircuitBreaker(name = "userServiceCircuitBreakerConfig", fallbackMethod = "getUsersInfoFallback")
    • name : 위 설정파일에 설정한 서킷브레이커 중 어느 것을 선택할지 인스턴스 이름을 설정
    • fallbackMethod : 서킷브레이커 상태가 Open일 때, 지정한 fallback 함수를 통해 원리 기능을 대체한다. 위에 말했던 것과 같이 유저 서비스 장애 발생 시, 임시 닉네임과 기본 이미지를 사용하여 반환해주었다.
default UserInfoServiceResponse getUsersInfoFallback(List<String> userIds, Exception ex) {
    return UserInfoServiceResponse.builder()
            .data(makeTemporaryResponse(userIds))
            .build();
}

 

fallback 함수를 작성시 주의할 점!

  • fallback 함수를 호출하는 함수의 매개변수가 동일해야 하며, 반환값도 동일해야 한다.
  • 추가로 Exception을 지정해주어야 한다. 

 

결과

  • 유저 서비스에 장애를 발생시킨 이후, 도서 서비스에서 유저 서비스 호출시 서킷브레이커가 잘 동작하여 fallback 함수가 동작함을 확인 할 수 있다.
  • 유저의 닉네임과 프로필 사진은 도서 리뷰를 조회할 때, 리뷰에 비해 중요한 데이터가 아니기 때문에, 아래와 같이 임시의 유저 닉네임과 default 프로필 사진 URL을 반환하도록 구현하도록 구현했다.

 

+ Spring Actuator Metrics를 통해 서킷브레이커 적용 과정 확인

{
   "circuitBreakerEvents":[
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:50.129101+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":160,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:50.957647+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":2,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:51.888410+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":2,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:52.884369+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":2,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:53.850780+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":1,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:54.921101+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":2,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:55.993618+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":3,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:57.132301+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":2,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:58.145902+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":2,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"ERROR",
         "creationTime":"2023-08-30T00:57:59.196012+09:00[Asia/Seoul]",
         "errorMessage":"feign.RetryableException: user-service executing POST http://user-service/api/v1/users/info",
         "durationInMs":3,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"FAILURE_RATE_EXCEEDED",
         "creationTime":"2023-08-30T00:57:59.197829+09:00[Asia/Seoul]",
         "errorMessage":null,
         "durationInMs":null,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"STATE_TRANSITION",
         "creationTime":"2023-08-30T00:57:59.203963+09:00[Asia/Seoul]",
         "errorMessage":null,
         "durationInMs":null,
         "stateTransition":"CLOSED_TO_OPEN"
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"NOT_PERMITTED",
         "creationTime":"2023-08-30T00:58:00.232438+09:00[Asia/Seoul]",
         "errorMessage":null,
         "durationInMs":null,
         "stateTransition":null
      },
      {
         "circuitBreakerName":"userServiceCircuitBreakerConfig",
         "type":"STATE_TRANSITION",
         "creationTime":"2023-08-30T00:58:10.103963+09:00[Asia/Seoul]",
         "errorMessage":null,
         "durationInMs":null,
         "stateTransition":"OPEN_TO_HALF_OPEN"
      }
   ]
}
  • Spring Actuator의 circuitbreakerevents 엔드포인트를 통해 기록된 이벤트를 위와 같이 확인할 수 있다.
    • 위 설정 파일을 적용시 다음과 같이 적용됨을 확인 할 수 있다.
  • recordExceptions에 등록된 예외가 10번 발생하여 circuitbreaker의 상태가 CLOSE에서 OPEN으로 전환된 것을 확인할 수 있다.
  • slidingWindowSize와 minimumNumberOfCalls가 10이므로, 10번째 요청까지 확인 이후 11번째 요청에서 failureRateThreshold을 계산하여 circuitbreaker 상태를 OPEN으로 전환할지 결정한다.
  • 실제 10번 중에 10번 모두 실패하였기에, failureRateThreshold의 수치가 넘어 12번째 요청에서 CLOSE에서 OPEN 상태로 전환됨을 확인할 수 있다.
  • waitDurationInOpenState가 10s이기 때문에, OPEN 상태에서 10초가 지난 이후 HALF_OPEN으로 자동으로 전환됨을 확인할 수 있다.
    • automaticTransitionFromOpenToHalfOpenEnabled가 true이기 때문에 10초 이후 자동으로 전환이 되었으며,
    • false 라면 10초 이후 다음 요청이 발생하면 HALF_OPEN으로 전환한다.
  • permittedNumberOfCallsInHalfOpenState를 통해 HALF_OPEN 상태에서 10번째까지의 요청을 받아 OPEN 상태로 넘어갈지, CLOSE 상태로 전환할지 결정한다.