결제 기능 구현 4 : 사후검증 - jsp, 오라클, mybatis

2024. 4. 3. 00:21카테고리 없음

이전 포스팅에서, 사전검증을 끝내고 나서, 결제창을 띄우는 것까지 해보았다. 

결제창이 띄워지면, 어떻게 되는지, 일단 전체적인 흐름을 정리해두는 게 좋을 거라고 생각했다. 

1. 첫번째 결제흐름

결제창이 띄워지면, 사용자는 아래와 같이 선택하고,

 

아래와 같이 정보를 입력하고, 

아래와 같이 이메일 정보까지 입력한 다음에, 결제버튼을 딱 클릭하면

 

아래와 같이 alert 창으로 결제가 성공했다고 알려준 후 

아래 처음 결제를 시작했던, 

이 부분으로 오게 된다. 

그럼 위에서 보듯이, 20:50:33 초에 결제된 이니시스-정기 1원이 출금된 걸 확인할 수 있다. 

결제된 금액은 이니시스의 경우 아래에서 보듯, 23:00 ~ 23:50 사이에 취소되기 때문에, 걱정하지 않아도 된다. 

2. 두번째 결제흐름

만약, 이니시스 결제창까지 띄운 사용자가 "에라이 안살래~" 하고 이니시스 결제창을 꺼버리면?

그럼 아래와 같은 alert 창이 띄워지게 되는데, alert 문구 중, "사용자가 결제를 취소하셨습니다" 부분은 내 애플리케이션에서 만들어준 문구가 아니라, 포트원에서 보내준 문구를 그대로 띄워준 것이다. 

 

이렇게 결제가 성공적으로 끝까지 마친 경우, 그리고, 결제가 취소된 경우 이렇게 두가지의 흐름이 있고, 이걸 구현해볼것이다. 

----------------------------------------------------------------------------------------------------------------------

우선, 결제가 성공하는 흐름에 대해서 먼저 할 것이다. 그러면, 우리가 저번 포스팅에서부터 봤던, orderSheet.jsp 파일의 script 태그를 다시 봐야 한다. 

<head>
      <link rel="stylesheet" href="../../css/payment/orderSheet.css">
      <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
      <script src="https://cdn.iamport.kr/v1/iamport.js"></script>
      <script>
      document.addEventListener("DOMContentLoaded", function() {

          var IMP = window.IMP;
          IMP.init("여기에는 뭐를 써야 할까요?????????");

          // 폼 선택
          const form = document.getElementById('order-form');

          form.addEventListener('submit', function(event) { //submit(제출) 되면 이벤트 발생
              event.preventDefault(); // 폼의 기본 제출 동작을 방지

              // FormData 객체 생성
              const formData = new FormData(form);

              // fetch API를 사용하여 폼 데이터를 서버로 비동기적으로 전송
              fetch('/order-process', {
                  method: 'POST',
                  body: formData, // 폼 데이터. , 끝에 붙이는 거 문제 안된다고 함. 오히려 개발자들이 선호한다고 함.
              })
              .then(response => {
                  if (!response.ok) {
                      throw new Error('Network response was not ok');
                  }
                  return response.json(); // 서버로부터 반환된 JSON 응답을 파싱. 이러면 아래 data 여기에 알아서 파싱된 json 이 담김. data.json키 를 쓰면, value 를 얻을 수 있지.
              })
              .then(data => {
                  if(data.result = 'success'){
                        //사전검증 시작
                            fetch('/pre-validation?productId=${productId}&merchantUid='+ data.merchantUid)
                            .then(response => {
                                if (!response.ok) {
                                  throw new Error('Network response was not ok');
                                }
                                return response.json();
                            })
                            .then(data => {
                                //사전검증이 끝나면 할 것들 : 포트원 api 통신
                                //일단, 재고부족인 경우 처리
                                if(data.outOfStock == true){
                                    alert('재고가 부족하여 주문이 취소되었습니다.'); // alert창 띄우고,
                                    window.location.href="/payment-home"; // paymentHome.jsp 로 인도하는 컨트롤러로 get요청.
                                }
                                // 재고가 있는 경우,
                                var merchantUid = data.merchantUid;
                                var productName = data.productName;
                                var amount = data.amount;
                                var email = data.memberTotalData.email;
                                var domain = data.memberTotalData.domain;
                                var emailaddress = email + '@' + domain;
                                var userName = data.memberTotalData.userName;
                                var phoneNumStart = data.memberTotalData.phoneNumStart;
                                var phoneNumMiddle = data.memberTotalData.phoneNumMiddle;
                                var phoneNumEnd = data.memberTotalData.phoneNumEnd;
                                var phone = phoneNumStart + phoneNumMiddle + phoneNumEnd;
                                var streetAddress = data.memberTotalData.streetAddress;
                                var zipCode = data.memberTotalData.zipCode;




                                //여기서, portone 시작
                                    IMP.request_pay(

                                              ///////////////////////// 첫번째 ////////////////////////////////
                                              {
                                                pg: "html5_inicis",
                                                pay_method: "card",
                                                merchant_uid: merchantUid,
                                                name: productName,
                                                amount: amount,
                                                buyer_email: emailaddress,
                                                buyer_name: userName,
                                                buyer_tel: phone,
                                                buyer_addr: streetAddress,
                                                buyer_postcode: zipCode,
                                              },

                                              ////////////////////////두번째 //////////////////////////////////
                                              function (rsp) {
                                                    if (rsp.success) {
                                                              // axios로 HTTP 요청
                                                              axios({
                                                                url: "/payment-response", // 실제 서버의 엔드포인트 주소로 수정
                                                                method: "post",
                                                                headers: { "Content-Type": "application/json" },
                                                                data: {
                                                                  imp_uid: rsp.imp_uid,
                                                                  merchant_uid: rsp.merchant_uid
                                                                }
                                                              })

                                                              .then((response) => {
                                                                // 서버 결제 API 성공 시 로직
                                                                if(response = 'success'){ // 성공한 경우
                                                                    alert('결제가 성공하였습니다.');
                                                                    window.location.href="/payment-home";


                                                                } else {
                                                                    alert(response); // 그 외의 경우
                                                                    window.location.href="/payment-home";
                                                                }
                                                              })
                                                              .catch((error) => {
                                                                console.error(error);
                                                              });
                                                    } else {
                                                            alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
                                                            //결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
                                                            window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
                                                    }
                                              }


                                    );

                                    //여기서, portone 끝



                            })
                            .catch(error => {
                                console.error('There has been a problem with your fetch operation:', error)
                                alert('결제 중 오류가 발생했습니다.');
                            });


                        //사전검증 끝


                  }

              })
              .catch(error => {
                  console.error('There has been a problem with your fetch operation:',
                                error);
              });

      		});

      });
      </script>


</head>

위 코드 중 아래부분을 시작할 차례이다. 

저번 포스팅에서 말했듯, rsp 에는 결제결과가 들어있다.

if(rsp.success){} : 만약, 결제가 성공했다면~ axios 로 ajax 통신을 하고 있다. 

else{} : 만약 결제가 어떠한 이유로 취소되었다면, 포트원이 보내준 에러메세지를 띄워주고 있다. 

지금 볼건, if(rsp.success){여기} 여기에 들어있는 것들을 볼것이다.

axios 로 /payment-response 라는 url 로 HTTP 요청을 POST 방식으로 보내고 있다. 

뭘 담아줬냐면, rsp.imp_uid 와 rsp.merchant_uid 를 담아줬다. 

즉, 포트원서버가 내려준 데이터에서 imp_uid 와 merchant_uid 를 보내준 것이다. 

여기서, merchant_uid 는 뭐냐면, 내 애플리케이션에서 ORDERS 테이블에 행을 삽입할 때 만들었던, 각 주문에 부여된 다른 주문과 구분되는 고유한 값을 말한다. 즉, 내 애플리케이션에서의 주문번호 를 말한다. 

imp_uid 는 뭐냐면, 포트원서버에서 해당 주문건에 대해 부여한 고유한 값이다.

