From 659cd8b69a2d37222926675bfa33289833270bf0 Mon Sep 17 00:00:00 2001 From: popeye0618 Date: Wed, 26 Aug 2026 17:10:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Feign=20=EB=82=B4=EB=B6=80=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EC=97=90=20=EC=84=9C=ED=82=B7=EB=B8=8C=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=BB=A4=EB=A5=BC=20=EA=B1=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 타임아웃(#78)만으로는 피호출 서비스가 죽어 있는 동안에도 모든 요청이 매번 타임아웃까지 기다린다. Resilience4j 서킷브레이커를 붙여 실패가 쌓이면 호출을 즉시 차단하고 503 으로 응답한다. - 브레이커는 메서드가 아니라 Feign 클라이언트(name) 단위로 묶는다. 내부 호출은 메서드별 트래픽이 적어 기본(메서드 단위) 네이밍으로는 minimum-number-of-calls 에 도달하지 못해 브레이커가 열리지 않는다. - 4xx 는 실패로 세지 않는다. 비즈니스 에러가 브레이커를 열면 멀쩡한 서비스로 가는 호출까지 차단된다. - 스레드풀 실행을 꺼서 Spring Cloud 기본 TimeLimiter(1s)가 read-timeout 보다 먼저 호출을 끊는 것을 막는다. - 폴백 없는 호출의 예외가 NoFallbackAvailableException 으로 래핑되는 문제는 프록시 단계 언랩퍼로 원본 예외를 복원한다. 기존 catch (FeignException) 분기가 그대로 동작한다. - 브레이커 open / 타임아웃은 503(GEN-105) 으로 내린다. Co-Authored-By: Claude Fable 5 --- common-module/build.gradle | 4 + ...FeignCircuitBreakerExceptionUnwrapper.java | 58 +++++ .../common/config/InternalFeignConfig.java | 15 ++ .../exception/code/GeneralErrorCode.java | 3 + .../handler/GlobalExceptionHandler.java | 58 +++++ .../src/main/resources/feign-resilience.yml | 36 +++ .../config/FeignCircuitBreakerTest.java | 225 ++++++++++++++++++ gateway-service/build.gradle | 3 + notification/build.gradle | 1 + 9 files changed, 403 insertions(+) create mode 100644 common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java create mode 100644 common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java diff --git a/common-module/build.gradle b/common-module/build.gradle index 04f40d9..5285683 100644 --- a/common-module/build.gradle +++ b/common-module/build.gradle @@ -62,6 +62,10 @@ dependencies { // feign api 'org.springframework.cloud:spring-cloud-starter-openfeign' + // 서킷브레이커 (Resilience4j) — Feign 내부 호출 보호. + // 설정은 feign-resilience.yml, 브레이커 이름 규칙은 InternalFeignConfig 참고. + api 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + // kafka api 'org.springframework.kafka:spring-kafka' diff --git a/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java b/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java new file mode 100644 index 0000000..ac24b03 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/config/FeignCircuitBreakerExceptionUnwrapper.java @@ -0,0 +1,58 @@ +package com.comatching.common.config; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; + +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.stereotype.Component; + +/** + * 서킷브레이커가 씌운 래퍼를 벗겨 Feign 원본 예외를 복원한다. + * + * spring.cloud.openfeign.circuitbreaker.enabled 를 켜면 폴백 없는 Feign 호출의 + * 모든 예외가 NoFallbackAvailableException 으로 래핑되어 나온다. 그대로 두면 + * 기존 호출부의 catch (FeignException) 분기(예: 404 를 비즈니스 에러로 변환)가 + * 전부 조용히 무력화된다 — 컴파일 에러도 없이 의미가 바뀌는 종류의 회귀다. + * + * 그래서 Feign 클라이언트 빈을 한 겹 감싸 래퍼의 cause(FeignException, + * CallNotPermittedException 등)를 그대로 다시 던진다. 호출부 입장에서는 + * 서킷브레이커 도입 전과 같은 예외 계약이 유지되고, 브레이커 open 만 + * CallNotPermittedException 이라는 새 예외로 추가된다. + */ +@Component +public class FeignCircuitBreakerExceptionUnwrapper implements BeanPostProcessor { + + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) { + if (!isFeignClient(bean)) { + return bean; + } + + return Proxy.newProxyInstance( + bean.getClass().getClassLoader(), + bean.getClass().getInterfaces(), + (proxy, method, args) -> { + try { + return method.invoke(bean, args); + } catch (InvocationTargetException e) { + Throwable thrown = e.getTargetException(); + if (thrown instanceof NoFallbackAvailableException && thrown.getCause() != null) { + throw thrown.getCause(); + } + throw thrown; + } + } + ); + } + + private boolean isFeignClient(Object bean) { + for (Class itf : bean.getClass().getInterfaces()) { + if (itf.isAnnotationPresent(FeignClient.class)) { + return true; + } + } + return false; + } +} diff --git a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java index 5a7dedc..d983d6a 100644 --- a/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java +++ b/common-module/src/main/java/com/comatching/common/config/InternalFeignConfig.java @@ -1,6 +1,7 @@ package com.comatching.common.config; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.openfeign.CircuitBreakerNameResolver; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.util.StringUtils; @@ -22,4 +23,18 @@ public RequestInterceptor internalServiceTokenRequestInterceptor( } }; } + + /** + * 서킷브레이커를 Feign 클라이언트(name) 단위로 묶는다. + * + * 기본 리졸버는 메서드 시그니처까지 붙여 브레이커를 만드는데, 그러면 + * 트래픽이 메서드별로 쪼개져 minimum-number-of-calls(feign-resilience.yml)에 + * 도달하지 못한 브레이커는 영영 열리지 않는다. 장애는 엔드포인트가 아니라 + * 프로세스 단위로 오므로(피호출 서비스의 GC 멈춤·커넥션 고갈은 그 서비스의 + * 모든 엔드포인트를 함께 느리게 만든다) 서비스 단위 판단이 맞다. + */ + @Bean + public CircuitBreakerNameResolver feignClientCircuitBreakerNameResolver() { + return (feignClientName, target, method) -> feignClientName; + } } diff --git a/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java b/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java index d1d8881..cac4d4c 100644 --- a/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java +++ b/common-module/src/main/java/com/comatching/common/exception/code/GeneralErrorCode.java @@ -30,6 +30,9 @@ public enum GeneralErrorCode implements ErrorCode { UNAUTHORIZED("GEN-010", HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."), FORBIDDEN("GEN-011", HttpStatus.FORBIDDEN, "접근 권한이 없습니다."), + // 503 Service Unavailable + INTERNAL_SERVICE_UNAVAILABLE("GEN-105", HttpStatus.SERVICE_UNAVAILABLE, "내부 서비스가 일시적으로 응답할 수 없습니다. 잠시 후 다시 시도해주세요."), + // 500 Internal Server Error INTERNAL_SERVER_ERROR("GEN-099", HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."), DATABASE_ERROR("GEN-100", HttpStatus.INTERNAL_SERVER_ERROR, "데이터베이스 오류가 발생했습니다."), diff --git a/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java b/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java index d664fda..ee73e5b 100644 --- a/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java +++ b/common-module/src/main/java/com/comatching/common/exception/handler/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Map; +import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindingResult; @@ -23,6 +24,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; +import feign.RetryableException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -138,6 +141,61 @@ public ResponseEntity> handleNotFoundException(Exception e) { .body(ApiResponse.errorResponse(GeneralErrorCode.NOT_FOUND)); } + /** + * 서킷브레이커 open (503) + * + * 피호출 서비스가 장애 판정을 받아 호출이 차단된 상태. 서버 오류(500)가 + * 아니라 일시적 불가(503)로 내려야 클라이언트·게이트웨이가 재시도 판단을 + * 할 수 있다. + */ + @ExceptionHandler(CallNotPermittedException.class) + public ResponseEntity> handleCallNotPermittedException(CallNotPermittedException e) { + log.error("[Circuit Breaker Open] Breaker: {}", e.getCausingCircuitBreakerName()); + + return ResponseEntity + .status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus()) + .body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE)); + } + + /** + * Feign 연결 실패·타임아웃 (503) + * + * FeignException 의 하위 타입이지만 더 구체적이라 이 핸들러가 먼저 잡는다. + * HTTP 응답을 받은 게 아니므로 아래 핸들러처럼 응답 본문을 전달할 수 없고, + * 피호출 서비스의 일시적 불가(503)로 내린다. + */ + @ExceptionHandler(RetryableException.class) + public ResponseEntity> handleFeignRetryableException(RetryableException e) { + log.error("[Feign Timeout] {}", e.getMessage()); + + return ResponseEntity + .status(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE.getHttpStatus()) + .body(ApiResponse.errorResponse(GeneralErrorCode.INTERNAL_SERVICE_UNAVAILABLE)); + } + + /** + * 서킷브레이커 래퍼 언랩 (방어선) + * + * 정상 경로에서는 FeignCircuitBreakerExceptionUnwrapper 가 프록시 단계에서 + * 원본 예외로 복원하므로 여기까지 오지 않는다. 그 층을 우회한 호출이 + * 생기더라도 원인에 맞는 응답이 나가도록 한 번 더 푼다. + */ + @ExceptionHandler(NoFallbackAvailableException.class) + public ResponseEntity handleNoFallbackAvailableException(NoFallbackAvailableException e) { + Throwable cause = e.getCause(); + + if (cause instanceof CallNotPermittedException callNotPermitted) { + return handleCallNotPermittedException(callNotPermitted); + } + if (cause instanceof RetryableException retryable) { + return handleFeignRetryableException(retryable); + } + if (cause instanceof FeignException feignException) { + return handleFeignException(feignException); + } + return handleException(e); + } + /** * Feign Client 예외 처리 */ diff --git a/common-module/src/main/resources/feign-resilience.yml b/common-module/src/main/resources/feign-resilience.yml index ad3c99c..7e77868 100644 --- a/common-module/src/main/resources/feign-resilience.yml +++ b/common-module/src/main/resources/feign-resilience.yml @@ -16,6 +16,11 @@ spring: cloud: openfeign: + # 모든 Feign 호출을 서킷브레이커로 감싼다. 브레이커 이름은 메서드 단위가 + # 아니라 Feign 클라이언트(name) 단위로 묶는다 — InternalFeignConfig 의 + # CircuitBreakerNameResolver 참고. + circuitbreaker: + enabled: true client: config: default: @@ -31,3 +36,34 @@ spring: user-service-admin: # 관리자 페이징 조회 connect-timeout: 1000 read-timeout: 5000 + circuitbreaker: + resilience4j: + # 호출 스레드에서 그대로 실행한다(세마포어 방식). 스레드풀 실행을 켜두면 + # ① Spring Cloud 기본 TimeLimiter(1s)가 위의 read-timeout 보다 먼저 + # 호출을 끊고 ② EC2 한 대에 6개 JVM 이 뜨는 전제에서 서비스마다 + # 스레드풀이 하나씩 늘어난다. 끄면 Feign read-timeout 이 유일한 + # 시간 상한이 된다. + disable-thread-pool: true + +resilience4j: + circuitbreaker: + configs: + # 모든 브레이커(= Feign 클라이언트 단위)의 공통값. 클라이언트별로 바꾸려면 + # resilience4j.circuitbreaker.instances. 으로 오버라이드한다. + default: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 + # 내부 호출은 클라이언트별 트래픽이 적다. 이 값에 못 미치면 브레이커가 + # 영영 안 열리므로 낮게 잡는다. + minimum-number-of-calls: 5 + failure-rate-threshold: 50 + # read-timeout(3s)까지 가기 전에 "느려짐"을 먼저 감지한다. + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 80 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + # 4xx 는 피호출 서비스가 정상 동작하며 내린 판정(없는 리소스, 잘못된 + # 요청)이라 장애 신호가 아니다. 실패율에 넣으면 비즈니스 에러가 + # 브레이커를 열어 멀쩡한 서비스로 가는 호출까지 차단한다. + ignore-exceptions: + - feign.FeignException$FeignClientException diff --git a/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java b/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java new file mode 100644 index 0000000..e089c6a --- /dev/null +++ b/common-module/src/test/java/com/comatching/common/config/FeignCircuitBreakerTest.java @@ -0,0 +1,225 @@ +package com.comatching.common.config; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JCircuitBreakerFactory; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.bind.annotation.GetMapping; + +import com.sun.net.httpserver.HttpServer; + +import feign.FeignException; +import feign.RetryableException; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; + +/** + * Feign + 서킷브레이커 체인이 feign-resilience.yml 대로 실제 동작하는지 검증. + * + * 설정 바인딩만 확인해서는(FeignResilienceConfigTest) 잡히지 않는 것들을 + * 실제 HTTP 서버(JDK 내장, 루프백)를 상대로 본다: + * 1) 브레이커가 메서드가 아닌 Feign 클라이언트(name) 단위로 만들어지고 + * yml 의 공통값이 그 브레이커에 실제로 들어간다 + * 2) 5xx 가 쌓이면 브레이커가 열려 이후 호출은 서버에 닿지 않는다 + * 3) 4xx 는 실패로 세지 않는다 — 비즈니스 에러가 브레이커를 열면 안 된다 + * 4) Spring Cloud 기본 TimeLimiter(1s)가 개입하지 않는다 — 켜져 있다면 + * read-timeout(3s) 안쪽의 1s 초과 호출이 잘려나간다 + * 5) read-timeout 초과는 RetryableException 으로 터지고 실패로 집계된다 + * 6) 호출부에는 래퍼(NoFallbackAvailableException)가 아니라 원본 예외가 + * 도달한다 — 아래의 모든 예외 타입 단언이 곧 언랩퍼 + * (FeignCircuitBreakerExceptionUnwrapper) 검증이다 + * + * 브로커·DB 없이 루프백 소켓만 쓰므로 단위 테스트 태스크에서 돈다. + */ +@SpringBootTest( + classes = FeignCircuitBreakerTest.App.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE, + properties = { + // 운영에 나가는 파일 그대로를 물려 테스트한다 + "spring.config.import=classpath:feign-resilience.yml", + // 타임아웃 검증용 클라이언트만 read 를 짧게 줄인다 (기본 3s 를 기다리지 않도록) + "spring.cloud.openfeign.client.config.cb-timeout.connect-timeout=1000", + "spring.cloud.openfeign.client.config.cb-timeout.read-timeout=500" + } +) +@DisplayName("Feign 서킷브레이커 동작") +class FeignCircuitBreakerTest { + + private static final HttpServer SERVER; + private static final AtomicInteger FAIL_HITS = new AtomicInteger(); + + private static final int SLOW_DELAY_MILLIS = 1500; + private static final String SLOW_BODY = "slow-ok"; + + static { + try { + SERVER = HttpServer.create(new InetSocketAddress(0), 0); + } catch (IOException e) { + throw new IllegalStateException(e); + } + SERVER.createContext("/fail", exchange -> { + FAIL_HITS.incrementAndGet(); + respond(exchange, 500, "boom"); + }); + SERVER.createContext("/notfound", exchange -> respond(exchange, 404, "no such thing")); + SERVER.createContext("/slow", exchange -> { + try { + Thread.sleep(SLOW_DELAY_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + respond(exchange, 200, SLOW_BODY); + }); + SERVER.start(); + } + + private static void respond(com.sun.net.httpserver.HttpExchange exchange, int status, String body) + throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/plain;charset=UTF-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + @DynamicPropertySource + static void serverUrl(DynamicPropertyRegistry registry) { + registry.add("cb-it.base-url", () -> "http://localhost:" + SERVER.getAddress().getPort()); + } + + @AfterAll + static void stopServer() { + SERVER.stop(0); + } + + @FeignClient(name = "cb-server-error", url = "${cb-it.base-url}") + interface ServerErrorClient { + @GetMapping("/fail") + String call(); + } + + @FeignClient(name = "cb-client-error", url = "${cb-it.base-url}") + interface ClientErrorClient { + @GetMapping("/notfound") + String call(); + } + + @FeignClient(name = "cb-slow", url = "${cb-it.base-url}") + interface SlowClient { + @GetMapping("/slow") + String call(); + } + + @FeignClient(name = "cb-timeout", url = "${cb-it.base-url}") + interface TimeoutClient { + @GetMapping("/slow") + String call(); + } + + @SpringBootConfiguration + @ImportAutoConfiguration({ + org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration.class, + org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration.class, + org.springframework.cloud.openfeign.FeignAutoConfiguration.class, + io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration.class, + io.github.resilience4j.springboot3.timelimiter.autoconfigure.TimeLimiterAutoConfiguration.class, + org.springframework.cloud.circuitbreaker.resilience4j.Resilience4JAutoConfiguration.class + }) + @EnableFeignClients(clients = { + ServerErrorClient.class, ClientErrorClient.class, SlowClient.class, TimeoutClient.class + }) + @Import({InternalFeignConfig.class, FeignCircuitBreakerExceptionUnwrapper.class}) + static class App { + } + + @Autowired + Resilience4JCircuitBreakerFactory circuitBreakerFactory; + + @Autowired + ServerErrorClient serverErrorClient; + + @Autowired + ClientErrorClient clientErrorClient; + + @Autowired + SlowClient slowClient; + + @Autowired + TimeoutClient timeoutClient; + + private CircuitBreaker breakerOf(String name) { + return circuitBreakerFactory.getCircuitBreakerRegistry() + .find(name) + .orElseThrow(() -> new AssertionError("브레이커가 없다: " + name)); + } + + @Test + @DisplayName("5xx 가 최소 호출 수만큼 쌓이면 브레이커가 열려 서버에 닿지 않는다") + void opensOnServerErrors() { + int minimumCalls = 5; // feign-resilience.yml 의 minimum-number-of-calls + + for (int i = 0; i < minimumCalls; i++) { + assertThatThrownBy(serverErrorClient::call).isInstanceOf(FeignException.class); + } + + int hitsBeforeOpen = FAIL_HITS.get(); + assertThatThrownBy(serverErrorClient::call).isInstanceOf(CallNotPermittedException.class); + assertThat(FAIL_HITS.get()) + .as("차단된 호출은 서버에 도달하면 안 된다") + .isEqualTo(hitsBeforeOpen); + + // 브레이커 이름이 메서드가 아니라 Feign 클라이언트 name 그대로인지, + // yml 공통값이 그 브레이커에 들어갔는지도 여기서 같이 확인한다. + CircuitBreaker breaker = breakerOf("cb-server-error"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.OPEN); + assertThat(breaker.getCircuitBreakerConfig().getMinimumNumberOfCalls()).isEqualTo(minimumCalls); + assertThat(breaker.getCircuitBreakerConfig().getSlidingWindowSize()).isEqualTo(10); + } + + @Test + @DisplayName("4xx 는 실패로 세지 않는다 — 비즈니스 에러로 브레이커가 열리면 안 된다") + void ignoresClientErrors() { + for (int i = 0; i < 8; i++) { + assertThatThrownBy(clientErrorClient::call) + .isInstanceOf(FeignException.NotFound.class); + } + + CircuitBreaker breaker = breakerOf("cb-client-error"); + assertThat(breaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + assertThat(breaker.getMetrics().getNumberOfFailedCalls()).isZero(); + } + + @Test + @DisplayName("read-timeout 안쪽의 느린 호출은 성공한다 — 기본 TimeLimiter(1s) 미개입 증명") + void slowCallWithinReadTimeoutSucceeds() { + // disable-thread-pool 이 풀리면 Spring Cloud 기본 TimeLimiter(1s)가 + // 이 1.5s 호출을 read-timeout(3s) 전에 끊어 이 테스트가 깨진다. + assertThat(slowClient.call()).isEqualTo(SLOW_BODY); + } + + @Test + @DisplayName("read-timeout 초과는 RetryableException 으로 터지고 실패로 집계된다") + void timeoutCountsAsFailure() { + assertThatThrownBy(timeoutClient::call).isInstanceOf(RetryableException.class); + + assertThat(breakerOf("cb-timeout").getMetrics().getNumberOfFailedCalls()).isEqualTo(1); + } +} diff --git a/gateway-service/build.gradle b/gateway-service/build.gradle index d7aba92..e1b72e0 100644 --- a/gateway-service/build.gradle +++ b/gateway-service/build.gradle @@ -16,6 +16,9 @@ dependencies { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' exclude group: 'org.springframework.boot', module: 'spring-boot-starter-security' exclude group: 'io.awspring.cloud', module: 'spring-cloud-aws-starter-s3' + // Feign 을 쓰지 않으므로 서킷브레이커 스타터도 제외한다. WebFlux 게이트웨이에 + // blocking 팩토리 자동설정이 뜨는 것을 막는 목적도 있다. + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-circuitbreaker-resilience4j' } implementation 'org.springframework.boot:spring-boot-starter-data-redis-reactive' implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' diff --git a/notification/build.gradle b/notification/build.gradle index 31dec79..64409ad 100644 --- a/notification/build.gradle +++ b/notification/build.gradle @@ -9,6 +9,7 @@ description = 'notification' dependencies { implementation(project(':common-module')) { exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-openfeign' + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-circuitbreaker-resilience4j' exclude group: 'io.awspring.cloud', module: 'spring-cloud-aws-starter-s3' }