이전 편에서는 토스페이먼츠 결제 플로우를 작성했습니다.
이번 편에서는 결제 연동 구현한 것을 정리했습니다.
이 방식이 옳은 방식은 아닐 수도 있으므로, 참고만 부탁드립니다!
더 좋은 방식이 있으면 댓글도 부탁드립니다. 🙇♀️
들어가며
이번 글에서는 토스페이먼츠 결제 위젯을 활용해
주문 → 결제 요청 → 결제 승인 → 결과 페이지 이동까지의 흐름을 정리했습니다.
- 주문서 진입 시 Payment 객체를 미리 생성해 서버 기준 결제 정보를 저장해두고, 결제 위젯 성공 이후에는 preparing 단계를 거치게 했습니다.
- preparing 단계에서 파라미터로 받은 paymentKey와 amount 정보 등을 서버에 보내 검증했습니다.
- 그리고 외부 toss api 호출을 하여 최종 결제 승인을 요청합니다.
- 결제 승인 결과에 따라 success 또는 fail 화면을 client에게 보여줍니다.
1. checkout.html 구성하기
결제 연동 시작은 주문서 페이지(checkout.html) 입니다.
1-1. 주문서 진입 시 Payment 객체 생성
@PostMapping("/checkout")
public String checkout(@RequestParam("userId") Long userId, @RequestParam("orderId") UUID orderId,
@RequestParam("orderName") String orderName, @RequestParam("amount") Long amount, Model model) {
User user = userService.findByUser(userId);
Order order = orderService.findByOrder(orderId);
// Payment 객체 생성
paymentPageService.createPayment(user, order);
List<String> itemNames = order.getOrderItems()
.stream()
.map(oi -> oi.getMenuItem().getName())
.collect(Collectors.toList());
// view에 전달
model.addAttribute("user", user);
model.addAttribute("order", order);
model.addAttribute("itemNames", itemNames);
return "checkout";
}
주문서 페이지에 진입하면 서버에서는 Payment 객체를 생성합니다.
이 시점에 Payment를 생성하는 이유는 이후 결제 승인 단계에서 클라이언트 위변조 여부 검증을 위해 미리 생성합니다.
Payment.java
public class Payment extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@OneToOne
@JoinColumn(name = "order_id")
private Order order;
private String tossOrderId;
@Enumerated(EnumType.STRING)
private PaymentType paymentType;
private BigInteger amount;
@Enumerated(EnumType.STRING)
private PaymentStatus paymentStatus;
@Column(name = "payment_key")
private String paymentKey;
@Enumerated(EnumType.STRING)
private FailReason failReason;
private String refundReason;
private LocalDateTime approvedAt;
}
1-2. 결제 위젯 초기화 및 결제 요청
결제 요청 시 필요한 값들은 JavaScript에서도 사용해야해서
js 변수에는 /[[${...}]]/ 방식을 사용해서 저 3가지 값을 가지고 옵니다.
checkout.html
<script th:inline="javascript">
/*<![CDATA[*/
const totalAmount = /*[[${order.totalPrice}]]*/ 0;
const orderId = /*[[${order.id}]]*/ '';
const itemNames = /*[[${itemNames}]]*/ [];
const orderName = itemNames.join(', ');
const userId = /*[[${user.id}]]*/ 0;
/*]]>*/
</script>
main() 함수에서는 토스페이먼츠 결제 위젯을 초기화하고,
결제 버튼 클릭 시 결제 요청을 수행합니다.
checkout.html
async function main() {
const button = document.getElementById("payment-button");
const coupon = document.getElementById("coupon-box");
// ------ 결제위젯 초기화 ------
const clientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
const tossPayments = TossPayments(clientKey);
const widgets = tossPayments.widgets({customerKey: "eyOAk1FoZbRguExkPqAH_"});
// ------ 주문의 결제 금액 설정 ------
await widgets.setAmount({currency: "KRW", value: totalAmount});
await Promise.all([
widgets.renderPaymentMethods({selector: "#payment-method", variantKey: "DEFAULT"}),
widgets.renderAgreement({selector: "#agreement", variantKey: "AGREEMENT"})
]);
// ------ 쿠폰 체크 시 결제 금액 업데이트 ------
coupon.addEventListener("change", async function () {
const amountToPay = coupon.checked ? totalAmount - 5000 : totalAmount;
await widgets.setAmount({currency: "KRW", value: amountToPay});
});
const successUrl = window.location.origin + "/preparing";
const failUrl = window.location.origin + "/fail";
// ------ 결제하기 버튼 클릭 시 결제 요청 ------
button.addEventListener("click", async function () {
await widgets.requestPayment({
orderId: finalOrderId,
orderName: orderName,
successUrl: successUrl,
failUrl: failUrl,
customerEmail: customerEmail,
customerName: customerName,
customerMobilePhone: customerMobileReplace,
});
});
}
여기에서 주의해야할 점은
1. 결제 금액은 서버 기준 값 사용
await widgets.setAmount({ value: totalAmount });
서버에서 전달한 order.totalPrice 로 set.
2. successUrl을 /preparing 으로 설정
const successUrl = window.location.origin + "/preparing";
이러면 위젯에서 성공적으로 승인이 일어나면 /preparing 로 redirect 됩니다.
2. preparingUrl에서 결제 승인 요청을 처리하기
checkout 페이지에서 결제 위젯을 통해 사용자 정보 입력과 카드사 승인이 완료되면, 결제 위젯은 설정해둔 successUrl로 이동합니다.
이번 구현에서는 successUrl을 바로 성공 페이지로 두지 않고, 중간 단계인 preparingUrl을 거치도록 설계했습니다.
2-1. 카드사 승인 이후 preparingUrl 로 redirect
결제 위젯에서 카드사 승인이 완료되면 다음과 같은 파라미터와 함께 preparingUrl로 이동합니다.
- paymentKey
- orderId
- amount
- userId
이때 paymentKey 발급됩니다!
@GetMapping("/preparing")
public String preparing(Long userId, Model model) {
model.addAttribute("userId", userId);
return "preparing";
}
2-2. preparing.html
preparing.html 전체 코드
<body>
<h2>결제 준비중...</h2>
<p id="paymentKey"></p>
<p id="orderId"></p>
<p id="amount"></p>
<script>
// 쿼리 파라미터 값이 결제 요청할 때 보낸 데이터와 동일한지 반드시 확인하세요.
// 클라이언트에서 결제 금액을 조작하는 행위를 방지할 수 있습니다.
const urlParams = new URLSearchParams(window.location.search);
const userId = urlParams.get("userId");
const paymentKey = urlParams.get("paymentKey");
const orderId = urlParams.get("orderId");
const amount = urlParams.get("amount");
async function confirm() {
const requestData = {
paymentKey: paymentKey,
orderId: orderId,
amount: amount,
};
const response = await fetch("/v1/test/payment/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
});
const json = await response.json();
if (!response.ok) {
// TossConfirmPageException 발생 → fail 페이지로 redirect
window.location.href = `/fail&code=${json.code}&message=${encodeURIComponent(json.message)}`;
return;
} else {
window.location.href = `/success&code=${json.code}&message=${encodeURIComponent(json.message)}&paymentKey=${paymentKey}&orderId=${orderId}&amount=${amount}`;
}
console.log("결제 승인 성공:", json);
}
confirm();
const paymentKeyElement = document.getElementById("paymentKey");
const orderIdElement = document.getElementById("orderId");
const amountElement = document.getElementById("amount");
orderIdElement.textContent = "주문번호: " + orderId;
amountElement.textContent = "결제 금액: " + amount;
paymentKeyElement.textContent = "paymentKey: " + paymentKey;
</script>
prearing.html은 결제 승인(confirm)을 서버에 요청하기 위한 중간 처리 페이지입니다.
<h2>결제 준비중...</h2>
이 화면이 잠깐 노출되는 동안,
클라이언트에서는 쿼리 파라미터로 전달받은 값들을 추출합니다.
const urlParams = new URLSearchParams(window.location.search);
const userId = urlParams.get("userId");
const paymentKey = urlParams.get("paymentKey");
const orderId = urlParams.get("orderId");
const amount = urlParams.get("amount");
이 값들은 신뢰할 수 있는 값이 아니라서 서버에서 반드시 검증을 해야합니다.
2-3. 결제 승인(confirm) 요청을 서버로 전달
preparingUrl에서는 서버에 paymentKey, orderId, amount를 requestBody에 담아 보냅니다.
const requestData = {
paymentKey: paymentKey,
orderId: orderId,
amount: amount,
};
const response = await fetch("/v1/test/payment/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestData),
});
2-4. 서버: 토스에 결제 승인 요청
preparing 페이지에서 전달받은 paymentKey, orderId, amount를 기반으로 서버에서 토스 결제 승인 API를 호출합니다.
@ResponseBody
@PostMapping("/v1/test/payment/confirm")
public ResponseEntity<BaseResponseDto<?>> confirmPayment(@RequestBody ConfirmPaymentPageRequestDto requestDto) {
ConfirmPaymentPageResponseDto response = paymentPageService.confirmPayment(requestDto);
return ResponseEntity.ok(BaseResponseDto.success("결제 요청을 승인합니다.", response));
}
@Transactional
@Override
public ConfirmPaymentPageResponseDto confirmPayment(ConfirmPaymentPageRequestDto requestDto) {
String orderIdWithTimestamp = validateAndExtractOrderId(requestDto.getOrderId());
Order order = findOrderOrThrow(orderIdWithTimestamp);
validatePaymentStatus(order); // 1. payment 객체 상태가 PENDING 인지 확인
Payment payment = findPaymentByOrderOrThrow(order);
validateAmount(payment, requestDto.getAmount()); // 2. 서버에 담긴 값과 같은지 확인
ConfirmPaymentPageResponseDto responseDto;
try {
responseDto = tossPaymentWebClient.confirmPayment(requestDto); // 3. 토스 서버에 결제 승인 요청
} catch (TossConfirmPageException ex) {
paymentTransactionService.markPaymentFailed(payment); // 3-1. 결제 실패 시 payment 객체 결제 상태 FAILED로 변경
throw ex;
}
updatePaymentAndOrder(payment, order, responseDto, orderIdWithTimestamp); // 4. 최종결제 승인 후, payment 객체와 order 객체 상태 변경
return responseDto;
}
- validatePaymentStatus()
- payment 객체 상태가 PENDING 인지 검증합니다.
- validateAmount()
- 이 함수에서 이전에 생성해둔 Payment에 저장된 amount 값과 client가 보낸 amount 값이 같은지 검증합니다.
- 클라이언트의 금액 조작을 방지하기 위해 검증합니다.
- 토스 승인 요청 전에 반드시 서버 기준 값으로 검증을해야합니다.
- 토스 서버에 결제 승인 요청.
2-5. 토스 결제 승인 API 호출
앞 단계에서 결제 금액과 결제 상태를 모두 검증했다면,
이제 서버는 토스 페이먼츠에 최종 결제 승인을 요청합니다.
try {
responseDto = tossPaymentWebClient.confirmPayment(requestDto); // 3. 토스 서버에 결제 승인 요청
}
토스 결제 승인 API 호출 방식
public ConfirmPaymentPageResponseDto confirmPayment(ConfirmPaymentPageRequestDto requestDto) {
String widgetSecretKey = secretKey;
String authorizations =
"Basic " + Base64.getEncoder().encodeToString((widgetSecretKey + ":").getBytes(StandardCharsets.UTF_8));
return webClient.post()
.uri("/payments/confirm")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", authorizations)
.bodyValue(requestDto)
.retrieve()
.onStatus(status -> !status.is2xxSuccessful(), clientResponse ->
clientResponse.bodyToMono(TossErrorResponseDto.class)
.flatMap(error -> Mono.error(
new TossConfirmPageException(error.getCode(), error.getMessage())
))
)
.bodyToMono(ConfirmPaymentPageResponseDto.class)
.block();
}
/payments/confirm API는 Secrekt Key를 사용해 인증합니다.
이 키는 절대 클라이언트에 노출되면 안됩니다.
webClient 비동기 방식으로 외부 api를 요청했습니다.
만약 토스 서버에서 승인 실패 응답이 내려오면 TossConfirmPageException() 커스텀 예외처리를합니다.
실패 시
catch (TossConfirmPageException ex) {
paymentTransactionService.markPaymentFailed(payment); // 3-1. 결제 실패 시 payment 객체 결제 상태 FAILED로 변경
throw ex;
}
토스 결제 승인 요청 중 오류가 나거나, 서버 검증 단계에서 문제가 발생되면 결제는 실패 처리됩니다.
markPaymentFailed() 함수를 실행됩니다.
이 경우 서버에서는
- Payment 상태를 FAILED로 변경
- 실패 사유(failReason) 기록
그 후 preparing 페이지는 실패 응답을 받고 사용자를 fail 페이지로 이동시킵니다.
보통 카드 잔액 부족, 네트워크 오류, 토스 승인 거절 등의 이유로 실패가 일어납니다.
3. 결제 승인 결과 여부에 따라 successUrl/failUrl 이동
updatePaymentAndOrder(payment, order, responseDto, orderIdWithTimestamp); // 4. 최종결제 승인 후, payment 객체와 order 객체 상태 변경
토스의 결제 승인 API가 정상적으로 응답하면,
서버는 해당 결제를 최종 승인된 결제로 기록합니다.
- Payment 상태를 PAID로 변환하고
- 토스에서 발급한 paymentKey 저장
- Order 상태도 결제 완료 상태로 변경
승인 결과에 따른 클라이언트 이동 처리
preparing.html
if (!response.ok) {
// TossConfirmPageException 발생 → fail 페이지로 redirect
window.location.href = `/fail&code=${json.code}&message=${encodeURIComponent(json.message)}`;
return;
} else {
window.location.href = `/success&code=${json.code}&message=${encodeURIComponent(json.message)}&paymentKey=${paymentKey}&orderId=${orderId}&amount=${amount}`;
}
서버에서 결제 승인과 상태 변경이 모두 완료되면, preparing 페이지의 JavaScript는 응답 결과에 따라 사용자를 success 페이지로 이동시킵니다.
@GetMapping("/success")
public String success(Model model) {
model.addAttribute("userId", userId);
return "success";
}
@GetMapping("/fail")
public String fail(Model model) {
model.addAttribute("userId", userId);
return "fail";
}
서버의 응답에 따라 successUrl로 갈 수도 있고, failUrl로 갈 수도 있습니다.
위 코드는 사용자에게 페이지 보여주는 controller입니다.
회고
처음에 결제 플로우를 이해하는 데 시간을 많이 썼습니다. Payment 객체에는 어떤 필드 값들이 필요하고, 결제 상태는 어떤 단계로 둘 것인지, 클라이언트 금액 위변조 방지는 어떻게 할지, Payment 객체는 언제 생성하는지 등 여러 가지를 고민하며 구현했습니다. 이 과정에서 팀원과 처음으로 페어프로그래밍을 진행했는데, 각자가 생각한 결제 플로우 의견을 얘기하면서 구현해 나간 점에서 많이 배우고 기억에 오래 남았습니다.
튜터님 피드백에서는 최종 결제 승인이 났는데 서버 오류로 인해 서버에서 처리가 제대로 이루어지지 않은 경우나, 중복 결제 요청이 발생하는 경우를 고려해서 고도화해보면 좋겠다는 피드백을 남겨주셨습니다. 튜터님의 피드백을 듣고 단순히 “결제가 된다”는 결과보다, 언제 어떤 상태를 서버가 책임져야 하는지 좀 더 고민하게 됐습니다. 아직 모든 예외 상황을 완벽하게 처리된 코드는 아니지만, 결제 시스템에서 서버가 맡아야 할 역할과 책임을 인식하게 된 경험이라는 점에서 의미있는 프로젝트였습니다.
'Spring Boot' 카테고리의 다른 글
| [SpringBoot] 토스페이먼츠 결제 플로우 이해하기 - 1편 (2) | 2026.01.04 |
|---|---|
| DB 동시성 문제 어떤 경우에 발생할까요? DB Lock의 종류 (0) | 2025.11.21 |
| 자주 사용하는 Lombok 생성자 어노테이션 (0) | 2025.10.23 |
| @Transactional(readOnly =true) 하는 이유 (0) | 2025.10.18 |
| Dto와 @Builder에 대하여 (0) | 2025.10.06 |