Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ out/

### Env ###
.env

### Secrets ###
*.key
*.crt
*.pem
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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())
Expand All @@ -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();
Expand Down
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) {

Copy link
Copy Markdown
Member

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만 받도록 의도하신 건지 궁금합니다!

Copy link
Copy Markdown
Member Author

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가 발생하면 그때 값을 보고 추가하려고 합니다!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하 좋습니다
상황 지켜보고 결정하는 방향으로 가시죠~


}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
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);
}
}
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;
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
130 changes: 130 additions & 0 deletions src/test/java/com/nexters/gotggam/toss/client/TossApiClientTest.java
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);
}
}