[Spring] 결제 연동 ( 포트원 연동 )

오형상·2024년 1월 14일
0

Spring

목록 보기
8/9
post-thumbnail

본 포스트는 테스트 환경 기준으로 작성되었습니다.

토이 프로젝트로 쇼핑몰 프로젝트를 진행중이다. 결제를 구현하기 위해 가장 유명하고 정보가 많은 포트원을 사용한 적용 과정을 기록할려고 한다.

포트원 동작 과정

1. 사전 준비

  1. 포트원 사이트 회원가입 후 결제 연동 페이지로 이동.

  2. REST API KEY, REST API SECRET 값 확인.

  3. 결제대행사 추가.

2. 결제 요청 (React)

포트원 결제창 연동하기 가이드에 예시가 있어 쉽게 만들 수 있다.

import React, { useEffect } from 'react';

const RequestPay = () => {
  // 결제 요청 함수
  const requestPay = () => {
    window.IMP.request_pay({
      pg: "html5_inicis",
      pay_method: "card",
      merchant_uid: "1234578",
      name: "스파게티면 500g",
      amount: 200,
      buyer_email: "gildong@gmail.com",
      buyer_name: "홍길동",
      buyer_tel: "010-4242-4242",
      buyer_addr: "서울특별시 강남구 신사동",
      buyer_postcode: "01181"
    }, rsp => {
      if (rsp.success) {
        // 결제 성공 시 로직
        console.log('Payment succeeded');
        // 추가로 실행할 로직을 여기에 작성
      } else {
        // 결제 실패 시 로직
        console.log('Payment failed', rsp.error_msg);
        // 추가로 실행할 로직을 여기에 작성
      }
    });
  };

  useEffect(() => {
    // 외부 스크립트 로드 함수
    const loadScript = (src, callback) => {
      const script = document.createElement('script');
      script.type = 'text/javascript';
      script.src = src;
      script.onload = callback;
      document.head.appendChild(script);
    };

    // 스크립트 로드 후 실행
    loadScript('https://code.jquery.com/jquery-1.12.4.min.js', () => {
      loadScript('https://cdn.iamport.kr/js/iamport.payment-1.2.0.js', () => {
        const IMP = window.IMP;
        // 가맹점 식별코드
        IMP.init("impXXXXXXXX");
      });
    });

    // 컴포넌트가 언마운트될 때 스크립트를 제거하기 위한 정리 함수
    return () => {
      const scripts = document.querySelectorAll('script[src^="https://"]');
      scripts.forEach((script) => script.remove());
    };
  }, []);

  return (
    <div>
      {/* 결제하기 버튼 */}
      <button onClick={requestPay}>Pay Now</button>
    </div>
  );
};

export default RequestPay;

위와 같이 결제창이 뜨면 성공입니다.

3. 결제 정보 검증

0. 아임포트 설정

서버에서 아임포트와 통신할 때 아임포트의 API를 참고하여 직접 통신해도 되지만 아임포트에서 제공하는 임포트 REST API 연동 모듈용하는 것이 훨씬 편리하다.

allprojects {
    repositories {
        mavenCentral()
        maven { url 'https://jitpack.io' }
    }
}

dependencies {
    // 포트원 REST API 연동 모듈
    implementation 'com.github.iamport:iamport-rest-client-java:0.2.23'
}

1. 사전 검증

결제창을 띄우는 프론트엔드를 보여주기 전에 어떤 주문번호로 얼마만큼의 결제가 이루어져야 하는지를 아래의 API를 사용하여 사전에 등록할 수 있습니다.

IMP.request_pay의 인자로 들어온 금액이 위의 API로 사전 등록해둔 금액과 일치하지 않으면 SDK 수준에서 결제 요청이 차단됩니다.

OrderController.java

    @Operation(summary = "사전 검증")
    @PostMapping("/preparation")
    public Response<PreparationResponse> prepareValid(@RequestBody PreparationRequest preparationRequest, Authentication authentication) throws IamportResponseException, IOException {
        return Response.success(paymentService.prepareValid(preparationRequest));
    }

