스프링부트 포트원(아임포트) 사용하기

이호석·2023년 6월 26일
5

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

한 달전, 프로젝트를 새로 하나 시작했다. 주제는 에어비앤비, 여기어때 같은 숙박업체 예약 사이트이고 이중에서 결제 파트를 내가 담당했다. 결제서비스는 포트원(구 아임포트)말고도 부트페이 등 여러가지가 있지만, 가장 유명하고 정보가 많은 포트원을 선택했다.

포트원

시스템 구조

PG사란? Payment GateWay로 카드사 또는 은행과 직접 거래대신, 거래 및 결제를 도와주는 결제대행사이다.

<일반적인 인증 결제 flow>

여기서 이 PG사들을 모아서 간단하면서 한번에 제공해주는 서비스가 바로 포트원이다.

개발 준비

가장 먼저 포트원 사이트 회원가입을 해주고 결제 연동 페이지로 가주자.

내 식별코드를 눌러서

REST API KEY와 REST API SECRET을 확인해 준다.

그런 다음 기존의 결제 연동 페이지에서 결제대행사를 추가해줄건데 나는 여기서 KG이니시스를 택했다. 카카오페이도 선택 가능한데, 카카오페이 결제는 QR코드로 이루어지며 실 결제가 아니다.

개발 시작

준비는 끝났고 이제 코드 작성만 하면 된다.

결제 요청

<!DOCTYPE html>
<html lang="en">
<head>
    <!-- jQuery -->
    <script
            type="text/javascript"
            src="https://code.jquery.com/jquery-1.12.4.min.js"
    ></script>
    <!-- iamport.payment.js -->
    <script
            type="text/javascript"
            src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js"
    ></script>
    <script>
        var IMP = window.IMP;
        IMP.init("impXXXXXXXXX");

        function requestPay() {
            IMP.request_pay(
                {
                    pg: "html5_inicis",		//KG이니시스 pg파라미터 값
                    pay_method: "card",		//결제 방법
                    merchant_uid: "1234578",//주문번호
                    name: "당근 10kg",		//상품 명
                    amount: 200,			//금액
      				buyer_email: "gildong@gmail.com",
      				buyer_name: "홍길동",
      				buyer_tel: "010-4242-4242",
      				buyer_addr: "서울특별시 강남구 신사동",
      				buyer_postcode: "01181"
     	
                },
                function (rsp) {
      				//rsp.imp_uid 값으로 결제 단건조회 API를 호출하여 결제결과를 판단합니다.
                    if (rsp.success) {
                        //서버 검증 요청 부분
                    } else {
                        alert("결제에 실패하였습니다. 에러 내용: " + rsp.error_msg);
                    }
                }
            );
        }
    </script>
    <meta charset="UTF-8"/>
    <title>Sample Payment</title>
</head>
<body>
<button onclick="requestPay()">결제하기</button>
<!-- 결제하기 버튼 생성 -->
</body>
</html>

코드를 작성하고 스프링부트에서 실행시킨 다음에 결제하기 버튼을 누르면 KG이니시스창이 뜰 것이다. 만약 본인이 선택한 PG사 파라미터 값이 궁금하다면 여기를 클릭해 확인하자.

결제가 제대로 이루어진다면 테스트환경이어도 실제로 결제가 진행되지만 다행히 자정쯤 자동 취소된다. 결제는 신용카드를 추천하며 부분환불 같은 경우에는 테스트 환경에서는 구현이 힘들다(맨 아래 참조).

결제가 이루어진다고 끝은 아니다. 결제가 정상적으로 이루어졌는데 확인 작업이 필요하다. 결제 금액을 검증해야하는데 그 이유는 유저가 스크립트를 조작해 금액을 위 변조할 수 있기 때문이다.
클라이언트 단에서는 스크립트 조작을 원천적으로 막을 수 없기에 결제 금액 검증은 서버단에서 해야한다. 검증을 포함한 결제 흐름은 다음과 같다.

참고로 사전 검증은 결제창을 띄우기전에 서버로부터 금액을 받아와서 미리 포트원 서버로 정보를 넘기는 작업이다(이중 검증이라고 생각하면 될듯하다).

검증 작업

서버에서 아임포트와 통신할 때 아임포트의 API를 참고하여 직접 통신해도 되지만(이럴 경우 포트원 토큰도 사용해야함), 아임포트에서 제공하는 자바용 모듈을 사용하는 것이 훨씬 편리하다.

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

gradle

implementation 'com.github.iamport:iamport-rest-client-java:0.2.21'

우선 스프링부트 프로젝트에 의존성을 추가해준다.(for maven)

<!DOCTYPE html>
<head>
    <script>
      	...
        function requestPay() {
            IMP.request_pay(
                {
     				...
                },
                function (rsp) {
      				//rsp.imp_uid 값으로 결제 단건조회 API를 호출하여 결제결과를 판단합니다.
                    if (rsp.success) {
                        $.ajax({
                            url: "/payment/validate/" + rsp.imp_uid,
                            method: "GET",
                            contentType: "application/json",
                            data: JSON.stringify({
                                imp_uid: rsp.imp_uid,            // 결제 고유번호
                                merchant_uid: rsp.merchant_uid,   // 주문번호
                                amount: rsp.paid_amount
                            }),
                        }).done(function (data) {
                            // 가맹점 서버 결제 API 성공시 로직
                        })
                    } else {
                        alert("결제에 실패하였습니다. 에러 내용: " + rsp.error_msg);
                    }
                }
            );
        }
    </script>
	...
</html>

포트원을 통해 결제가 이루어졌다면, 결제후 검증을 위하여 서버 검증 파트로 필요한 정보들을 전송한다.

@RequiredArgsConstructor
@RestController
public class PaymentController implements PaymentService {

