결제 부분에서는 어떻게 구현을 해야할까?
환경
--
- IntelliJ Community 2023.1.5
- Spring Boot 3.2.1
- JDK 17
build.gradle [ dependencies ]
repositories {
mavenCentral()
// 포트원(구 아임포트)은 maven 기반으로 의존성을 추가한다. (여기에 jitpack.io를 추가하면 gradle에서도 iamport라이브러리를 추가할 수 있다.)
maven { url 'https://jitpack.io' }
}
dependencies {
// 포트원(구 아임포트) 라이브러리
implementation 'com.github.iamport:iamport-rest-client-java:0.2.23'
}
포트원은 maven 기반의 라이브러리로
gradle 프로젝트에서 해당 라이브러리를 추가하려면
repositories에서 위와 같은 작업이 추가되어야 한다.
--
결제를 구현하려면?
--
결제를 구현하려면 각 PG(Payment Gateway)사에서 제공하는 API들을 사용하여 개발을 해야하는데
해당 API를 구현하려면 쉽지 않다.
PG(Payment Gateway)란?
VAN사의 업무를 온라인으로 대행하는 기능으로
온라인 결제시 단말기 없이 진행되는 과정에서 VAN사의 역할을 대신 수행하여
카드사와 가게(사용자)가 이어질 수 있도록 만들어준다.
카드사와 직접 계약하기 어려운 온라인 쇼핑몰을 대신하여 결제 업무를 대신해 주는 역할을 수행한다.
즉, 온라인상에서 쇼핑몰 결제를 할 때 결제 대금을 여러 결제수단을 이용하여
안전하고 편리하게 여러 카드사 결제를 할 수 있도록 해주는 전자지불 서비스이다.
VAN(Value Added Network)이란?
카드사와 가게의 통신을 연결하는 부가가치통신망으로
오프라인 가게에서 고객의 결제 데이터를 카드사가 안전하게 보내주는 역할을 한다.
즉, 결제 정보를 서로 주고 받을 수 있도록 하는 역할을 한다.
이를 보다 수월하게 진행할 수 있도록 포트원에서 제공하는 기능을 사용한다.
--
대한민국에서의 전자결제 서비스 흐름
--
한국은 해외와 다르게 일부 PG사를 제외하고는 대부분 카드 정보를 저장할 수 없도록 규정되어 있다.
즉, 카드 정보가 가맹점 서버, PG사 서버를 직접 거쳐가지 않고 바로 카드사 서버로 전달되도록 구성되어 있다.
- 구매자는 카드사 서버로 카드정보, 비밀번호 등을 입력하여 전달한다.
- 카드사 서버는 구매자가 전달한 정보들을 가지고 존재하는 카드인지, 카드 소지자가 본임인지 확인 후 해당 카드 정보 인증 결과를 다시 브라우저로 반환한다.
[ 이 때 카드사는 1회성 인증키를 PG사에게 제공해주며 해당 인증키는 실제 결제에 사용된다. ] - 구매자(브라우저)가 가맹점 서버로 결제를 요청한다.
- 가맹점 서버는 요청 받은 결제 요청 정보를 가지고 PG사 서버로 결제를 요청한다.
- PG사 서버는 요청 받은 정보와 인증키를 가지고 해당 카드사 서버로 결제 승인 요청을 보낸다.
- 카드사 서버는 결제 승인 요청을 보고 결제가 가능한지 판단 후 결과를 PG사로 반환해준다.
- PG사 서버는 승인 결과를 다시 가맹점 서버로 전달하여 알려준다.
- 가맹점 서버는 구매자(브라우저)에게 결제에 대한 결과를 응답해준다.
보다 자세한 내용은 포트원 GitHub에 작성되어 있다.
--
포트원 가맹점 생성하기
--
PG사는 KG 이니시스를 사용하는 방법으로 진행했습니다.
1. 결제 연동 -> 우측 하단의 채널 추가
2. 정보들을 입력해주고 다음을 클릭
- 연동 모드 : 실연동을 하려면 사업자 등록을 해줘야 하므로 테스트로 선택함
- 결제대행사 : KG이니시스를 사용할 것으로 KG이니시스를 선택함
- 결제 모듈 : 일반 결제만 구현할 것으로 일반/정기결제를 선택 (원하는 결제 방식이 있다면 선택하면 된다.)
3. 정보를 입력하고 저장을 클릭
- 채널 이름 : 원하는 채널 이름을 작성하면 된다. (어쩌피 MID를 선택하면 자동으로 변경된다.)
- 채널 속성 : 결제와 본인인증을 선택할 수 있는데 결제만 구현할 것으로 결제만 선택
- PG상점아이디(MID) : 공용 MID에서 일반 결제(INIpayTest)를 선택했다.
4. 완료
테스트용 결제는
밤 11 ~ 12시에 자동으로 환불이 된다.
물론 이전에 수동 결제 취소도 가능하다.
--
포트원에 대한 개인 정보 작성하기
--
application.yml
imp:
code: 가맹점 번호 (고객사 식별코드)
key: 키 값 (REST API Key)
secret_key: 비밀 키 값 (REST API Secret)
yml에 작성하는 이유는
해당 정보는 외부에 노출되면 안되기 때문에 gitignore에 등록된 application.yml 파일에 작성하여 사용하도록 했다.
위의 imp, code, key, secret_key의 이름과 구조는 마음대로 해도 상관없다.
어쩌피 변수처럼만 사용하는 용도이기 때문이다.
--
가맹점 서버(Spring Boot 서버)에 포트원 세팅하기
--
포트원에 정보 세팅하기
PortOneConfig.java
@Configuration
public class PortOneConfig {
@Value("${imp.key}")
private String apiKey;
@Value("${imp.secret_key}")
private String secretKey;
@Bean
public IamportClient iamportClient() {
return new IamportClient(apiKey, secretKey);
}
}
IamportClient(포트원) 객체를 내 가맹점 정보로 초기화 시킨다.
주문 엔티티 세팅하기
Order.java
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "order_id")
private Long orderId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
// 다른 필드 생략
@Enumerated(EnumType.STRING)
private PaymentStatus status;
// 주문 번호
private String merchantUid;
// 결제 번호
private String impUid = null;
// 결제시 결제창 열기 전에 임시 주문 객체 생성용
public Order(Member member, OrderDto.PaymentRequest request){
this.member = member;
totalPrice = request.getTotalPrice();
// 생략
status = PaymentStatus.PAYMENT_READY;
// 임시로 일단 랜덤 값 부여 (나중에 규칙 정하면 수정 에정)
merchantUid = UUID.randomUUID().toString();
}
// 아임포트로 부터 가져온 결제 번호를 저장 or 결제 상태 PAYMENT_DONE
public void updateBySuccess(String impUid){
this.status = PaymentStatus.PAYMENT_DONE;
this.impUid = impUid;
}
// 생략
}
결제 관련된 정보를 주고 받을 DTO 세팅하기 (관련된 DTO를 한 곳에 정리하기 위해 innerDto 구조로 구현했다.)
PortOneDto.java
public class PortOneDto {
// 결제를 위해 이니시스에게 전달할 데이터 (이니시스 결제창에 표시될 데이터)
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public static class InicisResponse{
private String merchantUid; // 주문 번호
private String itemName; // 상품 이름
private int paymentPrice; // 결제 금액
private String buyerName; // 구매자 이름
private String buyerEmail; // 구매자 이메일
private String buyerAddress; // 구매자 주소
public InicisResponse(Order order){
merchantUid = order.getMerchantUid();
// 여러 상품 구매시 어떻게 작성할지 미정으로 임시 정의
itemName = "item";
paymentPrice = order.getBuyPrice();
buyerName = order.getName();
buyerEmail = order.getMember().getEmail();
buyerAddress = order.getMainAddress() + " " + order.getDetAddress();
}
}
// 결제 완료시 포트원으로부터 응답 받을 데이터
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
@ToString
public static class InicisRequest {
// 결제 고유 번호 (imp_uid)
private String impUid;
// 주문 고유 번호 (merchant_uid)
private String merchantUid;
// JSON 문자열을 받아와서 파싱하여 PaymentCallbackRequestDto 객체로 변환
public static InicisRequest fromString(String request) {
JsonObject requestJson = JsonParser.parseString(request).getAsJsonObject();
String impUid = requestJson.getAsJsonPrimitive("imp_uid") == null ? null : requestJson.getAsJsonPrimitive("imp_uid").getAsString();
String merchantUid = requestJson.getAsJsonPrimitive("merchant_uid") == null ? null : requestJson.getAsJsonPrimitive("merchant_uid").getAsString();
return InicisRequest.builder()
.impUid(impUid)
.merchantUid(merchantUid)
.build();
}
}
}
- InicisResponse DTO : 브라우저에서 이니시스 결제창을 띄울 때 해당 결제창에 보여질 데이터이며 결과적으로 이니시스(포트원)에게 전달할 결제 정보중에 일부분이다.
- InicisRequest DTO : 결제 완료시 포트원으로부터 결제 정보를 받아올 DTO이다.
--
결제 API 구현하기
--
1. 주문 페이지에서 주문 정보(상품 정보, 주소, 포인트 등)를 입력 후 구매하기 버튼을 눌렀을 때
Controller
@PostMapping("/member/{id}/payment/first")
public PortOneDto.InicisResponse firstPayment(@PathVariable Long id, @RequestBody OrderDto.PaymentRequest request){
PortOneDto.InicisResponse inicisDto = orderService.firstPayment(id, request);
return inicisDto;
}
Service
@Transactional
public PortOneDto.InicisResponse firstPayment(Long id, OrderDto.PaymentRequest request) {
// 판매중인 상품인지, 재고가 있는지 확인
// order객체에 저장할 결제 금액과 진짜 결제 금액 계산 검증
// 회원 보유 적립금 이하로 적립금 사용하고 있는지 검증
// 사용한 적립금 차감
// orders 객체 생성
Order order = new Order(member, request);
orderRepository.save(order);
// orderDetail 객체 생성
for(OrderDto.OrderPageItemRequest itemRequest : request.getItems()){
Item item = itemRepository.findById(itemRequest.getItemId()).orElseThrow(IllegalArgumentException::new);
OrderDetail orderDetail = new OrderDetail(order, item, itemRequest.getCount());
orderDetailRepository.save(orderDetail);
}
// 이니시스에 전달할 DTO 생성
PortOneDto.InicisResponse inicisDto = new PortOneDto.InicisResponse(order);
return inicisDto;
}
임시로 Order 엔티티와 OrderDetails 엔티티를 만들어 놓는다.
그리고 결제정보를 이니시스에게 응답해준다. (정확히는 브라우저에게 응답하여 브라우저가 이니시스에게 전달할 데이터)
2. 구매자가 이니시스 결제창을 통해 결제 완료시 동작할 API
Controller
@PostMapping("/member/{id}/payment/second")
public ResponseEntity<IamportResponse<Payment>> secondPayment(@PathVariable Long id, @RequestBody PortOneDto.InicisRequest request){
IamportResponse<Payment> iamportResponse = orderService.validatePayment(id, request);
return new ResponseEntity<>(iamportResponse, HttpStatus.OK);
}
Service
@Transactional(noRollbackFor = IllegalArgumentException.class)
public IamportResponse<Payment> validatePayment(Long id, PortOneDto.InicisRequest request) {
try{
// 결제 단건 조회
IamportResponse<Payment> iamportResponse = getIamportResponse(request);
// 해당 주문 테이블 테이블를 가져온다.
Order order = orderRepository.findByMerchantUid(request.getMerchantUid()).orElseThrow(NoSuchElementException::new);
// 포트원으로부터 받은 결제 데이터 iamportResponse와 주문 데이터를 가져와서 결제에 대해 검증한다.
validatePaymentStatusAndPay(iamportResponse, order);
// 만약 결제 상태가 PYAMENT_DONE라면?(결제가 완료된 상태라면)
if(PaymentStatus.PAYMENT_DONE.equals(order.getStatus())){
log.info("이미 결제 완료된 주문입니다. imp_uid={}, merchant_uid={}", order.getImpUid(), order.getMerchantUid());
} else {
// 기존 orders 객체에 아임포트에서 가져온 imp_uid를 저장해준다. + 결제 상태 수정
order.updateBySuccess(iamportResponse.getResponse().getImpUid());
log.info("결제 완료 확인!, payment_uid={}, order_uid={}",
order.getImpUid(), order.getMerchantUid());
}
// 상품 재고 차감
// 주문일 정의
return iamportResponse;
} catch (IamportResponseException | IOException e){
throw new RuntimeException(e);
}
}
결제 완료하기 전에 임시로 만든 Order 엔티티를 포트원으로 부터 결제 완료 확인 후 결제 번호를 가져와서 완성한다.
--
결제 취소 API 구현하는 두 가지 방법
--
1. 포트원으로부터 직접 결제 취소를 위한 토큰을 발급 받아서 포트원에 결제 취소 요청 보내기
보기 편하게 토큰 발급과 결체 취소 요청 보내는 것을 API 2개로 분리했다.
포트원으로부터 토큰 발급 받기
Controller
@GetMapping("/order/getToken")
public String getToken() throws IOException {
String token = cancelService.getAccessToken();
return "토큰 발급이 완료되었습니다.";
}
Service
public String getAccessToken() throws IOException {
URL url = new URL("https://api.iamport.kr/users/getToken");
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
// 요청 방식을 Post 메서드로 설정
conn.setRequestMethod("POST");
// 요청의 Content-Type과 Accept 헤더 설정
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "application/json");
// 해당 연결을 출력 스트림(요청)으로 사용
conn.setDoOutput(true);
// JSON 객체에 해당 API가 필요로하는 데이터 추가.
JsonObject json = new JsonObject();
json.addProperty("imp_key", apiKey);
json.addProperty("imp_secret", secretKey);
// 출력 스트림으로 해당 conn에 요청
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
bw.write(json.toString()); // json 객체를 문자열 형태로 HTTP 요청 본문에 추가
bw.flush(); // BufferedWriter 비우기
bw.close(); // BufferedWriter 종료
// 입력 스트림으로 conn 요청에 대한 응답 반환
BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
Gson gson = new Gson(); // 응답 데이터를 자바 객체로 변환
String response = gson.fromJson(br.readLine(), Map.class).get("response").toString();
String accessToken = gson.fromJson(response, Map.class).get("access_token").toString();
br.close(); // BufferedReader 종료
conn.disconnect(); // 연결 종료
return accessToken;
}
포트원에게 결제 취소를 요청하기 위해서는
포트원에게 발급 받은 토큰이 필요하기 때문에 먼저 토큰부터 발급 받아야 한다.
포트원에게 결제 취소 요청하기
Controller
@PostMapping("/order/cancel")
public String orderCancel(@RequestBody CancelTestDto cancelTestDto) throws IOException {
cancelService.refundRequest(cancelTestDto);
return "주문 취소가 되었습니다.";
}
Service
public void refundRequest(CancelTestDto cancelTestDto) throws IOException {
URL url = new URL("https://api.iamport.kr/payments/cancel");
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
// 요청 방식을 POST로 설정
conn.setRequestMethod("POST");
// 요청의 Content-Type, Accept, Authorization 헤더 설정
conn.setRequestProperty("Content-type", "application/json");
conn.setRequestProperty("Accept", "application/json");
conn.setRequestProperty("Authorization", cancelTestDto.getToken());
// 해당 연결을 출력 스트림(요청)으로 사용
conn.setDoOutput(true);
// JSON 객체에 해당 API가 필요로하는 데이터 추가.
JsonObject json = new JsonObject();
json.addProperty("merchant_uid", cancelTestDto.getMerchantUid());
json.addProperty("reason", cancelTestDto.getReason());
// 출력 스트림으로 해당 conn에 요청
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(conn.getOutputStream()));
bw.write(json.toString());
bw.flush();
bw.close();
// 입력 스트림으로 conn 요청에 대한 응답 반환
BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream()));
br.close();
conn.disconnect();
}
2. 라이브러리를 이용하여 바로 결제 취소 요청 보내기
// 결제 번호인 ImpUid를 이용하여 해당 결제 정보를 IamportResponse 객체 생성
IamportResponse<Payment> iamportResponse = iamportClient.paymentByImpUid(ImpUid);
// 포트원에게 결제 취소를 요청하기 위해 필요한 객체인 CancelData 객체를 결제 취소에 필요한 데이터를 포함하여 생성
CancelData cancelData = new CancelData(iamportResponse.getResponse().getImpUid(), true, new BigDecimal(portOnePrice));
// iamportResponse를 이용하여 CancelData 객체를 제공하여 해당 결제 취소 정보에 알맞게 결제 취소한다.
iamportClient.cancelPaymentByImpUid(cancelData);
--
프론트엔드 코드 참고용
--
html
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>결제 테스트</title>
<script src="https://cdn.iamport.kr/v1/iamport.js"></script>
<script type="text/javascript" src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
<script>
var IMP = window.IMP;
IMP.init("가맹점 고유 번호");
function requestPay() {
var merchantUid = '[[${requestDto.merchantUid}]]';
var itemName = '[[${requestDto.itemName}]]';
var paymentPrice = '[[${requestDto.paymentPrice}]]';
var buyerName = '[[${requestDto.buyerName}]]';
var buyerEmail = '[[${requestDto.buyerEmail}]]';
var buyerAddress = '[[${requestDto.buyerAddress}]]';
IMP.request_pay({
pg : 'html5_inicis.INIpayTest',
pay_method : 'card',
merchant_uid: merchantUid, // 주문 번호
name : itemName, // 상품 이름
amount : paymentPrice, // 상품 가격
buyer_email : buyerEmail, // 구매자 이메일
buyer_name : buyerName, // 구매자 이름
buyer_tel : '010-0000-000', // 임의의 값
buyer_addr : buyerAddress, // 구매자 주소
buyer_postcode : '000-000', // 임의의 값
},
function(rsp) {
if (rsp.success) {
alert('결제 성공');
// 결제 성공 시
// jQuery로 HTTP 요청
jQuery.ajax({
url: "/payment",
method: "POST",
headers: {"Content-Type": "application/json"},
data: JSON.stringify({
"imp_uid": rsp.imp_uid, // 결제 번호
"merchant_uid": rsp.merchant_uid // 주문 번호
})
}).done(function (response) {
console.log(response);
// 가맹점 서버 결제 API 성공시 동작
alert('결제 완료');
})
} else {
alert('결제 실패');
}
});
}
</script>
</head>
<body>
<h1>결제 페이지</h1>
<button th:with="requestDto = ${requestDto}" onclick="requestPay()"> 결제하기 </button>
</body>
</html>
--
'Spring Boot' 카테고리의 다른 글
validation을 이용하여 유효성 검사하기 [ feat. 회원가입 ] (0) | 2024.04.26 |
---|---|
전역으로 예외 처리 통일 시키는 방법 (0) | 2024.04.24 |
JWT를 이용하여 로그인 구현하기 (+ Refresh Token ) (0) | 2024.04.21 |
Spring Security에 대해서 (0) | 2024.04.18 |
자동으로 데이터의 생성일, 수정일, 생성자, 수정자 저장하기 (Auditing) (0) | 2024.01.22 |