1. 개요

Resilience4j 는 웹 애플리케이션에 다양한 내결함성과 안정성 패턴을 제공하는 경량 내결함성 라이브러리입니다. 이 예제에서는 이 라이브러리를 간단한 Spring Boot 애플리케이션과 함께 사용하는 방법을 배웁니다 .

2. 설정

이 섹션에서는 Spring Boot 프로젝트의 중요한 측면을 설정하는 데 중점을 두겠습니다 .

2.1. 메이븐 의존성

먼저 간단한 웹 애플리케이션을 부트스트랩하기 위해 spring-boot-starter-web 의존성을 추가해야 합니다.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
<dependency>

다음으로 Spring Boot 애플리케이션의 어노테이션을 사용하여 Resilience-4j 라이브러리의 기능을 사용하려면 resilience4j-spring-boot2 및 spring-boot-starter-aop 의존성 필요 합니다 .

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot2</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

또한 노출된 엔드포인트 세트를 통해 애플리케이션의 현재 상태를 모니터링하기 위해 spring-boot-starter-actuator 의존성을 추가해야 합니다.

<dependency>
    <groupId>org.springframework.boot</groupId> 
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

마지막으로, 모의 HTTP 서버 를 사용하여 REST API를 테스트하는 데 도움이 될 wiremock-jre8 의존성을 추가해 보겠습니다 .

<dependency>
    <groupId>com.github.tomakehurst</groupId>
    <artifactId>wiremock-jre8</artifactId>
    <scope>test</scope>
</dependency>

2.2. RestController 및 외부 API 호출자

Resilience4j 라이브러리의 다양한 기능을 사용하는 동안 웹 애플리케이션은 외부 API와 상호 작용해야 합니다. 따라서 API 호출을 수행하는 데 도움이 되는 RestTemplate  용 빈을 추가해 보겠습니다 .

@Bean
   public RestTemplate restTemplate() {
   return new RestTemplateBuilder().rootUri("http://localhost:9090")
   .build();
   }

다음으로 ExternalAPICaller 클래스를 Component 로 정의하고 restTemplate 빈을 멤버로 사용 합시다.

@Component
   public class ExternalAPICaller {
   private final RestTemplate restTemplate;
   
   @Autowired
   public ExternalAPICaller(RestTemplate restTemplate) {
   this.restTemplate = restTemplate;
   }
   }

그런 다음 REST API Endpoints을 노출하고 내부적으로 ExternalAPICaller 빈을 사용하여 외부 API를 호출 하는 ResilientAppController 클래스를 정의 할 수 있습니다 .

@RestController
   @RequestMapping("/api/")
   public class ResilientAppController {
   private final ExternalAPICaller externalAPICaller;
   }

2.3. 액추에이터 Endpoints

Spring Boot 액추에이터 를 통해 상태 엔드포인트를 노출하여 주어진 시간 에 애플리케이션의 정확한 상태를 알 수 있습니다.

따라서 application.properties 파일에 구성을 추가하고 Endpoints을 활성화하겠습니다.

management.endpoints.web.exposure.include=*
   management.endpoint.health.show-details=always
   
   management.health.circuitbreakers.enabled=true
   management.health.ratelimiters.enabled=true

또한 필요할 때 동일한 application.properties 파일에 기능별 구성을 추가합니다.

2.4. 단위 테스트

우리의 웹 애플리케이션은 실제 시나리오에서 외부 서비스를 호출합니다. 그러나 WireMockExtension 클래스 를 사용하여 외부 서비스를 시작하여 실행 중인 서비스의 존재를 시뮬레이션 할 수 있습니다 .

