Repository navigation
[feature] 앱인토스 API 공통 응답 처리 클라이언트 추가 #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,3 +38,8 @@ out/ | |
|
|
||
| ### Env ### | ||
| .env | ||
|
|
||
| ### Secrets ### | ||
| *.key | ||
| *.crt | ||
| *.pem | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
75 changes: 75 additions & 0 deletions
75
src/main/java/com/nexters/gotggam/toss/client/TossApiClient.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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> T post( | ||
| String uri, | ||
| Object body, | ||
| ParameterizedTypeReference<TossApiResponse<T>> responseType | ||
| ) { | ||
| TossApiResponse<T> 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 <T> @Nullable TossApiResponse<T> exchange( | ||
| String uri, | ||
| Object body, | ||
| ParameterizedTypeReference<TossApiResponse<T>> 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; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
5 changes: 5 additions & 0 deletions
5
src/main/java/com/nexters/gotggam/toss/client/TossApiError.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.nexters.gotggam.toss.client; | ||
|
|
||
| public record TossApiError(String errorCode, String reason) { | ||
|
|
||
| } | ||
2 changes: 1 addition & 1 deletion
2
...ggam/global/config/TossApiProperties.java → ...otggam/toss/client/TossApiProperties.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
src/main/java/com/nexters/gotggam/toss/client/TossApiResponse.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package com.nexters.gotggam.toss.client; | ||
|
|
||
| import org.jspecify.annotations.Nullable; | ||
|
|
||
| // 앱인토스 API 공통 응답. HTTP 200이어도 resultType이 FAIL일 수 있으므로 반드시 resultType으로 성공 여부를 판단한다. | ||
| public record TossApiResponse<T>( | ||
| String resultType, | ||
| @Nullable T success, | ||
| @Nullable TossApiError error | ||
| ) { | ||
|
|
||
| private static final String SUCCESS = "SUCCESS"; | ||
|
|
||
| public boolean isSuccess() { | ||
| return SUCCESS.equals(resultType); | ||
| } | ||
| } |
20 changes: 20 additions & 0 deletions
20
src/main/java/com/nexters/gotggam/toss/exception/TossErrorCode.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
130 changes: 130 additions & 0 deletions
130
src/test/java/com/nexters/gotggam/toss/client/TossApiClientTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<TossApiResponse<SampleResponse>> 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("<html>Bad Gateway</html>")); | ||
|
|
||
| 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); | ||
| } | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
응답 형식 문서를 봤을 땐 에러 객체에
errorType,data,title도 있고, 요청 한도를 초과하면 HTTP 200에 errorCode: "4095"와error.data.retryAfterSeconds가 내려간다고 써있던데 일부러errorCode랑reason만 받도록 의도하신 건지 궁금합니다!There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
분기에 필요한
errorCode와 로깅용reason만 의도적으로 받았습니다!요청 한도 초과는
errorCode: 4095로 판단해TOSS_003(429)으로 변환하고 있습니다.data안의retryAfterSeconds도 고민해봤는데, 토스 api 호출은 백엔드 내부에서 처리되는 부분이라 프론트에 전달하는 건 과하다고 생각했고, 로그에 남기기에도 불필요하다고 판단했습니다.백엔드 내부 재시도에 활용할 여지는 있지만, 아직 토스에 미니앱으로 등록하지 않은 상태라
retryAfterSeconds규모를 아직 알 수 없어서 지금 시점에서 재시도 전략을 정하기 어려웠습니다 ㅜ 실제로4095가 발생하면 그때 값을 보고 추가하려고 합니다!There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
아하 좋습니다
상황 지켜보고 결정하는 방향으로 가시죠~