////////////////////////두번째 //////////////////////////////////
function (rsp) {
    if (rsp.success) {
              // axios로 HTTP 요청
              axios({
                url: "/payment-response", // 실제 서버의 엔드포인트 주소로 수정
                method: "post",
                headers: { "Content-Type": "application/json" },
                data: {
                  imp_uid: rsp.imp_uid,
                  merchant_uid: rsp.merchant_uid
                }
              })

              .then((response) => {
                // 서버 결제 API 성공 시 로직
                if(response = 'success'){ // 성공한 경우
                    alert('결제가 성공하였습니다.');
                    window.location.href="/payment-home";


                } else {
                    alert(response); // 그 외의 경우
                    window.location.href="/payment-home";
                }
              })
              .catch((error) => {
                console.error(error);
              });
    } else {
            alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
            //결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
            window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
    }
}

이제 /payment-response 를 매핑하는 컨트롤러로 가보자. 아래와 같다. 

    @PostMapping("/payment-response")
    @ResponseBody
    public String paymentResponeControllerMethod(@RequestBody PaymentDTO paymentDTO, HttpServletRequest request) {

    // 1. orders 테이블에 결제건에 해당하는 행의 status 컬럼을 Processing 이라고 바꿔줘야함.
        // 여기서 Processing 이란, 주문에 대한 결제를 처리 중이다. 라는 뜻이다. 아직 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)을 하기 전이니, 결제를 처리 중인게 맞지.
        ordersMapper.updateStatusByMerchantUid(paymentDTO.getMerchant_uid(),"Processing");

    // 2. 사후검증 시작
        try {
            // 2-1) Imp_uid 와 merchant_uid 를 꺼내오기.
            String impUid = paymentDTO.getImp_uid();
            String merchantUid = paymentDTO.getMerchant_uid();

            // 2-2) access 토큰 얻어오기
            TokenResponseDTO tokenDTO = accessToken.getAccessToken();

            // 2-3) 얻은 토큰으로 결제단건조회를 함.
            Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
            ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);

            // 2-4) 일단, payments 테이블에 행을 삽입한다. 왜? 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)여부와는 무관하게, 일단은 결제가 됬으니까.
                // ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
                String responseBody = response.getBody();
                // ㄴ. INSERT INTO PAYMENTS
                insertPaymentsRowService.insertRow(memberId, responseBody);

            // 2-5) 사후검증
            postValidationService.postValidation(response);


            //판매자들을 인도하는 페이지에서, 판매자들이 택배회사와 송장번호를 입력하면 그걸 받는 컨트롤러에서 orders 테이블의 status 컬럼값을 Shipped 로 변경하도록 해주면 됨.
            //그리고 고객들이 보는 페이지에서는 payments 테이블의 status 컬럼이 아니라, orders 테이블의 status 컬럼값에 따라서, 표시가 되도록 하면 됨.
            return "success";

        } catch (PaymentVerificationException e) {
        //사후검증에 실패한 경우 :
            // orders 테이블의 status, fail_reason 컬럼을 실패했다고 업데이트 해주는 것은 SinglePaymentQueryService 에서 해줬기 때문에 그 과정 생략.

            Integer portOneAmount = e.getPortOneAmount();
            Integer myDbAmount = e.getMyDbAmount();
            Integer priceDifference = e.getPriceDifference();

            if (priceDifference > 0) {
                //덜 결제된 경우
                return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되어야 합니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다. " ;
            } else{
                //더 결제된 경우 => 정상처리로 보고 정상처리를 해줘야지.
                return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되었습니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다." ;
            }
        } catch (JsonProcessingException e) {
            //JSON 파싱에 실패한 경우
            return "결제 중 오류가 발생되었습니다. JsonParsingException";
        } catch(Exception e){
            return "결제 중 오류가 발생되었습니다. Exception";
        }
    }

 

우선, 클라이언트에서 넘겨준 데이터를 담는 DTO로서, PaymentDTO 를 아래와 같이 만들었다. 

@Data
public class PaymentDTO {

    private String imp_uid;
    private String merchant_uid;
}

 

제일 먼저 실행될 코드는 

        ordersMapper.updateStatusByMerchantUid(paymentDTO.getMerchant_uid(),"Processing");

이거다. 

 

어떤 코드냐면, ORDERS 테이블에 삽입되었던 해당 merchant_uid 를 가진 행의 status 컬럼값을 Pending 에서 Processing 으로 바꾸는 코드이다. 

Pending 이 "주문이 생성되었지만 아직 그 주문이 결제되거나 하는등의 처리가 되지 않은 상태" 라고 했다면, 

Processing 은 "결제 처리중 이라는 상태" 라는 뜻으로 사용했다. 

포트원에서 결제는 되었으나, 사후검증까지 모두 마친 게 결제처리의 전 과정이라고 생각했기 때문에, Processing 이라고 표현하였다. 

 

            // 2-1) Imp_uid 와 merchant_uid 를 꺼내오기.
            String impUid = paymentDTO.getImp_uid();
            String merchantUid = paymentDTO.getMerchant_uid();

            // 2-2) access 토큰 얻어오기
            TokenResponseDTO tokenDTO = accessToken.getAccessToken();

다음으로 오는 코드는 위와 같은데, 여기까지는 복잡할 게 없으니, 간단히만 설명하자면, 클라이언트로부터 온 imp_uid 와 merchant_uid 를 꺼내오고, 포트원서버에게 방금 한 결제에 대한 정보를 조회(결제단건조회)를 할건데, 포트원서버에게 뭐 달라고 하려면 결제하는 거 빼고는 access token 이 필요하다고 했으니까, accessToken 을 얻어온 것이다. 

 

            // 2-3) 얻은 토큰으로 결제단건조회를 함.
            Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
            ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);

이제 얻은 코드로, 결제단건조회를 시작한다. 

FindLoginedMemberIdUtil 클래스의 findLoginedMember() 메서드를 호출하면서 서블릿 컨테이너가 만들어준 HttpServletRequest 타입 객체를 넘겨주면, 세션객체를 뒤져서, 현재 HTTP요청을 보낸 사용자의 MEMBER 테이블 id 가 나오도록 코드를 짜두었다. 아래 접은글에 남겨둠.

더보기
    public static Integer findLoginedMember(HttpServletRequest request){
        HttpSession session = request.getSession(false);
        MemberJoinDTO loginedMember = (MemberJoinDTO) session.getAttribute("loginedMember");
        Integer id = loginedMember.getId();
        return id;
    }

왜 HTTP요청을 보낸 사용자의 MEMBER 테이블 id 컬럼값을 조회해왔냐면, 지금 필요한 건 아니고, 이 코드 밑에 부분에 PAYMENTS 테이블에 행을 삽입할 때 필요해서 미리 꺼내둔 것이다. 

이제 singlePaymentQueryService 클래스의 singlePaymentQuery 메서드에 대해서 얘기할건데, 

우선, 이 메서드가 바로 결제단건조회(즉, 방금 결제된 행에 대한 정보를 달라고 포트원서버에게 요청하는 것)를 하는 메서드이다.

파라미터로, 포트원 서버가 내려준 데이터인 impUid 와 얻어온 access token 을 넘겨주었는데, 아래와 같이 생긴 메서드이다. 

@Service
// @Transactional 안쓴다. 왜? db 접근하는 코드 없음.
@Slf4j
@RequiredArgsConstructor
public class SinglePaymentQueryService {
    private final RestTemplate restTemplate;
    /*
    * 결제 단건 조회
    * */
    public ResponseEntity<String> singlePaymentQuery(String impUid, TokenResponseDTO tokenDTO) {

        String url = "https://api.iamport.kr/payments/" + impUid;

        //헤더 세팅
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", tokenDTO.getResponse().getAccess_token());
        //바디에 담을 거
        HttpEntity<String> entity = new HttpEntity<>(headers);
        // RestTemplate 사용해서 GET 요청 전송.
        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);

        return response;
    }




}

결제단건조회가 일어난 후 다음 코드가 실행되어야 하기 때문에, 비동기 방식인 WebClient 말고 RestTemplate 을 사용하였다. 

        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);

