• 작성일 : 2022.05.26
  • 작성자 : 황유진

  • 팀원 : 김진영, 박승지, 반현빈, 오성은, 오은현, 황유진
  • 팀장 : 황유진
  • 부팀장 : 오성은
  • GitHub Repository : https://github.com/miro7923/Uno-Mas


개발환경

  • MacBook Air (M1, 2020)
  • OpenJDK 8
  • Spring Tool Suite 4.14.0
  • Spring framework 4.3.1.RELEASE
  • Tomcat 8.5
  • MySQL Workbench 8.0.19


기간

  • 2022.4.13 ~ 2022.5.27


주제

  • 웹 백엔드 수업 중 마지막 과제로 팀 프로젝트를 진행하게 되었다.
  • 조건은 Spring 기반으로 웹 사이트를 제작하는 것이다.
  • 총 팀원은 6명이며, 우리 팀은 1인 가구를 위한 쇼핑몰을 주제로 정했다.
  • 팀 이름으로 정해진 Uno más는 스페인어로 하나 더라는 뜻이다.


진행상황

pom.xml

<properties>
    <java-version>1.8</java-version>
    <org.springframework-version>4.3.8.RELEASE</org.springframework-version>
    <org.aspectj-version>1.6.10</org.aspectj-version>
    <org.slf4j-version>1.6.6</org.slf4j-version>
</properties>

<!-- 상단 properties 아래에 추가 -->
<repositories>
  <repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
  </repository>
</repositories>

...

<!-- dependencies 안에 추가 -->
<dependency>
  <groupId>com.github.iamport</groupId>
  <artifactId>iamport-rest-client-java</artifactId>
  <version>0.2.14</version>
</dependency>
  • 시작 전에 결제 정보 검증을 위해 아임포트에서 제공하는 API를 사용할 수 있도록 의존성을 추가해 준다.

order.js

var IMP = window.IMP; // 생략 가능

$(document).ready(function() {
    ...
	
    IMP.init("가맹점식별코드"); // 예: imp00000000
});
  • KG이니시스 테스트모드로 진행할 것이다.
  • 나는 JSP 페이지와 JS 파일을 따로 분리했기 때문에 API 호출을 위한 코드를 작성했다.

  • 전체 코드