따라서 EXTERNAL_SERVICE 를 ResilientAppControllerUnitTest 클래스 의 정적 멤버로 정의해 보겠습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
   class ResilientAppControllerUnitTest {
   
   @RegisterExtension
   static WireMockExtension EXTERNAL_SERVICE = WireMockExtension.newInstance()
   .options(WireMockConfiguration.wireMockConfig()
   .port(9090))
   .build();

또한 API를 호출하기 위해 TestRestTemplate 의 인스턴스를 추가해 보겠습니다 .

@Autowired
   private TestRestTemplate restTemplate;

2.5. 예외 처리기

Resilience4j 라이브러리는 컨텍스트의 내결함성 패턴에 따라 예외를 발생시켜 서비스 리소스를 보호합니다. 그러나 이러한 예외는 클라이언트에 대해 의미 있는 상태 코드가 포함된 HTTP 응답으로 변환되어야 합니다.

따라서 다른 예외에 대한 처리기를 보유 하도록 ApiExceptionHandler 클래스를 정의해 보겠습니다 .

@ControllerAdvice
   public class ApiExceptionHandler {
   }

다양한 내결함성 패턴을 탐색할 때 이 클래스에 처리기를 추가합니다.

3. 회로 차단기

회로 차단기 패턴업스트림 서비스가 부분적 또는 완전한 다운타임 동안 다운스트림 서비스를 호출하지 못하도록 제한하여 다운스트림 서비스를 보호합니다 .

/api/circuit-breaker Endpoints 을 노출 하고 @CircuitBreaker 어노테이션을 추가하여 시작하겠습니다.

@GetMapping("/circuit-breaker")
   @CircuitBreaker(name = "CircuitBreakerService")
   public String circuitBreakerApi() {
   return externalAPICaller.callApi();
   }

필요에 따라 외부 Endpoints /api/external 을 호출하기 위해 ExternalAPICaller 클래스에서 callApi() 메서드 도 정의해야 합니다 .

public String callApi() {
   return restTemplate.getForObject("/api/external", String.class);
   }

다음으로 application.properties 파일 에 회로 차단기에 대한 구성을 추가해 보겠습니다 .

resilience4j.circuitbreaker.instances.CircuitBreakerService.failure-rate-threshold=50
   resilience4j.circuitbreaker.instances.CircuitBreakerService.minimum-number-of-calls=5
resilience4j.circuitbreaker.instances.CircuitBreakerService.automatic-transition-from-open-to-half-open-enabled=true
resilience4j.circuitbreaker.instances.CircuitBreakerService.wait-duration-in-open-state=5s
resilience4j.circuitbreaker.instances.CircuitBreakerService.permitted-number-of-calls-in-half-open-state=3
   resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-size=10
   resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-type=count_based

기본적으로 구성은 닫힌 상태 에서 서비스에 대한 실패한 호출의 50%를 허용하고 그 후에 회로를 열고 CallNotPermittedException 으로 요청을 거부하기 시작합니다 . 따라서 ApiExceptionHandler 클래스 에 이 예외에 대한 처리기를 추가하는 것이 좋습니다 .

@ExceptionHandler({CallNotPermittedException.class})
   @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
   public void handleCallNotPermittedException() {
   }

마지막으로 EXTERNAL_SERVICE 를 사용하여 다운스트림 서비스 다운타임 시나리오를 시뮬레이션하여 /api/circuit-breaker API 엔드포인트를 테스트해 보겠습니다.

@Test
   public void testCircuitBreaker() {
   EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
   .willReturn(serverError()));
   
   IntStream.rangeClosed(1, 5)
   .forEach(i -> {
   ResponseEntity response = restTemplate.getForEntity("/api/circuit-breaker", String.class);
          assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
   });
   
   IntStream.rangeClosed(1, 5)
   .forEach(i -> {
   ResponseEntity response = restTemplate.getForEntity("/api/circuit-breaker", String.class);
          assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
   });
   
   EXTERNAL_SERVICE.verify(5, getRequestedFor(urlEqualTo("/api/external")));
   }

다운스트림 서비스가 다운되면서 처음 5개의 호출이 실패했음을 알 수 있습니다. 그 후 회로는 개방 상태로 전환됩니다. 따라서 후속 5번의 시도는 기본 API를 실제로 호출하지 않고 503 HTTP 상태 코드로 거부됩니다.

4. 재시도

재시도 패턴일시적인 문제에서 복구하여 시스템에 탄력성을 제공합니다 . @Retry 어노테이션 을 사용하여 /api/retry API 엔드포인트를 추가하여 시작하겠습니다 .

@GetMapping("/retry")
   @Retry(name = "retryApi", fallbackMethod = "fallbackAfterRetry")
   public String retryApi() {
   return externalAPICaller.callApi();
   }

또한 모든 재시도가 실패할 때 선택적으로 대체 메커니즘을 제공할 수 있습니다 . 이 경우 fallbackAfterRetry  를 대체 메서드로 제공했습니다.

public String fallbackAfterRetry(Exception ex) {
   return "all retries have exhausted";
   }