OrderService.java

    public PreparationResponse prepareValid(PreparationRequest request) throws IamportResponseException, IOException {
        PrepareData prepareData = new PrepareData(request.getMerchantUid(), request.getTotalPrice());
        IamportResponse<Prepare> iamportResponse = iamportClient.postPrepare(prepareData);

        log.info("결과 코드 : {}", iamportResponse.getCode());
        log.info("결과 메시지 : {}", iamportResponse.getMessage());

        if (iamportResponse.getCode() != 0) {
            throw new AppException(FAILED_PREPARE_VALID, iamportResponse.getMessage());
        }
        return PreparationResponse.builder().merchantUid(request.getMerchantUid()).build();
    }

사후 검증

결제된 실 금액과 요청 금액을 비교하여 결제금액 위변조여부 검증합니다.(쿠폰이나 멤버쉽에 의한 할인이 있다면 적용시켜야 한다).
금액이 다르다면 주문을 취소하고 예외를 발생시키도록 작업했습니다.

OrderController.java

    @Operation(summary = "사후 검증")
    @PostMapping("/verification")
    public Response<MessageResponse> postVerification(@RequestBody PostVerificationRequest postVerificationRequest, Authentication authentication) throws IamportResponseException, IOException {
        log.info("imp_uid:{}", postVerificationRequest.getImpUid());
        return Response.success(orderService.postVerification(postVerificationRequest));
    }

OrderService.java

    // 사후 검증
    public MessageResponse postVerification(PostVerificationRequest request) throws IamportResponseException, IOException {
        //DB에 merchant_uid가 중복되었는지 확인
        Order order = validOrder(request.getMerchantUid());

        //DB에 있는 금액과 사용자가 결제한 금액이 같은지 확인
        BigDecimal dbAmount = calcDbAmount(order.getOrderItemList()); // db에서 가져온 금액

        IamportResponse<Payment> iamResponse = iamportClient.paymentByImpUid(request.getImpUid());
        BigDecimal paidAmount = iamResponse.getResponse().getAmount(); // 사용자가 결제한 금액

        // 금액이 다르면 결제 취소
        if (paidAmount.compareTo(dbAmount) != 0) {
            IamportResponse<Payment> response = iamportClient.paymentByImpUid(request.getImpUid());
            CancelData cancelData = createCancelData(response, BigDecimal.ZERO);
            iamportClient.cancelPaymentByImpUid(cancelData);

            throw new AppException(WRONG_PAYMENT_AMOUNT, WRONG_PAYMENT_AMOUNT.getMessage());
        }

        order.setImpUid(request.getImpUid());

        return new MessageResponse("사후 검증 완료되었습니다.");
    }
    
        private BigDecimal calcDbAmount(List<OrderItem> orderItemList) {

        BigDecimal totalPrice = BigDecimal.ZERO;
        for (OrderItem orderItem : orderItemList) {
            totalPrice = totalPrice.add(orderItem.getTotalPrice()); // 값을 누적하기 위해 totalPrice를 업데이트
        }
        return totalPrice;
    }

    private CancelData createCancelData(IamportResponse<Payment> response, BigDecimal refundAmount) {
        if (refundAmount.compareTo(BigDecimal.ZERO) == 0) { //전액 환불일 경우
            return new CancelData(response.getResponse().getImpUid(), true);
        }
        //부분 환불일 경우 checksum을 입력해 준다.
        return new CancelData(response.getResponse().getImpUid(), true, refundAmount);
    }

3. react에 api 적용

import React, { useEffect, useState } from 'react';
import axios from 'axios';

