먼저 script를 import해준다.
버전 최신화가 필요할 수 있다.
<script src="https://js.bootpay.co.kr/bootpay-4.3.3.min.js" type="application/javascript"></script>
그 후 버튼을 만들고,
<body>
<button id="paymentButton">결제하기</button>
</body>
부트페이 로직 실행
<script>
document
.getElementById("paymentButton")
.addEventListener("click", async function () {
// 부트페이 결제 로직
try {
// 이 코드는 개발 문서에 나와있는 그대로.
// 전달 데이터를 따로 뺌.
// 일반결제 요청하기 로직. 팝업 설정과 승인 분리 설정만 추가함.
const requestData = {
// apikey
application_id: "api키",
price: 1000,
order_name: "테스트결제",
order_id: "TEST_ORDER_ID",
// 아래의 두 속성 지정하지 않을 시 통합 결제
// pg: "카카오",
// method: "간편",
tax_free: 0,
user: {
id: "회원아이디",
username: "회원이름",
phone: "01000000000",
email: "test@test.com",
},
items: [
{
id: "item_id",
name: "테스트아이템",
qty: 1,
price: 1000,
},
],
extra: {
open_type: "popup", // 팝업 형태로 결제 창 열기
popup: {
width: 800, // 팝업 창의 너비 (픽셀)
height: 600, // 팝업 창의 높이 (픽셀)
},
card_quota: "0,2,3",
escrow: false,
separately_confirmed: true, // 승인 전 로직 필요할 시
},
};
// 위의 Data로 부트페이 결제 요청.
const response = await Bootpay.requestPayment(requestData);
} catch (error) {
// 결제 진행중 오류 발생
// e.error_code - 부트페이 오류 코드
// e.pg_error_code - PG 오류 코드
// e.message - 오류 내용
console.log(error.message);
}
});
</script>
{
"event": "confirm",
"receipt_id": "63057b73cc125a00171ac13d",
"gateway_url": "https://gw.bootpay.co.kr",
"order_id": "1661303666911"
}
receipt_id를 서버로 넘겨주기 전, 혹시 Bootpay로 보낸 데이터가 조작되었을 가능성을 대비해
화면에서 데이터를 뽑아 order테이블에 먼저 저장하도록 하겠다.
입력 폼을 만들고
<body>
<h1>제품 결제</h1>
<span>제품 idx : </span><input type="number" id="item_idx"><br>
<span>제품 가격 : </span><input type="number" id="item_price"><br>
<span>제품 이름 : </span><input type="text" id="item_name"><br>
<span>제품 수량 : </span><input type="number" id="item_amount"><br>
<button id="paymentButton">결제하기</button>
</body>
Js로 dto를 만들어 넘겨준다.
document
.getElementById("paymentButton")
.addEventListener("click", async function () {
saveOrderDto = {
item: {
itemIdx: document.querySelector("#item_idx").value,
itemPrice: document.querySelector("#item_price").value,
itemName: document.querySelector("#item_name").value,
itemAmount: document.querySelector("#item_amount").value,
},
};
var orderIdx;
await fetch("/api/v1/order", {
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify(saveOrderDto),
})
.then((response) => response.json())
.then((data) => {
if (data.code == 0) {
// 성공적으로 DB에 저장되었다면, orderIdx를 data로 반환한다.
orderIdx = data.data;
} else {
console.log(data.message);
}
})
.catch((error) => {
// 오류 처리
console.error("Error:", error);
});
// 부트페이 결제 로직
try {
응답 데이터를 구조화함.
package com.example.bootpayvelog.common.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
// 메세지와 코드를 같이 보내기 위해 사용
public class ResponseDTO<T> {
private Integer code;
private String message;
private T data;
}
주문 정보를 저장할 테이블(엔티티)를 만들고
package com.example.bootpayvelog.model.order.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name= "`ORDER`")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "idx", nullable = false, unique = true)
private Long idx;
@Column(name = "item_idx", nullable = false, unique = false)
private Long itemIdx;
@Column(name = "item_name", nullable = false, unique = false)
private String itemName;
@Column(name = "item_amount", nullable = false, unique = false)
private Long itemAmount;
@Column(name = "item_price", nullable = false, unique = false)
private Long itemPrice;
@Column(name = "order_status", nullable = false, unique = false)
private String orderStatus;
}
Repository도 생성
package com.example.bootpayvelog.model.order.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.example.bootpayvelog.model.order.entity.OrderEntity;
@Repository
public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
// 나중에 orderIdx로 DB에서 검색하기 위함
Optional<OrderEntity> findByIdx(Long orderIdx);
}
아까 프론트에서 만들어준 dto와 같은 형식으로 DTO 만들어주기. 변수 이름이 전부 똑같아야 함(클래스 이름은 상관X).
Validation도 해준다.
package com.example.bootpayvelog.domain.order.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class ReqOrderSaveDTO {
@NotNull(message = "item 정보가 없습니다.")
private Item item;
@Valid
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public static class Item{
@NotNull(message = "itemIdx가 잘못되었습니다.")
private Long itemIdx;
@NotNull(message = "itemPirce가 잘못되었습니다.")
private Long itemPrice;
@NotNull(message = "itemName이 잘못되었습니다.")
private String itemName;
@NotNull(message = "itemAmount가 잘못되었습니다.")
private Long itemAmount;
}
}
주문과 관련된 요청을 매핑을 컨트롤러를 생성한다.
package com.example.bootpayvelog.domain.order.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.bootpayvelog.domain.order.dto.ReqOrderSaveDTO;
import com.example.bootpayvelog.domain.order.service.OrderServiceApiV1;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/v1/order")
public class OrderControllerApiV1 {
@Autowired
private OrderServiceApiV1 orderServiceApiV1;
@PostMapping()
public ResponseEntity<?> saveOrder(@Valid @RequestBody ReqOrderSaveDTO dto){
return orderServiceApiV1.saveOrder(dto);
}
}
DB에 주문 정보를 저장하기 위한 로직을 작성한다.
package com.example.bootpayvelog.domain.order.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import com.example.bootpayvelog.common.dto.ResponseDTO;
import com.example.bootpayvelog.domain.order.dto.ReqOrderSaveDTO;
import com.example.bootpayvelog.model.order.entity.OrderEntity;
import com.example.bootpayvelog.model.order.repository.OrderRepository;
import jakarta.transaction.Transactional;
@Service
public class OrderServiceApiV1 {
@Autowired
private OrderRepository orderRepository;
@Transactional
public ResponseEntity<?> saveOrder(ReqOrderSaveDTO dto){
// 엔티티를 만들고,
OrderEntity entityForSaving = OrderEntity.builder()
.itemIdx(dto.getItem().getItemIdx())
.itemPrice(dto.getItem().getItemPrice())
.itemName(dto.getItem().getItemName())
.itemAmount(dto.getItem().getItemAmount())
.build();
// DB에 저장한다.
// 이 때 entity에는 DB에 저장된 entity 정보가 들어감(idx가 자동 생성된 데이터)
OrderEntity entity = orderRepository.save(entityForSaving);
// 저장이 성공했음을 알리는 code와 message,
// data에 위에서 저장한 entity의 idx를 넘겨준다.
return new ResponseEntity<>(
ResponseDTO.builder()
.code(0)
.message("주문 저장 성공")
.data(entity.getIdx())
.build(),
HttpStatus.OK);
}
}
index에서 우리가 값을 입력 받았으니, 해당 값을 부트페이 서버로 전달해보자.
const requestData = {
// apikey
application_id: "",
// 가격 수정
price: saveOrderDto.item.itemPrice,
order_name: "테스트결제",
// order테이블에서 받아온 idx 값을 넣는다.
order_id: orderIdx,
// 아래의 두 속성 지정하지 않을 시 통합 결제
// pg: "카카오",
// method: "간편",
tax_free: 0,
user: {
id: "회원아이디",
username: "회원이름",
phone: "01000000000",
email: "test@test.com",
},
items: [
{
id: "item_id",
name: "테스트아이템",
qty: 1,
// 가격 수정
price: saveOrderDto.item.itemPrice,
},
],
extra: {
open_type: "popup",
popup: {
width: 800,
height: 600,
},
card_quota: "0,2,3",
escrow: false,
separately_confirmed: true,
},
};
이제 사용자가 해당 가격으로 결제를 진행 시, 아까 설명했던 구조대로 데이터가 날아온다.
해당 데이터에서 receipt_id를 추출해서, 서버로 보낸다.
switch (response.event) {
case "issued":
// 가상계좌 입금 완료 처리
break;
case "confirm":
// receipt_id를 dto에 담아서
const dto = {
receiptId: response.receipt_id,
};
// '/api/v1/bootpay/check' 로 보낸다. (구현 예정)
fetch("/api/v1/bootpay/check", {
method: "POST",
headers: {
"Content-type": "application/json",
},
body: JSON.stringify(dto),
})
.then((res) => res.json())
.then((result) => {
// 정상적으로 처리되었는지 메시지를 띄운다.
// 정상적으로 승인이 되면 코드에 0을 반환할 것이고,
if (result.code === 0) {
// 결제창을 닫는다.
Bootpay.destroy();
alert(result.message);
// 아니라면 다른 숫자가 반환된다.
} else {
alert(result.message);
}
location.replace("/");
})
.catch((err) => {
console.error(err);
});
break;
case "done":
// 결제 완료 처리
alert("결제 done");
case "cancel":
// 결제 취소 처리
alert("결제 cancel");
default:
break;
}
DTO를 만들 필요가 있나 싶지만 일단 만들었다.
package com.example.bootpayvelog.domain.bootpay.dto;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Builder
public class ReqBootpayConfirmDTO {
@NotNull(message = "receiptId가 없습니다.")
private String receiptId;
}
package com.example.bootpayvelog.domain.bootpay.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.bootpayvelog.domain.bootpay.dto.ReqBootpayConfirmDTO;
import com.example.bootpayvelog.domain.bootpay.service.BootpayServiceApiV1;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/v1/bootpay")
public class BootpayCotnrollerApiV1 {
@Autowired
private BootpayServiceApiV1 bootpayServiceApiV1;
@PostMapping("/check")
public ResponseEntity<?> priceCheck(@Valid @RequestBody ReqBootpayConfirmDTO dto){
return bootpayServiceApiV1.priceCheck(dto);
}
}
승인은 그냥 하면 되는데,
부트페이에서는 승인이 안 된 결제 건은 취소가 안되기 때문에,
취소가 아닌 승인 요청을 하지 않고, order테이블의 status에 취소되었다고 유지할 것이다.(물론 승인되면 승인으로)
그리고 각 코드들은 공식 문서에 가면 친절하게 설명되어 있다.
토큰 가져오는 코드는 중복이라 뺐고, 조회와 승인도 추후에 다른 로직 구현시에도 쓰일 수 있을 듯 싶어 각각 함수로 만들었다.
그리고 승인 시엔 order테이블의 정보를 바꾼다.(status column)
package com.example.bootpayvelog.domain.bootpay.service;
import java.util.HashMap;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import com.example.bootpayvelog.domain.bootpay.dto.ReqBootpayConfirmDTO;
import com.example.bootpayvelog.model.order.entity.OrderEntity;
import com.example.bootpayvelog.model.order.repository.OrderRepository;
import jakarta.transaction.Transactional;
import kr.co.bootpay.Bootpay;
@Service
public class BootpayServiceApiV1 {
@Autowired
private OrderRepository orderRepository;
private Bootpay bootpay;
// 부트페이 서버에서 토큰을 가져올 수 있는지 확인.
// 중복되는 코드라 빼봤습니다.
// 당연히 키 넣어줘야 함.
public void getBootpayToken() {
try {
bootpay = new Bootpay("rest api key", "private key");
HashMap token = bootpay.getAccessToken();
if (token.get("error_code") != null) { // failed
System.out.println("getAccessToken false: " + token);
return;
}
} catch (Exception e) {
e.printStackTrace();
}
}
// 단건 조회
public HashMap getBootpayReceipt(String receiptId) {
try {
getBootpayToken();
HashMap res = bootpay.getReceipt(receiptId);
if (res.get("error_code") == null) { // success
System.out.println("getReceipt success: " + res);
} else {
System.out.println("getReceipt false: " + res);
}
return res;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
// 결제 승인
@Transactional
public HashMap confirm(String receiptId){
try {
getBootpayToken();
HashMap res = bootpay.confirm(receiptId);
if(res.get("error_code") == null) { //success
System.out.println("confirm success: " + res);
// order테이블의 status column 데이터를 바꿔준다.
Long orderIdx = Long.valueOf(res.get("order_id").toString());
Optional<OrderEntity> orderEntityOptional = orderRepository.findByIdx(orderIdx);
if(!orderEntityOptional.isPresent()){
System.out.println("주문 번호에 해당하는 주문 정보가 없음.");
return null;
}
OrderEntity orderEntity = orderEntityOptional.get();
orderEntity.setOrderStatus("결제 승인");
} else {
System.out.println("confirm false: " + res);
}
return res;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
public ResponseEntity<?> priceCheck(ReqBootpayConfirmDTO dto) {
// 여기 메인 로직 구현 예정
getBootpayReceipt(dto.getReceiptId());
confirm(dto.getReceiptId());
return null;
}
}
단건 조회 성공
getReceipt success: {cancelled_price=0, metadata={}, cancelled_tax_free=0, method=카카오페이, gateway_url=https://gw.bootpay.co.kr, sandbox=true, receipt_id=651bb07800c78a00229948dd, method_origin=카카오페이, order_name=테스트결제, method_origin_symbol=kakaopay, method_symbol=kakaopay, tax_free=0, price=2000, company_name=테스트, pg=나이스페이먼츠, status_locale=입금/승인대기, currency=KRW, http_status=200, order_id=2, requested_at=2023-10-03T15:11:04+09:00, status=2}
Receipt Details:
- Receipt ID: 651bb07800c78a00229948dd
- Order ID: 2
- Order Name: 테스트결제
- Company Name: 테스트
- Payment Method: 카카오페이
- Payment Method Symbol: kakaopay
- Payment Method Origin: 카카오페이
- Payment Method Origin Symbol: kakaopay
- Payment Gateway URL: https://gw.bootpay.co.kr
- Payment Gateway Sandbox: true
- Payment PG: 나이스페이먼츠
- Currency: KRW
- Price: 2000
- Tax Free Amount: 0
- Cancelled Price: 0
- Cancelled Tax Free Amount: 0
- Status: 입금/승인대기
- HTTP Status: 200
- Requested At: 2023-10-03T15:11:04+09:00
- Metadata: {}
결제 승인 성공
confirm success: {cancelled_price=0, kakao_money_data={tid=nickakao1m01012310031511231870, cancel_tid=null}, metadata={}, cancelled_tax_free=0, method=카카오머니, gateway_url=https://gw.bootpay.co.kr, sandbox=true, receipt_id=651bb07800c78a00229948dd, method_origin=카카오페이, order_name=테스트결제, method_origin_symbol=kakaopay, receipt_url=https://door.bootpay.co.kr/receipt/UnRiMnc0aitmSkFkRGY0ME5FczN5N2dMeGlEQjBSUGJ5cUtLWWNENTBpa3JQ%0AZz09LS1NdEtlYitVMUd0VTlYSm1OLS1xcExKZXM1S1l1cU9wTDlpNTdjbTZR%0APT0%3D%0A, method_symbol=kakao_money, purchased_at=2023-10-03T15:11:24+09:00, tax_free=0, price=2000, company_name=테스트, pg=나이스페이
먼츠, status_locale=결제완료, currency=KRW, http_status=200, order_id=2, requested_at=2023-10-03T15:11:04+09:00, status=1}
Confirmation Details:
- Receipt ID: 651bb07800c78a00229948dd
- Order ID: 2
- Order Name: 테스트결제
- Company Name: 테스트
- Payment Method: 카카오머니
- Payment Method Symbol: kakao_money
- Payment Method Origin: 카카오페이
- Payment Method Origin Symbol: kakaopay
- Payment Gateway URL: https://gw.bootpay.co.kr
- Payment Gateway Sandbox: true
- Payment PG: 나이스페이먼츠
- Currency: KRW
- Price: 2000
- Tax Free Amount: 0
- Cancelled Price: 0
- Cancelled Tax Free Amount: 0
- Status: 결제완료
- HTTP Status: 200
- Requested At: 2023-10-03T15:11:04+09:00
- Purchased At: 2023-10-03T15:11:24+09:00
- Receipt URL: https://door.bootpay.co.kr/receipt/UnRiMnc0aitmSkFkRGY0ME5FczN5N2dMeGlEQjBSUGJ5cUtLWWNENTBpa3JQ%0AZz09LS1NdEtlYitVM1G0VTlYSm1OLS1xcExKZXM1S1l1cU9wTDlpNTdjbTZR%0A
- Kakao Money Data: {tid=nickakao1m01012310031511231870, cancel_tid=null}
- Metadata: {}
이제 priceCheck 함수에 값을 검토하는 로직을 구현한다.
@Transactional
public ResponseEntity<?> priceCheck(ReqBootpayConfirmDTO dto) {
// 조회해서 영수증 받아오기
HashMap res = getBootpayReceipt(dto.getReceiptId());
// 영수증의 price와 order table의 price 가져오기
Long receiptPrice = Long.valueOf(res.get("price").toString());
Optional<OrderEntity> orderEntityOptional = orderRepository.findByIdx(Long.valueOf(res.get("order_id").toString()));
// order 테이블에 해당 정보가 있는 지 확인
if(!orderEntityOptional.isPresent()){
return new ResponseEntity<>(
ResponseDTO.builder()
.code(1)
.message("해당 주문이 존재하지 않습니다.")
.build(),
HttpStatus.BAD_REQUEST);
}
OrderEntity entity = orderEntityOptional.get();
Long orderPrice = entity.getItemPrice();
// 두 값이 같으면
// Long은 equals로 비교해야 정확히 비교가 되더라.
if(receiptPrice.equals(orderPrice)){
// confirm()
HashMap resData = confirm(dto.getReceiptId());
return new ResponseEntity<>(
ResponseDTO.builder()
.code(0)
.message("결제 승인")
.data(resData)
.build(),
HttpStatus.OK);
}
// 아니면
else {
// order table에 status를 취소 상태로
entity.setOrderStatus("결제 취소");
return new ResponseEntity<>(
ResponseDTO.builder()
.code(2)
.message("결제가 취소되었습니다.")
.build(),
HttpStatus.BAD_REQUEST);
}
}
index.html의 스크립트 부분에 코드를 살짝 바꾼다.
const requestData = {
// apikey
application_id: "",
// 가격 수정
price: 100,
order_name: "테스트결제",
// order테이블에서 받아온 idx 값을 넣는다.
order_id: orderIdx,
// 아래의 두 속성 지정하지 않을 시 통합 결제
// pg: "카카오",
// method: "간편",
tax_free: 0,
user: {
id: "회원아이디",
username: "회원이름",
phone: "01000000000",
email: "test@test.com",
},
items: [
{
id: "item_id",
name: "테스트아이템",
qty: 1,
// 가격 수정
price: 100,
},
],
extra: {
open_type: "popup", // 팝업 형태로 결제 창 열기
popup: {
width: 800, // 팝업 창의 너비 (픽셀)
height: 600, // 팝업 창의 높이 (픽셀)
},
card_quota: "0,2,3",
escrow: false,
separately_confirmed: true, // 승인 전 로직 필요할 시
},
};