결제 기능 구현 2 : 사전검증 전 단계 - jsp, 오라클, mybatis
사전 검증 전 단계를 해보자.
위 페이지에서, 3개 있는 저게 상품이다.
결제하기 버튼은 a태그로 만들었다.
<a href="/show-order-sheet?productId=21">결제하기</a>
이런식으로 되어 있는데, 원래는 상품을 판매하는 사람이 판매할 상품을 등록하면, 그 상품의 PRODUCTS 테이블 id 컬럼값이 지금 이 a태그의 href 속성 중 productId 값으로 바인딩되도록 해야겠지만, 현재 그것까지 고려할 수는 없었다. 그래서, 그냥 terminal 에서 insert 쿼리를 PRODUCTS 테이블에 삽입했다.
INSERT INTO PRODUCTS (id, product_name, stock, amount) VALUES (21, '3개월 무료', 1, 1);
어쨋든, 위 a 태그를 클릭하면, 해당 상품의 PRODUCTS 테이블의 id 컬럼값인 21이 컨트롤러로 전송되도록 하였다.
이 get요청을 받는 컨트롤러는 아래와 같다.
private final ShowOrderSheetService showOrderSheetService;
@GetMapping("/show-order-sheet")
public String showOrderSheet(@RequestParam String productId, HttpServletRequest request, Model model) {
String returnString = showOrderSheetService.showOrderSheet(productId, request, model);
if (returnString.equals("outOfStock")) {
return "/payment/paymentHome";
} else{
return "/payment/orderSheet";
}
}
ShowOrderSheetService 클래스의 showOrderSheet() 이라는 메서드를 호출하였다.
@Service
@Slf4j
@RequiredArgsConstructor
//@Transactional <- select 문 만 존재하는 서비스계층이기 때문에 쓰지 않는다.
public class ShowOrderSheetService {
private final ProductsMapper productsMapper;
private final MemberMapper memberMapper;
public String showOrderSheet(String productId, HttpServletRequest request, Model model){
//재고확인
Integer stock = productsMapper.findStock(productId);
if (stock == 0) {
model.addAttribute("outOfStockMessage", "죄송합니다. 상품이 품절되었습니다.");
return "outOfStock";
}
//회원 정보 model에 담아주기
Integer id = FindLoginedMemberIdUtil.findLoginedMember(request);
MemberAllDataFindDTO memberTotalData = memberMapper.findMemberTotalData(String.valueOf(id));
model.addAttribute("memberTotalData", memberTotalData);
//사려는 상품의 <id + 이름 + 가격> 을 model에 담아주기
String productName = productsMapper.findProductNameByProductId(productId);
Integer amount = productsMapper.findAmount(productId);
model.addAttribute("productId", productId);
model.addAttribute("productName", productName);
model.addAttribute("amount", amount);
return "success";
}
}
사용자가 결제하기 버튼을 딱 클릭하면, 가장 먼저 체크해줘야 할 것이, 재고확인이라고 생각했다.
ProductsMapper 의 findStock 메서드를 호출하면 실행되는 쿼리는 아래와 같다.
<select id="findStock" resultType="Integer">
SELECT stock FROM products
WHERE id = #{productId}
</select>
조회된 stock 이 0이라면, outOfStockMessage 라는 키에 "죄송합니다. 상품이 품절되었습니다." 라는 메세지를 담은 다음, outOfStock 이라는 문자열을 리턴한다.
그러면, 리턴되서, showOrderSheet 이라는 메서드 중 아래 부분에 의해 paymentHome.jsp 파일로 제어권이 넘겨질 것이다.
if (returnString.equals("outOfStock")) {
return "/payment/paymentHome";
}
paymentHome.jsp 는 처음에 봤던,
이 페이지이다. 이 페이지에서는
<!-- 재고가 없을 경우에만 띄워질 alert 창 -->
<% if (request.getAttribute("outOfStockMessage") != null) { %>
<script>
// alert 창으로 메시지 띄우기
alert("<%= request.getAttribute("outOfStockMessage") %>");
</script>
<% } %>
<!-- ---------------------------- -->
이런식으로, outOfStockMessage 라는 키에 담긴 게 null 이 아니라면, alert 창을 띄워주도록 하였다.
다시 ShowOrderSheetService 클래스의 showOrderSheet() 이라는 메서드 로 돌아가자.
만약 재고가 0이 아닐 경우에는, 어떻게 할 것인가?
아래와 같이 주문서를 띄워줄 것이다.
그러기 위해서는, 사용자에 대한 정보,
사용자가 구매하려고 클릭한 상품의 PRODUCTS 테이블 "id컬럼값" + "product_name컬럼값" + "amount(가격) 컬럼값" 을 model 에 담아줘야 한다.
그럼 이제, ShowOrderSheetService 클래스의 showOrderSheet() 이라는 메서드 중 아래 부분에 의해 orderSheet.jsp 파일로 제어권이 넘어갈 것이다. orderSheet.jsp 파일이 렌더링 된 모습이 위 이미지이다.
else{
return "/payment/orderSheet";
}
orderSheet.jsp 파일의 전체 코드는 아래와 같다.
<form method="post" id="order-form">
<div id="full-container">
<div id="order-sheet-container">
<div id="left-container">
<div id="page-title">주문서</div>
<div id="buyer-info-title">구매자 정보</div>
<div id="buyer-info-container">
<div id="buyer-info-first">
<span>이름 </span>
<input type="text" name="userName" value="${memberTotalData.userName}" readonly="true">
</div>
<div id="buyer-info-second">
<span>이메일 </span>
<span>${memberTotalData.email} @ ${memberTotalData.domain}</span>
</div>
<div id="buyer-info-third">
<span>휴대폰 번호 </span>
<input type="text" name="phoneNumStart" value="${memberTotalData.phoneNumStart}" class="can-write">
<input type="text" name="phoneNumMiddle" value="${memberTotalData.phoneNumMiddle}" class="can-write">
<input type="text" name="phoneNumEnd" value="${memberTotalData.phoneNumEnd}" class="can-write">
</div>
<div id="buyer-info-fourth">
<input class="address-btn" type="button" onclick="sample4_execDaumPostcode()" value="우편번호 찾기"><br>
</div>
<div id="buyer-info-fifth">
<span>우편번호</span>
<input type="text" name="zipCode" id="sample4_postcode" class="input-tag" placeholder="우편번호" value="${memberTotalData.zipCode}" readonly="true"/>
</div>
<div id="buyer-info-sixth">
<span>도로명 주소</span>
<input type="text" name="streetAddress" id="sample4_roadAddress" class="input-tag" placeholder="도로명주소" style="margin-top:1.5px;" value="${memberTotalData.streetAddress}" readonly="true" />
</div>
<div id="buyer-info-seventh">
<span>지번주소</span>
<input type="text" name="address" id="sample4_jibunAddress" class="input-tag" placeholder="지번주소" value="${memberTotalData.address}"style="margin-top:1.5px;" readonly="true"/>
</div>
<span id="guide" style="color:#999;display:none"></span>
<div id="buyer-info-eightth">
<span>상세주소</span>
<input type="text" name="detailAddress" id="sample4_detailAddress" class="can-write" value="${memberTotalData.detailAddress}" placeholder="상세주소" style="margin-top:1.5px;" class="can-write" />
</div>
<div id="buyer-info-nineth">
<span>참고항목</span>
<input type="text" name="referenceItem" id="sample4_extraAddress" class="input-tag" placeholder="참고항목" value="${memberTotalData.referenceItem}" readonly="true" />
</div>
<div id="product-info-title">상품 정보</div>
<div id="product-info-first">
<span>상품 이름 </span>
<input type="text" name="productName" value="${productName}" readonly="true">
<input type="hidden" name="productId" value="${productId}">
</div>
<div id="pay-info-title">결제 정보</div>
<div id="pay-info-container">
<span>총상품가격 : </span>
<input type="text" name="totalAmount" value="${amount}" readonly="true">
</div>
</div>
</div>
<div id="middle-container"></div>
<div id="right-container">
<button type="submit" id="pay-btn">결제하기</button>
</div>
</div>
</div>
</form>
특별한 건 없다. 서비스계층에서 model 에 담아줬던 구매자에 대한 정보나 상품의 이름 결제가격 등에 대한 걸 표현하고 있다.
주문서에서 전화번호와 배송지는 변경할 수 있도록 해두었다.
이제 결제하기 버튼을 딱 누르면, form 이 전송될 거 아니야?
이제 이 순간부터 orderSheet.jsp 파일 중 head 태그 안에 있는 아래 코드가 실행될 것이다.
<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>
이번 포스팅은 사전 검증 전 까지의 단계를 담을 거라고 했기 때문에, 위 코드 중 아래부분만 다룰 것이다.
document.addEventListener("DOMContentLoaded", function() {
var IMP = window.IMP;
IMP.init("impXXXXXXXXXX");
// 폼 선택
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'){
우선,
var IMP = window.IMP;
IMP.init("impXXXXXXXXXX");
이 부분은 아직 필요 없으니까, 놔둔다.
그리고 그 밑에 보면, 폼이 제출되면, 폼의 기본 제출 동작을 방지한 다음에, /order-process 라는 url 로 폼데이터를 POST 방식으로 넘기고 있다. 이 폼데이터를 받는 컨트롤러는 아래와 같은데, OrderProcessService 클래스의 orderProcessService 메서드를 호출하고, 리턴된 Map자료구조를 그대로 @ResponseBody 로 jsp파일에 돌려주고 있다.
private final OrderProcessService orderProcessService;
@PostMapping("/order-process")
@ResponseBody
public Map<String, String> orderProcess(@ModelAttribute OrderFormDTO orderFormDTO, HttpServletRequest request) {
Map<String, String> returnMap = orderProcessService.orderProcess(orderFormDTO, request);
return returnMap;
}
참고로, OrderFormDTO 는 아래 접은글에 담아두었다.
@Data
public class OrderFormDTO {
private String userName;
private String email;
private String domain;
private String phoneNumStart;
private String phoneNumMiddle;
private String phoneNumEnd;
private String zipCode;
private String streetAddress;
private String address;
private String detailAddress;
private String referenceItem;
private String productName;
private String productId;
private String totalAmount;
}
OrderProcessService 클래스의 orderProcessService 메서드 는 아래와 같다.
여기서, 해준건 뭐냐면, 폼데이터를 가지고, ORDERS 테이블에 행을 삽입하는 것이다.
즉, 주문 행을 삽입하는 코드이다. 결제하기를 누르는 순간, 주문 테이블에 행을 삽입했다.
왜 지금인가? 왜 지금 주문테이블에 행을 삽입했는가?
결제를 하다가 구매자가 그냥 결제창을 닫아버릴 수 있잖아. 또는 결제에 문제가 생길 수도 있잖아.
그럴 경우, 어떤 주문이 어떤 문제로 인해 실패했는지에 대해 담아두려면, 주문 테이블에 행이 존재하고 있어야 하기 때문이다.
@Slf4j
@Service
@RequiredArgsConstructor
//@Transactional 은 쓰지 않는다. 왜? '하나의' insert 문만 있는 서비스계층이기 때문이다.
public class OrderProcessService {
private final OrdersMapper ordersMapper;
public Map<String, String> orderProcess(OrderFormDTO orderFormDTO, HttpServletRequest request){
//orders 테이블에 행을 삽입하기 위한 재료 모음.
// 1. memberId 가져오기
Integer memberId = FindLoginedMemberIdUtil.findLoginedMember(request);
// 2. productId
String productId = orderFormDTO.getProductId();
// 3. merchantUid(주문번호, 이 주문에 부여된 고유한 키로서, 다른 주문과의 구별을 하게 해주는 식별키) 생성
long nano = System.currentTimeMillis();
String merchantUid = "pid-" + nano;
// 4. amount : 해당 주문을 하는 사용자가 지불해야할 총 가격
Integer amount = Integer.parseInt(orderFormDTO.getTotalAmount());
// 5. phoneNum : member 테이블에서 해당 사용자의 핸드폰번호를 가져오지 않았다. 왜? 해당 주문을 한 사용자가 어떤 주문에서는 연락받을 핸드폰번호를 바꾸고 싶어하는 경우도 있을 수 있기 때문에.
String phoneNumStart = orderFormDTO.getPhoneNumStart();
String phoneNumMiddle = orderFormDTO.getPhoneNumMiddle();
String phoneNumEnd = orderFormDTO.getPhoneNumEnd();
// 6. address : member 테이블에서 해당 사용자의 주소를 가져오지 않았다. 왜? 해당 주문을 한 사용자가 어떤 주문은 다른 곳에서 상품을 받고 싶어할 수 있기 때문이다.
String zipCode = orderFormDTO.getZipCode();
String streetAddress = orderFormDTO.getStreetAddress();
String address = orderFormDTO.getAddress();
String detailAddress = orderFormDTO.getDetailAddress();
String referenceItem = orderFormDTO.getReferenceItem();
//orders 테이블에 행을 삽입해야해.
// Pending : 주문이 접수되었지만, 아직 재고확인 & 결제확인 이 되지 않은 상태. 라고 통용되는 단어라고 함.
// 참고로, 현재, 재고확인은 showOrderSheet 라는 메서드에서 하고 있지만, preparePayment 메서드 에서 사전검증에 들어가기 전에 한번 더 할 것이다.
ordersMapper.insertOrder(memberId,
productId,
merchantUid,
amount,
"Pending",
"내용없음",
phoneNumStart,
phoneNumMiddle,
phoneNumEnd,
zipCode,
streetAddress,
address,
detailAddress,
referenceItem);
Map<String, String> returnMap = new ConcurrentHashMap<>();
returnMap.put("result", "success");
// orderSheet.jsp 에 생성된 merchantUid 를 넘겨주는 이유는, orderSheet.jsp 에서 다시 preparePayment메서드에 넘겨주기 위함이다.
returnMap.put("merchantUid", merchantUid);
return returnMap;
}
}
일단,
// 3. merchantUid(주문번호, 이 주문에 부여된 고유한 키로서, 다른 주문과의 구별을 하게 해주는 식별키) 생성
long nano = System.currentTimeMillis();
String merchantUid = "pid-" + nano;
이 부분을 보자.
merchantUid 라는 게 뭐냐면, 내 애플리케이션단에서 랜덤하게 만들어준 해당 주문건에 대한 고유한 값이다.
간단히 말해, 내 애플리케이션에서 해당 주문을 식별할 수 있는 값을 만들었다는 것이다. orderSheet.jsp 파일에서 써야 되기 때문에, Map<String,String> 자료구조에 담아놨다.
그리고 주문 테이블에 행을 삽입하는
ordersMapper.insertOrder(memberId,
productId,
merchantUid,
amount,
"Pending",
"내용없음",
phoneNumStart,
phoneNumMiddle,
phoneNumEnd,
zipCode,
streetAddress,
address,
detailAddress,
referenceItem);
이 코드 중 주의깊게 봐야할 부분은, status 컬럼에는 Pending 이라는 문자열을 넣고 있는데, 이는 "주문이 생성되었지만 아직 그 주문이 결제되거나 하는등의 처리가 되지 않은 상태" 로서 "주문이 생성된 초기 상태" 를 표현하는 단어라고 한다.
여차저차해서, orderSheet.jsp 에 만들어진 Map<String,String> 자료구조를 넘겨준다.
그럼
document.addEventListener("DOMContentLoaded", function() {
var IMP = window.IMP;
IMP.init("impXXXXXXXXXX");
// 폼 선택
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'){
위 코드 중,
.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'){
이 부분으로 오겠지. result 에 success 를 담아줬으므로, 이제 다음 부분인 아래 부분이 시작될 것이다.
즉, 이제 사전검증의 시작이다.
.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;