다음 으로 재시도 동작을 제어하는 ​​구성을 추가하도록 application.properties 파일을 업데이트하겠습니다 .

resilience4j.retry.instances.retryApi.max-attempts=3
   resilience4j.retry.instances.retryApi.wait-duration=1s
   resilience4j.retry.metrics.legacy.enabled=true
   resilience4j.retry.metrics.enabled=true

따라서 최대 3회까지 재시도할 계획이며 각 시도는 1초 지연 됩니다.

마지막으로 /api/retry API 엔드포인트의 재시도 동작을 테스트해 보겠습니다.

@Test
   public void testRetry() {
   EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
   .willReturn(ok()));
    ResponseEntity<String> response1 = restTemplate.getForEntity("/api/retry", String.class);
   EXTERNAL_SERVICE.verify(1, getRequestedFor(urlEqualTo("/api/external")));
   
   EXTERNAL_SERVICE.resetRequests();
   
   EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
   .willReturn(serverError()));
    ResponseEntity<String> response2 = restTemplate.getForEntity("/api/retry", String.class);
    Assert.assertEquals(response2.getBody(), "all retries have exhausted");
   EXTERNAL_SERVICE.verify(3, getRequestedFor(urlEqualTo("/api/external")));
   }

첫 번째 시나리오에서는 문제가 없었으므로 한 번의 시도로 충분했음을 알 수 있습니다. 반면에 문제가 발생하면 API가 폴백 메커니즘을 통해 응답한 후 3번의 시도가 있었습니다.

5. 시간 제한

시간 제한기 패턴사용 하여 외부 시스템에 대한 비동기 호출에 대한 임계값 시간 초과 값을 설정할 수 있습니다 .

내부적으로 느린 API를 호출하는 /api/time-limiter API 엔드포인트를 추가해 보겠습니다 .

@GetMapping("/time-limiter")
   @TimeLimiter(name = "timeLimiterApi")
   public CompletableFuture<String> timeLimiterApi() {
   return CompletableFuture.supplyAsync(externalAPICaller::callApiWithDelay);
   }

또한 callApiWithDelay() 메서드 에 휴면 시간을 추가하여 외부 API 호출의 지연을 시뮬레이션해 보겠습니다 .

public String callApiWithDelay() {
   String result = restTemplate.getForObject("/api/external", String.class);
   try {
   Thread.sleep(5000);
   } catch (InterruptedException ignore) {
   }
   return result;
   }

다음으로 application.properties 파일 에 timeLimiterApi 에 대한 구성을 제공해야 합니다.

resilience4j.timelimiter.metrics.enabled=true
   resilience4j.timelimiter.instances.timeLimiterApi.timeout-duration=2s
   resilience4j.timelimiter.instances.timeLimiterApi.cancel-running-future=true

임계값이 2s로 설정되었음을 알 수 있습니다. 그 후 Resilience4j 라이브러리는 TimeoutException 으로 비동기 작업을 내부적으로 취소합니다 . 따라서 ApiExceptionHandler 클래스 에 이 예외에 대한 핸들러를 추가 하여 408 HTTP 상태 코드 와 함께 API 응답을 반환해 보겠습니다 .

@ExceptionHandler({TimeoutException.class})
   @ResponseStatus(HttpStatus.REQUEST_TIMEOUT)
   public void handleTimeoutException() {
   }

마지막으로 /api/time-limiter API 엔드포인트 에 대해 구성된 시간 제한 패턴을 확인하겠습니다 .

@Test
   public void testTimeLimiter() {
   EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(ok()));
    ResponseEntity<String> response = restTemplate.getForEntity("/api/time-limiter", String.class);
   
   assertThat(response.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT);
   EXTERNAL_SERVICE.verify(1, getRequestedFor(urlEqualTo("/api/external")));
   }

예상대로 다운스트림 API 호출이 완료되는 데 5초 이상 걸리도록 설정되었기 때문에 API 호출에 대한 시간 초과를 목격했습니다.

6. 격벽

벌크헤드 패턴외부 서비스에 대한 최대 동시 호출 수를 제한합니다.

@Bulkhead 어노테이션 으로 /api/bulkhead API 엔드포인트를 추가하는 것으로 시작하겠습니다 .

@GetMapping("/bulkhead")
   @Bulkhead(name="bulkheadApi")
   public String bulkheadApi() {
   return externalAPICaller.callApi();
   }