    private final ReservationService reservationService;

	@PostMapping("/payment/validate")
	public Response<PaymentRes> createPayment(@RequestBody PaymentReq paymentReq){
    
        PaymentRes paymentRes = reservationService.createReservation(paymentReq);
        return new Response<>(paymentRes);
    }
@Service
public class PaymentService {

    private static final String API_KEY = "";
    private static final String API_SECRET = "";

    public PaymentService() {
        this.iamportClient = new IamportClient(API_KEY, API_SECRET);
    }
    
    public PaymentRes createPayment(PaymentReq paymentReq)
            throws IamportResponseException, IOException {
        //DB에 imp_uid나 merchant_uid가 중복되었는지 확인
        checkDuplicatePayment(paymentReq);
        
        //DB에 있는 금액과 사용자가 결제한 금액이 같은지 확인
        int amountForPay = fromDB; //db에서 가져온 금액
		IamportResponse<Payment> iamResponse = iamportClient.paymentByImpUid(paymentReq.getImpUid());
        int paidAmount = iamResponse.getResponse().getAmount(); //사용자가 결제한 금액
        
        if (paidPrice != priceToPay) {
            // 금액이 다르면 결제 취소
            IamportResponse<Payment> response = iamportClient.paymentByImpUid(postReservationReq.getImpUid());
            CancelData cancelData = createCancelData(response, FULL_REFUND);
            iamportClient.cancelPaymentByImpUid(cancelData);

            throw new IamException();
        }
        
        return PaymentRes(new PaymentRes(payment.getId()));
    }

}

검증은 유저가 결제한 금액과 DB에서 가져온 금액만 비교하면 된다(물론 쿠폰이나 유저 등급에 의한 할인이 있었다면 그런 것도 적용시켜야 한다). 금액이 다르다면 예외를 발생시키든 원하는 작업을 하면된다(여기서는 예외에 관하여 다루지 않는다).

결제 취소

checksum은 포트원에서 말하는 대로 필수는 아니지만, 부분 환불 기능을 사용한다면 검증을 위해 반드시 써야할 것이다(물론 앞서 말한대로 테스트 환경에서는 부분 환불이 불가능하다).

포트원 api사이트에서 결제 취소(POST /payments/cancel)를 보면 파라미터는 많지만 필수 데이터는 imp_uid나 merchant_uid 둘 중 하나이다.

@RequiredArgsConstructor
@RestController
public class PaymentController implements PaymentService {

    private final ReservationService reservationService;

	@PostMapping("/payment/validate")
	public Response<PaymentRes> createPayment(@RequestBody PaymentReq paymentReq){
        PaymentRes paymentRes = reservationService.createReservation(paymentReq);
        return new Response<>(paymentRes);
    }
    
    @PostMapping("/payment/cancel")
	public Response<Void> createPayment(@RequestBody CancelReq cancelReq){
        reservationService.cancelReservation(paymentReq);
        return new Response<>();
    }
@Service
public class PaymentService {

    private static final String API_KEY = "";
    private static final String API_SECRET = "";

    public PaymentService() {
        this.iamportClient = new IamportClient(API_KEY, API_SECRET);
    }
    
    public PaymentRes createPayment(PaymentReq paymentReq)
            throws IamportResponseException, IOException {
        //DB에 imp_uid나 merchant_uid가 중복되었는지 확인
        checkDuplicatePayment(paymentReq);
        
        //DB에 있는 금액과 사용자가 결제한 금액이 같은지 확인
        int amountForPay = fromDB; //db에서 가져온 금액
		IamportResponse<Payment> iamResponse = iamportClient.paymentByImpUid(paymentReq.getImpUid());
        int paidAmount = iamResponse.getResponse().getAmount(); //사용자가 결제한 금액
        
        if (paidPrice != priceToPay) {
            // 금액이 다르면 결제 취소
            IamportResponse<Payment> response = iamportClient.paymentByImpUid(postReservationReq.getImpUid());
            CancelData cancelData = createCancelData(response, FULL_REFUND);
            iamportClient.cancelPaymentByImpUid(cancelData);

            throw new IamException();
        }
        
        return PaymentRes(new PaymentRes(payment.getId()));
    }
    
    public void cancelReservation(CancelReq cancelReq){
    	IamportResponse<Payment> response = iamportClient.paymentByImpUid(cancelReq.getImpUid());
        //cancelData 생성
        CancelData cancelData = createCancelData(response, cancelReq.getRefundAmount());
        //결제 취소
        iamportClient.cancelPaymentByImpUid(cancelData);
    }
    
    private CancelData createCancelData(IamportResponse<Payment> response, int refundAmount) {
        if (refundAmount == 0) { //전액 환불일 경우
            return new CancelData(response.getResponse().getImpUid(), true);
        }
        //부분 환불일 경우 checksum을 입력해 준다.
        return new CancelData(response.getResponse().getImpUid(), true, new BigDecimal(refundAmount));

    }

}

여기서 CancelData는 포트원 라이브러리에 있는 클래스이다.


정리

최대한 간추려서 결제 검증, 환불까지 알아보았다. 작성되지 않은 다른 정보는 아래 참고 사이트들을 통해서 알아가면 된다.

헤딩 목록 정리
1. 부분 환불 테스트 환경에서 불가능(체크 카드 쓰면 부분환불 후 전액 환불 못 받을 수도 있음. 만약 그러한 상황이라면 메일을 보내야한다.)
2. 웹훅도 테스트 환경에서 불가능(테스트 환경에서도 url을 통해 사용할 수 있다고 한다)
3. 가상계좌도 테스트 환경에서 사실상 불가능(기능 구현 자체는 가능하지만 환불 불가능)

참고 사이트

포트원 개발자 센터
포트원 API 사이트
포트원 노션

0개의 댓글