전자상거래 또는 결제 시스템을 개발할 때 가장 민감한 문제 중 하나는 **"중복 결제"**입니다.
사용자가 결제를 완료한 후 페이지를 새로고침(F5) 했더니 결제가 두 번 처리되는 현상,
이거 정말 무서운 상황이죠.
이번 글에서는 중복 결제 발생 원인과 이를 방지하기 위한 PRG 패턴(Post → Redirect → Get) 적용 방법을 실제 코드와 함께 정리해보겠습니다.
문제 상황: 왜 새로고침 시 결제가 2번 처리될까?
아래와 같은 결제 로직을 생각해봅시다.
@PostMapping("/pay")
public String pay(@ModelAttribute PaymentRequest request, Model model) {
paymentService.process(request);
model.addAttribute("message", "결제가 완료되었습니다!");
return "pay-success";
}
이 구조에서는 결제가 끝난 뒤 pay-success.html을 반환합니다.
하지만 이건 POST 요청 결과를 그대로 화면에 보여주고 있기 때문에,
사용자가 브라우저에서 새로고침(F5) 하면 POST 요청이 다시 전송됩니다.
그 결과?
paymentService.process()가 두 번 호출되어 중복 결제 발생
해결 방법: PRG 패턴이란?
PRG(Post → Redirect → Get) 패턴은 다음 순서로 동작합니다:
- 사용자가 POST 요청을 보냄 → 서버가 처리
- 처리 후 서버는 리디렉션(redirect) 으로 응답
- 브라우저가 새로운 GET 요청을 보냄
- 서버는 성공 화면 반환
이 구조는 POST → GET으로 흐름을 끊기 때문에 새로고침 시 POST가 재전송되지 않아 중복 처리를 예방할 수 있습니다.
프로젝트 구조
payment-app/
├── src/
│ └── main/
│ ├── java/
│ │ └── com/example/payment/
│ │ ├── controller/
│ │ │ └── PaymentController.java
│ │ ├── dto/
│ │ │ └── PaymentRequest.java
│ │ ├── service/
│ │ │ └── PaymentService.java
│ │ └── PaymentAppApplication.java
│ └── resources/
│ ├── templates/
│ │ ├── pay-success.html
│ │ ├── pay-fail.html
│ │ └── index.html (선택)
│ └── application.properties
└── build.gradle
폴더/파일 | 설명 |
controller/PaymentController.java | 결제 요청을 처리하는 컨트롤러 |
dto/PaymentRequest.java | 사용자가 전달한 결제 정보 DTO |
service/PaymentService.java | 결제 비즈니스 로직 처리 |
templates/pay-success.html | 결제 성공 시 사용자에게 보여줄 페이지 |
templates/pay-fail.html | 결제 실패 시 사용자에게 보여줄 페이지 |
application.properties | 포트, 뷰 설정 등 환경 설정 |
build.gradle or pom.xml | 프로젝트 의존성 관리 |
PaymentAppApplication.java
package com.example.payment;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class PaymentAppApplication {
public static void main(String[] args) {
SpringApplication.run(PaymentAppApplication.class, args);
}
}
PaymentRequest.java (DTO)
package com.example.payment.dto;
public class PaymentRequest {
private String orderId;
private int amount;
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
public int getAmount() {
return amount;
}
public void setAmount(int amount) {
this.amount = amount;
}
}
PaymentService.java
package com.example.payment.service;
import com.example.payment.dto.PaymentRequest;
import org.springframework.stereotype.Service;
import java.util.HashSet;
import java.util.Set;
@Service
public class PaymentService {
private final Set<String> processedOrders = new HashSet<>();
public boolean process(PaymentRequest request) {
if (processedOrders.contains(request.getOrderId())) {
return false;
}
System.out.println("결제 처리됨: 주문번호=" + request.getOrderId() + ", 금액=" + request.getAmount());
processedOrders.add(request.getOrderId());
return true;
}
}
PaymentController.java
package com.example.payment.controller;
import com.example.payment.dto.PaymentRequest;
import com.example.payment.service.PaymentService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/pay")
public String pay(@ModelAttribute PaymentRequest request,
RedirectAttributes redirectAttributes) {
boolean success = paymentService.process(request);
if (success) {
redirectAttributes.addFlashAttribute("message", "결제가 완료되었습니다!");
return "redirect:/pay/success";
} else {
redirectAttributes.addFlashAttribute("errorMessage", "결제에 실패했습니다. (중복 결제 또는 오류)");
return "redirect:/pay/fail";
}
}
@GetMapping("/pay/success")
public String showSuccessPage(Model model) {
return "pay-success";
}
@GetMapping("/pay/fail")
public String showFailPage(Model model) {
return "pay-fail";
}
}
pay-success.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>결제 성공</title>
</head>
<body>
<h2>✅ 결제 성공</h2>
<div th:if="${message}" style="color:green;" th:text="${message}"></div>
<br>
<a href="/">홈으로</a>
</body>
</html>
pay-fail.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>결제 실패</title>
</head>
<body>
<h2>❌ 결제 실패</h2>
<div th:if="${errorMessage}" style="color:red;" th:text="${errorMessage}"></div>
<br>
<a href="/">홈으로</a>
</body>
</html>
index.html (예시: 결제 폼)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>결제 폼</title>
</head>
<body>
<h2>? 결제 요청</h2>
<form th:action="@{/pay}" method="post">
<label>주문번호: <input type="text" name="orderId" required></label><br><br>
<label>결제 금액: <input type="number" name="amount" required></label><br><br>
<button type="submit">결제하기</button>
</form>
</body>
</html>
application.properties
# Thymeleaf 템플릿 위치 설정 (기본값이지만 명시적으로 설정)
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.cache=false
build.gradle (Gradle 사용 시)
plugins {
id 'org.springframework.boot' version '3.1.0'
id 'io.spring.dependency-management' version '1.1.0'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
실행 및 테스트
- localhost:8080에 접속해 index.html 폼에서 주문번호와 금액을 입력
- 결제 성공 시 /pay/success로 리디렉션되고 메시지 출력
- 새로고침해도 POST 재요청이 발생하지 않아 중복 결제 방지
- 동일 주문번호로 다시 결제 요청하면 /pay/fail로 이동
마무리 요약
포인트 | 설명 |
PRG 패턴 | POST 후 바로 페이지를 반환하지 않고 redirect 처리로 GET 요청 유도 |
FlashAttribute | 리디렉트 후 일회성 메시지를 전달 (Thymeleaf에서 사용 가능) |
중복 방지 | 결제 처리 전에 이미 처리된 주문인지 확인하는 로직 필요 (예: UUID + DB) |
댓글