이 코드로 인해, 결제단건조회가 실행되고, 그 응답결과가 response 라는 ResponseEntity<String> 타입 객체에 담기게 되었다. 

그럼 이 응답결과(ResponseEntity<String> response)를 리턴해준다.

 

그럼 이제, 아래코드가 실행된다.

// 2-4) 일단, payments 테이블에 행을 삽입한다. 왜? 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)여부와는 무관하게, 일단은 결제가 됬으니까.
    // ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
    String responseBody = response.getBody();
    // ㄴ. INSERT INTO PAYMENTS
    insertPaymentsRowService.insertRow(memberId, responseBody);

response.getBody(); 라고 해서, ResponseEntity<String> 타입 객체에 대고 .getBody() 메서드를 호출하게 되면, ResponseEntity<String> 타입 객체 안에 들어있는 포트원 서버가 준 HTTP응답의 바디 부분이 String 타입으로 리턴되게 된다. 

참고로, ResponseEntity<여기> 여기에 String 타입이었기 때문에, .getBody() 를 했을 때 String 타입이 반환되는 것이다. 

그리고 나서, InsertPaymentRowService 라는 클래스(서비스계층)가 있는데, 이 안에 있는 insertRow 라는 메서드를 호출한다. 파라미터로는 "이전에 구해놨던 현재 HTTP요청을 한 구매자의 MEMBER테이블 id 컬럼값"과 "포트원 서버가 준 HTTP 응답의 body 부분을 String 타입으로 변환한 걸" 넘겨주고 있다. 

 

insertRow 라는 메서드는 아래와 같이 생겼다. 

넘겨받은 응답을 가지고, 데이터들을 꺼내서,PAYMENTS 테이블에 행을 삽입하는 코드이다. 

@Slf4j
@RequiredArgsConstructor
@Service
public class InsertPaymentsRowService {
    private final OrdersMapper ordersMapper;
    private final PaymentsMapper paymentsMapper;

    public void insertRow(Integer memberId, String responseBody ){

        //member_id 는 memberId 를 그대로 넣으면 됨.

        //responseBody 에 보면, merchant_uid 라는 게 있는데, 그걸 이용해서 order테이블에서 행을 조회해서, 그 행의 id컬럼값을 빼서 payment테이블의 order_id 컬럼의 값으로 하면 됨.

        //responseBody에서 merchant_uid 꺼내기
        try {
            ObjectMapper objectMapper = new ObjectMapper();
            JsonNode rootNode = objectMapper.readTree(responseBody);
            JsonNode responseNode = rootNode.path("response");

            //결제취소시 필요한 정보 3개 : amount, impUid, merchantUid + 혹시 몰라서, currency 까지 포함.
                String impUid = responseNode.path("imp_uid").asText(); //포트원 거래 고유번호 : imp_088022300265
                String merchantUid = responseNode.path("merchant_uid").asText(); //가맹점 주문번호. 내 프로젝트에서 결제건마다 부여한 고유번호로서 랜덤한 숫자로 구성함 : 57008833-33010
                int amount = responseNode.path("amount").asInt(); //결제건의 결제금액 : 1
                String currency = responseNode.path("currency").asText(); //결제통화 구분코드 (KRW..등) : KRW
            //관리자 페이지에서 status 가 fail 이거나 cancelled 인 것만 보기 뭐 이런거 할 때 필요할 거 같아서.
                String status = responseNode.path("status").asText(); //결제건의 결제상태 : paid(결제완료), ready(브라우저 창 이탈, 가상계좌 발급완료 미결제 상태), failed(신용카드 한도초과, 체크카드 잔액부족, 브라우저 창 종료, 취소버튼 클릭 등 결제실패상태)
                    //결제실패 (결제 자체가 성공된 적이 없음. 카드한도초과, 유효하지 않은 카드번호, 은행 거절 등)
                    String failReason = responseNode.path("fail_reason").asText(); // 결제실패 사유로, 결제상태가 결제실패(failed)가 아닐 경우 null로 표시됨 : null
                    Long failedAtLong = responseNode.path("failed_at").asLong(); //결제실패시각. 결제상태가 결제실패(failed)가 아닌 경우 0으로 표시됨. : 0
                    LocalDateTime failedAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(failedAtLong), ZoneId.systemDefault());

            String payMethod = responseNode.path("pay_method").asText(); //결제수단 구분코드 :
                    // card 외
                    // trans(실시간 계좌이체),
                    // vbank(가상계좌),
                    // phone(휴대폰소액결제),
                    // cultureland(컬쳐랜드상품권  (구)문화상품권),
                    // smatculture(스마트문상(게임 문화 상품권)),
                    // happymoney(해피머니),
                    // booknlif(도서문화상품권),
                    // culturegift(문화상품권)
                    // samsung(삼성페이),
                    // kakaopay(카카오페이)
                    // naverpay(네이버페이)
                    // payco(페이코)
                    // lpay(LPAY),
                    // ssgpay(SSG페이),
                    // tosspay(토스페이),
                    // applepay(애플페이),
                    // pinpay (핀페이)
                    // skpay(11pay(구.SKPay))
                    // wechat(위쳇페이),
                    // alipay(알리페이),
                    // unionpay(유니온페이),
                    // tenpay(텐페이)
                    // paysbuy(페이스바이)
                    // econtext(편의점 결제)
                    // molpay(MOL페이)
                    // point(베네피아 포인트 등 포인트 결제),
                    // paypal(페이팔)
                    // toss_brandpay(토스페이먼츠 브랜드페이)
                    // naverpay_card(네이버페이 - 카드)
                    // naverpay_point(네이버페이 - 포인트)


            // 결제와 관련된 정보
            String name = responseNode.path("name").asText(); // 결제건의 제품명 : \ub2f9\uadfc 10kg
            Long paidAtLong = responseNode.path("paid_at").asLong(); //결제된 시각. 결제상태가 결제완료(paid)가 아닌 경우 0으로 표시됨. : 1709722069
            LocalDateTime paidAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(paidAtLong), ZoneId.systemDefault());



            String receiptUrl = responseNode.path("receipt_url").asText(); //결제건의 매출전표 URL로 PG사 또는 결제수단에 따라 매출전표가 없을 수 있다. : https:\/\/iniweb.inicis.com\/receipt\/iniReceipt.jsp?noTid=StdpayCARDINIBillTst20240306194748374762

