Springboot Toss 결제 API 연동
외부 API 연동하기
우테코에서 방탈출 미션을 수행하면서, 방탈출 예약 결제 처리에 Toss 결제 API 를 연동하게 되었다.
방탈출 미션은 방탈출 예약 웹 서비스를 만드는 미션으로 JS/CSS/HTTP 기반 웹 클라이언트 와 Springboot/Java 기반 백엔드를 함께 개발한다.
하지만 백엔드에서 고려할 부분에 대한 흐름을 기록하고싶기 때문에
이번 내용은 클라이언트 측이 아닌 백엔드 측의 기준으로 설명한다.
또한 Toss API 연결이라는 하나의 작업보다는 외부 API 연결이라는 큰 틀에 집중해보았다는 점을 참고해서 글을 읽으면 도움이 될 것이다.
또한 이번에 다루는 내용은 실제 결제까지의 과정이 아니라 학습을 위한 과정이기 때문에
토스에서 지원하는 '테스트'용 키값 등을 사용했다는 점도 참고바란다.
(실제 결제까지 연결하려면 사업자등록부터 꽤나 복잡한 것으로 알고 있다.)
Toss 공식 문서
사실 내가 어떻게 쓰든간에 중요한건 공식문서니까 맨 위에 첨부하고 글을 시작한다.
여태껏 보았던 문서 중 가장 친절한 문서인 것 같다.
문서보는게 편한 사람이라면 이 글을 통해서는 외부 API 연결 시 고려해야하는 부분들에 대해서만 구경하고 가면 좋을 것 같다 :)
Toss 결제 API Flow
우선 Toss 결제는 어떤 플로우로 진행되는 지 알아보자
위 이미지는 결제 플로우이며, 우리는 Promise 방식을 사용하기 때문에 successUrl, failUrl 이 없다는 점에서 흐름이 조금 다르다는 점을 인지하고 보자.
[참고사항] 방탈출 예약 서비스는 웹 서비스이기 때문에 Promise 방식을 사용할 수 있지만, 앱이라면 Promise 방식 사용이 불가능하다.
0. 상품 선택
방탈출 웹사이트에서 원하는 상품을 선택하고 결제버튼을 클릭한다.
1. 결제위젯 렌더 요청
Toss 결제 연동의 첫 단추는 Toss 공식문서에서 제공하는 결제위젯을 클라이언트 코드에 추가하는 것이다.
해당 코드가 존재하는 페이지가 열리면, 클라이언트는 토스에 결제위젯 렌더 요청을 보내어 위와 같은 화면을 띄워준다.
2. 결제 요청 호출
결제 수단을 선택하고 나면 위와 같은 화면으로 넘어온다.
여기서 화면 하단에 보이는 ‘동의하고 결제하기’ 버튼을 클릭하면 ‘Toss 결제요청 API’ 를 호출한다.
‘결제 요청’ 은 토스 서버에 현재 수행하려하는 결제에 대한 정보를 등록하는 과정이다.
현재 서버에서는 orderId, orderName, amount 를 통해 결제 요청을 호출하여 Toss에 해당 결제정보를 저장한다.
결제 요청은 PaymentWidget의 requestPayment() 메서드를 통해 수행할 수 있는데, 각각의 파라미터는 아래와 같은 역할을 갖는다.
- orderId: 주문을 구분하는 ID이다. 충분히 무작위한 값을 생성해서 각 주문마다 고유한 값을 넣어야한다. 값은 영문 대소문자, 숫자, 특수문자 -, _, =로 이루어진 6자 이상 64자 이하의 문자열이어야 한다.
- orderName: 구매상품명이다. 예를 들면 생수 외 1건 같은 형식입니다. 최대 길이는 100자입니다.
- amount: 상품의 가격이다.
이 외에 더 많은 파라미터가 존재하는데 해당 문서에서 확인할 수 있다.
requestPayment() 메서드는 응답을 리다이렉트URL 또는 Promise 형식으로 반환받을 수 있다. 관련 내용은 해당 문서를 참고하자.
어찌되었건 위 과정을 거쳐 ‘결제 요청’ 에 성공하면 클라이언트는 토스 서버로부터 paymentType, paymentKey, orderId, amount이 4가지 값을 응답받는다.
orderId와 amount는 위에서 설명했으니 나머지에 대해서 설명한다.
- paymentKey: 결제를 식별하는 키 값으로 토스페이먼츠에서 발급한다. 결제에 대한 처리(결제 승인, 결제 조회, 결제 취소 등)와 운영에 필요한 값이다.
- paymentType: 결제 유형으로 NORMAL, BRANDPAY, KEYIN 중 하나이다.
결제 요청 실패 시에는 error code 를 응답받는데, 그에 대한 내용은 해당 문서를 참고하자.
3. 결제정보 전달
결제 요청이 정상적으로 완료되어 클라이언트로부터 paymentType, paymentKey, orderId, amount 를 응답받았다면 클라이언트는 결제승인 요청을 위해 우리 서버의 방탈출 예약 API 를 통해 해당 정보와 예약에 필요한 정보를 전달한다.
4. 결제 승인 API 호출
서버는 결제승인 API 호출을 위해 클라이언트로부터 paymentType, paymentKey, orderId, amount 와 그 외 예약 관련 정보들을 전달받았다.
서버는 클라이언트로 전달받은 값 중 paymentKey, orderId, amount 와 토스에서 발급받을 수 있는 SecretKey 를 통해 토스의 결제승인 API를 호출한다.
이때 orderId가 이전에 클라이언트 측에서 보낸 orderId와 동일한 지 비교하고 호출한다.
SecretKey 발급 방법은 공식문서를 참조하자
만약 승인에 성공했다면 위와 같은 화면이 클라이언트 측 토스 위젯에 출력될 것이다.
하지만 승인을 요청한 사용자나 클라이언트 또는 서버에 모두 문제가 없다면 결제가 완료되고, 문제가 있다면 실패한다.
이때 발생할 수 있는 문제들은 해당 문서를 참고하자.
이번 글에서 다룰 내용
PaymentWidget을 초기화하고 결제요청(PaymentRequest)를 보내서 PaymentKey를 흭득하여 서버로 보내주는 것은 클라이언트의 역할이다.
위에서 말했듯이 이번 글에서는 서버의 역할에 대해서만 다룰 예정이기 때문에 사전 설명은 여기까지하고 아래 글에서는 서버 관점에서의 결제승인 API 호출, 결제취소 API 호출 만을 다루겠다.
클라이언트를 포함한 전체 코드가 보고싶다면 깃허브 주소를 올려둘테니 참고하길 바란다.
코드에 대해 질문이나 의견이 있으시다면 댓글에 남겨주세요!
Reference
HttpClient 선택
외부 API를 사용하기 전에는 어떤 HttpClient를 사용할 지 정하고 시작해야한다.
HttpClient는 HTTP로 요청을 날릴 수 있도록 도와주는 역할을 하는데, 바로 이 HttpClient를 통해 Toss 결제 API에 요청을 보낼 계획이다.
Client-Server에 대해서
우리 서비스의 Client에게는 스프링부트 애플리케이션이 Server지만, Toss 입장에서는 우리 스프링부트 애플리케이션이 Client가 된다.
이렇듯 요청을 보내는 쪽이 Client, 요청을 받아 무언가를 제공하는 곳이 Server이며, HttpClient 사용 학습은 꼭 스프링부트 애플리케이션이라고 해서 Server의 역할만을 가지지는 않는다는 살짝의 학습 키 포인트가 있다고 생각한다.
아무튼 스프링 환경에서 외부 API 연결은 HttpClient로 수행할 수 있는데, 스프링에서는 HttpClient로 WebClient, RestClient, RestTemplate가 존재한다.
각각의 클라이언트들은 장단점이 존재하는데, 이 중에 나는 RestClient를 선택하였다.
그 이유는 아래와 같다.
WebClient
WebClient는 WebFlux 의존성에 있는 비동기/논블로킹 을 지원하는 HttpClient이다.
조금 사용해보긴 했지만 능숙하게 다룰만큼 사용해보지 않았던 기술을 시간 제한이 있고, 그 시간이 조금은 부족하다고 느껴지는 현재 시점에 공부해보면서 적용하는 것은 좋지 않은 판단이라고 생각했다.
그리고 WebClient를 도입한다고 해도 해당 클라이언트의 장점인 비동기/논블로킹 방식을 통해 얻을 수 있는 이점이 크게 없다고 생각했다.
RestTemplate Vs RestClient
우테코에서 제공해준 자료중 RestTemplate이 Deprecated된다는 내용의 Baeldung 글이 있었는데, 반면 RestClient는 스프링 6.2부터 나온 메서드 체이닝 형식의 깔끔한 HttpClient 이라고 소개되고 있었다.
이때 굳이 Deprecated라는 단점을 감안하고 RestTemplate을 선택할 이유가 없다고 느꼈고 메서드체이닝 방식의 편리하고 깔끔한 코드를 장점삼아 RestClient를 선택하였다.
그런데 그 뒤에 조금 더 알아보니 RestTemplate이 사실은 Deprecated 되지 않는 것 같았다.
RestTemplate에 대한 공식 문서를 아무리 찾아보아도 Deprecated에 대한 내용은 존재하지 않았다.
이 시점에 Baeldung에서 이상한 정보를 줬나? 싶어서 더 찾아보게 되었는데, 위 영상에서 알게 된 점은 스프링 공식 문서에 표시가 되었었지만 이후에 Deprecated를 철회했다고 한다.
(아마 이미 RestTemplate을 사용하고 있는 코드가 많아서 Deprecated 된다면 유지보수에 어려움을 겪을까봐 철회한 것 같다.)
이번 HttpClient 선택 과정을 통해 직접 본 게 아니면 완전히 믿지 말자는 신념이 조금 자라난 것 같다. Baeldung이 아무리 인지도가 높고 의미있는 글이 많은 곳이라고는 하지만, 거기서 보았다고 하더라도 직접 공식문서를 흝어보고 찾아보는 습관을 가지자.
아무튼 RestTemplate이 Deprecated 되지 않는다고 하더라도 나는 RestClient를 선택하게 되었다.
공식문서에서 RestTemplate 보다 현대적인 API를 제공한다고 권장하는데 한 번 써봐야하지 않겠는가.
그리고 개인적으로 나는 가독성, 불필요한 변수 생성 로직X 등의 이유로 메서드 체이닝 방식을 좋아한다.
RestClient 공식문서
Timeout 처리
외부 API를 연결하기 위해선 Timeout에 대한 처리에 대한 고민도 필요하다.
Timeout 이란?
타임아웃이란 서버로 요청을 보냈을 때, 요청 시간이 오래 걸리거나 요청은 도달했으나 응답까지의 시간이 너무 오래 지연되는 경우, 일정 시간이 지나면 요청을 취소하는 것을 의미한다.
타임아웃은 시스템 자원을 효율적으로 사용하기 위해 중요한 요소 중 하나이다.
연결되지 않는 API 또는 응답이 너무 늦는 API에 쓰레드가 묶여있으면 이는 자원 낭비이다.
따라서 타임아웃을 적절히 설정하여, 특정 시간이 지나도 응답이 없는 경우 해당 요청을 취소하고 시스템 자원을 해제하는 타임아웃이 필요한 것이다.
타임아웃은 클라이언트 측과 서버 측에서 각각 설정될 수 있다. 클라이언트 측에서는 요청을 보내고 일정 시간 내에 응답이 없으면 요청을 취소하고, 서버 측에서는 요청을 받아들이고 처리하는 과정에서 일정 시간 내에 작업이 완료되지 않으면 해당 요청을 중단할 수 있다.
이러한 타임아웃은 크게 2종류로 구분할 수 있는데, 바로 살펴보자.
Connection Timeout
- 요청한 서버에 연결하는 시간에 대한 타임아웃이다.
- HTTP 연결은 TCP 3way handshake를 통해 수행되는데, 해당 연결 과정 중에 설정해둔 ConnectionTimeout 설정 시간이 지나게 되면 해당 타임아웃이 발생한다.
Read Timeout
- 요청을 보낸 서버에서 데이터를 읽어오는 시간에 대한 타임아웃이다.
- Connection이 성공하고, 그 이후 서버에서 데이터를 읽어오는 과정에서 설정해둔 ReadTimeout 설정 시간이 지나게 되면 해당 타임아웃이 발생한다.
ConnectTimeout은 몇 초가 적당할까?
HTTP는 3way-handshaking 과정을 통해 연결하고, 결국 ConnectTimeout의 Time은 TCP 3way-handshacking에 걸리는 시간을 의미한다.
나는 이러한 핸드셰이킹 과정의 타임아웃 발생은 몇 초 뒤에 발생해야 적당하며, 어떤 기준을 둘 수 있을까에 대해 아래와 같이 정의해보았다.
- 핸드셰이킹(handshaking) 과정에서 패킷은 광섬유를 통해 전달된다.
- 광섬유에서는 데이터(패킷)가 빛의 속도로 이동하며, 빛은 1초에 약 300,000km, 1밀리초(ms)에 약 300km를 이동한다.
- 제주도에서 강원도 철원까지의 최대 거리는 약 545km이다. 한국 내에서만 서비스를 제공한다는 기준 하에 크게 잡아 요청의 end-to-end 최대 거리를 600km로 설정한다.
- 이 거리를 기준으로 대한민국 끝에서 끝으로 1번 패킷이 전달되는 데 걸리는 시간은 약 2ms이고, 3번 오가는 데 걸리는 시간은 약 6ms 이다.
- 위에서 설명한 네트워크 연결에서 발생하는 다양한 상황을 고려하여, connectTimeout 시간을 넉넉하게 1초~3초 로 설정하면 된다고 생각하였다.
개인적인 생각일 뿐이기 때문에 이견이 있으시다면 남겨주시면 감사하겠습니다!
ReadTimeout은 몇 초가 적당할까?
토스에서 제공하는 문서를 읽어보면 토스 API의 ReadTimeout은 30~60초로 설정하는 것이 적절하다고 적혀있다.
ReadTimeout은 토스 서버 내부에 의해 응답시간이 결정되는 것이기 때문에 내가 생각해서 처리하는 것보다 토스에서 계산한 값을 사용하는 것이 가장 안정적일 것이라고 생각한다.
때문에 권장하는 값 중 가장 작은 값인 30초로 설정하였다.
API 문서를 흝어보면 위와 같이 기능마다 최대로 소요되는 초가 적혀있는데, 이걸 기준으로 설정하자.
리뷰어였던 호돌에게도 ReadTimeout과 관련해서 질문했는데, 다음과 같은 답변을 얻을 수 있었다.
요약하자면 결제와 같이 시간이 오래걸려도 사용자가 용인할 수 있는 부분이라면 타임아웃을 길게 잡아도 괜찮을 것 같다는 의견이었고 나도 충분히 동의했다.
타임아웃 처리 로직이 제대로 터지는 지도 테스트를 해야할까?
그렇지 않다고 생각한다.
타임아웃이 터지는 지를 테스트하는 것은 라이브러리의 기능을 테스트하는 것이므로 내가 하고자하는 테스트의 본질과는 조금 거리가 먼 것같다고 생각한다.
나는 타임아웃이 제대로 발생하는 지를 테스트하기 보다, 타임아웃 발생 시 서버에서 설정한 커스텀 예외로 변환이 잘 되는 지 등의 우리 애플리케이션 로직을 테스트하는 것이 더 적절한 테스트라고 생각했다.
물론 제대로 잘 터지는 지 궁금해서 테스트해보는 것은 절대 나쁘지 않다. 하지만 ‘타임아웃이 잘 터지는 지’를 테스트하는 것은 사실 상 라이브러리를 테스트하는 것이기 때문에 “과연 적절한 테스트일까?” 라는 생각이 든다. ”타임아웃을 발생시키는 로직은 라이브러리 로직이며, 라이브러리 로직은 우리 애플리케이션의 로직이 아니기에 테스트 범위가 아니라고 생각한다.”
Reference
결제승인 API 연동해보기
사전에 필요한 퍼즐조각을 모두 모았으니, 이제 직접 코드로 API 연결 퍼즐을 맞춰보자
application.yml 설정
payment:
secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
base-url: https://api.tosspayments.com/v1/payments
confirm-endpoint: /confirm
먼저 application.yml 에 API 연동에 필요한 프로퍼티 값들을 담아준다.
결제 승인 API 호출에 필요한 정보는 결제승인 API URL, 그리고 SecretKey 이다.
API URL은 공식문서에서 확인할 수 있으며, SecretKey 또한 토스 개발자 센터에서 얻을 수 있다.
다만 실제로 결제를 연동하기 위해서는 여러 복잡한 사전 작업을 수행해야하며, 지금 만드는 것은 연습용이니 테스트용 SecretKey를 사용한다. 테스트용 SecretKey 또한 개발자 센터의 문서에서 제공한다.
PaymentProperties.java 설정
@Component
@ConfigurationProperties(prefix = "payment")
public class PaymentProperties {
private String secretKey;
private String baseUrl;
private String confirmEndpoint;
public String getSecretKey() {
return secretKey;
}
public String getBaseUrl() {
return baseUrl;
}
public String getConfirmUrl() {
return baseUrl + confirmEndpoint;
}
public void setSecretKey(final String secretKey) {
this.secretKey = secretKey;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public void setConfirmEndpoint(String confirmEndpoint) {
this.confirmEndpoint = confirmEndpoint;
}
}
다음으로 만들어줄 것은 PaymentProperties 클래스이다.
@Value 애노테이션을 통해 yml 파일의 프로퍼티 값을 가져올 수 있지만, 필요한 값들을 하나하나 가져와야하기 때문에 코드가 길어지고 보기도 안좋다.
스프링의 @ConfigurationProperties(prefix = "") 를 활용하면 이런 문제를 없애준다.
prefix에 yml파일의 프로퍼티값 prefix를 적어주면 prefix 하위에 존재하는 값들을 객체에 담아준다.
해당 객체는 @Component 를 통해 빈으로 등록하여 싱글톤으로 어디서든 재활용할 수 있다.
또한 baseUrl과 confirmEnpoint 를 외부에서 별도로 합칠 필요없이 최종 URL을 만들어주는 메서드를 두어 편리하게 값을 다룰 수도 있다.
PaymentClient.java - RestClient 설정
@Component
public class PaymentClient {
private static final String BASIC_DELIMITER = ":";
private static final String AUTH_HEADER_PREFIX = "Basic ";
private static final int CONNECT_TIMEOUT_SECONDS = 1;
private static final int READ_TIMEOUT_SECONDS = 30;
private final ObjectMapper objectMapper;
private final PaymentProperties paymentProperties;
private RestClient restClient;
public PaymentClient(PaymentProperties paymentProperties,
ObjectMapper objectMapper) {
this.paymentProperties = paymentProperties;
this.objectMapper = objectMapper;
this.restClient = RestClient.builder()
.requestFactory(createPaymentRequestFactory())
.requestInterceptor(new PaymentExceptionInterceptor())
.defaultHeader(HttpHeaders.AUTHORIZATION, createPaymentAuthHeader(paymentProperties))
.build();
}
private ClientHttpRequestFactory createPaymentRequestFactory() {
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
.withReadTimeout(Duration.ofSeconds(READ_TIMEOUT_SECONDS));
return ClientHttpRequestFactories.get(SimpleClientHttpRequestFactory.class, settings);
}
private String createPaymentAuthHeader(PaymentProperties paymentProperties) {
byte[] encodedBytes = Base64.getEncoder().encode((paymentProperties.getSecretKey() + BASIC_DELIMITER).getBytes(StandardCharsets.UTF_8));
return AUTH_HEADER_PREFIX + new String(encodedBytes);
}
PaymentClient는 위에서 정한 RestClient를 통해 결제 관련 HTTP 요청을 수행하는 객체이다.
코드를 하나하나 살펴보자
requestFactory() 로 요청 관련 설정값 적용
.requestFactory(createPaymentRequestFactory())
- createPaymentRequestFactory() 메서드에서 ConnectTimeout, ReadTimeout 시간을 설정 후, SimpleClientHttpRequestFactory 객체로 만들어 Return 한다.
- ClientRequestFactory는 다양한 종류가 존재하니 학습해보고 필요한 팩토리를 골라 사용하면 된다.
예외 핸들링 Interceptor 적용
.requestInterceptor(new PaymentExceptionInterceptor())
RestClient는 요청에 대해 Interceptor를 따로 적용시킬 수 있다.
요청 시, 발생하는 예외에 대한 처리역할을 맡길 수 있는데 직접 핸들링하는 것보다 인터셉터에게 시키는 것이 간편하고 코드도 보기 좋다.
ClientHttpRequestInterceptor 코드
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import roomescape.exception.payment.PaymentConfirmException;
import roomescape.exception.payment.PaymentTimeoutException;
import java.io.IOException;
public class PaymentExceptionInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) {
try {
return execution.execute(request, body);
} catch (IOException e) {
throw new PaymentTimeoutException(e);
} catch (Exception e) {
throw new PaymentConfirmException(e);
}
}
}
ClientHttpRequestInterceptor를 implements하면 되는데, execution을 execute하는 과정에서 예외를 잡을 수 있도록 try/catch 문을 사용했고, 내부에서 잡히는 예외를 직접 만든 커스텀 예외로 변환시켰다.
굳이 이렇게까지 하나 싶을 수도 있지만 아래 남겨놓은 적용 전, 적용 후 코드를 통해 장점을 비교해보면 그 효용을 자세히 이해할 수 있다.
Interceptor 적용 전
public PaymentConfirmOutput confirmPayment(PaymentConfirmInput confirmRequest) {
try {
return restClient.method(HttpMethod.POST)
.uri("/confirm")
.contentType(MediaType.APPLICATION_JSON)
.body(confirmRequest)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new PaymentConfirmException(getPaymentConfirmErrorCode(response));
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new PaymentConfirmException(getPaymentConfirmErrorCode(response));
})
.body(PaymentConfirmOutput.class);
} catch (ResourceAccessException e) {
if (e.getCause() instanceof SocketTimeoutException) {
throw new PaymentTimeoutException(e);
}
throw new PaymentIOException(e);
} catch (Exception e) {
throw new PaymentConfirmException(e);
}
}
Interceptor 적용 후
public PaymentConfirmOutput confirmPayment(PaymentConfirmInput confirmRequest) {
return restClient.method(HttpMethod.POST)
.uri("/confirm")
.contentType(MediaType.APPLICATION_JSON)
.body(confirmRequest)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new PaymentConfirmException(getPaymentConfirmErrorCode(response));
})
.body(PaymentConfirmOutput.class);
}
DefaultHeader 설정
.defaultHeader(HttpHeaders.AUTHORIZATION, createPaymentAuthHeader(paymentProperties))
토스 결제 요청은 secretKey 뒤에 콜론(:)을 붙이고 Base64로 인코딩한 값 앞에 “Basic ” prefix를 붙여 Authorization 헤더에 담아 요청을 보내야한다.
때문에 기본 헤더에 이를 추가해주었다.
헤더 Value 생성은 PaymentClient.java의 createPaymentAuthHeader() 메서드를 참고하자.
PaymentClient.java - 결제승인(Confirm) API 호출 로직
@Component
public class PaymentClient {
//.. 생략
// 결제 요청 API 호출
public PaymentConfirmOutput confirmPayment(PaymentConfirmInput confirmRequest) {
return restClient.method(HttpMethod.POST)
.uri(paymentProperties.getConfirmUrl())
.contentType(MediaType.APPLICATION_JSON)
.body(confirmRequest)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new PaymentConfirmException(getPaymentConfirmErrorCode(response));
})
.body(PaymentConfirmOutput.clbass);
}
/**
* @see 결제 승인 API 에러 코드 문서
*/
private PaymentConfirmErrorCode getPaymentConfirmErrorCode(final ClientHttpResponse response) throws IOException {
PaymentFailOutput confirmFailResponse = objectMapper.readValue(
response.getBody(), PaymentFailOutput.class);
return PaymentConfirmErrorCode.findByName(confirmFailResponse.code());
}
위에서 세팅을 마친 RestClient에 결제승인 API에서 요구하는 값들을 담아 호출하는 메서드이다.
body
body에 담는 값(paymentConfirmInput)은 orderId, amount, paymentKey 이다.
각 값들의 역할은 위에서 설명했으니 넘어간다.
onStatus
RestClient는 onStatus 메서드에서 HttpStatusCode의 isError() 메서드를 통해 4xx, 5xx 응답을 한 번에 잡을 수 있다.
onStatus 메서드는 Request, Response 값을 모두 제공하기 때문에 요청 실패 응답 시, 예외를 발생시키는 것 또한 간편하게 처리할 수 있다.
에러 발생 시, 토스는 에러 코드, 메시지가 담긴 에러 객체를 resposne하는데, 해당 에러 객체 매핑역할은 getPaymentConfirmErrorCode 에서 수행하며 PaymentConfirmErrorCode 객체에서 에러코드를 관리한다.
PaymentConfirmErrorCode 객체는 단순히 에러 응답을 우리 서버만의 객체로 변경해주는 역할 뿐만 아니라, 에러 메시지 중, 클라이언트에게 공개하기 어려운 정보(예를 들어 보안과 관련된 정보)가 담긴 메시지를 공개할 수 있는 정보만 담은 메시지로 변환하여 클라이언트에게 제공하는 역할도 수행한다.
PaymentConfirmErrorCode.java
public enum PaymentConfirmErrorCode {
ALREADY_PROCESSED_PAYMENT(HttpStatus.BAD_REQUEST, "이미 처리된 결제 입니다."),
PROVIDER_ERROR(HttpStatus.BAD_REQUEST, "일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
INVALID_API_KEY(HttpStatus.BAD_REQUEST, "잘못된 시크릿키 연동 정보 입니다."),
INVALID_EXCEED_MAX_DAILY_PAYMENT_COUNT(HttpStatus.BAD_REQUEST, "하루 결제 가능 횟수를 초과했습니다."),
EXCEED_MAX_PAYMENT_AMOUNT(HttpStatus.BAD_REQUEST, "하루 결제 가능 금액을 초과했습니다."),
NOT_FOUND_TERMINAL_ID(HttpStatus.BAD_REQUEST, "단말기번호(Terminal Id)가 없습니다. 관리자에게 문의해주세요."),
UNAPPROVED_ORDER_ID(HttpStatus.BAD_REQUEST, "아직 승인되지 않은 주문번호입니다."),
UNAUTHORIZED_KEY(HttpStatus.BAD_REQUEST, "인증되지 않은 시크릿 키 혹은 클라이언트 키 입니다."),
INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "결제 비밀번호가 일치하지 않습니다."),
NOT_FOUND_PAYMENT(HttpStatus.BAD_REQUEST, "존재하지 않는 결제 정보 입니다."),
NOT_FOUND_PAYMENT_SESSION(HttpStatus.BAD_REQUEST, "결제 시간이 만료되어 결제 진행 데이터가 존재하지 않습니다."),
INVALID_AUTHORIZE_AUTH(HttpStatus.BAD_GATEWAY, "유효하지 않은 인증 방식입니다."),
NOT_AVAILABLE_PAYMENT(HttpStatus.BAD_GATEWAY, "결제가 불가능한 시간대입니다"),
INCORRECT_BASIC_AUTH_FORMAT(HttpStatus.BAD_GATEWAY, "서버 측 오류로 결제에 실패했습니다. 관리자에게 문의해주세요."),
FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING(HttpStatus.BAD_GATEWAY, "결제가 완료되지 않았어요. 다시 시도해주세요."),
FAILED_INTERNAL_SYSTEM_PROCESSING(HttpStatus.BAD_GATEWAY, "내부 시스템 처리 작업이 실패했습니다. 잠시 후 다시 시도해주세요."),
UNKNOWN_PAYMENT_ERROR(HttpStatus.BAD_GATEWAY, "결제에 실패했어요. 같은 문제가 반복된다면 은행이나 카드사로 문의해주세요."),
PAYMENT_CONFIRM_ERROR_MISMATCH_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "결제 과정에서 서버 에러가 발생했습니다. 관리자에게 문의해주세요."),
;
private final HttpStatus httpStatus;
private final String message;
public static PaymentConfirmErrorCode findByName(String name) {
return Arrays.stream(values())
.filter(v -> v.name().equals(name))
.findAny()
.orElse(PAYMENT_CONFIRM_ERROR_MISMATCH_ERROR);
}
PaymentConfirmErrorCode(final HttpStatus httpStatus, final String message) {
this.httpStatus = httpStatus;
this.message = message;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
public String getMessage() {
return message;
}
}
PAYMENT_CONFIRM_ERROR_MISMATCH_ERROR 는 토스에서 전달하는 에러는 아니고,
해당 객체의 에러코드와 토스 객체의 에러코드명이 일치하지 않을 때 발생시키기 위해 따로 만든 에러코드이다.
retrieve 이후의 body
retrieve 는 요청을 전송하는 메서드이기 때문에, retrieve 이후의 body는 응답값을 담을 body이다.
응답에서 받을 값(PaymentConfirmOutput) 은 다음 값들이다. paymentKey, orderId, totalAmount(총 결제금액), orderName, requestedAt(요청시간), approvedAt(승인시간), status(결제상태)
응답값에 대한 자세한 설명은 위에 첨부한 공식문서를 참고하자.
결과
로깅(Logging)
이제 거의 다 왔으니 이제 로깅에 대해서 고려해보면 좋을 것 같다.
주변 대기업에 다니는 지인들에게 물어보니, 보통 로깅에 있어서 개인정보 관련 고려사항이 많은걸로 알고 있는데 회사마다 규정도 다르고 관리방법도 달라서 이것까진 고려하지 않고 로깅 자체에 대해서만 생각해보겠다.
우선 나누고 싶은 주제는 “외부 API 호출 시, 로깅은 왜 해야할까?” 이다.
주변 크루원들의 코드를 보면 LoggingInterceptor 등의 로깅 시스템을 구축해서 외부 API에 대한 request, response 정보를 로깅하고 있었다.
사실 처음에는 이걸 로깅할 필요가 있을까? 싶었는데, 조금 생각해보니 그 이유는 확실히 있었다.
만약 우리 방탈출 서비스에서 누군가 결제를 했는데 돈만 빠져나가고 예약이 안됐다고 전화가 오면, 개발자는 무엇을 해야할까?
로그를 통해 해당 고객이 실제로 결제 요청을 보내 결제에 성공까지 했는지에 대해 확인해야한다.
그러나 로그가 없다면 어떻게 될까?
우리는 아무런 정보도 얻을 수 없어 문제 해결 과정이 복잡해진다. 아마 소비자에게 이런저런 증빙자료를 요구해야할 것이고 소비자는 돈을 돌려받는 것에 어려움을, 우리는 이 문제를 해결하는 데에 어려움을 느낄 것이다.
실제로 개인정보 보호 문제로 필수적인 몇몇 데이터만 로깅하기 때문에, 위와 같은 CS가 들어왔을 때 로그가 없어 추적이 힘들 때도 있다는 IT 대기업에 근무하는 지인의 이야기도 들어볼 수 있었다.
이와 같이 로깅은 critical한 외부 API 호출에 있어서 필수적이라고 생각이 든다.
물론 지금 한 이야기들은 방금 말했듯이 결제 등과 같은 critical한 API에 한해서이다. 날씨 정보 조회 API 와 같은 내용은 실패하던 성공하던 로깅을 남길 필요가 전혀 없는 non-critical의 범주에 있다. 이런 부분들을 잘 고려하면서 로깅 처리도 잘 해두면 분명 효용을 얻을 수 있을 것이다.
이러한 로깅은 아까 예외처리 방식과 동일하게, 로깅 또한 Interceptor를 활용할 수 있다.
PaymentLoggingInterceptor
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import java.io.IOException;
public class PaymentLoggingInterceptor implements ClientHttpRequestInterceptor {
private static final Logger logger = LoggerFactory.getLogger(PaymentLoggingInterceptor.class);
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
String host = request.getURI().getHost();
String path = request.getURI().getPath();
String httpMethod = request.getMethod().toString();
logger.info("[Payment Request] {}\t: {} {} \n \t{}", path, httpMethod, host, new String(body));
return execution.execute(request, body);
}
}
요청을 보내는 host, path, httpMethod 그리고 Http Body를 기록한다.
PaymentClient - 로깅 Interceptor 등록
@Component
public class PaymentClient {
//.. 생략
public PaymentClient(PaymentProperties paymentProperties,
ObjectMapper objectMapper) {
this.paymentProperties = paymentProperties;
this.objectMapper = objectMapper;
this.restClient = RestClient.builder()
.requestFactory(createPaymentRequestFactory())
.requestInterceptor(new PaymentExceptionInterceptor())
.requestInterceptor(new PaymentLoggingInterceptor()) // 로깅 인터셉터 등록
.defaultHeader(HttpHeaders.AUTHORIZATION, createPaymentAuthHeader(paymentProperties))
.build();
}
이렇게 만든 Interceptor를 RestClient builder에 requestInterceptor 메서드로 등록한다.
결제취소 API
예약을 취소하면 결제 또한 취소되어야 한다.
때문에 우리는 결제승인 API 뿐만 아니라 취소 API도 연결해야한다.
한 번 구현해보자.
application.yml 에 cancel 관련 값 추가
payment:
secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
base-url: https://api.tosspayments.com/v1/payments
confirm-endpoint: /confirm
cancel-endpoint: /%s/cancel #cancel 엔드포인트 추가
위에 세팅해둔 코드가 있으니 cancelEndpoint만 추가해준다.
endpoint에 %s 가 들어가있는 이유는 저 부분에 PaymentKey 가 들어가기 때문에
String.format() 으로 값을 삽입해줄 예정이기 때문이다.
PaymentProperties.java 에 cancel 프로퍼티값 추가
@Component
@ConfigurationProperties(prefix = "payment")
public class PaymentProperties {
private String secretKey;
private String baseUrl;
private String confirmEndpoint;
private String cancelEndpoint; // cancelEndpoint 추가
public String getSecretKey() {
return secretKey;
}
public String getBaseUrl() {
return baseUrl;
}
public String getConfirmUrl() {
return baseUrl + confirmEndpoint;
}
public String getCancelUrl(String paymentKey) { // cancelUrl 흭득 메서드 추가
return String.format(baseUrl + cancelEndpoint, paymentKey);
}
public void setSecretKey(final String secretKey) {
this.secretKey = secretKey;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public void setConfirmEndpoint(String confirmEndpoint) { // cancelEnpoint Setter 추가
this.confirmEndpoint = confirmEndpoint;
}
public void setCancelEndpoint(String cancelEndpoint) { // cancelEnpoint Getter 추가
this.cancelEndpoint = cancelEndpoint;
}
}
Properties 객체에도 cancel과 관련된 필드, 메서드를 추가한다.
PaymentClient.java에 cancel 메서드 추가
@Component
public class PaymentClient {
//.. 생략
// 결제 취소 API 호출
public PaymentCancelOutput cancelPayment(Payment payment) {
PaymentCancelInput cancelRequest = new PaymentCancelInput("단순 변심");
return restClient.method(HttpMethod.POST)
.uri(paymentProperties.getCancelUrl(payment.getPaymentKey()))
.contentType(MediaType.APPLICATION_JSON)
.body(cancelRequest)
.retrieve()
.onStatus(HttpStatusCode::isError, (request, response) -> {
throw new PaymentCancelException(getPaymentCancelErrorCode(response));
})
.body(PaymentCancelOutput.class);
}
/**
* @see 결제 취소 API 에러 코드 문서
*/
private PaymentCancelErrorCode getPaymentCancelErrorCode(final ClientHttpResponse response) throws IOException {
PaymentFailOutput cancelFailResponse = objectMapper.readValue(
response.getBody(), PaymentFailOutput.class);
return PaymentCancelErrorCode.findByName(cancelFailResponse.code());
}
RestClient API 요청에 대해서는 위에서 많이 살펴보았기 때문에 이곳에서는 Body만 살펴보자.
나머지 다른 부분(PaymentCancelException, getPaymentCancelErrorCode, PaymentCancelErrorCode)가 존재하지만, 에러만 다르지 내부 구현은 똑같다.
관련 에러는 역시 위에 첨부한 공식 문서를 참고하면 쉽게 확인할 수 있다.
Body
PaymentCancelInput cancelRequest = new PaymentCancelInput("단순 변심");
결제 취소 API의 필수 요청 값은 paymentKey와 cancelReason이다. 이 외의 값은 지금 필요하지 않기 때문에 추가하지 않았다.
paymentKey는 결제취소 API URL에 포함되었으니, body에는 cancelReason만 담아주면 된다.
취소 사유는 사용자에게 받을 수도 있지만, 클라이언트를 그 부분까지는 구현해두지 않아서 단순 변심 으로 통일했다.
Retrieve 이후의 Body
결제 취소 Response body 중 나는 다음 값만 받았다.
paymentKey, orderId, orderName, status, requestedAt, approvedAt
이 값을 통해 DB에 취소 작업 처리 시간을 기록한다.
적용 결과
외부 API 테스트
외부 API가 정상 작동하는지 검증하는 테스트도 작성해야할까?
나는 이번에 학습을 진행하면서 외부 API를 꼭 테스트 해야할 지에 대해 고민해보았다.
결론적으로 나는 외부 API를 테스트하지는 않고, 외부 서버에서 특정 응답이 왔을 때 서버가 어떻게 작동할 지에 대한 테스트만 Mocking을 통해 진행하기로 하였다.
그 이유는 다음과 같다.
Toss API는 외부 API이기 때문에, 우리가 원하는 대로 컨트롤할 수 없다.
성공하던 테스트가 항상 성공하고 실패하는 테스트는 항상 실패해야한다는 테스트의 일관성이 외부의 상황 또는 네트워크 상태에 따라 어긋날 가능성이 있다.
때문에 이렇게 외부에 의존하는 부분은 Mock 또는 테스트 더블로 처리해야한다는 생각이 들었다.
이번 미션에 배포가 있어서 함께 고려해보자면 우리 코드에 문제는 없지만 Toss API가 잠깐 먹통이 되었을 때, build가 되지 않아 배포가 되지 않는다면 이건 문제가 아닐까 싶었다.
Toss의 결제 API가 먹통이되는 것은 새로운 버전을 배포하나 현재 버전에서나 똑같이 발생하고 있을텐데
이 문제 때문에 저희 애플리케이션의 새로운 버전이 배포되는 것이 막히는 건 문제가 아닐까 싶었다.
그리고 외부 서버 문제가 아니라 정말로 우리가 요청을 잘못보내고 있는 경우도
“테스트가 통과되더라도 개발단에서의 검증과 QA만 제대로 되면 발생하지 않는 문제이지 않을까?” 라는 생각이 들었다.
물론 검증과 QA로 모든 것을 해결할 순 없겠지만 최소 95% 정도는 웬만한 버그 발생을 잡을 수 있을 거라고 생각한다.
트레이드 오프의 관점으로 보았을 때도 테스트를 외부에 의존시키는 것보다 꼼꼼한 개발단의 검증과 QA를 하는 것이 더 낫지 않을까 라는 생각이 들었다.
때문에 나는 외부 API를 직접 호출해서 테스트하지는 않기로 했다.
결론
이전에 외부 API를 적용할 때는 빨리빨리 되는대로만 구현했던 경험이 있다. 그때는 왜 그랬는 지 모르겠지만 연결만 되면 끝인 줄 알았다.
반면, 우테코에 들어와서 이것저것 많이 배우고 주워들은 결과 한 번의 외부 API 요청에 대해 정말 많은 것들을 고려해야한다는 것을 깨닫게 되었다.
항상 코드 한 줄 한 줄 생각하며 개발하자.
그리고 그 생각을 충분히 코드에 녹여낼 수 있는 개발자가 되기 위해 수준을 길러나가자.
성장에 지름길은 없다. 꼼수없이 깊게 배우고 기억을 위해 기록하자.
+ 추가로 토스 API 문서 진짜 친절했다..! 짱!
긴 글 읽어주셔서 감사합니다. 피드백은 환영입니다! 댓글에 얼마든지 남겨주세요!