결제 API를 리팩토링 시작하며 외부 API를 연동 부분에 대해서 생각해봤다.
기존에도 카카오페이를 이용했었고, 지금도 카카오페이를 이용할 것이지만, 추가적으로 다른 결제 API를 연동할 수 있을 만한 상황을 생각해봤다.
스프링을 처음 공부하기 시작할 때 읽었던 책인 개구리 (스프링 입문을 위한 뭐시기..) 책에서 스프링에서 사용하는 다양한 디자인 패턴들을 알게 되었다.
그 때는 디자인 패턴이란 것에 대해 감이 잘 오지 않았는데, 직접 적용할 기회와 상황이 없었기 때문이라고 생각해본다.
계속 면접 질문 대비해 앵무새처럼 달달 외우고 다니던 도중 직접 적용할 기회가 딱 생겼고, 객체지향 개발 2원칙인 OCP에 찰떡일 것이라는 머리 속의 외침이 울렸다.
그대로 적용해보았다.
기존 플로우
컨트롤러 <-> 서비스(카카오페이) <-> 저장소 <-> DB
=> 이제 와서 다시 보니 그냥 카카오페이 API에 완전히 종속적인 구조였고, 유지보수에 부적합한 설계 구조였다. (그땐 괜찮다 생각했는데..)
리팩토링을 해보자
결제 전략에 따른 값을 enum으로 관리
- 컨트롤러에서 Path로 결제 전략을 전달 받는다.
@Getter
@AllArgsConstructor
public enum PaymentApi {
KAKAO; // 카카오페이 결제 API
}
=> 현재는 카카오페이 결제만 다룬다.
/**
* 단일 상품 결제 요청
*/
@PostMapping("/{paymentApi}")
public ResponseEntity<String> itemPay(@PathVariable PaymentApi paymentApi,
@RequestBody ItemPayRequestDto itemPayRequestDto,
HttpServletRequest request) {
String loginId = getLoginId(request);
String requestUrl = getRequestURL(request);
String payableUrl = paymentApplicationCrew.getSingleItemPayResultUrl(itemPayRequestDto, requestUrl, loginId, paymentApi);
return ResponseEntity.ok(payableUrl);
}
=> Path로 결제 전략을 포함해 요청한다.
결제 전략 서비스를 제공하기 위한 크루 객체
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/payment")
public class PayApiController {
private final PaymentApplicationCrew paymentApplicationCrew;
=> 전략이 여러개 이므로 팩토리에게 전략을 제공받기 위해 Crew(내가 지은 이름) 객체를 참조했다.
PaymentApplicationCrew
- 결제 전략을 직접 제공받고 요청하는 중간자 역할을 한다.
- 컨트롤러에서 참조하는 메서드들도 다 추상화했다.
/**
* 결제 API Enum을 전달 받음
* 맞춤 결제 전략을 제공 및 메서드 호출
*/
@Service
@RequiredArgsConstructor
public class PaymentApplicationCrew {
private final PaymentStrategyFactory paymentStrategyFactory;
public String getSingleItemPayResultUrl(ItemPayRequestDto itemPayRequestDto, String requestUrl, String loginId, PaymentApi paymentApi) {
PaymentStrategyApplication<PaymentResult> paymentApplication = getPaymentStrategyApplication(paymentApi);
return paymentApplication.payItem(itemPayRequestDto, requestUrl, loginId).getRedirectUrl();
}
public String getCartItemsPayUrl(String cookieValue, String requestUrl, String loginId, PaymentApi paymentApi) {
PaymentStrategyApplication<PaymentResult> paymentApplication = getPaymentStrategyApplication(paymentApi);
return paymentApplication.payCart(cookieValue, requestUrl, loginId).getRedirectUrl();
}
// 애매한 놈
/* public ApiPayInfoDto getApiPaymentDetail(String franchiseeId, String payId, PaymentApi paymentApi) {
PaymentStrategyApplication<PaymentResult> paymentapplication = getPaymentStrategyApplication(paymentApi);
return null;
}*/
public void approvePayment(String payToken, String loginId, PaymentApi paymentApi) {
PaymentStrategyApplication<PaymentResult> paymentApplication = getPaymentStrategyApplication(paymentApi);
paymentApplication.completePayment(payToken, loginId);
}
public void cancelPayment(PayCancelRequestDto payCancelRequestDto, PaymentApi paymentApi) {
PaymentStrategyApplication<PaymentResult> paymentApplication = getPaymentStrategyApplication(paymentApi);
paymentApplication.cancelPayment(payCancelRequestDto);
}
private PaymentStrategyApplication<PaymentResult> getPaymentStrategyApplication(PaymentApi paymentApi) {
return paymentStrategyFactory.findStrategy(paymentApi);
}
}
=> 전략 팩토리 클래스를 통해 넘겨 받은 enum 객체를 가지고 전략 서비스를 제공받아 요청한다.
전략 제공 팩토리 클래스
- 각각의 전략 객체들을 Map에 관리하며 Key는 enum이다.
- 생성자로 전략들을 주입받는다.
- Key를 통해 전략 서비스를 제공한다.
/**
* Payment 전략 제공 팩토리 클래스
* Payment Strategy Application 제공
*/
@Component
public class PaymentStrategyFactory {
private Map<PaymentApi, PaymentStrategyApplication<PaymentResult>> strategies;
@Autowired
public PaymentStrategyFactory(Set<PaymentStrategyApplication<PaymentResult>> services) {
createStrategy(services);
}
public PaymentStrategyApplication<PaymentResult> findStrategy(PaymentApi paymentApi) {
if (!Arrays.asList(PaymentApi.values()).contains(paymentApi)) {
throw new BadRequestException(UNCORRECTED_API.getMessage());
}
return strategies.get(paymentApi);
}
private void createStrategy(Set<PaymentStrategyApplication<PaymentResult>> services) {
strategies = new HashMap<>();
services.forEach(
strategy -> strategies.put(strategy.getPaymentApiName(), strategy)
);
}
}
전략 인터페이스
- 여러 개의 전략이 있으므로 당연히 확장을 위해 인터페이스를 사용
/**
* 결제 API 서비스 전략
*/
public interface PaymentStrategyApplication <T extends PaymentResult> {
PaymentApi getPaymentApiName();
T getOrderInfo();
T payItem(ItemPayRequestDto itemPayRequestDto, String requestUrl, String loginId);
T payCart(String cookieValue, String requestURL, String loginId);
T completePayment(String payToken, String loginId);
T cancelPayment(PayCancelRequestDto payCancelRequestDto);
}
=> 각 결제 API마다 파라미터가 다르기 때문에 당연히 Type이 다르므로 PaymentResult 또한 추상화했다.
(뒤에서 설명)
/**
* 전략에 대한 조회 결과 Interface
*/
public interface PaymentResult {
String getRedirectUrl();
}
전략 구현체 (카카오페이)
- 카카오페이를 이용하기 위해 특성을 살려 구현했다.
- 여기서 DB 로직을 위해 트랜잭션을 처리할 생각이다.
- 직접적인 카카오페이 요청 서비스를 호출한다.
/**
* 전략 결제 API 서비스 구현체 (카카오페이)
*/
@Service
@RequiredArgsConstructor
public class KakaopayStrategyApplication implements PaymentStrategyApplication<PaymentResult>{
private final KakaoPaymentService kakaoPaymentService;
private final UserRepository userRepository;
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final PayRepository payRepository;
private final OrderProductRepository orderedProductRepository;
private final CardRepository cardRepository;
@Override
public PaymentApi getPaymentApiName() {
return KAKAO;
}
@Override
public OrderInfoResponse getOrderInfo() {
return null;
}
@Override
public PaymentResult payItem(ItemPayRequestDto itemPayRequestDto, String requestUrl, String loginId) {
return kakaoPaymentService.payItem(itemPayRequestDto, requestUrl, loginId);
}
@Override
public PaymentResult payCart(String cookieValue, String requestUrl, String loginId) {
return kakaoPaymentService.payCart(cookieValue, requestUrl, loginId);
}
@Override
public PaymentResult completePayment(String payToken, String loginId) {
return kakaoPaymentService.completePayment(payToken, loginId);
}
@Override
public PaymentResult cancelPayment(PayCancelRequestDto payCancelRequestDto) {
return kakaoPaymentService.cancelPayment(payCancelRequestDto);
}
}
PaymentService
- API 요청의 책임을 가지는 객체가 필요하다.
- 이 역시 여러가지 구현체가 필요할 것이므로 인터페이스를 생성.
public interface PaymentService {
PaymentApi getPaymentApiName();
}
KakaoPaymentService (카카오페이)
- PaymentService를 확장하는 카카오페이 전용 인터페이스
- Impl 패턴을 사용하기 위해 추상화했다.
/**
* 카카오페이 서비스 인터페이스
*/
public interface KakaoPaymentService extends PaymentService {
OrderInfoResponse getOrderInfo(String franchiseeId, String payId);
PayReadyResponse payItem(ItemPayRequestDto itemPayRequestDto, String requestUrl, String loginId);
PayReadyResponse payCart(String cookieValue, String requestUrl, String loginId);
PayApproveResponse completePayment(String payToken, String loginId);
PayCancelResponse cancelPayment(PayCancelRequestDto payCancelRequestDto);
}
카카오 페이 서비스 구현체
- 요청을 위한 파라미터를 다루기 위한 객체를 생성한다.
- 결제 플로우에 필요한 데이터를 연계하고 잠시 저장하기 위한 Redis를 이용했다.
- 장바구니 결제건도 있으므로 장바구니 관련 작업도 한다.
/**
* 카카오페이 결제 서비스 API 호출
*/
@Service
@RequiredArgsConstructor
public class KakaopayService implements KakaoPaymentService {
private final KakaoPayClient kakaoPayClient;
private final KakaoPayClientProperty kakaoPayClientProperty;
private final CartDisassembler cartDisassembler;
private final RedisStore redisStore;
@Override
public PaymentApi getPaymentApiName() {
return KAKAO;
}
@Override
public KakaoPayResponse.OrderInfoResponse getOrderInfo(String franchiseeId, String payId) {
return null;
}
@Override
public PayReadyResponse payItem(ItemPayRequestDto itemPayRequestDto, String requestUrl, String loginId) {
PayReadyRequest request = createItemPayRequest(itemPayRequestDto, requestUrl, loginId);
PayReadyResponse response = kakaoPayClient.ready(kakaoPayClientProperty.getUri().getReady(), request);
savePayableData(response, request, loginId);
return response;
}
@Override
public PayReadyResponse payCart(String cookieValue, String requestUrl, String loginId) {
CartValue cartValue = getCartValue(cookieValue);
PayReadyRequest request = createCartPayRequest(cartValue, requestUrl, loginId);
PayReadyResponse response = kakaoPayClient.ready(kakaoPayClientProperty.getUri().getReady(), request);
savePayableData(response, request, loginId);
return response;
}
@Override
public PayApproveResponse completePayment(String payToken, String loginId) {
String[] savedParams = redisStore.getData(loginId).split(",");
PayApproveRequest request = createPayApproveRequest(payToken, savedParams, loginId);
return kakaoPayClient.approve(kakaoPayClientProperty.getUri().getApprove(), request);
}
@Override
public PayCancelResponse cancelPayment(PayCancelRequestDto payCancelRequestDto) {
PayCancelRequest request = createPayCancelRequest(payCancelRequestDto);
return kakaoPayClient.cancel(kakaoPayClientProperty.getUri().getCancel(), request);
}
private void savePayableData(PayReadyResponse response, PayReadyRequest request, String loginId) {
String payableParams = String.join(",", Stream.of(response.getTid(), request.getPartnerOrderId(), String.valueOf(request.getTotalAmount())).toArray(String[]::new));
redisStore.setData(loginId, payableParams);
}
private PayReadyRequest createItemPayRequest(ItemPayRequestDto dto, String requestUrl, String loginId) {
String orderId = loginId + " / " + dto.getItemName();
return PayReadyRequest.builder()
.partnerOrderId(orderId)
.partnerUserId(loginId)
.itemName(dto.getItemName())
.quantity(dto.getQuantity())
.totalAmount(dto.getTotalAmount())
.taxFreeAmount(kakaoPayClientProperty.getParameter().getTaxFree())
.cid(kakaoPayClientProperty.getParameter().getCid())
.approvalUrl(requestUrl + kakaoPayClientProperty.getApi().getApproval())
.cancelUrl(requestUrl + kakaoPayClientProperty.getApi().getCancel())
.failUrl(requestUrl + kakaoPayClientProperty.getApi().getFail())
.build();
}
private PayReadyRequest createCartPayRequest(CartValue cartValue, String requestUrl, String loginId) {
String itemName = cartValue.getItemNames().get(0) + " 그 외 " + (cartValue.getItemNames().size() - 1) + "개";
String orderId = loginId + " / " + itemName;
String itemCode = String.join(", ", cartValue.getItemNumbers().stream().map(String::valueOf).toArray(String[]::new));
return PayReadyRequest.builder()
.partnerOrderId(orderId)
.partnerUserId(loginId)
.itemName(itemName)
.itemCode(itemCode)
.quantity(cartValue.getItemQuantities().size())
.totalAmount((int) cartValue.getTotalPrice())
.taxFreeAmount(kakaoPayClientProperty.getParameter().getTaxFree())
.cid(kakaoPayClientProperty.getParameter().getCid())
.approvalUrl(requestUrl + kakaoPayClientProperty.getApi().getApproval())
.cancelUrl(requestUrl + kakaoPayClientProperty.getApi().getCancel())
.failUrl(requestUrl + kakaoPayClientProperty.getApi().getFail())
.build();
}
private PayApproveRequest createPayApproveRequest(String payToken, String[] params, String loginId) {
String tid = params[0];
String orderId = params[1];
Integer totalPrice = Integer.valueOf(params[2]);
return PayApproveRequest.builder()
.cid(kakaoPayClientProperty.getParameter().getCid())
.tid(tid)
.partnerOrderId(orderId)
.partnerUserId(loginId)
.pgToken(payToken)
.totalAmount(totalPrice)
.build();
}
private PayCancelRequest createPayCancelRequest(PayCancelRequestDto payCancelRequestDto) {
return PayCancelRequest.builder()
.cid(kakaoPayClientProperty.getParameter().getCid())
.tid(payCancelRequestDto.getPayId())
.cancelAmount(payCancelRequestDto.getCancelAmount())
.cancelTaxFreeAmount(payCancelRequestDto.getCancelTaxFreeAmount())
.build();
}
private CartValue getCartValue(String cookieValue) {
return CartValue.builder()
.itemNumbers(cartDisassembler.getItemNumbers(cookieValue, Long.class, CartItem.class, CartItem::getItemNo))
.itemNames(cartDisassembler.getItemNames(cookieValue, Long.class, CartItem.class, CartItem::getItemName))
.itemQuantities(cartDisassembler.getItemQuantities(cookieValue, Long.class, CartItem.class, CartItem::getItemQuantity))
.totalPrice(cartDisassembler.getTotalPrice(cookieValue, Long.class, CartItem.class, CartItem::getTotalPrice))
.build();
}
}
결제 API를 리팩토링하며 전략패턴을 구현해본 과정을 나열했다.
- WebClient를 설정하고 사용하며 실제 카카오페이 서비스를 요청하며 결제를 진행하는 과정을 다음 포스트에 담아보겠다.
반응형
'📕 Spring Framework > Spring Project' 카테고리의 다른 글
[Redisson]을 이용한 분산 Lock 구현 & 동시성 문제 해결 (2) | 2022.09.27 |
---|---|
결제 API 리팩토링 - [2] (feat. WebClient) (7) | 2022.09.22 |
동시성 조회 문제 해결 및 성능에 관한 고민 [Lock, Queue, Redis] (0) | 2022.09.14 |
객체 간 매핑을 위한 MapStruct 사용 방법 (0) | 2022.08.29 |
DB 수정 & jpa 세팅 (0) | 2022.08.08 |