            //startedAtLong : 1709722022L
            //startedAt : 2024-03-06 10:47:02
            Long startedAtLong = responseNode.path("started_at").asLong(); //결제건의 결제요청 시각 UNIX timestamp : 1709722022
            LocalDateTime startedAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(startedAtLong), ZoneId.systemDefault());

            String userAgent = responseNode.path("user_agent").asText(); //구매자가 결제시 사용한 단말기의 UserAgent 문자열 : Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/605.1.15 (KHTML, like Gecko) Version\/16.6 Safari\/605.1.15

            //구매자에 대한 정보- buyer 테이블 (확)
            String buyerName = responseNode.path("buyer_name").asText(); //주문자 이름 : \ud3ec\ud2b8\uc6d0 \uae30\uc220\uc9c0\uc6d0\ud300
            String buyerTel = responseNode.path("buyer_tel").asText(); //주문자 전화번호 : 010-1234-5678
            String buyerAddr = responseNode.path("buyer_addr").asText(); //주문자 주소 : \uc11c\uc6b8\ud2b9\ubcc4\uc2dc \uac15\ub0a8\uad6c \uc0bc\uc131\ub3d9
            String buyerPostcode = responseNode.path("buyer_postcode").asText(); //주문자 우편번호 : 123-456
            String buyerEmail = responseNode.path("buyer_email").asText(); //주문자 이메일 : wowns590@naver.com

            //카드 결제
            String applyNum = responseNode.path("apply_num").asText(); //결제건의 신용카드 승인번호 : 31491375
            String cardCode = responseNode.path("card_code").asText(); //결제건의 카드사 코드번호(금융결제원 표준코드번호) - 카드 결제건의 경우 : 366
            String cardName = responseNode.path("card_name").asText(); //결제건의 카드사명 - 카드 결제건의 경우 : \uc2e0\ud55c\uce74\ub4dc
            String cardNumber = responseNode.path("card_number").asText(); //카드번호 : 559410*********4
            Integer cardQuota = responseNode.path("card-quota").asInt(); //할부개월 수 : 0
            Integer cardType = responseNode.path("card-type").asInt(); //0이면 신용카드, 1이면 체크카드. 해당 정보를 제공하지 않는 일부 PG사의 경우 null로 응답됨.(ex : JTNet, 이니시스-빌링) : 1 내가 체크카드로 결제해서 그래

            //실시간 계좌이체
            String bankCode = responseNode.path("bank_code").asText(); //결제건의 은행 표준코드(금융결제원기준) - 실시간계좌이체 결제건의 경우 : null
            String bankName = responseNode.path("bank_name").asText(); //결제건의 은행명 - 실시간계좌이체 결제건의 경우 : null

            //가상계좌(virtual bank)
            String vbankCode = responseNode.path("vbank_code").asText(); //결제건의 가상계좌 은행 표준코드(금융결제원기준) - 가상계좌 결제건의 경우 : null
            Integer vbankDate = responseNode.path("vbank_date").asInt(); //결제건의 가상계좌 입금기한 - 가상계좌 결제 건의 경우 : 0


            String vbankHolder = responseNode.path("vbank_holder").asText(); //결제건의 입금받을 가상계좌 입금주 - 가상계좌 결제건의 경우 : null
            Long vbankIssuedAtLong = responseNode.path("vbank_issued_at").asLong(); //결제건의 가상계좌 생성시각 UNIX timestamp - 가상계좌 결제건의 경우 : 0
            LocalDateTime vbankIssuedAt = LocalDateTime.ofInstant(Instant.ofEpochSecond(vbankIssuedAtLong), ZoneId.systemDefault());



            String vbankNum = responseNode.path("vbank_num").asText(); //결제건의 입금받을 가상계좌 계좌번호 - 가상계좌 결제건의 경우 : null
            String vbankName = responseNode.path("vbank_name").asText(); //결제건의 입금받을 가상계좌 은행명 - 가상계좌 결제건의 경우 : null

            //쩌리
            String customData = responseNode.path("custom_data").asText(); //결제 요청시 가맹점에서 전달한 추가정보(JSON string으로 전달) : null
            String customerUid = responseNode.path("customer_uid").asText(); //결제건에 사용된 빌링키와 매핑되며 가맹점에서 채번하는 구매자의 결제수단 식별 고유번호 : null
            String customerUidUsage = responseNode.path("customer_uid_usage").asText(); //결제처리에 사용된 구매자의 결제수단 식별 고유번호의 사용 구분코드 : null
            String channel = responseNode.path("channel").asText(); //결제환경 구분코드 : pc
            Boolean cashReceiptIssuedBoolean = responseNode.path("cash_receipt_issued").asBoolean(); //결제건의 현금영수증 발급 여부 : false
            String cashReceiptIssued = String.valueOf(cashReceiptIssuedBoolean);
            boolean escrowBoolean = responseNode.path("escrow").asBoolean(); //에스크로결제 여부 : false
            String escrow = String.valueOf(escrowBoolean);

            //PG사
            String pgId = responseNode.path("pg_id").asText(); //PG사 상점아이디 : INIBillTst
            String pgProvider = responseNode.path("pg_provider").asText(); //PG사 구분코드 : html5_inicis
            String embPgProvider = responseNode.path("emb_pg_provider").asText(); //허브형 결제 PG사 구분코드 : null
            String pgTid = responseNode.path("pg_tid").asText(); //PG사 거래번호 : StdpayCARDINIBillTst20240306194748374762

            //결제취소와 관련 정보 (결제 성공했다가 취소되는 경우. 구매자의 요청에 의한 취소, 상품의 결함으로 인한 취소 등.)
            int cancelAmount = responseNode.path("cancel_amount").asInt(); //결제건의 누적 취소금액 : 0
            String cancelReason = responseNode.path("cancel_reason").asText(); //결제취소된 사유. 결제상태가 결제취소(cancelled)가 아닐경우 null 로 표시됨. : null
            String cancelledAt = responseNode.path("cancelled_at").asText(); //결제취소된 시각. 결제상태가 결제취소(cancelled)가 아닐 경우 null 로 표시됨. : 0 ? 이거 왜 null 로 표시 안되어있지? 뭐가 들어올지 모르므로 String 으로 받아오자.
            // "cancel_history":[], -- 결제건의 취소/부분취소 내역 : PaymentCancelAnnotation[]
            // "cancel_receipt_urls":[], -- 더이상 사용하지 않는다고 한다. cancel_history 를 쓰도록 권장하고 있음.

            JsonNode cancelHistoryNode = responseNode.path("cancel_history");
            PaymentCancelAnnotation[] paymentCancelArr = objectMapper.treeToValue(cancelHistoryNode, PaymentCancelAnnotation[].class);
            for (PaymentCancelAnnotation anno : paymentCancelArr) {
                String pgTid2 = anno.getPgTid();
                Integer amount1 = anno.getAmount();
                Integer cancelledAt1 = anno.getCancelledAt();
                String reason = anno.getReason();
                String cancellationId = anno.getCancellationId();
                String receiptUrl1 = anno.getReceiptUrl();
                //cancel_history 테이블에 insert
            }

            //merchantUid 로 orders 테이블에서 행을 찾아서, 그 행의 id 컬럼값을 가져온다.
            Integer orderId = ordersMapper.findOrderIdByMerchantUid(merchantUid);

            paymentsMapper.insertPayment(memberId, orderId, impUid, merchantUid, amount, currency, status, failReason, failedAt, payMethod, name, paidAt, receiptUrl, startedAt, userAgent, buyerName, buyerTel, buyerAddr, buyerPostcode, buyerEmail, applyNum, cardCode, cardName,cardNumber ,cardQuota ,cardType , bankCode, bankName, vbankCode, vbankDate, vbankHolder, vbankIssuedAt, vbankNum, vbankName, customData, customerUid, customerUidUsage, channel, cashReceiptIssued, escrow, pgId, pgProvider, embPgProvider, pgTid, cancelAmount, cancelReason, cancelledAt);


            //paymentsMapper.insertPayment(memberId);

        } catch (JsonProcessingException e) {
            log.error("SinglePaymentQueryService : JSON 파싱 중 오류 발생", e);
            throw new JsonParsingException("InsertPaymentRowService : JSON 파싱 중 오류 발생");
        }




    }


}

void 타입 메서드이기 때문에, 반환되는 건 없다. 

 

이제 드디어 사후검증하는 코드인

postValidationService.postValidation(response); 이다.

@Service
@Slf4j
@RequiredArgsConstructor
// @Transactional 쓰지 않는다. update 문 여러 개 있는 거 아님? 왜 안씀? 아니지. 지금 if else 조건문으로 쓰여져 있잖아. 즉, 어떠한 경우라도 단 하나의 update 쿼리만 발생하게 된다.
// 다시말해서, 하나의 update 쿼리이기 때문에, @Transactional 을 쓰지 않는다.
public class PostValidationService {
    private final OrdersMapper ordersMapper;
    private final InsertPaymentsRowService insertPaymentsRowService;
    private final ProductsMapper productsMapper;