다음으로, 벌크헤드 기능을 제어하기 위해 application.properties 파일 에서 구성을 정의해 보겠습니다 .

resilience4j.bulkhead.metrics.enabled=true
   resilience4j.bulkhead.instances.bulkheadApi.max-concurrent-calls=3
   resilience4j.bulkhead.instances.bulkheadApi.max-wait-duration=1

이를 통해 최대 동시 호출 수를 3 으로 제한 하여 벌크헤드가 가득 찬 경우 각 스레드가 1ms 동안만 기다릴 수 있도록 합니다 . 그 후에는 BulkheadFullException 예외와 함께 요청이 거부됩니다. 또한 의미 있는 HTTP 상태 코드를 클라이언트에 반환하기를 원하므로 예외 처리기를 추가해 보겠습니다.

@ExceptionHandler({ BulkheadFullException.class })
   @ResponseStatus(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)
   public void handleBulkheadFullException() {
   }

마지막으로 5개의 요청을 병렬로 호출하여 격벽 동작을 테스트해 보겠습니다.

@Test
   public void testBulkhead() throws InterruptedException {
   EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
   .willReturn(ok()));
   Map<Integer, Integer> responseStatusCount = new ConcurrentHashMap<>();
   
   IntStream.rangeClosed(1, 5)
   .parallel()
   .forEach(i -> {
          ResponseEntity<String> response = restTemplate.getForEntity("/api/bulkhead", String.class);
   int statusCode = response.getStatusCodeValue();
          responseStatusCount.put(statusCode, responseStatusCount.getOrDefault(statusCode, 0) + 1);
   });
   
   assertEquals(2, responseStatusCount.keySet().size());
   assertTrue(responseStatusCount.containsKey(BANDWIDTH_LIMIT_EXCEEDED.value()));
   assertTrue(responseStatusCount.containsKey(OK.value()));
   EXTERNAL_SERVICE.verify(3, getRequestedFor(urlEqualTo("/api/external")));
   }

세 개의 요청만 성공했지만 나머지 요청은 BANDWIDTH_LIMIT_EXCEEDED HTTP 상태 코드 로  거부되었습니다 .

7. 속도 제한기

속도 제한기 패턴리소스에 대한 요청 속도를 제한합니다.

@RateLimiter 어노테이션 으로 /api/rate-limiter API 엔드포인트를 추가하여 시작하겠습니다 .

@GetMapping("/rate-limiter")
   @RateLimiter(name = "rateLimiterApi")
   public String rateLimitApi() {
   return externalAPICaller.callApi();
   }

다음으로 application.properties 파일 에서 속도 제한기에 대한 구성을 정의하겠습니다 .

resilience4j.ratelimiter.metrics.enabled=true
resilience4j.ratelimiter.instances.rateLimiterApi.register-health-indicator=true
   resilience4j.ratelimiter.instances.rateLimiterApi.limit-for-period=5
   resilience4j.ratelimiter.instances.rateLimiterApi.limit-refresh-period=60s
   resilience4j.ratelimiter.instances.rateLimiterApi.timeout-duration=0s
resilience4j.ratelimiter.instances.rateLimiterApi.allow-health-indicator-to-fail=true
   resilience4j.ratelimiter.instances.rateLimiterApi.subscribe-for-events=true
resilience4j.ratelimiter.instances.rateLimiterApi.event-consumer-buffer-size=50

이 구성을 사용하여 API 호출 속도를 대기 없이 5req /min 으로 제한하려고 합니다. 허용된 비율의 임계값에 도달한 후 요청은 RequestNotPermitted 예외 와 함께 거부됩니다 . 따라서 의미 있는 HTTP 상태 응답 코드로 변환하기 위해 ApiExceptionHandler 클래스에 핸들러를 정의해 보겠습니다 .

@ExceptionHandler({ RequestNotPermitted.class })
   @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
   public void handleRequestNotPermitted() {
   }

마지막으로 50개의 요청 으로 속도가 제한된 API 엔드포인트를 테스트해 보겠습니다 .

