diff --git a/.gitignore b/.gitignore index 9bf8e68..30bc224 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,8 @@ out/ ### Env ### .env + +### Secrets ### +*.key +*.crt +*.pem diff --git a/build.gradle b/build.gradle index 9b56636..2fb0453 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-restclient' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' diff --git a/src/main/java/com/nexters/gotggam/toss/client/TossApiClient.java b/src/main/java/com/nexters/gotggam/toss/client/TossApiClient.java new file mode 100644 index 0000000..4013921 --- /dev/null +++ b/src/main/java/com/nexters/gotggam/toss/client/TossApiClient.java @@ -0,0 +1,75 @@ +package com.nexters.gotggam.toss.client; + +import com.nexters.gotggam.global.exception.BusinessException; +import com.nexters.gotggam.toss.exception.TossErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +// 앱인토스 API 호출 결과를 공통 응답 형식으로 해석해, 성공 시 success 값만 반환하고 실패 시 BusinessException으로 변환한다. +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "toss.api", name = "enabled", havingValue = "true") +public class TossApiClient { + + private static final String RATE_LIMITED_ERROR_CODE = "4095"; + + private final RestClient tossApiRestClient; + + public T post( + String uri, + Object body, + ParameterizedTypeReference> responseType + ) { + TossApiResponse response = exchange(uri, body, responseType); + + if (response == null || !response.isSuccess()) { + log.warn("토스 API 실패 응답: uri={}, error={}", uri, response == null ? null : response.error()); + throw new BusinessException(toErrorCode(response)); + } + if (response.success() == null) { + log.warn("토스 API 성공 응답에 success 값이 없음: uri={}", uri); + throw new BusinessException(TossErrorCode.TOSS_API_FAILED); + } + return response.success(); + } + + // 4xx/5xx도 공통 응답 형식으로 내려올 수 있어, 상태 코드로 예외를 던지지 않고 본문을 그대로 해석한다. + private @Nullable TossApiResponse exchange( + String uri, + Object body, + ParameterizedTypeReference> responseType + ) { + try { + return tossApiRestClient.post() + .uri(uri) + .body(body) + .retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { + }) + .body(responseType); + } catch (ResourceAccessException e) { + log.error("토스 API 연결 실패: uri={}", uri, e); + throw new BusinessException(TossErrorCode.TOSS_API_UNAVAILABLE); + } catch (RestClientException e) { + log.error("토스 API 응답 처리 실패: uri={}", uri, e); + throw new BusinessException(TossErrorCode.TOSS_API_FAILED); + } + } + + private TossErrorCode toErrorCode(@Nullable TossApiResponse response) { + if (response != null && response.error() != null + && RATE_LIMITED_ERROR_CODE.equals(response.error().errorCode())) { + return TossErrorCode.TOSS_RATE_LIMITED; + } + return TossErrorCode.TOSS_API_FAILED; + } +} diff --git a/src/main/java/com/nexters/gotggam/global/config/TossApiClientConfig.java b/src/main/java/com/nexters/gotggam/toss/client/TossApiClientConfig.java similarity index 79% rename from src/main/java/com/nexters/gotggam/global/config/TossApiClientConfig.java rename to src/main/java/com/nexters/gotggam/toss/client/TossApiClientConfig.java index 386c8a7..7d6f503 100644 --- a/src/main/java/com/nexters/gotggam/global/config/TossApiClientConfig.java +++ b/src/main/java/com/nexters/gotggam/toss/client/TossApiClientConfig.java @@ -1,4 +1,4 @@ -package com.nexters.gotggam.global.config; +package com.nexters.gotggam.toss.client; import java.net.http.HttpClient; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -16,8 +16,13 @@ @ConditionalOnProperty(prefix = "toss.api", name = "enabled", havingValue = "true") public class TossApiClientConfig { + // Boot가 구성한 RestClient.Builder를 받아 공통 메시지 컨버터(Jackson 설정)와 관측(Observation) 설정을 그대로 적용한다. @Bean - public RestClient tossApiRestClient(TossApiProperties properties, SslBundles sslBundles) { + public RestClient tossApiRestClient( + RestClient.Builder builder, + TossApiProperties properties, + SslBundles sslBundles + ) { SslBundle sslBundle = sslBundles.getBundle(properties.sslBundle()); HttpClient httpClient = HttpClient.newBuilder() .sslContext(sslBundle.createSslContext()) @@ -27,7 +32,7 @@ public RestClient tossApiRestClient(TossApiProperties properties, SslBundles ssl JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); requestFactory.setReadTimeout(properties.readTimeout()); - return RestClient.builder() + return builder .baseUrl(properties.baseUrl()) .requestFactory(requestFactory) .build(); diff --git a/src/main/java/com/nexters/gotggam/toss/client/TossApiError.java b/src/main/java/com/nexters/gotggam/toss/client/TossApiError.java new file mode 100644 index 0000000..1682785 --- /dev/null +++ b/src/main/java/com/nexters/gotggam/toss/client/TossApiError.java @@ -0,0 +1,5 @@ +package com.nexters.gotggam.toss.client; + +public record TossApiError(String errorCode, String reason) { + +} diff --git a/src/main/java/com/nexters/gotggam/global/config/TossApiProperties.java b/src/main/java/com/nexters/gotggam/toss/client/TossApiProperties.java similarity index 94% rename from src/main/java/com/nexters/gotggam/global/config/TossApiProperties.java rename to src/main/java/com/nexters/gotggam/toss/client/TossApiProperties.java index 82a277e..e8096c2 100644 --- a/src/main/java/com/nexters/gotggam/global/config/TossApiProperties.java +++ b/src/main/java/com/nexters/gotggam/toss/client/TossApiProperties.java @@ -1,4 +1,4 @@ -package com.nexters.gotggam.global.config; +package com.nexters.gotggam.toss.client; import java.time.Duration; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/nexters/gotggam/toss/client/TossApiResponse.java b/src/main/java/com/nexters/gotggam/toss/client/TossApiResponse.java new file mode 100644 index 0000000..130862d --- /dev/null +++ b/src/main/java/com/nexters/gotggam/toss/client/TossApiResponse.java @@ -0,0 +1,17 @@ +package com.nexters.gotggam.toss.client; + +import org.jspecify.annotations.Nullable; + +// 앱인토스 API 공통 응답. HTTP 200이어도 resultType이 FAIL일 수 있으므로 반드시 resultType으로 성공 여부를 판단한다. +public record TossApiResponse( + String resultType, + @Nullable T success, + @Nullable TossApiError error +) { + + private static final String SUCCESS = "SUCCESS"; + + public boolean isSuccess() { + return SUCCESS.equals(resultType); + } +} diff --git a/src/main/java/com/nexters/gotggam/toss/exception/TossErrorCode.java b/src/main/java/com/nexters/gotggam/toss/exception/TossErrorCode.java new file mode 100644 index 0000000..9e274e6 --- /dev/null +++ b/src/main/java/com/nexters/gotggam/toss/exception/TossErrorCode.java @@ -0,0 +1,20 @@ +package com.nexters.gotggam.toss.exception; + +import com.nexters.gotggam.global.exception.error.BaseError; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TossErrorCode implements BaseError { + + TOSS_API_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "TOSS_001", "토스 서버와 통신할 수 없습니다."), + TOSS_API_FAILED(HttpStatus.BAD_GATEWAY, "TOSS_002", "토스 API 요청에 실패했습니다."), + TOSS_RATE_LIMITED(HttpStatus.TOO_MANY_REQUESTS, "TOSS_003", "요청이 많습니다. 잠시 후 다시 시도해 주세요."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/test/java/com/nexters/gotggam/global/config/TossApiClientConfigTest.java b/src/test/java/com/nexters/gotggam/toss/client/TossApiClientConfigTest.java similarity index 97% rename from src/test/java/com/nexters/gotggam/global/config/TossApiClientConfigTest.java rename to src/test/java/com/nexters/gotggam/toss/client/TossApiClientConfigTest.java index e5b2a10..d927446 100644 --- a/src/test/java/com/nexters/gotggam/global/config/TossApiClientConfigTest.java +++ b/src/test/java/com/nexters/gotggam/toss/client/TossApiClientConfigTest.java @@ -1,4 +1,4 @@ -package com.nexters.gotggam.global.config; +package com.nexters.gotggam.toss.client; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -121,7 +121,7 @@ private RestClient createRestClient(Duration readTimeout) throws NoSuchAlgorithm readTimeout ); - return new TossApiClientConfig().tossApiRestClient(properties, sslBundles); + return new TossApiClientConfig().tossApiRestClient(RestClient.builder(), properties, sslBundles); } private static void respond(com.sun.net.httpserver.HttpExchange exchange, String body) throws IOException { diff --git a/src/test/java/com/nexters/gotggam/toss/client/TossApiClientTest.java b/src/test/java/com/nexters/gotggam/toss/client/TossApiClientTest.java new file mode 100644 index 0000000..4965a68 --- /dev/null +++ b/src/test/java/com/nexters/gotggam/toss/client/TossApiClientTest.java @@ -0,0 +1,130 @@ +package com.nexters.gotggam.toss.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withException; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import com.nexters.gotggam.global.exception.BusinessException; +import com.nexters.gotggam.toss.exception.TossErrorCode; +import java.io.IOException; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +class TossApiClientTest { + + private static final String BASE_URL = "https://apps-in-toss-api.toss.im"; + private static final String URI = "/api-partner/v1/sample"; + private static final ParameterizedTypeReference> RESPONSE_TYPE = + new ParameterizedTypeReference<>() { + }; + + private MockRestServiceServer server; + private TossApiClient client; + + record SampleResponse(String value) { + + } + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder().baseUrl(BASE_URL); + server = MockRestServiceServer.bindTo(builder).build(); + client = new TossApiClient(builder.build()); + } + + private void respond(HttpStatus status, String body) { + server.expect(requestTo(BASE_URL + URI)) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(status).contentType(MediaType.APPLICATION_JSON).body(body)); + } + + private void assertThrowsWith(TossErrorCode errorCode) { + assertThatThrownBy(() -> client.post(URI, Map.of(), RESPONSE_TYPE)) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getBaseError()) + .isEqualTo(errorCode); + } + + @Test + @DisplayName("resultType이 SUCCESS면 success 값만 반환한다") + void returnsSuccessValue() { + server.expect(requestTo(BASE_URL + URI)) + .andRespond(withSuccess(""" + {"resultType":"SUCCESS","success":{"value":"ok"}} + """, MediaType.APPLICATION_JSON)); + + SampleResponse result = client.post(URI, Map.of(), RESPONSE_TYPE); + + assertThat(result.value()).isEqualTo("ok"); + } + + @Test + @DisplayName("HTTP 200이어도 resultType이 FAIL이면 TOSS_API_FAILED 예외를 던진다") + void throwsOnFailResultWithHttp200() { + respond(HttpStatus.OK, """ + {"resultType":"FAIL","success":null,"error":{"errorType":0,"errorCode":"4050","reason":"인증서버에 등록된 미니앱이 아닙니다.","data":{},"title":null}} + """); + + assertThrowsWith(TossErrorCode.TOSS_API_FAILED); + } + + @Test + @DisplayName("요청 한도 초과(4095)면 TOSS_RATE_LIMITED 예외를 던진다") + void throwsRateLimitedOn4095() { + respond(HttpStatus.OK, """ + {"resultType":"FAIL","error":{"errorCode":"4095","reason":"요청 한도를 초과했습니다."}} + """); + + assertThrowsWith(TossErrorCode.TOSS_RATE_LIMITED); + } + + @Test + @DisplayName("4xx/5xx가 공통 응답 형식으로 오면 본문의 에러로 처리한다") + void handlesCommonFormatOnHttpError() { + respond(HttpStatus.BAD_REQUEST, """ + {"resultType":"FAIL","error":{"errorCode":"4095","reason":"요청 한도를 초과했습니다."}} + """); + + assertThrowsWith(TossErrorCode.TOSS_RATE_LIMITED); + } + + @Test + @DisplayName("4xx/5xx 본문이 공통 응답 형식이 아니면 TOSS_API_FAILED 예외를 던진다") + void throwsFailedOnNonJsonErrorBody() { + server.expect(requestTo(BASE_URL + URI)) + .andRespond(withStatus(HttpStatus.BAD_GATEWAY).contentType(MediaType.TEXT_HTML).body("Bad Gateway")); + + assertThrowsWith(TossErrorCode.TOSS_API_FAILED); + } + + @Test + @DisplayName("SUCCESS인데 success 값이 없으면 TOSS_API_FAILED 예외를 던진다") + void throwsFailedOnMissingSuccessValue() { + respond(HttpStatus.OK, """ + {"resultType":"SUCCESS","success":null} + """); + + assertThrowsWith(TossErrorCode.TOSS_API_FAILED); + } + + @Test + @DisplayName("연결에 실패하면 TOSS_API_UNAVAILABLE 예외를 던진다") + void throwsUnavailableOnConnectionFailure() { + server.expect(requestTo(BASE_URL + URI)) + .andRespond(withException(new IOException("connection refused"))); + + assertThrowsWith(TossErrorCode.TOSS_API_UNAVAILABLE); + } +}