    public void postValidation(ResponseEntity<String> response)throws JsonProcessingException {
        log.info("response={}", response); // 아래와 같은 것들이 포트원으로부터 왔다.
        //<200 OK OK,
        // { -- HTTP응답의 body 시작
        // "code":0,
        // "message":null,
        // "response":
        // {
        // "amount":1,
        // "apply_num":"31491375",
        // "bank_code":null,
        // "bank_name":null,
        // "buyer_addr":"\uc11c\uc6b8\ud2b9\ubcc4\uc2dc \uac15\ub0a8\uad6c \uc0bc\uc131\ub3d9",
        // "buyer_email":"wowns590@naver.com",
        // "buyer_name":"\ud3ec\ud2b8\uc6d0 \uae30\uc220\uc9c0\uc6d0\ud300",
        // "buyer_postcode":"123-456",
        // "buyer_tel":"010-1234-5678",
        // "cancel_amount":0,
        // "cancel_history":[],
        // "cancel_reason":null,
        // "cancel_receipt_urls":[],
        // "cancelled_at":0,
        // "card_code":"366",
        // "card_name":"\uc2e0\ud55c\uce74\ub4dc",
        // "card_number":"559410*********4",
        // "card_quota":0,
        // "card_type":1,
        // "cash_receipt_issued":false,
        // "channel":"pc",
        // "currency":"KRW",
        // "custom_data":null,
        // "customer_uid":null,
        // "customer_uid_usage":null,
        // "emb_pg_provider":null,
        // "escrow":false,
        // "fail_reason":null,
        // "failed_at":0,
        // "imp_uid":"imp_088022300265",
        // "merchant_uid":"57008833-33010",
        // "name":"\ub2f9\uadfc 10kg",
        // "paid_at":1709722069,
        // "pay_method":"card",
        // "pg_id":"INIBillTst",
        // "pg_provider":"html5_inicis",
        // "pg_tid":"StdpayCARDINIBillTst20240306194748374762",
        // "receipt_url":"https:\/\/iniweb.inicis.com\/receipt\/iniReceipt.jsp?noTid=StdpayCARDINIBillTst20240306194748374762",
        // "started_at":1709722022,
        // "status":"paid",
        // "user_agent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/605.1.15 (KHTML, like Gecko) Version\/16.6 Safari\/605.1.15",
        // "vbank_code":null,
        // "vbank_date":0,
        // "vbank_holder":null,
        // "vbank_issued_at":0,
        // "vbank_name":null,
        // "vbank_num":null
        // }
        // }-- HTTP응답의 body 끝,
        // [
        // Date:"Wed, 06 Mar 2024 10:47:49 GMT",
        // Content-Type:"application/json;
        // charset=UTF-8",
        // Content-Length:"1393",
        // Connection:"keep-alive",
        // Server:"Apache",
        // X-Content-Type-Options:"nosniff",
        // X-XSS-Protection:"1; mode=block",
        // X-Frame-Options:"SAMEORIGIN"
        // ]
        // >

        // 사후검증 계획은 아래와 같다.
            // 1. 포트원으로부터 온 데이터 중, "merchant_uid":"57008833-33010" 이거를 꺼내서,
            // 내 DB의 orders 테이블에 있는 merchant_uid 컬럼의 값이 57008833-33010 인 행을 조회한다.
            // 2. 조회된 행의 amount 컬럼의 값은 해당 주문이 결제했어야 하는 금액을 넣어두었다.
            // 3. 따라서, 포트원으로부터 온 데이터 중 amount 와 방금 조회된 행의 amount 컬럼값이 일치하는지 검사한다.
            // 4. 같다면, 사후검증 통과.

        // 사후검증 시작.
        // 1. 포트원으로부터 온 HTTP응답 메세지 바디 가져오기
        String responseBody = response.getBody();

        int amount = 0;
        Integer findAmount = 0;
        String merchantUid = "";

        try {
            // 2.포트원이 보내준 데이터 중 amount 와 merchant_uid 를 파싱.
            //   그러기 위해서, 포트원으로부터 온 HTTP응답을 보면, 크게 code, message, response 이렇게 3가지로 구성되어 있는데, 이 중 response 를 파헤친다.

                // 2-1) jackson 라이브러리의 ObjectMapper 인스턴스 생성.
                // 왜? ObjectMapper 는 유연해서, 'JSON 형식'의 '문자열'을 파싱해서 JsonNode 타입 객체로 변환해줄 수 있다.
                // JsonNode 타입 객체를 알면, .path 메서드를 통해 쉽게 JSON 데이터에 접근할 수 있게된다.
                ObjectMapper objectMapper = new ObjectMapper();
                JsonNode rootNode = objectMapper.readTree(responseBody);
                JsonNode responseNode = rootNode.path("response");
                // 2-2) 포트원이 나에게 보내준 HTTP응답메세지 중 얻고 싶었던 데이터인 amount 와 merchantUid 를 얻었다. 얻어오는 과정을 보니, JsonNode 타입 객체는 JSON 데이터를 계층적으로 구분해 놓는다고 예상됨.
                amount = responseNode.path("amount").asInt();
                merchantUid = responseNode.path("merchant_uid").asText();

            // 3. 내 데이터베이스에서 파싱해서 얻은 정보 중 merchantUid 와 동일한 merchant_uid 컬럼값을 지닌 행의 amount 를 조회
            // SELECT amount FROM orders
            // WHERE merchant_uid = #{merchantUid}
            findAmount = ordersMapper.findAmountByMerchantUid(merchantUid);

            // 4. 사후검증 판단
            if (amount == findAmount) {
                // 4-1) 일치하는 경우
                //  ORDERS 테이블의 status 컬럼값을 Paid 로 바꾸어주면 된다. 이때, Paid 의 의미는 이니시스에서의 결제는 성공적으로 처리되었으며, 사후검증도 통과했다는 의미로 정의한다.
                ordersMapper.updateStatusByMerchantUid(merchantUid,"Paid");
                //  PAYMENTS 테이블의 status 컬럼값을 바꾸어주어야 하는지에 대하여 생각해봤다. 결론 : 건들지 않는다.
                // 왜? payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과

            } else {
                // 4-2) 일치하지 않는 경우
                // 예외를 터뜨린다. 내가 만든 RuntimeException 인 PaymentVerificationException 을 터뜨려 줄 것이다.
                throw new PaymentVerificationException("SinglePaymentQueryService : 결제 검증 실패 - 결제금액 불일치");
            }
        } catch (PaymentVerificationException e) {
            // (사후검증에 통과하지 못한 경우) == (결제됬어야 하는 금액과 실제 결제된 금액이 불일치 하는 경우)
            log.error("SinglePaymentQueryService : 사후검증 중 결제금액 불일치", e);

            Integer priceDifference = findAmount - amount;

            if(priceDifference >0){
                // 사용자가 덜 지불한 경우
                ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_MINUS");
                // payments 테이블에도 status 를 fail 로 해둬야할까?
                // payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과

                ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이 " + priceDifference + "원 더 결제하셔야 합니다.");
                throw new PaymentVerificationException(amount, findAmount, priceDifference);
            } else if (priceDifference < 0) {
                // 사용자가 더 지불한 경우
                ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_PLUS");
                ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이" + priceDifference + "원 더 결제하셨습니다.");
                throw new PaymentVerificationException(amount, findAmount, priceDifference);
            }

        } catch (JsonProcessingException e) {
            // JsonNode rootNode = objectMapper.readTree(responseBody); 이거 하다가 발생할 수 있는 예외를 처리해줘야 함.
            // 컨트롤러로 다시 예외를 발생시키도록 했음. 왜? 이렇게 잡고 다시 던지면, 로그를 남길 수 있잖아. 그래서, throws 로 던질수도 있었는데, 그렇게 안함.
            log.error("SinglePaymentQueryService : JSON 파싱 중 오류 발생", e);
            throw e;
        }

    }
}

우선 주석이 써두었듯이, 사후검증을 어떻게 할 계획이냐면,            

            // 1. 포트원으로부터 온 데이터 중, "merchant_uid":"57008833-33010" 이거를 꺼내서,
            // 내 DB의 orders 테이블에 있는 merchant_uid 컬럼의 값이 57008833-33010 인 행을 조회한다.
            // 2. 조회된 행의 amount 컬럼의 값은 해당 주문이 결제했어야 하는 금액을 넣어두었다.
            // 3. 따라서, 포트원으로부터 온 데이터 중 amount 와 방금 조회된 행의 amount 컬럼값이 일치하는지 검사한다.
            // 4. 같다면, 사후검증 통과.

