티스토리 뷰
결제 기능 구현 목적
사용자가 포인트를 충전하기 위해 결제하는 기능을 만들 예정이다.
기술 스택
프로그래밍 언어: java 언어
프레임워크: spring boot, MyBatis
템플릿 엔진: mustache
라이브러리: jquery, lombok 등
토스 페이먼츠 결제 API
먼저, 토스페이먼츠 개발자센터에서
회원가입 하면
아래와 같이
테스트용 API 개별 연동 키
클라이언트 키, 시크릿 키, 보안 키를 발급 받을 수 있다.
클라이언트 키와 시크릿 키는 항상 ‘세트’로 묶여 있고, 한 세트로 써야 된다.
세트가 아닌 키를 사용하거나 테스트 또는 라이브 키를 섞어 사용하면 INVALID_API_KEY 오류가 발생한다.
클라이언트 키는 브라우저에서 토스페이먼츠 SDK를 초기화할 때 사용한다.
시크릿 키는 토스페이먼츠 API를 호출할 때 사용한다.
ID와 비밀번호 대신 시크릿 키로 API 요청을 인증한다.
참고 문서
API 키 | 토스페이먼츠 개발자센터
토스페이먼츠 클라이언트 키 및 시크릿 키를 발급받고 사용하는 방법을 알아봅니다. 클라이언트 키는 SDK를 초기화할 때 사용하고 시크릿 키는 API를 호출할 때 사용합니다.
docs.tosspayments.com
코드 예시
application.yml에 코드 추가
payment:
toss:
test_client_api_key : test_ck_DnyRpQWGrNqpzLg1DZKOVKwv1M9E
test_secrete_api_key : test_sk_....(본인이 발급 받은 시크릿키)
pointcharge.mustache
포인트 충전 화면
여기서 나는 version 1 이 아닌 version 2를 사용해보았다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="/css/chargePage.css" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>포인트 충전 화면</title>
<script src="https://js.tosspayments.com/v2/standard"></script>
</head>
<body>
<h1>결제 방법</h1>
<h5>결제 방법을 선택해주세요</h5>
<!-- 결제 UI -->
<div id="payment-method" style="display: flex">
<button id="CARD" class="button2" onclick="selectPaymentMethod('CARD')">카드</button>
<button id="TRANSFER" class="button2" onclick="selectPaymentMethod('TRANSFER')">계좌이체</button>
<button id="MOBILE_PHONE" class="button2" onclick="selectPaymentMethod('MOBILE_PHONE')">휴대폰</button>
</div>
<h1>포인트 충전</h1>
<section class="point-container">
<span>포인트</span>
<div class="point-list">
<div class="point-box">
<span id="point-1000">1,000 원</span>
<button id="chargeButton-1000" onclick="requestPayment(1000)">충전하기</button>
</div>
<div class="point-box">
<span id="point-3000">3,000 원</span>
<button id="chargeButton-3000" onclick="requestPayment(3000)">충전하기</button>
</div>
<div class="point-box">
<span id="point-5000">5,000 원</span>
<button id="chargeButton-5000" onclick="requestPayment(5000)">충전하기</button>
</div>
<div class="point-box">
<span id="point-10000">10,000 원</span>
<button id="chargeButton-10000" onclick="requestPayment(10000)">충전하기</button>
</div>
<div class="point-box">
<span id="point-30000">30,000 원</span>
<button id="chargeButton-30000" onclick="requestPayment(30000)">충전하기</button>
</div>
<div class="point-box">
<span id="point-50000">50,000 원</span>
<button id="chargeButton-50000" onclick="requestPayment(50000)">충전하기</button>
</div>
<div class="point-box">
<span id="point-100000">100,000 원</span>
<button id="chargeButton-100000" onclick="requestPayment(100000)">충전하기</button>
</div>
</div>
</section>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="/js/payment/payment.js">
</script>
</body>
</html>
payment.js
let selectedPaymentMethod = null;
function selectPaymentMethod(method) {
if(selectedPaymentMethod != null){
$('#selectedPaymentMethod');
}
selectedPaymentMethod = method; // 선택한 결제 수단이 변수명에 담기도록함.
$('#selectedPaymentMethod');
}
// SDK 초기화(TossPayments())
// 클라이언트 키를 파라미터로 넣으면 상점의 정보 확인 가능
const clientKey = "test_ck_DnyRpQWGrNqpzLg1DZKOVKwv1M9E"; // API 개별 연동 키
const tossPayments = TossPayments(clientKey);
const customerKey = generateRandomString(); // 랜덤 난수 생성
const payment = tossPayments.payment({customerKey});
let phoneNumber = "{{phoneNumber}}"; // 계좌 이체 시 휴대폰 비밀번호 등록 시 필요
async function requestPayment(point){
// amount(현금)과 point는 1 : 1
let amount = {
currency: "KRW",
value: point
};
switch (selectedPaymentMethod){
case "CARD":
await payment.requestPayment({
method: "CARD", // 카드 및 간편 결제
amount,
orderId: generateRandomString(),
orderName: "포인트 충전" + point + "원",
successUrl: 'http://localhost:8080/pay/success',
failUrl: 'http://localhost:8080/pay/fail',
card: {
useEscrow: false,
flowMode: "DEFAULT",
useCardPoint: false,
useAppCardOnly: false,
},
});
break;
case "TRANSFER":
await payment.requestPayment({
method: "TRANSFER", // 계좌이체 결제
amount,
orderId: generateRandomString(),
orderName: "포인트 충전 " + point + "원",
customerMobilePhone: phoneNumber, // 휴대폰 비밀번호 설정 시 필요
successUrl: 'http://localhost:8080/pay/success',
failUrl: 'http://localhost:8080/pay/fail',
transfer: {
cashReceipt: {
type: "소득공제",
},
useEscrow: false,
},
});
break;
case "MOBILE_PHONE":
await payment.requestPayment({
method: "MOBILE_PHONE", // 휴대폰 결제
amount,
orderId: generateRandomString(),
orderName: "포인트 충전" + point + "원",
successUrl: 'http://localhost:8080/pay/success',
failUrl: 'http://localhost:8080/pay/fail',
});
break;
}
}
// Math.random(): 0 ~ 1 사이 무작위 난수 생성
// window.btoa : 문자열을 Base64 인코딩된 문자열로 변환
// slice(0, 20): 문자열 첫번째 인덱스 ~ 20번째 인덱스까지의 문자 추출
function generateRandomString() {
return window.btoa(Math.random()).slice(0, 20);
}
위의 코드는
아래의 링크에서 결제창 부분을 참고하면 된다.
https://docs.tosspayments.com/sdk/v2/j
토스페이먼츠 JavaScript SDK | 토스페이먼츠 개발자센터
토스페이먼츠 JavaScript SDK를 추가하고 메서드를 사용하는 방법을 알아봅니다.
docs.tosspayments.com
가이드 > 마이그레이션하기 > 결제창
v1과 v2 코드를 비교하여 적용 가능하다.
https://docs.tosspayments.com/guides/v2/get-started/migration-guide
마이그레이션하기 | 토스페이먼츠 개발자센터
최신 버전의 토스페이먼츠 SDK로 마이그레이션하는 방법을 알아보세요.
docs.tosspayments.com
실행 결과
mustache로 구현한 화면
여기서 카드를 누른 후
결제하고자 하는 포인트에서 충전하기 버튼을 누르면
아래와 같이 결제 화면이 뜬다.
토스페이를 선택하면 위와 같은 화면이 뜬다.
다음을 누르면 휴대폰 / QR 코드가 뜨고
인증을 하면
모바일 토스 앱에서 결제를 진행한다.
결제가 완료되면 아래의 그림과 같이
성공 페이지로 가서
결제 내역이 뜨도록 하였다.
이렇게 mustach와 js만으로도 토스 결제 화면이 열리고 결제가 되는 것을 확인할 수 있다.
하지만 이는 결제 기능을 완성한 것이 아니다!
먼저 결제 흐름을 파악하자.
가이드 > 결제 흐름
https://docs.tosspayments.com/guides/v2/get-started/payment-flow
결제 흐름 이해하기 | 토스페이먼츠 개발자센터
카드 결제 과정의 세 가지 핵심 단계인 요청, 인증, 승인을 이해하고 결제 정보를 검증하는 방법을 알아보세요.
docs.tosspayments.com
지금까지는 구매자가 상품 또는 서비스를 구매하기 위해 결제를 요청하는 과정( 결제 요청 - 인증 )이었다.
결제 요청을 마치면 구매자 입장에서는 모든 과정이 끝난 것처럼 보이지만 개발자 입장에서는 요청만 마친 것이다.
기능을 완성하기 위해서는 카드로 구매한 경우 서버에서 카드사에 승인해달라고 요청해야 한다.
승인이 성공하면 가맹점은 구매자에게 상품이나 서비스를 제공하고,
카드사나 은행은 결제 금액을 구매자에게 청구한다.
결제 요청 - 인증 뒤에 이동한 성공 리다이렉트 URL의 쿼리 파라미터로 받은 정보를 이용해 결제 승인 API를 호출한다.
성공 리다이렉트 URL의 쿼리 파라미터에는
아래와 같이 결제 키(paymentKey), 주문번호(orderId), 금액(amount) 결제 정보가 있다.
이 값들을 가맹점에서 직접 받아 그 정보로 승인을 요청하므로 결제 승인 시 필요하다.
실제로 카드사에 결제를 승인해달라는 요청이 가고, 결제 승인 응답이 성공으로 돌아오면 결제가 완료된다.
하지만 지금은 테스트 중으로 실제로 요청이 가지는 않는다.
구매자와 판매자에게 결제 완료 메시지가 전달되면 개발자는 결제 성공 응답을 확인할 수 있다.
요청과 승인을 따로 하는 이유는
문서를 참고하자면 데이터 정합성과 가맹점의 연동 편의를 위해서 이다.
요청-승인을 따로 하면
결제창을 닫아버리거나 가맹점 서버 이슈 때문에
승인 데이터 정합성에 문제가 생길 가능성을 감소시킨다.
결제 승인 요청 기능에 들어가기 전 코드 흐름을 파악하자
위의 그림과 문서를 참고하자면 결제 요청 데이터를 임시로 서버에 저장해야 하지만
적립금이나 쿠폰 기능을 구현하지 않을 것이기 때문에
임시 데이터를 저장하지 않았다.
가이드 > 결제 흐름에서
결제 정보 검증하기
결제 흐름 이해하기 | 토스페이먼츠 개발자센터
카드 결제 과정의 세 가지 핵심 단계인 요청, 인증, 승인을 이해하고 결제 정보를 검증하는 방법을 알아보세요.
docs.tosspayments.com
ApproveDTO
결제 승인 요청 시 사용하는 DTO
package com.example.amigo_project.dto.payment;
import lombok.*;
@Data
@Builder
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class ApproveDTO {
private String orderName; // 주문 이름
private int amount; // 주문한 금액(현금)
private String paymentKey;
private String orderId; // 랜덤으로 생성된
private String customerMobilePhone; // 계좌 이체에서 휴대폰으로 비밀번호 설정 시 사용
}
PaymentRepository
package com.example.amigo_project.repository.interfaces;
import com.example.amigo_project.repository.model.ChargeHistory;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface PaymentRepository {
// 결제 내역 생성
public void createChargeHistory(ChargeHistory chargeHistory);
// 포인트 충전
public void chargePoint(ChargeHistory chargeHistory);
}
charge_history_tb
결제 내역 조회를 위함
create table charge_history_tb (
id int primary key auto_increment,
user_id int,
order_name varchar(100),
order_id varchar(100),
point int,
total_amount int,
approved_at timestamp default CURRENT_TIMESTAMP,
method varchar(20),
payment_key varchar(100),
foreign key (user_id) references user_tb(id)
);
ChargeHistory
결제 승인 요청 후 값을 담은 model
package com.example.amigo_project.repository.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.*;
import java.sql.Timestamp;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public class ChargeHistory {
private int id;
private int userId; // user_tb의 PK
private String orderName;
private String orderId;
private int point; // 포인트
private int totalAmount; // 최종 결제 금액(현금)
private Timestamp approvedAt; // 결제 승인 시간
private String method;
private String paymentKey;
}
payment.xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- mapper DTD 선언 -->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.amigo_project.repository.interfaces.PaymentRepository">
<insert id="createChargeHistory">
INSERT INTO charge_history_tb(user_id, order_Name, order_id, point, total_amount, approved_at, method, payment_key)
VALUES (#{userId}, #{orderName}, #{orderId}, #{point}, #{totalAmount}, #{approvedAt}, #{method}, #{paymentKey})
</insert>
<update id="chargePoint">
UPDATE user_tb SET point = point + #{point} WHERE id = #{userId}
</update>
</mapper>
PaymentService
package com.example.amigo_project.service;
import com.example.amigo_project.dto.payment.ApproveDTO;
import com.example.amigo_project.repository.interfaces.PaymentRepository;
import com.example.amigo_project.repository.model.ChargeHistory;
import com.example.amigo_project.repository.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final HttpSession session;
/**
* 서버 -> 판매점(카드사 등)으로 결제 승인 요청
*/
@Transactional
public ChargeHistory requestPayment(ApproveDTO dto) throws IOException, InterruptedException {
// 사용자가 결제 요청 후 받은 값을 ApproveDTO에 담음
User user = (User) session.getAttribute("principal");
int userId = user.getUserId;
String paymentKey = dto.getPaymentKey();
String orderId = dto.getOrderId();
int amount = dto.getAmount();
// 헤더 + 바디
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.tosspayments.com/v1/payments/confirm"))
.header("Authorization", "Basic dGVzdF9za180eUtlcTViZ3JwUDdlV2dXenE0eHJHWDBselc2Og==")
.header("Content-Type", "application/json")
.method("POST", HttpRequest.BodyPublishers.ofString(String.format("{\"paymentKey\":\"%s\",\"orderId\":\"%s\",\"amount\":%d}", paymentKey, orderId, amount)))
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());
// JSON으로 온 결제 요청 승인 후 받은 값을 모델에 담음
ObjectMapper objectMapper = new ObjectMapper();
ChargeHistory chargeHistory = objectMapper.readValue(response.body(), ChargeHistory.class);
int point = chargeHistory.getTotalAmount(); // 충전한 금액(포인트)만
chargeHistory.setPoint(point);
return chargeHistory;
}
/**
* 포인트 충전
*/
@Transactional
public void chargePoint(ChargeHistory chargeHistory){
paymentRepository.chargePoint(chargeHistory);
}
/**
* 결제 내역 생성
*/
@Transactional
public void createChargeHistory(ChargeHistory chargeHistory) {
paymentRepository.createChargeHistory(chargeHistory);
}
}
ChargeHistoryDTO
사용자가 결제 내역 확인 가능하도록(성공 페이지에 띄우기 위함)
package com.example.amigo_project.dto.payment;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.*;
import java.sql.Timestamp;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@ToString
@JsonIgnoreProperties(ignoreUnknown = true)
public class ChargeHistoryDTO {
private int userId; // user_tb의 PK
private String name; // 사용자 이름
private String orderName; // 구매 상품
private String orderId; // 주문 번호
private int totalAmount; // 결제 금액
private Timestamp approvedAt; // 결제 일시
private String method; // 결제 방식
private String paymentKey;
}
PaymentController
package com.example.amigo_project.controller;
import com.example.amigo_project.dto.payment.ApproveDTO;
import com.example.amigo_project.dto.payment.ChargeHistoryDTO;
import com.example.amigo_project.repository.model.ChargeHistory;
import com.example.amigo_project.repository.model.User;
import com.example.amigo_project.service.PaymentService;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
@Controller
@RequestMapping("/pay")
@RequiredArgsConstructor
public class PaymentController {
private final HttpSession session;
private final PaymentService paymentService;
/**
* 포인트 충전 화면
*/
@GetMapping("/pointcharge")
public String getPaymentPage(Model model) {
User user = (User) session.getAttribute("principal");
String phoneNumber = user.getPhoneNumber;
model.addAttribute("phoneNumber", phoneNumber);
return "/payment/pointcharge"; // Mustache 파일 이름 (payment.mustache)
}
/**
* 토스 성공 페이지
*/
@GetMapping("/success")
public String getSuccessPage(ApproveDTO approvedDTO, Model model) throws IOException, InterruptedException {
// orderId, paymentKey, amount를 서버에 저장해야 함.
// paymentKey는 토스 페이먼츠에서 각 주문에 발급하는 고유 키 값이다. 결제 승인, 취소, 조회에 사용된다.
System.out.println("orderId : " + approvedDTO.getOrderId() + " paymentKey : "
+ approvedDTO.getPaymentKey() + " amount : " + approvedDTO.getAmount()); // TODO - 삭제 예정
// 결제 승인 요청
ChargeHistory result = paymentService.requestPayment(approvedDTO);
paymentService.createChargeHistory(result); // 결제 내역 저장 완료
// 구매내역(포인트 충전) update
paymentService.chargePoint(result);
User user = (User) session.getAttribute("principal");
// ChargeHistory에 담긴 값을 ChargeHistoryDTO에 담음
ChargeHistoryDTO dto = ChargeHistoryDTO.builder()
.name(user.getName())
.orderName(result.getOrderName())
.totalAmount(result.getTotalAmount())
.approvedAt(result.getApprovedAt())
.orderId(result.getOrderId())
.method(result.getMethod())
.build();
// 결제 내역 보여주기 위해 model에 값 담기
model.addAttribute("payment", dto);
model.addAttribute("user", user);
return "/payment/success";
}
/**
* 토스 실패 페이지
*/
@GetMapping("/fail")
public String getFailPage(){
return "/payment/fail";
}
}
success.mustache
결제 내역 확인 페이지
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>결제 성공</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f9f9f9;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
background-color: #fff;
padding: 30px;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
max-width: 500px;
text-align: center;
}
h1 {
color: #333;
font-size: 1.5rem;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
}
th, td {
padding: 10px;
text-align: left;
}
th {
background-color: #f2f2f2;
font-weight: bold;
}
td {
background-color: #fafafa;
}
.confirm-btn {
background-color: #007bff;
color: white;
border: none;
padding: 10px 20px;
font-size: 1rem;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
}
.confirm-btn:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<h1>{{user.username}}님, 결제가 완료되었어요.</h1>
<table>
<tr>
<th>구매상품</th>
<td>{{payment.orderName}}</td>
</tr>
<tr>
<th>결제금액</th>
<td>{{payment.totalAmount}}원</td>
</tr>
<tr>
<th>결제일시</th>
<td>{{payment.approvedAt}}</td>
</tr>
<tr>
<th>주문번호</th>
<td>{{payment.orderId}}</td>
</tr>
<tr>
<th>결제방식</th>
<td>{{payment.method}}</td>
</tr>
</table>
<!-- 확인 버튼 누르면 메인 화면으로 넘어가도록, 나중에 경로 수정하기 -->
<a href="/main" class="confirm-btn">확인</a>
</div>
</body>
</html>