1. 글을 작성하게 된 계기
Resilience4j가 제공하는 기능 중 서킷브레이커, 재시도 둘의 동작 순서를 조절 하고 싶었습니다. 재시도 -> 서킷브레이커 순으로 동작 시키고 싶었는데, 반대로 동작해 참 애를 먹었네요. 여튼, 이 과정에서 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다. 참고로 Resilience4j는 다음과 같은 기능을 제공합니다.
- Bulkhead
- TimeLimiter
- RateLimiter
- CircuitBreaker
- Retry
2. 상황
책을 주문하는 상황을 가정해 보겠습니다. 주문을 하기 위해서는 상품이 존재하는지, 상품의 수량은 충분한지와 같은 정보를 먼저 조회해 와야 합니다. 이때, 주문과 상품 서버는 분리돼 있으며, 서킷브레이커를 사용해 장애를 예방하고 있습니다.
그런데 상품 서버를 호출했을 때, Service Unavailable(503) 또는 Gateway Timeout(504) 응답을 받을 수도 있습니다. 이 경우, 서버가 완전히 다운된 것이 아닐 수 있기 때문에 재시도 를 할 수 있습니다.
일시적 장애는 네트워크 지연, 서버가 많은 트래픽을 받아 밀린 요청을 처리하는 경우와 같은 상황 입니다.
서킷브레이커를 사용하면, 요청이 실패했을 때 실패한 횟수 를 기록하는데요, 저는 이 횟수를 모든 재시도 후, 한 번의 실패로 기록 하고 싶었습니다. 즉, 재시도를 먼저 한 후, 마지막에 서킷브레이커를 동작 시키고 싶었던 것이죠.
방금까지 설명한 상황을 코드로 보면 다음과 같습니다. 먼저 주문 과정에서 상품 정보를 조회하는 호출부 입니다. 실험을 위해 작성한 코드라, 실제 주문 과정보다는 간단한데요, 이를 감안하고 봐주세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderFacade {
private final ProductQueryClient productClient;
private final OrderService orderService;
public Long order(OrderRequest request) {
log.info("[Order/Facade]----xx> Order Request: {}", request);
// 주문을 위해 상품 정보 조회
ProductResponse response = productQueryClient.findProductById(request.getProductId());
// 상품이 올바르다면 주문 완료. @Transactional은 여기에 있어요.
Order newOrder = orderService.save(createOrder(request, response));
return newOrder.getOrderId();
}
......
}
다음은 상품 서버를 호출하는 코드입니다. 해당 클래스를 통해 주문 전, 상품의 정보를 조회해 옵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductQueryClient {
private final ProductFeignClient client;
/**
* 상품 정보를 조회하는 과정에서 최대 n번까지 Retry
* n번 시도 후에도 실패하면, 서킷브레이커로 API 호출 실패 횟수 기록.
* */
@Retry(name = RESILIENCE4J_CONFIGURATION, fallbackMethod = "fallbackAfterRetries")
@CircuitBreaker(name = RESILIENCE4J_CONFIGURATION, fallbackMethod = "fallbackAfterCircuitBreaker")
public ProductResponse findProductById(Long productId) {
try {
return client.findProduct(productId);
} catch (FeignException ex) {
checkExceptionThrows(ex);
throw ProductTypeException.from(PRODUCT_NOT_FOUND);
}
}
......
}
Resilience4j 설정은 다음과 같은데, 위에 설명한 서킷브레이커, 재시도 조건을 반영하고 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Slf4j
@Configuration
public class Resilience4jConfiguration {
public static final String RESILIENCE4J_CONFIGURATION = "resilience4jConfiguration";
@Bean
public RetryConfig retryConfiguration() {
IntervalFunction intervalFunction =
IntervalFunction.ofExponentialBackoff(Duration.ofMillis(1_000), 1.5);
return RetryConfig.custom()
.retryOnException(throwable ->
throwable instanceof BadGatewayException ||
throwable instanceof TimeoutException ||
throwable instanceof ServiceUnavailableException
)
.intervalFunction(intervalFunction)
.maxAttempts(3)
.build();
}
@Bean
public RetryRegistry retryRegistry() {
return RetryRegistry.of(retryConfiguration());
}
@Bean
public CircuitBreaker circuitBreaker(CircuitBreakerRegistry circuitBreakerRegistry) {
return circuitBreakerRegistry.circuitBreaker(
RESILIENCE4J_CONFIGURATION,
CircuitBreakerConfig.custom()
.recordException(ProductTypeException.class::isInstance)
.failureRateThreshold(50)
.slidingWindowType(TIME_BASED)
.slidingWindowSize(20)
.minimumNumberOfCalls(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.build()
);
}
}
하지만 이는 생각했던 대로 동작하지 않았습니다. 제가 원했던 결과는 재시도 3번 후, 최종 실패 1회 기록, 즉, 재시도 후 서킷브레이커의 동작 이었지만, 실제로는 반대로 동작하고 있었습니다. 왜 해당 코드가 의도대로 동작하지 않았는지 공식 문서를 바탕으로 원인을 살펴보겠습니다.
설명이 어려울 수 있는데, 요약하면 재시도를 하지 않고 있었던 것입니다.
3. 문제 원인
Resilience4j를 사용할 때, 기능의 동작 순서가 존재합니다. 이를 이해하기 위해서는 데코레이터 패턴 과 프록시 패턴 을 알아야 하는데, 먼저 이에 대해 살펴보겠습니다.
Bulkhead -> TimeLimiter -> RateLimiter -> CircuitBreaker -> Retry
3-1. 데코레이터 패턴
Resilience4j는 내부적으로 각 기능들을 데코레이터 패턴으로 연결합니다. 즉, 다음과 같이 각 기능이 체이닝 돼 있으며, 이를 통해 동작을 순차적으로 호출할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
RateLimiter rateLimiter = RateLimiter.of("rateLimiter", RateLimiterConfig.custom()
.limitForPeriod(4)
.limitRefreshPeriod(Duration.ofSeconds(1))
.timeoutDuration(Duration.ofMillis(500))
.build());
CircuitBreaker circuitBreaker = CircuitBreaker.of("circuitBreaker", CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.slidingWindowSize(4)
.build());
Retry retry = Retry.of("retry", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.retryExceptions(RuntimeException.class)
.build());
Supplier<String> rateLimiterSupplier = ......
Supplier<String> circuitBreakerSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, rateLimiterSupplier);
Supplier<String> retrySupplier = Retry.decorateSupplier(retry, circuitBreakerDecorated);
// RateLimiter -> CircuitBreaker -> Retry 순으로 실행
retrySupplier.get();
3-2. 프록시 패턴
또한 Resilience4j는 AOP를 사용하는데요, 따라서 프록시 를 사용합니다. 즉, @Retry, @CircuitBreaker 가 있으면 이를 프록시로 감싸는 것이죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Component
@RequiredArgsConstructor
public class ProductQueryClient {
private final ProductFeignClient client;
/**
* @CircuitBreaker -> @Retry 순으로 동작.
* 그런데 예외가 발생하면 @CircuitBreaker의 fallback 메서드를 호출 후 종료.
* */
@Retry(name = RESILIENCE4J_CONFIGURATION, fallbackMethod = "fallbackAfterRetries")
@CircuitBreaker(name = RESILIENCE4J_CONFIGURATION, fallbackMethod = "fallbackAfterCircuitBreaker")
public ProductResponse findProductById(Long productId) {
try {
return client.findProduct(productId);
} catch (FeignException ex) {
checkExceptionThrows(ex);
throw ProductTypeException.from(PRODUCT_NOT_FOUND);
}
}
......
}
위 코드는 대략 다음과 같습니다. 프록시 내부에 target이 있으며, 데코레이터 패턴이 이를 실행하는 것이죠. 만약 예외가 발생하면 다음 기능이 동작하지 않을 수도 있겠죠?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Resilience4jProxy {
private final Object target;
private final CircuitBreaker circuitBreaker;
private final Retry retry;
......
@Override
public Object invoke(
Method method,
Object[] args
) throws Throwable {
// 서킷브레이커(1)
Callable<Object> circuitBreakerCallable = CircuitBreaker.decorateCallable(circuitBreaker, () -> {
try {
return method.invoke(target, args);
} catch (InvocationTargetException ex) {
throw ex.getCause();
}
});
// 재시도(2)
Callable<Object> retryCallable = Retry.decorateCallable(retry, () -> {
try {
return circuitBreakerCallable.call();
} catch (Exception ex) {
throw ex;
}
});
return retryCallable.call();
}
}
이를 토대로 에러가 발생한 원인을 살펴보면, 별도의 설정이 없다면 Resilience4j가 설정한 순서대로 기능이 실행되며, 이 과정에서 예외가 발생해 다음 기능이 정상적으로 동작하지 않은 것입니다.
- 별도의 설정이 없다면 서킷브레이커 -> 재시도 순으로 동작한다.
- 서킷브레이커에서 예외가 발생하면, 재시도가 동작하지 않을 수 있다.
이는 디버깅을 통해 알 수 있는데요, 재시도 로직에 예외가 전달 돼 다음 로직을 수행하지 않는 것을 볼 수 있습니다.
4. 해결
해결책은 간단한데요, .yml 또는 .properties 파일에 다음과 같은 설정만 추가하면 됩니다. 이를 통해 각 기능의 동작 순서를 바꿀 수 있습니다. 참고로 값이 클수록 우선순위가 더 높습니다.
1
2
3
4
5
resilience4j:
retry:
retry-aspect-order: 2
circuitbreaker:
circuit-breaker-aspect-order: 1
물론 코드(데코레이터 패턴)로 해결할 수도 있지만, 비즈니스로직도 존재할 수 있기 때문에 YAML로 해결하는게 더 적절하다고 판단했습니다.
해결하고 보니 공식 문서에 제가 필요한 답을 참 친절하게 작성해 놓았더라고요.
여튼, 이렇게 설정 후, 아래 테스트 코드를 돌려보면 다음과 같이 원하는 결과가 나오는 것을 볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@SpringBootTest
@ActiveProfiles("test")
@DisplayName("[IntegrationTest] Retry 통합 테스트")
class CircuitBreakerRetryIntegrationTest {
@SpyBean
private ProductFeignClient productFeignClient;
@Autowired
private ProductQueryClient productQueryClient;
@Test
@DisplayName("Resilience4j 동작 순서를 설정하면, 재시도 후 서킷브레이커가 동작한다.")
void retry_order_test_with_resilience4j_setting() {
Long productId = 502L;
doThrow(createFeignException())
.when(productFeignClient).findProduct(any());
assertThrows(ProductTypeException.class,
() -> productQueryClient.findProductById(productId)
);
verify(productFeignClient, times(3)).findProduct(any());
}
......
}
5. 정리
Resilience4j에는 다양한 기능이 있으며, 설정을 통해 기능의 동작 순서를 조절할 수 있습니다. 내가 원하는 대로 동작하지 않는다면 공식 문서를 꼭 보는 습관을 지닙시다.