이렇게 할 생각이었다. 

 

그래서, 포트원으로부터 온 데이터 중 merchant_uid 를 뽑아오려고 하였다. 

 String responseBody = response.getBody();

이렇게 일단, ResponseEntity<String> 타입 객체에 대고 .getBody() 를 호출해서 String 타입으로 HTTP 응답 메세지의 바디부분을 추출하였다. 

문제는 이 String 타입 객체에서 어떻게 merchant_id 만 뽑아올 수 있을까? 였다. 

왜냐하면, 이 String 타입객체에는 아래와 같은 문자열이 들어있을 것이기 때문이었다. 

        //<200 OK OK,
        // { -- HTTP응답의 body 시작
        // "code":0,
        // "message":null,
        // "response":
        // {
        // "amount":1,
        // "apply_num":"31491375",
        // "bank_code":null,
        // "bank_name":null,
        // "buyer_addr":"\uc11c\uc6b8\ud2b9\ubcc4\uc2dc \uac15\ub0a8\uad6c \uc0bc\uc131\ub3d9",
        // "buyer_email":"wowns590@naver.com",
        // "buyer_name":"\ud3ec\ud2b8\uc6d0 \uae30\uc220\uc9c0\uc6d0\ud300",
        // "buyer_postcode":"123-456",
        // "buyer_tel":"010-1234-5678",
        // "cancel_amount":0,
        // "cancel_history":[],
        // "cancel_reason":null,
        // "cancel_receipt_urls":[],
        // "cancelled_at":0,
        // "card_code":"366",
        // "card_name":"\uc2e0\ud55c\uce74\ub4dc",
        // "card_number":"559410*********4",
        // "card_quota":0,
        // "card_type":1,
        // "cash_receipt_issued":false,
        // "channel":"pc",
        // "currency":"KRW",
        // "custom_data":null,
        // "customer_uid":null,
        // "customer_uid_usage":null,
        // "emb_pg_provider":null,
        // "escrow":false,
        // "fail_reason":null,
        // "failed_at":0,
        // "imp_uid":"imp_088022300265",
        // "merchant_uid":"57008833-33010",
        // "name":"\ub2f9\uadfc 10kg",
        // "paid_at":1709722069,
        // "pay_method":"card",
        // "pg_id":"INIBillTst",
        // "pg_provider":"html5_inicis",
        // "pg_tid":"StdpayCARDINIBillTst20240306194748374762",
        // "receipt_url":"https:\/\/iniweb.inicis.com\/receipt\/iniReceipt.jsp?noTid=StdpayCARDINIBillTst20240306194748374762",
        // "started_at":1709722022,
        // "status":"paid",
        // "user_agent":"Mozilla\/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit\/605.1.15 (KHTML, like Gecko) Version\/16.6 Safari\/605.1.15",
        // "vbank_code":null,
        // "vbank_date":0,
        // "vbank_holder":null,
        // "vbank_issued_at":0,
        // "vbank_name":null,
        // "vbank_num":null
        // }
        // }-- HTTP응답의 body 끝,
        // [
        // Date:"Wed, 06 Mar 2024 10:47:49 GMT",
        // Content-Type:"application/json;
        // charset=UTF-8",
        // Content-Length:"1393",
        // Connection:"keep-alive",
        // Server:"Apache",
        // X-Content-Type-Options:"nosniff",
        // X-XSS-Protection:"1; mode=block",
        // X-Frame-Options:"SAMEORIGIN"
        // ]
        // >

 

그래서, 포트원이 준 HTTP 응답 중 merchant_uid 를 꺼내기 위해서,  아래와 같이 ObjectMapper(); 와 JsonNode 를 사용하였다. 

merchant_uid 뿐만 아니라, amount 값도 꺼내왔는데, 이는 이 값과 내 ORDERS 테이블의 amount 값(해당 주문건에서 결제됬어야 할 금액) 을 비교하기 위해서이다. 

// 2.포트원이 보내준 데이터 중 amount 와 merchant_uid 를 파싱.
//   그러기 위해서, 포트원으로부터 온 HTTP응답을 보면, 크게 code, message, response 이렇게 3가지로 구성되어 있는데, 이 중 response 를 파헤친다.

    // 2-1) jackson 라이브러리의 ObjectMapper 인스턴스 생성.
    // 왜? ObjectMapper 는 유연해서, 'JSON 형식'의 '문자열'을 파싱해서 JsonNode 타입 객체로 변환해줄 수 있다.
    // JsonNode 타입 객체를 알면, .path 메서드를 통해 쉽게 JSON 데이터에 접근할 수 있게된다.
    ObjectMapper objectMapper = new ObjectMapper();
    JsonNode rootNode = objectMapper.readTree(responseBody);
    JsonNode responseNode = rootNode.path("response");
    // 2-2) 포트원이 나에게 보내준 HTTP응답메세지 중 얻고 싶었던 데이터인 amount 와 merchantUid 를 얻었다. 얻어오는 과정을 보니, JsonNode 타입 객체는 JSON 데이터를 계층적으로 구분해 놓는다고 예상됨.
    amount = responseNode.path("amount").asInt();
    merchantUid = responseNode.path("merchant_uid").asText();

 

그 다음에 아래 코드처럼 얻어낸 merchantUid를 가지고 ORDERS 테이블에서 그 merchantUid 값을 가진 행의 amount(가격)을 찾아왔다. 즉, 해당 주문건에서 결제됬어야 할 금액을 찾아온 것이다. 

// 3. 내 데이터베이스에서 파싱해서 얻은 정보 중 merchantUid 와 동일한 merchant_uid 컬럼값을 지닌 행의 amount 를 조회
// SELECT amount FROM orders
// WHERE merchant_uid = #{merchantUid}
findAmount = ordersMapper.findAmountByMerchantUid(merchantUid);

 

두 값("ORDERS 테이블에 저장된 해당 주문건에서 결제됬어야 할 금액" 과 "포트원서버에서 결제되었다고 전달해준 amount 값")이 일치한다면, ORDERS 테이블에서 해당행의 status 컬럼값을 Paid 로 바꾸도록 해주었다. 결제되었다는 의미이다. 

그리고 일치하지 않는 경우, 즉 결제됬어야 할 금액과 실제로 결제한 금액이 일치하지 않는다면, PaymetnVerificationException 이라는 예외가 터지도록 하였다. 

// 4. 사후검증 판단
if (amount == findAmount) {
    // 4-1) 일치하는 경우
    //  ORDERS 테이블의 status 컬럼값을 Paid 로 바꾸어주면 된다. 이때, Paid 의 의미는 이니시스에서의 결제는 성공적으로 처리되었으며, 사후검증도 통과했다는 의미로 정의한다.
    ordersMapper.updateStatusByMerchantUid(merchantUid,"Paid");
    //  PAYMENTS 테이블의 status 컬럼값을 바꾸어주어야 하는지에 대하여 생각해봤다. 결론 : 건들지 않는다.
    // 왜? payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과

} else {
    // 4-2) 일치하지 않는 경우
    // 예외를 터뜨린다. 내가 만든 RuntimeException 인 PaymentVerificationException 을 터뜨려 줄 것이다.
    throw new PaymentVerificationException("SinglePaymentQueryService : 결제 검증 실패 - 결제금액 불일치");
}

PaymetnVerificationException 예외는 내가 만든 예외클래스로 만든 예외객체이다.

아래와 같이 생겼다. 

public class PaymentVerificationException extends RuntimeException {
    private Integer portOneAmount; // 실제 결제한 금액
    private Integer myDbAmount; // 결제했어야 할 db 내 금액
    private Integer priceDifference; // 둘의 가격차이
    public PaymentVerificationException() {
    }
    public PaymentVerificationException(String message) {
        super(message);
    }

    public PaymentVerificationException(Integer portOneAmount, Integer myDbAmount, Integer priceDifference) {
        this.portOneAmount = portOneAmount;
        this.myDbAmount = myDbAmount;
        this.priceDifference = priceDifference;
    }

    //getter
    public Integer getPortOneAmount() {
        return portOneAmount;
    }