const RequestPay = () => {
  const [isPaymentRequested, setIsPaymentRequested] = useState(false);
  const [merchantUid, setMerchantUid] = useState(null);

  // 임의의 6자리 숫자를 생성하는 함수
  const generateRandomNumber = () => {
    return Math.floor(100000 + Math.random() * 900000).toString();
  };

  // 결제 요청 함수
  const requestPay = () => {
    window.IMP.request_pay({
      pg: "html5_inicis",
      pay_method: "card",
      merchant_uid: merchantUid,
      name: "스파게티면 200g",
      amount: 200,
      buyer_email: "string@naver.com",
      buyer_name: "string",
      buyer_tel: "string",
      buyer_addr: "string",
      buyer_postcode: "01181"
    }, rsp => {
      if (rsp.success) {
        // 결제 성공 시 로직
        console.log('결제 성공');
        console.log(rsp);
        createOrder(rsp.imp_uid);
      } else {
        // 결제 실패 시 로직
        console.log('결제 실패', rsp.error_msg);
        // 추가로 실행할 로직을 여기에 작성
      }
    });
  };

  // Axios POST 요청 함수 (주문 생성)
  const createOrder = (imp_uid) => {
    console.log(merchantUid);
    axios.post('/api/v1/orders', {
      itemId : 1,
      itemCnt : 1,
      recipientName : "string",
      recipientTel : "string",
      recipientCity : "string",
      recipientStreet : "string",
      recipientDetail : "string",
      recipientZipcode : "string",
      merchantUid : merchantUid,
      totalPrice : 0,
    })
      .then((orderResponse) => {
        console.log(orderResponse);
        if (orderResponse.status === 200) {
          console.log('주문이 성공적으로 생성되었습니다.');
          // 성공한 경우 사후 검증 API 호출
          sendPostVerificationRequest(imp_uid);
        } else {
          console.error('주문 생성 실패');
        }
      })
      .catch((error) => {
        console.error('주문 생성 요청 오류', error);
      });
  };



  // Axios POST 요청 함수 (사전 검증)
  const sendPreVerificationRequest = async () => {
    try {
      const currentDate = new Date();
      const year = currentDate.getFullYear();
      const month = (currentDate.getMonth() + 1).toString().padStart(2, '0');
      const day = currentDate.getDate().toString().padStart(2, '0');

      const response = await axios.post('/api/v1/orders/preparation', {
          merchantUid: `${year}.${month}.${day}_${generateRandomNumber()}`, // 가맹점 주문번호
          totalPrice: 200 // 결제 예정금액
      });

      if (response.data.resultCode === "SUCCESS") {
        console.log(response);
        // 사전 검증 성공 시 결제 요청 실행
        setIsPaymentRequested(true);
        setMerchantUid(response.data.result.merchantUid);
      } else {
        console.error('사전 검증 실패');
      }
    } catch (error) {
      console.error('사전 검증 요청 오류', error);
    }
  };

  // Axios POST 요청 함수 (사후 검증)
  const sendPostVerificationRequest = async (imp_uid) => {
    try {
      const response = await axios.post('/api/v1/orders/verification', {
        merchantUid : merchantUid,
        impUid : imp_uid
      });

      if (response.data.resultCode === "SUCCESS") {
        alert(response.data.result.msg);
      } else {
        console.error('사후 검증 실패');
      }
    } catch (error) {
      console.error('사후 검증 요청 오류', error);
    }
  };

  useEffect(() => {
    // 외부 스크립트 로드 함수
    const loadScript = (src, callback) => {
      const script = document.createElement('script');
      script.type = 'text/javascript';
      script.src = src;
      script.onload = callback;
      document.head.appendChild(script);
    };

    // 스크립트 로드 후 실행
    loadScript('https://code.jquery.com/jquery-1.12.4.min.js', () => {
      loadScript('https://cdn.iamport.kr/js/iamport.payment-1.2.0.js', () => {
        const IMP = window.IMP;
        // 아임포트 초기화
        IMP.init("impXXXXXXXX");
      });
    });

    // 컴포넌트가 언마운트될 때 스크립트를 제거하기 위한 정리 함수
    return () => {
      const scripts = document.querySelectorAll('script[src^="https://"]');
      scripts.forEach((script) => script.remove());
    };
  }, []);

  useEffect(() => {
    // 컴포넌트가 마운트될 때 사전 검증 API 호출
    sendPreVerificationRequest();
  }, []);


  return (
    <div>
      {/* 결제하기 버튼 */}
      <button onClick={requestPay}>지금 결제하기</button>
    </div>
  );
};

export default RequestPay;

Reference

0개의 댓글