본문 바로가기
📕 Spring Framework/Spring Project

결제 API 리팩토링 - [2] (feat. WebClient)

by GroovyArea 2022. 9. 22.

https://sweeeetgoguma.tistory.com/entry/%EA%B2%B0%EC%A0%9C-API-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81-1-feat-%EC%A0%84%EB%9E%B5-%ED%8C%A8%ED%84%B

 

결제 API 리팩토링 - [1] (feat. 전략 패턴)

결제 API를 리팩토링 시작하며 외부 API를 연동 부분에 대해서 생각해봤다. 기존에도 카카오페이를 이용했었고, 지금도 카카오페이를 이용할 것이지만, 추가적으로 다른 결제 API를 연동할 수 있

sweeeetgoguma.tistory.com

 

지난 포스팅에 이어서 작성하겠습니다~

실제 결제 API를 호출하기 위해서는 httpClient 기반의 모듈이 필요하다.
기존에는 동기방식, 멀티스레드를 이용한 RestTemplate을 사용했지만, 곧 사장되는 모듈이다.

Spring 5에서 출시한 모듈인 WebClient는 비동기방식, 논블로킹 방식으로 동작하며 블로킹 방식으로도 추가적으로 이용가능하다.

이를 설정하며 실제 결제 API를 호출하는 과정을 나열하겠습니다~

 

WebClient 설정

  • 여러 개의 결제 API를 사용할 수 있다는 것을 고려하자.
  • 상속을 위해 추상클래스로 정의
/**
 * Web Client 추상화한 설정
 * 상속 용도 클래스
 */
public abstract class WebClientConfig {

    public WebClient createWebClientFrame(String baseUrl, int readTimeout, int connectTimeOut) {
        HttpClient httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, connectTimeOut)
                .responseTimeout(Duration.ofMillis(3000))
                .doOnConnected(connection -> connection.addHandlerLast(new ReadTimeoutHandler(readTimeout, TimeUnit.MILLISECONDS))
                        .addHandlerLast(new WriteTimeoutHandler(3000, TimeUnit.MILLISECONDS)));

        return WebClient.builder()
                .baseUrl(baseUrl)
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }
}

=> 기본 설정을 한뒤, 프레임을 만드는 메서드를 정의

 

 

KakaoPayClientConfig 구현

  • 프레임 생성 메서드를 이용
  • 프로퍼티를 이용해 카카오페이 클라이언트 필요 값 인자로 넘기자
/**
 * 카카오페이 WebClient 생성
 */
@Configuration
public class KakaoPayClientConfig extends WebClientConfig {

    @Value("${kakaopay.url}")
    private String baseUrl;

    @Value("${kakaopay.readTime}")
    private int readTime;

    @Value("${kakaopay.connectTime}")
    private int connectTime;

    @Bean
    public WebClient kakaoPayWebClient() {
        return createWebClientFrame(baseUrl, readTime, connectTime);
    }

}

 

 

KakaopayProperty

  • 카카오 페이 API 요청에 필요한 기본 설정 값들을 yml에 저장했는데 이를 클래스로 관리한다.
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "kakaopay")
public class KakaoPayClientProperty {

    private Api api;
    private Admin admin;
    private String url;
    private Uri uri;
    private Parameter parameter;
    private int readTime;
    private int connectTime;

    @Getter
    @Setter
    public static class Api {

        private String approval;
        private String cancel;
        private String fail;
    }

    @Getter
    @Setter
    public static class Admin {

        private String key;
    }

    @Getter
    @Setter
    public static class Uri {

        private String ready;
        private String approve;
        private String cancel;
        private String order;
    }

    @Getter
    @Setter
    public static class Parameter {

        private String cid;
        private int taxFree;
    }

}

 

 

KakaoPayReqeust

  • 요청에 필요한 파라미터들을 static 클래스로 관리했다.
  • Response도 동일하다.
/**
 * KakaoPay API 요청 모델
 */
public class KakaoPayRequest {

    @Getter
    @Builder
    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public static class OrderInfoRequest {

        private String cid;// 가맹점 코드, 10자
        private String tid;// 결제 고유번호, 20자

    }

    @Getter
    @Builder
    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public static class PayReadyRequest {

        private String cid; // 가맹점 코드
        private String partnerOrderId; // 가맹점 주문 번호
        private String partnerUserId; // 가맹점 회원 ID
        private String itemName; // 상품명
        private String itemCode; // 상품 코드
        private Integer quantity; // 상품 수량
        private Integer totalAmount; // 상품 총액
        private Integer taxFreeAmount; // 상품 비과세 금액
        private String approvalUrl; // 결제 성공 시 redirect url
        private String cancelUrl; // 결제 취소 시 redirect url
        private String failUrl; // 결제 실패 시 redirect url
    }