    public Integer getMyDbAmount() {
        return myDbAmount;
    }

    public Integer getPriceDifference() {
        return priceDifference;
    }
}

 

catch 문은 아래와 같이 2개가 있다. 

일단, PaymentVerificationException 이 발생했다는 건, 결제됬어야 할 금액과 실제로 결제된 금액이 일치하지 않아 사후검증에 걸린경우를 의미한다.

일단, 로그를 찍어줬다. 

그리고     Integer priceDifference = findAmount - amount; 

이렇게 두 값을 뺏다. 

그리고, 이 값이 양수라면 사용자가 덜 지불한 경우이므로, ORDERS 테이블에 해당 행의 status 컬럼값으로POST_VALIDATION_FAILED_MINUS 라고 넣었다. 

그리고선, ORDERS 테이블의 해당 행의 fail_reason 컬럼값을 예를 들어, "최재준 고객님이 2000원 더 결제하셔야 합니다." 이런식으로 삽입되도록 하였다. 

그리고선, PaymentVerificationException 을 한번 더 발생시켰는데, 이 인스턴스를 만들 때에, 파라미터로 실제로 결제된 가격(amount) , 결제될거라고 저장해뒀던 가격(findAmount), 두 가격의 차이(priceDifference) 를 넘겨줬다. 

priceDifference 값이 음수라면 사용자가 더 지불한 경우이므로, ORDERS 테이블의 해당 행의 status 값을 POST_VALIDATION_FAILED_PLUS 로 두었고, ORDERS 테이블의 fail_reason 컬럼값을 업데이트 시켜주었다. 

그리고선, PaymentVerificationException 타입 객체를 다시 발생시켜주면서 amount, findAmount, priceDifference 를 담아주었다.

JsonProcessingException 은 ObjectMapper 타입 객체에 대고 .readTree 메서드를 호출하게 되면 발생할 수 있는 체크예외라서 잡고 다시 발생시켜줌. 

} catch (PaymentVerificationException e) {
    // (사후검증에 통과하지 못한 경우) == (결제됬어야 하는 금액과 실제 결제된 금액이 불일치 하는 경우)
    log.error("SinglePaymentQueryService : 사후검증 중 결제금액 불일치", e);

    Integer priceDifference = findAmount - amount;

    if(priceDifference >0){
        // 사용자가 덜 지불한 경우
        ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_MINUS");
        // payments 테이블에도 status 를 fail 로 해둬야할까?
        // payments 테이블의 status 컬럼값과 fail_reason 컬럼값을 포함한 모든 컬럼값은 결제단건조회한 결과 그대로를 저장해두는 것이 옳다고 생각한다. 즉, payments 테이블 = 포트원 결제단건조회 결과

        ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이 " + priceDifference + "원 더 결제하셔야 합니다.");
        throw new PaymentVerificationException(amount, findAmount, priceDifference);
    } else if (priceDifference < 0) {
        // 사용자가 더 지불한 경우
        ordersMapper.updateStatusByMerchantUid(merchantUid, "POST_VALIDATION_FAILED_PLUS");
        ordersMapper.updateFailReasonByMerchantUid(merchantUid, "고객님이" + priceDifference + "원 더 결제하셨습니다.");
        throw new PaymentVerificationException(amount, findAmount, priceDifference);
    }

} catch (JsonProcessingException e) {
    // JsonNode rootNode = objectMapper.readTree(responseBody); 이거 하다가 발생할 수 있는 예외를 처리해줘야 함.
    // 컨트롤러로 다시 예외를 발생시키도록 했음. 왜? 이렇게 잡고 다시 던지면, 로그를 남길 수 있잖아. 그래서, throws 로 던질수도 있었는데, 그렇게 안함.
    log.error("SinglePaymentQueryService : JSON 파싱 중 오류 발생", e);
    throw e;
}

 

 

그럼 다시 아래 컨트롤러로 리턴될 것이다. 

    @PostMapping("/payment-response")
    @ResponseBody
    public String paymentResponeControllerMethod(@RequestBody PaymentDTO paymentDTO, HttpServletRequest request) {

    // 1. orders 테이블에 결제건에 해당하는 행의 status 컬럼을 Processing 이라고 바꿔줘야함.
        // 여기서 Processing 이란, 주문에 대한 결제를 처리 중이다. 라는 뜻이다. 아직 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)을 하기 전이니, 결제를 처리 중인게 맞지.
        ordersMapper.updateStatusByMerchantUid(paymentDTO.getMerchant_uid(),"Processing");

    // 2. 사후검증 시작
        try {
            // 2-1) Imp_uid 와 merchant_uid 를 꺼내오기.
            String impUid = paymentDTO.getImp_uid();
            String merchantUid = paymentDTO.getMerchant_uid();

            // 2-2) access 토큰 얻어오기
            TokenResponseDTO tokenDTO = accessToken.getAccessToken();

            // 2-3) 얻은 토큰으로 결제단건조회를 함.
            Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
            ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);

            // 2-4) 일단, payments 테이블에 행을 삽입한다. 왜? 사후검증(결제됬어야 할 금액과 실제로 결제한 금액이 같은지 여부를 검증)여부와는 무관하게, 일단은 결제가 됬으니까.
                // ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
                String responseBody = response.getBody();
                // ㄴ. INSERT INTO PAYMENTS
                insertPaymentsRowService.insertRow(memberId, responseBody);

            // 2-5) 사후검증
            postValidationService.postValidation(response);


            //판매자들을 인도하는 페이지에서, 판매자들이 택배회사와 송장번호를 입력하면 그걸 받는 컨트롤러에서 orders 테이블의 status 컬럼값을 Shipped 로 변경하도록 해주면 됨.
            //그리고 고객들이 보는 페이지에서는 payments 테이블의 status 컬럼이 아니라, orders 테이블의 status 컬럼값에 따라서, 표시가 되도록 하면 됨.
            return "success";

        } catch (PaymentVerificationException e) {
        //사후검증에 실패한 경우 :
            // orders 테이블의 status, fail_reason 컬럼을 실패했다고 업데이트 해주는 것은 SinglePaymentQueryService 에서 해줬기 때문에 그 과정 생략.

            Integer portOneAmount = e.getPortOneAmount();
            Integer myDbAmount = e.getMyDbAmount();
            Integer priceDifference = e.getPriceDifference();

            if (priceDifference > 0) {
                //덜 결제된 경우
                return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되어야 합니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다. " ;
            } else{
                //더 결제된 경우 => 정상처리로 보고 정상처리를 해줘야지.
                return "결제했어야 할 금액 : " + myDbAmount + ", 결제된 금액 : " + portOneAmount + ", " + priceDifference + "가 더 결제되었습니다. 최대한 빠른 시일 내에 당사 직원이 연락드리겠습니다." ;
            }
        } catch (JsonProcessingException e) {
            //JSON 파싱에 실패한 경우
            return "결제 중 오류가 발생되었습니다. JsonParsingException";
        } catch(Exception e){
            return "결제 중 오류가 발생되었습니다. Exception";
        }
    }

만약, 사전검증에 통과한 경우, "success" 라는 문자열이 리턴되도록 했고, 

사전검증에 걸린 경우, 더 결제됬어야 했다면 얼마나 더 결제됬어야 하는지를 문자열로 리턴해줬다. 

사전검증에 걸린 경우, 덜 결제됬어야 했다면 얼마나 덜 결제됬어야 했는지를 문자열로 리턴해줬다. 

 

그럼 다시 orderSheet.jsp 파일로 가보자.