function requestPay() {
    // 수령자 배송지 설정 
    var addr = '';
    var postalcode = '';
    if ($('input:radio[name=deliverSpot]:checked').val() == 1) {
        // 기본배송지 사용
        addr = $('#roadAddr').val() + ' ' + $('#detailAddr').val();
        postalcode = $('#postalcode').val();
    }
    else {
        // 신규배송지 사용
        addr = $('#newRoadAddress').val() + ' ' + $('#newDetailAddress').val();
        postalcode = $('#newPostalcode').val();
    }
	
    var uid = '';
    // IMP.request_pay(param, callback) 결제창 호출
    IMP.request_pay({ // param
        pg: "html5_inicis",
        pay_method: "card",
        merchant_uid: $('#orderCode').val(),
        name: $('#prodName0').val(),
        amount: $('#total').val(),
        buyer_email: $('#userEmail').text(),
        buyer_name: $('#userName').text(),
        buyer_tel: $('#phone').val(),
        buyer_addr: addr,
        buyer_postcode: postalcode
    }, function (rsp) { // callback
        if (rsp.success) { // 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우
            uid = rsp.imp_uid;
            // 결제검증
            $.ajax({
                url: '/order/verify_iamport/' + rsp.imp_uid,
                type: 'post'
            }).done(function(data) {
                // 결제를 요청했던 금액과 실제 결제된 금액이 같으면 해당 주문건의 결제가 정상적으로 완료된 것으로 간주한다.
                if ($('#total').val() == data.response.amount) {
                    // jQuery로 HTTP 요청
                    // 주문정보 생성 및 테이블에 저장 
                    // @@ 주문정보는 상품 개수만큼 생성되어야 해서 상품 개수만큼 반복문을 돌린다
                    // 이때 order code는 모두 같아야 한다.
                    var roadAddr = '';
                    var detailAddr = '';
                    var recipient = '';
                    if ($('input:radio[name=deliverSpot]:checked').val() == 1) {
                        roadAddr = $('#roadAddr').val();
                        detailAddr = $('#detailAddr').val();
                        recipient = $('#name').val();
                    }
                    else {
                        roadAddr = $('#newRoadAddress').val();
                        detailAddr = $('#newDetailAddress').val();
                        recipient = $('#newName').val();
                    }
		        	
                    for (var i = 0; i < $('#prodCnt').val(); i++) {
                        // 데이터를 json으로 보내기 위해 바꿔준다.
                        var orderVO = JSON.stringify({
                            order_code: $('#orderCode').val(),
                            cart_num: $('#cartNum'+i).val(),
                            order_postalcode: postalcode,
                            order_roadaddr: roadAddr,
                            order_detailaddr: detailAddr,
                            user_num: $('#userNum').val(),
                            prod_num: $('#prodNum'+i).val(),
                            order_quantity: $('#prodQuantity'+i).val(),
                            order_total: $('#orderTotal'+i).val(),
                            user_point: $('#userPoint').val(),
                            order_recipient: recipient,
                            order_memo: $('#ask').val()
                        });
					
                        jQuery.ajax({
                            url: "/order/complete", // 예: https://www.myservice.com/payments/complete
                            type: "POST",
                            dataType: 'json',
                            contentType: 'application/json',
                            data: orderVO
                        }).done(function(data) {
                            if (data == 1) {
                                console.log(data);
                                alert('주문정보 저장 성공');
                            }
                            else {
                                console.log(data);
                                alert('주문정보 저장 실패');
                            }
                        })
                    }
                    createPayInfo(uid);
                }
                else {
                    alert('결제 실패');
                }
            })
            } else {
                alert("결제에 실패하였습니다. 에러 내용: " +  rsp.error_msg);
            }
        });
}