@Test
   public void testRatelimiter() {
   EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
   .willReturn(ok()));
   Map<Integer, Integer> responseStatusCount = new ConcurrentHashMap<>();
   
   IntStream.rangeClosed(1, 50)
   .parallel()
   .forEach(i -> {
          ResponseEntity<String> response = restTemplate.getForEntity("/api/rate-limiter", String.class);
   int statusCode = response.getStatusCodeValue();
          responseStatusCount.put(statusCode, responseStatusCount.getOrDefault(statusCode, 0) + 1);
   });
   
   assertEquals(2, responseStatusCount.keySet().size());
   assertTrue(responseStatusCount.containsKey(TOO_MANY_REQUESTS.value()));
   assertTrue(responseStatusCount.containsKey(OK.value()));
   EXTERNAL_SERVICE.verify(5, getRequestedFor(urlEqualTo("/api/external")));
   }

예상대로 5개의 요청만 성공했지만 다른 모든 요청은 TOO_MANY_REQUESTS HTTP 상태 코드 로 실패했습니다 .

8. 액추에이터 Endpoints

모니터링 목적으로 액추에이터 엔드포인트를 지원하도록 애플리케이션을 구성했습니다. 이러한 Endpoints을 사용하여 구성된 내결함성 패턴 중 하나 이상을 사용하여 시간이 지남에 따라 애플리케이션이 어떻게 작동하는지 결정할 수 있습니다.

첫째, 일반적으로 /actuator 엔드포인트 에 대한 GET 요청을 사용하여 노출된 모든 엔드포인트를 찾을 수 있습니다 .

http://localhost:8080/actuator/
   {
   "_links" : {
   "self" : {...},
   "bulkheads" : {...},
   "circuitbreakers" : {...},
   "ratelimiters" : {...},
   ...
   }
   }

bulkheads , circuit breaker , ratelimiters 등과 같은 필드가 있는 JSON 응답을 볼 수 있습니다 . 각 필드는 내결함성 패턴과의 연관성에 따라 특정 정보를 제공합니다.

다음으로 재시도 패턴과 관련된 필드를 살펴보겠습니다.

"retries": {
   "href": "http://localhost:8080/actuator/retries",
   "templated": false
   },
   "retryevents": {
   "href": "http://localhost:8080/actuator/retryevents",
   "templated": false
   },
   "retryevents-name": {
   "href": "http://localhost:8080/actuator/retryevents/{name}",
   "templated": true
   },
   "retryevents-name-eventType": {
   "href": "http://localhost:8080/actuator/retryevents/{name}/{eventType}",
   "templated": true
   }

계속해서 애플리케이션을 검사하여 재시도 인스턴스 List을 확인하겠습니다.

http://localhost:8080/actuator/retries
   {
   "retries" : [ "retryApi" ]
   }

예상대로 구성된 재시도 인스턴스 List에서 retryApi 인스턴스를 볼 수 있습니다 .

마지막으로 브라우저를 통해 /api/retry API 엔드포인트 에 GET 요청을 하고 /actuator/retryevents 엔드포인트 를 사용하여 재시도 이벤트를 관찰해 보겠습니다 .

{
   "retryEvents": [
   {
   "retryName": "retryApi",
   "type": "RETRY",
   "creationTime": "2022-10-16T10:46:31.950822+05:30[Asia/Kolkata]",
   "errorMessage": "...",
   "numberOfAttempts": 1
   },
   {
   "retryName": "retryApi",
   "type": "RETRY",
   "creationTime": "2022-10-16T10:46:32.965661+05:30[Asia/Kolkata]",
   "errorMessage": "...",
   "numberOfAttempts": 2
   },
   {
   "retryName": "retryApi",
   "type": "ERROR",
   "creationTime": "2022-10-16T10:46:33.978801+05:30[Asia/Kolkata]",
   "errorMessage": "...",
   "numberOfAttempts": 3
   }
   ]
   }

다운스트림 서비스가 다운되었으므로 두 번의 시도 사이 에 대기 시간이 1초인 세 번의 재시도를 볼 수 있습니다 . 우리가 구성한 것과 같습니다.

9. 결론

이 기사에서는 Sprint Boot 애플리케이션에서 Resilience4j 라이브러리를 사용하는 방법에 대해 배웠습니다. 또한 회로 차단기, 속도 제한기, 시간 제한기, 격벽 및 재시도와 같은 여러 내결함성 패턴에 대해 자세히 살펴보았습니다.

항상 그렇듯이 사용방법(예제)의 전체 소스 코드는 GitHub에서 사용할 수 있습니다 .

Generic footer banner