<head>
      <link rel="stylesheet" href="../../css/payment/orderSheet.css">
      <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
      <script src="https://cdn.iamport.kr/v1/iamport.js"></script>
      <script>
      document.addEventListener("DOMContentLoaded", function() {

          var IMP = window.IMP;
          IMP.init("imp46526078");

          // 폼 선택
          const form = document.getElementById('order-form');

          form.addEventListener('submit', function(event) { //submit(제출) 되면 이벤트 발생
              event.preventDefault(); // 폼의 기본 제출 동작을 방지

              // FormData 객체 생성
              const formData = new FormData(form);

              // fetch API를 사용하여 폼 데이터를 서버로 비동기적으로 전송
              fetch('/order-process', {
                  method: 'POST',
                  body: formData, // 폼 데이터. , 끝에 붙이는 거 문제 안된다고 함. 오히려 개발자들이 선호한다고 함.
              })
              .then(response => {
                  if (!response.ok) {
                      throw new Error('Network response was not ok');
                  }
                  return response.json(); // 서버로부터 반환된 JSON 응답을 파싱. 이러면 아래 data 여기에 알아서 파싱된 json 이 담김. data.json키 를 쓰면, value 를 얻을 수 있지.
              })
              .then(data => {
                  if(data.result = 'success'){
                        //사전검증 시작
                            fetch('/pre-validation?productId=${productId}&merchantUid='+ data.merchantUid)
                            .then(response => {
                                if (!response.ok) {
                                  throw new Error('Network response was not ok');
                                }
                                return response.json();
                            })
                            .then(data => {
                                //사전검증이 끝나면 할 것들 : 포트원 api 통신
                                //일단, 재고부족인 경우 처리
                                if(data.outOfStock == true){
                                    alert('재고가 부족하여 주문이 취소되었습니다.'); // alert창 띄우고,
                                    window.location.href="/payment-home"; // paymentHome.jsp 로 인도하는 컨트롤러로 get요청.
                                }
                                // 재고가 있는 경우,
                                var merchantUid = data.merchantUid;
                                var productName = data.productName;
                                var amount = data.amount;
                                var email = data.memberTotalData.email;
                                var domain = data.memberTotalData.domain;
                                var emailaddress = email + '@' + domain;
                                var userName = data.memberTotalData.userName;
                                var phoneNumStart = data.memberTotalData.phoneNumStart;
                                var phoneNumMiddle = data.memberTotalData.phoneNumMiddle;
                                var phoneNumEnd = data.memberTotalData.phoneNumEnd;
                                var phone = phoneNumStart + phoneNumMiddle + phoneNumEnd;
                                var streetAddress = data.memberTotalData.streetAddress;
                                var zipCode = data.memberTotalData.zipCode;




                                //여기서, portone 시작
                                    IMP.request_pay(

                                              ///////////////////////// 첫번째 ////////////////////////////////
                                              {
                                                pg: "html5_inicis",
                                                pay_method: "card",
                                                merchant_uid: merchantUid,
                                                name: productName,
                                                amount: amount,
                                                buyer_email: emailaddress,
                                                buyer_name: userName,
                                                buyer_tel: phone,
                                                buyer_addr: streetAddress,
                                                buyer_postcode: zipCode,
                                              },

                                              ////////////////////////두번째 //////////////////////////////////
                                              function (rsp) {
                                                    if (rsp.success) {
                                                              // axios로 HTTP 요청
                                                              axios({
                                                                url: "/payment-response", // 실제 서버의 엔드포인트 주소로 수정
                                                                method: "post",
                                                                headers: { "Content-Type": "application/json" },
                                                                data: {
                                                                  imp_uid: rsp.imp_uid,
                                                                  merchant_uid: rsp.merchant_uid
                                                                }
                                                              })

                                                              .then((response) => {
                                                                // 서버 결제 API 성공 시 로직
                                                                if(response = 'success'){ // 성공한 경우
                                                                    alert('결제가 성공하였습니다.');
                                                                    window.location.href="/payment-home";


                                                                } else {
                                                                    alert(response); // 그 외의 경우
                                                                    window.location.href="/payment-home";
                                                                }
                                                              })
                                                              .catch((error) => {
                                                                console.error(error);
                                                              });
                                                    } else {
                                                            alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
                                                            //결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
                                                            window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
                                                    }
                                              }


                                    );

                                    //여기서, portone 끝



                            })
                            .catch(error => {
                                console.error('There has been a problem with your fetch operation:', error)
                                alert('결제 중 오류가 발생했습니다.');
                            });


                        //사전검증 끝


                  }

              })
              .catch(error => {
                  console.error('There has been a problem with your fetch operation:',
                                error);
              });

      		});

      });
      </script>


</head>

위 코드 중 현재 어디에 있냐면, 아래 부분으로 온다.

              .then((response) => {
                // 서버 결제 API 성공 시 로직
                if(response = 'success'){ // 성공한 경우
                    alert('결제가 성공하였습니다.');
                    window.location.href="/payment-home";


                } else {
                    alert(response); // 그 외의 경우
                    window.location.href="/payment-home";
                }
              })
              .catch((error) => {
                console.error(error);
              });

온 문자열이 success 라면, '결제가 성공하였습니다.' 라는 alert 창을 띄워주고, /payment-home 으로 get 요청하고 있다.

온 문자열이 success 가 아니면, 온 문자열을 그대로 alert 창으로 띄워주면 되겠지.  그리고, /payment-home 으로 get요청하고 있다. 

참고로, /payment-home 이라는 url 로 get요청 보내면, 

위페이지가 렌더링되도록 되어있다. 

 

그 다음은 아래와 같은 코드가 있었다. 

    } else {
            alert('결제에 실패하였습니다. 에러 내용 : ' + rsp.error_msg);
            //결제에 실패한 경우, 재고 +1 해줘야 하고, orders테이블에서 행 삭제
            window.location.href="/payment-fail?merchantUid=" + merchantUid + "&impUid=" + rsp.imp_uid;
    }

구매자가 포트원 결제창을 그냥 닫았다거나 해서 결제가 진행되지 않았을 때, 포트원 서버가 보내준 error_msg 를 alert창으로 보여주는 것이다.

그리고 나서, /payment-fail 이라는 url 로 get요청을 보내게 된다. /payment-fail 이라는 url 을 매핑하는 컨트롤러는 아래와 같다. 

    @RequestMapping("/payment-fail")
    public String paymentFail(@RequestParam String merchantUid, HttpServletRequest request, @RequestParam String impUid){
        // 1. 결제자체가 실패한 경우이므로, 사전검증에서 감소시켰던 재고를 +1 해줘야 한다.
            // 1-1) orders 테이블에서 해당 행의 productId 를 찾아라.
            Integer productId = ordersMapper.findProductIdByMerchantUid(merchantUid);
            // 1-2) 그 productId 를 가진 상품의 stock 컬럼을 1 증가시키자.
            productsMapper.stockCountPlusByProductId(String.valueOf(productId), 1);

        // 2. orders 테이블의 status 를 PAYMENT_FAILED 로 처리한다.
            // orders 테이블에 해당 merchantUid 를 가진 행의 status컬럼값이 PAYMENT_FAILED 이면 결제자체가 실패 됬다는걸로 알면 됨.
            ordersMapper.updateStatusByMerchantUid(merchantUid, "PAYMENT_FAILED");

        // 3. payments 테이블의 행을 넣어준다.
            // 결제가 성공했든, 결제가 (잔액부족, 카드정보오류등으로) 실패했든, 결제단건조회를 해서, 포트원으로부터 받은 정보 그대로를 payments 테이블에 저장해두도록 한다.
            // 즉, payments 테이블 = 포트원 결제단건조회 결과

            // 3-1) access 토큰 얻어오기
            TokenResponseDTO tokenDTO = accessToken.getAccessToken();

            // 3-2) 얻은 토큰으로 결제단건조회를 함.
            Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
            ResponseEntity<String> response = singlePaymentQueryService.singlePaymentQuery(impUid, tokenDTO);

            // 3-3) payments 테이블에 행을 삽입한다.
                // ㄱ. 포트원으로부터 온 HTTP응답의 body 가져오기
                String responseBody = response.getBody();
                // ㄴ. INSERT INTO PAYMENTS
                insertPaymentsRowService.insertRow(memberId, responseBody);

        return "/payment/paymentHome";
    }

 

끝. 이제 orderSheet.jsp 파일은 끝났다. 

 

근데, 이제 웹훅이 남았다. 다음 포스팅으로..