diff --git a/build.gradle b/build.gradle index 59106f76..4cc4b752 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,7 @@ dependencies { // Feign implementation "org.springframework.cloud:spring-cloud-starter-openfeign:4.1.3" + testImplementation("io.github.openfeign:feign-core") } sentry { diff --git a/src/main/java/com/example/spot/auth/application/refactor/member/RefreshTokenStore.java b/src/main/java/com/example/spot/auth/application/refactor/member/RefreshTokenStore.java index ef47a8b0..b6f9f0c5 100644 --- a/src/main/java/com/example/spot/auth/application/refactor/member/RefreshTokenStore.java +++ b/src/main/java/com/example/spot/auth/application/refactor/member/RefreshTokenStore.java @@ -14,8 +14,8 @@ public class RefreshTokenStore { public void replace(Long memberId, String refresh) { refreshTokenRepository.deleteAllByMemberId(memberId); refreshTokenRepository.save(RefreshToken.builder() - .memberId(memberId). - token(refresh) + .memberId(memberId) + .token(refresh) .build()); } } diff --git a/src/main/java/com/example/spot/common/config/FeignConfig.java b/src/main/java/com/example/spot/common/config/FeignConfig.java index 4fbc9e71..2bb2ec58 100644 --- a/src/main/java/com/example/spot/common/config/FeignConfig.java +++ b/src/main/java/com/example/spot/common/config/FeignConfig.java @@ -1,9 +1,24 @@ package com.example.spot.common.config; +import com.example.spot.common.infrastructure.feign.retry.SelectiveRetryErrorDecoder; +import feign.Retryer; +import feign.codec.ErrorDecoder; import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @EnableFeignClients(basePackages = "com.example.spot") @Configuration public class FeignConfig { + + @Bean + public ErrorDecoder errorDecoder() { + return new SelectiveRetryErrorDecoder(); + } + + @Bean + public Retryer retryer() { + // 초기대기 200ms, 최대대기 800ms, 최대 3번 재시도 + return new Retryer.Default(200, 800, 3); + } } diff --git a/src/main/java/com/example/spot/common/config/FeignRetryConfig.java b/src/main/java/com/example/spot/common/config/FeignRetryConfig.java deleted file mode 100644 index 60ac571f..00000000 --- a/src/main/java/com/example/spot/common/config/FeignRetryConfig.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.spot.common.config; - -import feign.Retryer; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class FeignRetryConfig { - - @Bean - Retryer retryer() { - return new Retryer.Default(200, 800, 3); - } -} \ No newline at end of file diff --git a/src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java b/src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java index 8e6bcad4..986462be 100644 --- a/src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java +++ b/src/main/java/com/example/spot/common/infrastructure/feign/SafeFeignExecutor.java @@ -13,14 +13,25 @@ public static T run(Supplier call) { try { return call.get(); } catch (FeignException e) { + String message = e.getMessage() != null ? e.getMessage() : ""; + String masked = mask(message); throw new ExternalApiException( - "Feign API 호출 실패: " + extractMessage(e), e); + "Feign API 호출 실패(" + e.status() + "): " + masked, e + ); } } - private static String extractMessage(FeignException e) { - return e.responseBody() - .map(body -> new String(body.array())) - .orElse(e.getMessage()); + private static String mask(String s) { + if (s == null || s.isEmpty()) { + return s; + } + String out = s; + out = out.replaceAll("(?i)(Authorization)\\s*[:=]\\s*([^\\r\\n]+)", "$1: [REDACTED]"); + out = out.replaceAll("(?i)(Set-Cookie|Cookie)\\s*[:=]\\s*([^\\r\\n]+)", "$1: [REDACTED]"); + out = out.replaceAll("(?i)(access[_-]?token|id[_-]?token|refresh[_-]?token)\\s*[:=]\\s*([\\w\\.-]+)", + "$1=[REDACTED]"); + out = out.replaceAll("(?i)(\"(?:password|pass|secret|token|authorization)\"\\s*:\\s*\")([^\"]+)(\")", + "$1[REDACTED]$3"); + return out; } } \ No newline at end of file diff --git a/src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java b/src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java new file mode 100644 index 00000000..fa88ca68 --- /dev/null +++ b/src/main/java/com/example/spot/common/infrastructure/feign/retry/SelectiveRetryErrorDecoder.java @@ -0,0 +1,66 @@ +package com.example.spot.common.infrastructure.feign.retry; + +import feign.Response; +import feign.RetryableException; +import feign.codec.ErrorDecoder; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +public class SelectiveRetryErrorDecoder implements ErrorDecoder { + + public static final String GET = "GET"; + public static final String HEAD = "HEAD"; + public static final String OPTIONS = "OPTIONS"; + public static final String RETRYABLE_STATUS = "retryable status "; + public static final String RETRY_AFTER = "Retry-After"; + public static final long THRESHOLD = 1000L; + + private final ErrorDecoder defaultDecoder = new Default(); + + @Override + public Exception decode(String methodKey, Response response) { + int status = response.status(); + + // 재시도 대상 상태코드 + boolean retryableStatus = status == 429 || status == 408 || (status >= 500 && status <= 599); + + // 멱등 메서드만 재시도 (GET/HEAD/OPTIONS) + String method = response.request().httpMethod().name(); + boolean idempotent = method.equals(GET) || method.equals(HEAD) || method.equals(OPTIONS); + if (!(retryableStatus && idempotent)) { + return defaultDecoder.decode(methodKey, response); + } + + Long retryAfter = extractRetryAfter(response.headers()); + return new RetryableException(status, RETRYABLE_STATUS + status, response.request().httpMethod(), + retryAfter, response.request(), readBody(response), response.headers()); + } + + private Long extractRetryAfter(Map> headers) { + Collection value = headers.getOrDefault(RETRY_AFTER, Collections.emptyList()); + if (value.isEmpty()) { + return null; + } + String v = value.iterator().next().trim(); + try { + long seconds = Long.parseLong(v); + return System.currentTimeMillis() + (seconds * THRESHOLD); + } catch (NumberFormatException ignore) { + return null; + } + } + + private byte[] readBody(Response response) { + if (response.body() == null) { + return null; + } + try (InputStream in = response.body().asInputStream()) { + return in.readAllBytes(); + } catch (IOException e) { + return null; + } + } +} \ No newline at end of file diff --git a/src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java b/src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java new file mode 100644 index 00000000..13326a19 --- /dev/null +++ b/src/test/java/com/example/spot/common/feign/SelectiveRetryErrorDecoderTest.java @@ -0,0 +1,71 @@ +package com.example.spot.common.feign; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.spot.common.infrastructure.feign.retry.SelectiveRetryErrorDecoder; +import feign.Request; +import feign.Response; +import feign.RetryableException; +import feign.codec.ErrorDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class SelectiveRetryErrorDecoderTest { + + private final ErrorDecoder decoder = new SelectiveRetryErrorDecoder(); + + private Response newResponse(int status, String method, Map> headers, String body) { + Request req = Request.create( + Request.HttpMethod.valueOf(method), + "https://api.test/resource", + Map.of(), // request headers + null, // body + StandardCharsets.UTF_8, + null + ); + + Response.Builder builder = Response.builder() + .status(status) + .reason("test") + .headers(headers == null ? Map.of() : headers) + .request(req); + + if (body != null) { + builder.body(body.getBytes(StandardCharsets.UTF_8)); + } + + return builder.build(); + } + + @Test + void GET_500_is_retryable() { + Response res = newResponse(500, "GET", null, "{\"msg\":\"oops\"}"); + Exception ex = decoder.decode("key", res); + assertThat(ex).isInstanceOf(RetryableException.class); + } + + @Test + void GET_429_with_retryAfter_is_retryable() { + Map> headers = Map.of("Retry-After", List.of("2")); + Response res = newResponse(429, "GET", headers, null); + Exception ex = decoder.decode("key", res); + assertThat(ex).isInstanceOf(RetryableException.class); + } + + @Test + void POST_500_is_NOT_retryable() { + Response res = newResponse(500, "POST", null, null); + Exception ex = decoder.decode("key", res); + assertThat(ex).isNotInstanceOf(RetryableException.class); + } + + @Test + void GET_400_is_NOT_retryable() { + Response res = newResponse(400, "GET", null, null); + Exception ex = decoder.decode("key", res); + assertThat(ex).isNotInstanceOf(RetryableException.class); + } +} \ No newline at end of file