function createPayInfo(uid) {
    // 결제정보 생성 및 테이블 저장 후 결제완료 페이지로 이동 
    $.ajax({
        type: 'get',
        url: '/order/pay_info',
        data: {
            'imp_uid': uid,
            'amount': $('#total').val(),
            'ship': $('#shippingFee').val(),
            'point': $('#userPoint').val()
        },
        success: function(data) {
            alert('결제가 완료 되었습니다.');
            // 결제완료 페이지로 이동
            location.replace('/order/complete?pay_num='+data);
        },
        error: function() {
            alert('결제정보 저장 통신 실패');
        }
    });
}
  • 결제 부분
 var uid = '';
    // IMP.request_pay(param, callback) 결제창 호출
    IMP.request_pay({ // param
        pg: "html5_inicis",
        pay_method: "card",
        merchant_uid: $('#orderCode').val(),
        name: $('#prodName0').val(),
        amount: $('#total').val(),
        buyer_email: $('#userEmail').text(),
        buyer_name: $('#userName').text(),
        buyer_tel: $('#phone').val(),
        buyer_addr: addr,
        buyer_postcode: postalcode
    }, function (rsp) { // callback
        if (rsp.success) { // 결제 성공 시: 결제 승인 또는 가상계좌 발급에 성공한 경우
            uid = rsp.imp_uid;
            // 결제검증
            $.ajax({
                url: '/order/verify_iamport/' + rsp.imp_uid,
                type: 'post'
            }).done(function(data) {
                ...
}
  • 먼저 결제 정보를 아임포트 서버로 보내 결제를 완료한다.
  • 결제가 완료되면 결제 정보를 담은 json 데이터가 리턴된다. 여기서 결제건의 인덱스 번호인 imp_uid를 이용해 정말 정상적으로 결제가 된 것인지 검증을 하기 위해 이동한다.

아임포트 결제모듈 테스트

  • 아임포트 결제모듈 테스트 ver.JAVA

  • 시작 전에 아임포트사에서 제공하는 테스트 코드를 따라 써 보면서 테스트를 진행했다. API에서 제공되는 객체와 메서드들이 어떤 것들이 있는지 살펴보기 좋았다. 전체적으로 개발자에게 아주 친절한 느낌!

OrderController.java

private IamportClient client = new IamportClient("REST API", "REST API Secret");

@ResponseBody
@RequestMapping(value = "/verify_iamport/{imp_uid}", method = RequestMethod.POST)
public IamportResponse<Payment> verifyIamportPOST(@PathVariable(value = "imp_uid") String imp_uid) throws IamportResponseException, IOException {
    return client.paymentByImpUid(imp_uid);
}
  • 아까 리턴받은 결제 번호를 이용해 서버에 해당 결제 정보가 있는지 확인한 뒤 가져온다.
  • 아임포트 서버에 접속해서 정보를 가져오기 위해서는 그냥은 안 되고 인증에 필요한 key 정보가 있어야 하기 때문에 내 계정에서 key 정보를 가져와서 넣어준다.
  • iamport REST Client for JAVA 아임포트사에서 제공하는 공식 문서를 참고해서 진행했다. 정리가 잘 되어 있어 보기 편했다.

order.js

// 결제검증
$.ajax({
    url: '/order/verify_iamport/' + rsp.imp_uid,
    type: 'post'
}).done(function(data) {
    // 결제를 요청했던 금액과 실제 결제된 금액이 같으면 해당 주문건의 결제가 정상적으로 완료된 것으로 간주한다.
    if ($('#total').val() == data.response.amount) {
        // jQuery로 HTTP 요청
        // 주문정보 생성 및 테이블에 저장 
        // @@ 주문정보는 상품 개수만큼 생성되어야 해서 상품 개수만큼 반복문을 돌린다
        // 이때 order code는 모두 같아야 한다.
        
        // 이번 주문의 배송지 설정 
        var roadAddr = '';
        var detailAddr = '';
        var recipient = '';
        
        // 기본 배송지로 설정된 경우
        if ($('input:radio[name=deliverSpot]:checked').val() == 1) {
            roadAddr = $('#roadAddr').val();
            detailAddr = $('#detailAddr').val();
            recipient = $('#name').val();
        }
        // 신규 배송지로 설정된 경우 
        else {
            roadAddr = $('#newRoadAddress').val();
            detailAddr = $('#newDetailAddress').val();
            recipient = $('#newName').val();
        }
        
        for (var i = 0; i < $('#prodCnt').val(); i++) {
            // 데이터를 json으로 보내기 위해 바꿔준다.
            var orderVO = JSON.stringify({
                order_code: $('#orderCode').val(),
                cart_num: $('#cartNum'+i).val(),
                order_postalcode: postalcode,
                order_roadaddr: roadAddr,
                order_detailaddr: detailAddr,
                user_num: $('#userNum').val(),
                prod_num: $('#prodNum'+i).val(),
                order_quantity: $('#prodQuantity'+i).val(),
                order_total: $('#orderTotal'+i).val(),
                user_point: $('#userPoint').val(),
                order_recipient: recipient,
                order_memo: $('#ask').val()
            });
        
            jQuery.ajax({
                url: "/order/complete", // 예: https://www.myservice.com/payments/complete
                type: "POST",
                dataType: 'json',
                contentType: 'application/json',
                data: orderVO
            }).done(function(data) {
                if (data == 1) {
                    console.log(data);
                    alert('주문정보 저장 성공');
                }
                else {
                    console.log(data);
                    alert('주문정보 저장 실패');
                }
            })
        }
        createPayInfo(uid);
    }
    else {
        alert('결제 실패');
    }
  • 결제 검증이 완료되면 주문 정보를 생성해서 DB 테이블에 저장해야 한다.
  • 우리 프로젝트에는 주문정보를 저장하는 테이블과 결제정보를 저장하는 테이블 두 개가 존재한다. 두 개를 따로 둔 이유는 하나의 결제건에 대해서 어떤 상품들을 주문했는지를 알 수 있어야 하기 때문이다.
  • 만약 결제테이블 하나만 써서 고객의 결제정보를 관리한다면 DB 테이블의 로우 하나에는 상품정보 하나만 저장할 수 있다. 하지만 쇼핑몰에서 고객은 항상 하나의 상품만 주문하지 않고 여러 개의 상품을 주문할 수 있다. 이 때 각 상품을 어떤 옵션으로 몇 개를 주문했는지도 알고 있어야 그걸 참고해서 상품을 배송할 수 있고 고객 문의를 처리하거나 고객에게 결제건에 대한 정보를 제공할 때 사용할 수 있다.
  • 그래서 주문테이블을 따로 두어서 하나의 결제건에서 다수의 상품이 주문되었을 경우 각 상품 정보를 저장할 수 있도록 했다. 이때 PK는 주문번호와 상품번호를 복합키로 사용했다. 주문테이블의 데이터는 고유한 PK가 있어야 각 주문건을 구별할 수 있는데 주문번호만 PK로 두면 주문번호 하나에 상품정보 하나만 들어갈 수 있기 때문에 주문테이블과 결제테이블을 따로 사용하는 의미가 없어진다.

  • 그런데 주문번호와 상품번호를 복합키로 사용하면 어떤 주문번호로 그에 해당되는 상품정보를 모두 불러올 수 있고 여러개의 주문번호도 등록할 수 있다. 그리고 각 상품을 몇 개 주문했는지에 대한 정보도 저장할 수 있다.
  • 그렇기 때문에 주문정보를 DB 테이블에 저장하기 위해서는 이번 결제건에서 주문된 상품 종류의 개수만큼 반복문을 돌려 주문정보를 생성해야 한다.
@ResponseBody
@RequestMapping(value = "/complete", method = RequestMethod.POST)
public ResponseEntity<Integer> completePOST(@RequestBody OrderVO vo) throws Exception {
    // 결제 완료된 주문정보 DB에 저장
    int result = orderService.createOrder(vo);

    if (result == 0) {
        log.info("@@@@@@@@@@@ 주문정보 저장 실패");
        return new ResponseEntity<Integer>(0, HttpStatus.NOT_ACCEPTABLE);
    }

    log.info("@@@@@@@@@@@@ 주문정보 저장 성공");
    return new ResponseEntity<Integer>(1, HttpStatus.CREATED);
}
for (var i = 0; i < $('#prodCnt').val(); i++) {
    // 데이터를 json으로 보내기 위해 바꿔준다.
    var orderVO = JSON.stringify({
        order_code: $('#orderCode').val(),
        cart_num: $('#cartNum'+i).val(),
        order_postalcode: postalcode,
        order_roadaddr: roadAddr,
        order_detailaddr: detailAddr,
        user_num: $('#userNum').val(),
        prod_num: $('#prodNum'+i).val(),
        order_quantity: $('#prodQuantity'+i).val(),
        order_total: $('#orderTotal'+i).val(),
        user_point: $('#userPoint').val(),
        order_recipient: recipient,
        order_memo: $('#ask').val()
    });

    jQuery.ajax({
        url: "/order/complete", // 예: https://www.myservice.com/payments/complete
        type: "POST",
        dataType: 'json',
        contentType: 'application/json',
        data: orderVO
    }).done(function(data) {
        if (data == 1) {
            console.log(data);
            alert('주문정보 저장 성공');
        }
        else {
            console.log(data);
            alert('주문정보 저장 실패');
        }
    })
}

createPayInfo(uid);
  • 주문정보를 모두 생성하고 나면 결제 정보를 생성하기 위한 메서드 createPayInfo(uid)를 호출한다.
function createPayInfo(uid) {
    // 결제정보 생성 및 테이블 저장 후 결제완료 페이지로 이동 
    $.ajax({
        type: 'get',
        url: '/order/pay_info',
        data: {
            'imp_uid': uid,
            'amount': $('#total').val(),
            'ship': $('#shippingFee').val(),
            'point': $('#userPoint').val()
        },
        success: function(data) {
            alert('결제가 완료 되었습니다.');
            // 결제완료 페이지로 이동
            location.replace('/order/complete?pay_num='+data);
        },
        error: function() {
            alert('결제정보 저장 통신 실패');
        }
    });
}
  • 결제 정보 생성을 위해 필요한 정보를 추가적으로 담은 후 해당 url로 이동한다.
@RequestMapping(value = "/pay_info", method = RequestMethod.GET)
public ResponseEntity<Integer> payInfoPOST(Model model,
        HttpServletRequest request, HttpServletResponse response,
        @RequestParam String imp_uid, @RequestParam int amount,
        @RequestParam int ship, @RequestParam double point, HttpSession session) throws Exception {
    
    IamportResponse<Payment> result = client.paymentByImpUid(imp_uid);
        
    // 결제 정보 저장
    PayVO payVO = new PayVO();
    payVO.setOrder_code(Integer.parseInt(result.getResponse().getMerchantUid()));
    payVO.setPay_card_company(result.getResponse().getCardName());
    payVO.setPay_card_num(result.getResponse().getCardNumber());
    payVO.setPay_installment(result.getResponse().getCardQuota());
    payVO.setPay_method(result.getResponse().getPayMethod());
    payVO.setPay_num(Integer.parseInt(result.getResponse().getMerchantUid()));
    payVO.setPay_shippingfee(ship);
    payVO.setPay_total_price(result.getResponse().getAmount().intValue());
    payVO.setUser_num((int) session.getAttribute("saveNUM"));
    
    orderService.createPay(payVO);
    
    // 방금 생성된 결제 정보의 인덱스 번호 가져옴 
    payVO = orderService.getLastPay();
    model.addAttribute("payVO", payVO);
    
    // 회원 적립금 추가
    userService.updatePoint((int)session.getAttribute("saveNUM"), (int)point);
    
    // 적립금 테이블에 내역 추가
    PointVO pointVO = new PointVO();
    pointVO.setOrder_code(payVO.getOrder_code());
    pointVO.setPoint_content("결제");
    pointVO.setPoint_cost((int)point);
    pointVO.setUser_num((int)session.getAttribute("saveNUM"));
    
    orderService.createPointInfo(pointVO);
    
    return new ResponseEntity<Integer>(payVO.getPay_num(), HttpStatus.OK);
}
  • 결제 정보는 아임포트 서버에서 불러와 생성한다.
  • 결제 번호 컬럼에 Auto increasement 속성을 걸어 놓아서 테이블에 데이터가 삽입될 때 결제 번호가 결정된다. 결제 완료 화면에 결저 번호도 뿌려줄 것이라 결제 정보를 저장한 뒤 해당 정보를 다시 가져와서 model 객체에 저장하도록 했다.
  • 결제 정보 저장이 완료되면 결제번호 정보를 리턴한다.

  • 여기까지 하면 모든 결제 프로세스가 완료되고 결제완료 페이지로 이동한다.


구현하며 했던 고민

for (var i = 0; i < $('#prodCnt').val(); i++) {
    // 데이터를 json으로 보내기 위해 바꿔준다.
    var orderVO = JSON.stringify({
        order_code: $('#orderCode').val(),
        cart_num: $('#cartNum'+i).val(),
        order_postalcode: postalcode,
        order_roadaddr: roadAddr,
        order_detailaddr: detailAddr,
        user_num: $('#userNum').val(),
        prod_num: $('#prodNum'+i).val(),
        order_quantity: $('#prodQuantity'+i).val(),
        order_total: $('#orderTotal'+i).val(),
        user_point: $('#userPoint').val(),
        order_recipient: recipient,
        order_memo: $('#ask').val()
    });

    jQuery.ajax({
        url: "/order/complete", // 예: https://www.myservice.com/payments/complete
        type: "POST",
        dataType: 'json',
        contentType: 'application/json',
        data: orderVO,
        success: function(data) {
            alert(data);
        },
        error: function() {
            alert('주문정보 생성 실패!');
        }
    });
}
  • 처음에는 requestPay() 메서드의 주문정보를 생성하는 부분을 이런식으로 작성해서 통신의 성공 유무를 확인하려 했다. 그런데 통신에 실패해서 주문정보 생성에 실패했다는 메시지가 출력되는데 주문정보는 내가 의도한 대로 생성이 잘 되는… 결과적으로 동작에는 아무 문제가 없는 상황이 발생되었다. 원인이 궁금해서 정말 여러 가지 방법으로 테스트를 해 보았지만 주문 정보 생성은 정말정말 잘 되는데 에러 데이터는 계속 리턴되는 희안한 상황이 계속되었다. 아직까지도 원인을 못 찾음…
  • 프로젝트 진행 마감날도 다가오고 일단 전체적인 동작에는 문제가 없기 때문에 ajax 통신의 success 이하 부분은 삭제하고 가기로 했다.

=> 해결!

@ResponseBody
@RequestMapping(value = "/complete", method = RequestMethod.POST)
public ResponseEntity<String> completePOST(@RequestBody OrderVO vo) throws Exception {
    // 결제 완료된 주문정보 DB에 저장
    orderService.createOrder(vo);

    return new ResponseEntity<String>("주문정보 생성 완료", HttpStatus.OK);
}
  • 처음엔 DB에 데이터를 삽입하는 동작을 수행하는 컨트롤러 메서드에서 따로 조건 처리를 해주지 않고 그냥 저장한 뒤 String 데이터와 함께 항상 200을 리턴하도록 했다. 그런데 이게 문제였던 것 같다.
@ResponseBody
@RequestMapping(value = "/complete", method = RequestMethod.POST)
public ResponseEntity<Integer> completePOST(@RequestBody OrderVO vo) throws Exception {
    // 결제 완료된 주문정보 DB에 저장
    int result = orderService.createOrder(vo);

    if (result == 0) {
        log.info("@@@@@@@@@@@ 주문정보 저장 실패");
        return new ResponseEntity<Integer>(0, HttpStatus.NOT_ACCEPTABLE);
    }

    log.info("@@@@@@@@@@@@ 주문정보 저장 성공");
    return new ResponseEntity<Integer>(1, HttpStatus.CREATED);
}
  • 리턴 데이터를 정수형으로 바꿔주니까 작동이 잘 되었다… 문자열 데이터를 파싱하는 과정에서 문제가 있었던 것일까? 문자열 리턴값을 영어로 썼을 때에도 똑같이 error가 리턴되었기 때문에 한글을 사용한 것이 문제가 되는 것은 아닌 것 같고, json을 이용한 post 통신에서 문자열을 리턴값으로 받아오는 것 자체가 HTTP 상태 코드와 상관없이 error를 리턴시키는 원인인 듯 하다.
jQuery.ajax({
    url: "/order/complete", // 예: https://www.myservice.com/payments/complete
    type: "POST",
    dataType: 'json',
    contentType: 'application/json',
    data: orderVO,
    success: function(data) {
        if (data != 1) {
            alert('주문정보 생성 실패');
        }
    },
    error: function() {
        alert('주문정보 생성 실패');
    }
});
  • 수정한 컨트롤러 메서드에 따라 콜백 받는 부분도 약간 수정해 주었다. 원인을 찾기까지 좀 오래 걸렸지만 새로운 거 하나 배웠다…


마감까지

  • D-1


참고