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

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

by GroovyArea 2022. 9. 20.
결제 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를 설정하고 사용하며 실제 카카오페이 서비스를 요청하며 결제를 진행하는 과정을 다음 포스트에 담아보겠다.
반응형