TokyoAJ

도쿄아재

SPRINGBOOT 2025.04.11

Spring Boot 결제 처리: 새로고침 시 중복 결제 방지하기 (PRG 패턴 적용)

전자상거래 또는 결제 시스템을 개발할 때 가장 민감한 문제 중 하나는 **"중복 결제"**입니다.

사용자가 결제를 완료한 후 페이지를 새로고침(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) 패턴은 다음 순서로 동작합니다:

  1. 사용자가 POST 요청을 보냄 → 서버가 처리
  2. 처리 후 서버는 리디렉션(redirect) 으로 응답
  3. 브라우저가 새로운 GET 요청을 보냄
  4. 서버는 성공 화면 반환

이 구조는 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;

/**
* 사용자의 결제 요청 정보를 담는 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 {

// 예시로 중복 결제를 막기 위해 간단한 in-memory 처리
private final Set<String> processedOrders = new HashSet<>();

/**
* 결제 처리 메서드
* @param request 사용자의 결제 요청
* @return 결제 성공 여부
*/
public boolean process(PaymentRequest request) {
// 이미 처리된 주문이면 false
if (processedOrders.contains(request.getOrderId())) {
return false;
}

// 실제 결제 처리 로직 (PG 연동, DB 저장 등)
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;
}

/**
* 결제 요청 처리 (POST)
* 결제 후 성공/실패에 따라 리디렉션 처리 (PRG 패턴)
*/
@PostMapping("/pay")
public String pay(@ModelAttribute PaymentRequest request,
RedirectAttributes redirectAttributes) {

boolean success = paymentService.process(request);

if (success) {
// 결제 성공 메시지를 FlashAttribute로 전달 (리디렉션 후 한 번만 사용 가능)
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'
}


실행 및 테스트

  1. localhost:8080에 접속해 index.html 폼에서 주문번호와 금액을 입력
  2. 결제 성공 시 /pay/success로 리디렉션되고 메시지 출력
  3. 새로고침해도 POST 재요청이 발생하지 않아 중복 결제 방지
  4. 동일 주문번호로 다시 결제 요청하면 /pay/fail로 이동


마무리 요약

포인트설명
PRG 패턴POST 후 바로 페이지를 반환하지 않고 redirect 처리로 GET 요청 유도
FlashAttribute리디렉트 후 일회성 메시지를 전달 (Thymeleaf에서 사용 가능)
중복 방지결제 처리 전에 이미 처리된 주문인지 확인하는 로직 필요 (예: UUID + DB)





댓글