    @Getter
    @Builder
    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public static class PayApproveRequest {

        private String cid;// 가맹점 코드
        private String tid; // 결제 고유 번호 (결제 준비 API 응답에 포함)
        private String partnerOrderId; // 가맹점 주문 번호, 결제 준비 API 요청과 일치해야 함
        private String partnerUserId; // 가맹점 회원 id, 결제 준비 API 요청과 일치해야 함
        private String pgToken;  // 결제승인 요청을 인증하는 토큰 사용자 결제 수단 선택 완료 시, approval_url로 redirection해줄 때 pg_token을 query string으로 전달
        private Integer totalAmount; // 상품 총액, 결제 준비 API 요청과 일치해야 함
    }

    @Getter
    @Builder
    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public static class PayCancelRequest {

        private String cid; // 가맹점 코드
        private String tid; // 결제 고유번호
        private Integer cancelAmount; // 취소 금액
        private Integer cancelTaxFreeAmount; // 취소 비과세 금액
    }


}

 

 

KakaoPayClientConfig

  • 카카오페이 요청에 필요한 Webclient를 생성했다.
**
 * 카카오페이 WebClient 생성
 */
@Configuration
@RequiredArgsConstructor
public class KakaoPayClientConfig extends WebClientConfig {

    private final KakaoPayClientProperty property;

    @Bean
    public WebClient kakaoPayWebClient() {
        return createWebClientFrame(property.getUrl(), property.getReadTime(), property.getConnectTime());
    }

}

 

 

KakaoPayClient

  • 직접 요청을 하는 객체
  • WebClient는 비동기, 논블로킹 방식으로 사용하는 것이 옳지만, 나는 동기방식 코드 구성이므로 block()을 사용했다. (웬만하면 사용하지 마세요, 저는 단순히 RestTemplate이 Deprecated 되어서 사용했습니다)
/**
 * kakaoPay Web Client
 */
@Component
@RequiredArgsConstructor
public class KakaoPayClient {

    private final KakaoPayClientProperty kakaoPayClientProperty;
    private final WebClient kakaoPayWebClient;
    private final ObjectMapper objectMapper;

    public OrderInfoResponse getOrderInfo(String uri, OrderInfoRequest orderInfoRequest) {
        return kakaoPayWebClient.post()
                .uri(uri)
                .headers(httpHeaders -> httpHeaders.addAll(setHeaders()))
                .bodyValue(ParamConverter.convert(objectMapper, orderInfoRequest))
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .bodyToMono(OrderInfoResponse.class)
                .block();
    }

    public PayReadyResponse ready(String uri, KakaoPayRequest.PayReadyRequest payReadyRequest) {
        return kakaoPayWebClient.post()
                .uri(uri)
                .headers(httpHeaders -> httpHeaders.addAll(setHeaders()))
                .accept(MediaType.APPLICATION_JSON)
                .bodyValue(ParamConverter.convert(objectMapper, payReadyRequest))
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .bodyToMono(PayReadyResponse.class)
                .block();
    }

    public PayApproveResponse approve(String uri, PayApproveRequest payApproveRequest) {
        return kakaoPayWebClient.post()
                .uri(uri)
                .headers(httpHeaders -> httpHeaders.addAll(setHeaders()))
                .bodyValue(ParamConverter.convert(objectMapper, payApproveRequest))
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .bodyToMono(PayApproveResponse.class)
                .block();
    }

    public PayCancelResponse cancel(String uri, PayCancelRequest payCancelRequest) {
        return kakaoPayWebClient.post()
                .uri(uri)
                .headers(httpHeaders -> httpHeaders.addAll(setHeaders()))
                .bodyValue(ParamConverter.convert(objectMapper, payCancelRequest))
                .retrieve()
                .onStatus(HttpStatus::is4xxClientError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .onStatus(HttpStatus::is5xxServerError, clientResponse -> Mono.error(new KakaoPayException(FAILED_POST.getMessage())))
                .bodyToMono(PayCancelResponse.class)
                .block();
    }

    private HttpHeaders setHeaders() {
        HttpHeaders headers = new HttpHeaders();
        headers.add(HttpHeaders.AUTHORIZATION, "KakaoAK " + kakaoPayClientProperty.getAdmin().getKey());
        headers.add(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE);
        headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE + ";charset=UTF-8");
        return headers;
    }

}

 

 

이렇게 해서 요청에 대한 Webclient 구성을 완료했다.

지금은 단순히 블로킹 방식으로 구현한 것이지만, 추후에 비동기 논블로킹으로 구현할 수 있는 수준으로 끌어올려야겠다. 

반응형