
결제는 “돈 이동을 안전하게 약속/기록”하는 절차.
사용자가 결제 버튼을 누르면, 우리 서버가 결제대행사(PG) 와 대화하고, PG가 카드사/은행과 정산을 약속·확인하며, 결과를 우리에게 알려준다. 우리는 그 결과를 주문 상태로 반영한다.
결제의 기본 구조
사용자 → 상점서버 → 결제대행사(PG사) → 카드사/은행
사용자(User) : 결제 요청을 보냄 (ex. “구매하기” 클릭)
상점서버(Server) : 주문 생성, 금액 검증, 결제요청 데이터 구성
PG사(Payment Gateway) : 카드·계좌 결제 중계 (토스페이먼츠, 이니시스, 카카오페이 등)
카드사/은행 : 실제 승인 및 금액 이동
[클라이언트] 결제하기 클릭
↓
[서버] 주문 생성(Order) ──> [PG] 결제요청(금액·주문번호·서명)
↓(리다이렉트)
[사용자] 카드/간편결제 승인
↓
[PG] 결과 통지 ①리턴URL(브라우저) ②웹훅(서버로)
↓
[서버] 금액/서명 검증 → 결제확정(PAID) → 후처리(재고/영수증)
주문 생성(Order Create)
상품, 금액, 유저 정보 저장
결제 요청(Payment Request)
PG사의 SDK나 API 호출
승인/실패 결과 수신(Callback or Webhook)
PG사가 상점서버에 결제 성공/실패 결과 전달
결제 검증(Verification)
서버에서 금액·상태 재확인 (위변조 방지)
주문 상태 변경(Order Update)
성공 → 결제완료, 실패 → 결제취소
| 용어 | 의미 |
|---|---|
| PG(Payment Gateway) | 결제 요청을 대신 처리해주는 중간 플랫폼 |
| 결제토큰(Token) | 민감한 결제정보를 암호화한 임시키 |
| Webhook | PG → 서버로 보내는 결제결과 통지 |
| Approval | 결제 승인 요청 단계 |
| Refund | 결제 취소(환불) 단계 |
PG 테스트키·시크릿 받기 (Sandbox 모드)
리턴URL: https://내도메인/pay/return
웹훅URL: https://내도메인/pay/webhook
고유키: OrderId, PaymentId, IdempotencyKey(중복방지)
서버 시간 UTC, TLS(https), 방화벽에서 PG IP 허용
A. 결제 요청 단계
클라가 “결제하기” 클릭
서버 POST /api/pay/request
Order 생성(상태 PENDING)
서명(HMAC) 만들어서 PG 결제세션 생성
PG가 돌려준 paymentUrl을 응답 → 클라가 그 URL로 이동(리다이렉트)
B. 사용자 결제 & 리턴URL
사용자가 PG 결제화면에서 승인
PG가 브라우저를 GET /pay/return?orderId=..&pgTxnId=..&status=..&sig=.. 로 돌려보냄
서버는 쿼리 신뢰 X → HMAC 서명 검증 + PG 재조회(서버↔서버)
금액 일치·승인 OK면 임시로 PAID 반영(혹은 HOLD)
C. 웹훅(최종 확정)
거의 동시에 PG가 POST /pay/webhook (서버로)
HMAC 검증, 멱등성(pgTxnId 중복 차단), PG 재조회
조건 일치하면 최종 PAID 확정 및 Payment 레코드 기록
D. 환불
POST /api/pay/refund
마지막 성공 결제 조회 → PG 환불 API 호출 → 성공 시 REFUNDED 전이
핵심 알고리즘 포인트:
PG 재조회(verify): 브라우저 리턴은 위변조 가능 → 서버↔PG 직통으로 다시 확인
웹훅 멱등성: 동일 거래가 여러 번 와도 결과 1번만 반영
서명(HMAC): 요청·응답 위변조 방지
클라(결제하기) → 서버(Order 생성) → PG 세션생성 URL 반환
→ 사용자 결제 → 리턴URL 도착(브라우저)
→ 서버가 PG에 재조회 → 임시 승인
→ 웹훅 도착(서명검증+재조회) → 최종 PAID 확정
→ 환불 시 환불 API 호출
// Order.java
@Entity @Table(name="orders")
@Getter @Setter
public class Order {
@Id private String id; // ORD-uuid
private String userId;
private Long amount;
private String currency;
@Enumerated(EnumType.STRING)
private Status status; // PENDING, PAID, CANCELED, REFUNDED
private Instant createdAt;
public enum Status { PENDING, PAID, CANCELED, REFUNDED }
}
// Payment.java
@Entity @Table(name="payments", uniqueConstraints=@UniqueConstraint(columnNames="pgTxnId"))
@Getter @Setter
public class Payment {
@Id private String id; // PAY-uuid
private String orderId;
private String pgTxnId; // 멱등성(중복차단)
private Long amount;
private String method;
@Enumerated(EnumType.STRING)
private Status status; // REQ, SUCC, FAIL, REFUNDED
private Instant approvedAt;
public enum Status { REQ, SUCC, FAIL, REFUNDED }
}
// WebhookLog.java
@Entity @Getter @Setter
public class WebhookLog {
@Id @GeneratedValue private Long id;
@Lob private String raw;
private String signature;
private Instant receivedAt;
}
Order: 주문의 단 하나 진실(금액·통화·상태).
Payment: 결제 트랜잭션 이력(승인/실패/환불). pgTxnId 유니크로 멱등성 보장.
WebhookLog: 웹훅 원본 저장. 분쟁/장애시 감사 추적.
JPA가 테이블 매핑. 서비스 로직에서 상태 전이(PENDING→PAID→REFUNDED/ CANCELED)만 허용.
public interface OrderRepo extends JpaRepository<Order, String> {}
public interface PaymentRepo extends JpaRepository<Payment, String> {
Optional<Payment> findByPgTxnId(String pgTxnId);
Optional<Payment> findTopByOrderIdOrderByApprovedAtDesc(String orderId);
}
public interface WebhookLogRepo extends JpaRepository<WebhookLog, Long> {}
엔티티 CRUD·조회. 특히 findByPgTxnId는 중복 웹훅 차단에 필수.
인터페이스만 선언하면 Spring Data JPA가 구현체 자동 생성.
@Component
@RequiredArgsConstructor
public class PayUtil {
@Value("${pay.secret}") private String secret;
private final RestTemplate rt = new RestTemplate();
public String hmac(String data) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : raw) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) { throw new RuntimeException(e); }
}
public <T> T post(String url, Object body, Class<T> type) {
return rt.postForObject(url, body, type);
}
}
HMAC-SHA256: orderId|amount|currency 같은 정해진 규칙으로 서명해 위변조 방지.
HTTP 클라: PG API 호출(세션 생성/재조회/환불) 공통화.
Mac(HmacSHA256)로 해시 → hex 문자열.
RestTemplate로 PG에 POST.
@Data public class CreatePayReq { @NotBlank String userId; @NotNull Long amount; String currency="KRW"; }
@Data public class CreatePayRes { String orderId; String paymentUrl; }
@Data public class ReturnQuery { String orderId; String pgTxnId; String status; String sig; }
@Data public class WebhookDto {
String orderId; String pgTxnId; Long amount; String status; String signature; String eventTime;
}
@Data public class RefundReq { @NotBlank String orderId; @NotNull Long amount; }
@Component
@RequiredArgsConstructor
public class PgClient {
@Value("${pay.pgBase}") private String base;
private final PayUtil util;
@Data public static class SessionReq { String orderId; Long amount; String currency; String returnUrl; String webhookUrl; String idempotencyKey; String signature; }
@Data public static class SessionRes { String paymentUrl; }
@Data public static class VerifyReq { String pgTxnId; }
@Data public static class VerifyRes { boolean approved; Long amount; String method; }
public SessionRes createSession(SessionReq req){
// 실제: util.post(base+"/v1/payments", req, SessionRes.class)
SessionRes r=new SessionRes(); r.setPaymentUrl("https://sandbox.pg.com/pay/checkout?sid=TEST"); return r;
}
public VerifyRes verify(String pgTxnId){
VerifyRes v=new VerifyRes(); v.setApproved(true); v.setAmount(1000L); v.setMethod("CARD"); return v; // 데모
}
public boolean refund(String pgTxnId, long amount){ return true; } // 데모
}
PG별 엔드포인트/필드명이 다름. 이 컴포넌트만 갈아끼우면 토스/아임포트/이니시스 교체 가능.
createSession: 결제세션 만들고 paymentUrl 받음.
verify: 서버↔서버 재조회로 승인/금액/수단 확인.
refund: 환불 요청.
지금은 데모 리턴이지만, 실전에서는 util.post(base + "/v1/..", req) 형태로 호출.
@Service
@RequiredArgsConstructor
@Transactional
public class PayService {
private final OrderRepo orders; private final PaymentRepo pays;
private final WebhookLogRepo whlogs; private final PayUtil util; private final PgClient pg;
@Value("${pay.returnUrl}") String returnUrl; @Value("${pay.webhookUrl}") String webhookUrl;
public CreatePayRes requestPay(CreatePayReq req){
// (1) 주문 생성
Order od=new Order();
od.setId("ORD-"+UUID.randomUUID().toString().replace("-",""));
od.setUserId(req.getUserId()); od.setAmount(req.getAmount());
od.setCurrency(req.getCurrency()); od.setStatus(Order.Status.PENDING); od.setCreatedAt(Instant.now());
orders.save(od);
// (2) PG 세션 생성(+서명)
String signData = od.getId()+"|"+od.getAmount()+"|"+od.getCurrency();
String sig = util.hmac(signData);
PgClient.SessionReq s = new PgClient.SessionReq();
s.setOrderId(od.getId()); s.setAmount(od.getAmount()); s.setCurrency(od.getCurrency());
s.setReturnUrl(returnUrl); s.setWebhookUrl(webhookUrl);
s.setIdempotencyKey("IDEMP-"+UUID.randomUUID()); s.setSignature(sig);
PgClient.SessionRes res = pg.createSession(s);
// (3) Payment pre-row(Optional)
Payment p=new Payment();
p.setId("PAY-"+UUID.randomUUID().toString().replace("-",""));
p.setOrderId(od.getId()); p.setAmount(od.getAmount());
p.setStatus(Payment.Status.REQ); pays.save(p);
CreatePayRes out=new CreatePayRes(); out.setOrderId(od.getId()); out.setPaymentUrl(res.getPaymentUrl()); return out;
}
public String handleReturn(ReturnQuery q){
// (1) 서명검증
String calc = util.hmac(q.getOrderId()+"|"+q.getPgTxnId()+"|"+q.getStatus());
if(!calc.equals(q.getSig())) return "서명 오류";
// (2) 재조회(브라우저 리턴은 신뢰 X)
PgClient.VerifyRes v = pg.verify(q.getPgTxnId());
if(!v.isApproved()) return "결제 미승인";
// (3) 임시반영(최종은 웹훅에서 확정)
Order od = orders.findById(q.getOrderId()).orElseThrow();
if(od.getAmount().longValue()!=v.getAmount()) return "금액 불일치";
od.setStatus(Order.Status.PAID); // 임시승인(원하면 HOLD로 두고 웹훅에서 PAID로)
// 결제행 업데이트
Payment pay = pays.findTopByOrderIdOrderByApprovedAtDesc(od.getId()).orElse(new Payment());
pay.setOrderId(od.getId()); pay.setAmount(v.getAmount()); pay.setPgTxnId(q.getPgTxnId());
pay.setMethod(v.getMethod()); pay.setStatus(Payment.Status.SUCC); pay.setApprovedAt(Instant.now());
pays.save(pay);
return "결제 성공";
}
public void handleWebhook(WebhookDto dto){
// 로그
WebhookLog log=new WebhookLog(); log.setRaw(new Gson().toJson(dto)); log.setSignature(dto.getSignature()); log.setReceivedAt(Instant.now()); whlogs.save(log);
// 서명검증
String calc = util.hmac(dto.getOrderId()+"|"+dto.getPgTxnId()+"|"+dto.getAmount()+"|"+dto.getStatus());
if(!calc.equals(dto.getSignature())) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"bad signature");
// 멱등성
if(pays.findByPgTxnId(dto.getPgTxnId()).isPresent()) return;
// 재조회
PgClient.VerifyRes v = pg.verify(dto.getPgTxnId());
if(dto.getAmount()!=null && !Objects.equals(dto.getAmount(), v.getAmount())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"amount mismatch");
Order od = orders.findById(dto.getOrderId()).orElseThrow();
if("SUCCESS".equals(dto.getStatus()) && v.isApproved()){
od.setStatus(Order.Status.PAID);
Payment p=new Payment();
p.setId("PAY-"+UUID.randomUUID().toString().replace("-",""));
p.setOrderId(od.getId()); p.setPgTxnId(dto.getPgTxnId());
p.setAmount(v.getAmount()); p.setMethod(v.getMethod());
p.setStatus(Payment.Status.SUCC); p.setApprovedAt(Instant.now());
pays.save(p);
} else if ("FAIL".equals(dto.getStatus())) {
od.setStatus(Order.Status.CANCELED);
Payment p=new Payment();
p.setId("PAY-"+UUID.randomUUID().toString().replace("-",""));
p.setOrderId(od.getId()); p.setPgTxnId(dto.getPgTxnId());
p.setAmount(dto.getAmount()); p.setStatus(Payment.Status.FAIL);
pays.save(p);
}
}
public boolean refund(RefundReq req){
Order od = orders.findById(req.getOrderId()).orElseThrow();
Payment last = pays.findTopByOrderIdOrderByApprovedAtDesc(req.getOrderId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST,"no payment"));
if(last.getStatus()!=Payment.Status.SUCC) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"not refundable");
boolean ok = pg.refund(last.getPgTxnId(), req.getAmount());
if(ok){
od.setStatus(Order.Status.REFUNDED);
last.setStatus(Payment.Status.REFUNDED);
}
return ok;
}
}
상태 전이·검증·멱등성·로깅을 트랜잭션으로 보장.
requestPay
주문 생성(PENDING) → 서명 생성 → PG 세션 호출 → 임시 Payment(REQ) 기록 → paymentUrl 반환.
handleReturn
리턴URL 서명 검증 → PG 재조회 → 금액 일치/승인 OK면 주문 상태 갱신(PAID 또는 HOLD) 및 Payment 갱신.
(리턴은 “유저 화면용”이라 임시 반영, 최종은 웹훅에서 확정하는 패턴이 안전)
handleWebhook
웹훅 원문 저장 → HMAC 검증 → 멱등성 검사(pgTxnId 중복 차단) → 재조회 → 일치 시 최종 PAID 확정(또는 실패 처리).
refund
마지막 성공 결제 찾기 → PG 환불 API → 성공 시 Order=REFUNDED, Payment=REFUNDED.
왜 “리턴에서 바로 확정” 안 하냐?
리턴URL은 사용자가 쿼리를 조작할 수 있고, 프록시/차단으로 누락될 수도 있다.
웹훅은 서버 직통이고, 다시 한 번 재조회하므로 신뢰도가 높아 최종 확정에 적합.
@RestController
@RequiredArgsConstructor
public class PayController {
private final PayService svc;
@PostMapping("/api/pay/request")
public CreatePayRes request(@Valid @RequestBody CreatePayReq req){ return svc.requestPay(req); }
@GetMapping("/pay/return")
public ResponseEntity<String> ret(ReturnQuery q){ return ResponseEntity.ok(svc.handleReturn(q)); }
@PostMapping("/pay/webhook")
public ResponseEntity<Void> webhook(@RequestBody WebhookDto dto){ svc.handleWebhook(dto); return ResponseEntity.ok().build(); }
@PostMapping("/api/pay/refund")
public Map<String,Object> refund(@RequestBody @Valid RefundReq req){
boolean ok = svc.refund(req); return Map.of("refunded", ok);
}
}
/api/pay/request : 결제 시작(결제URL 발급)
/pay/return : 브라우저 리다이렉트 수신(임시확인/화면 피드백)
/pay/webhook : PG 서버 콜백(최종확정)
/api/pay/refund : 환불
서명(HMAC)
목적: 파라미터 위변조 방지(금액·주문번호 바꾸기 방어).
방식: 서버만 아는 secret으로 데이터 문자열을 HMAC → PG/서버가 서로 비교.
서버↔서버 재조회
목적: 리턴URL(브라우저 경유) 신뢰 불가. PG 원장에게 “정말 승인됐냐?” 확인.
확인 항목: 승인여부, 금액/통화, 결제수단, 거래 ID.
멱등성(pgTxnId 유니크)
목적: 중복 웹훅/중복요청으로 2번 처리되는 사고 방지.
구현: payments.pgTxnId에 유니크 인덱스 + findByPgTxnId 체크.
상태머신(전이만 허용)
목적: 비정상 상태 “점프” 방지.
예: PENDING → PAID → REFUNDED(OK) / PENDING → REFUNDED(NG).
로깅(WebhookLog/원문)
목적: 장애/분쟁 시 복구·증빙.
저장: 원문 JSON, 서명, 수신시간.
리턴URL만 오고 웹훅 누락:
주기적으로 PENDING/HOLD 주문을 PG 재조회해서 보정(배치 or 관리자 버튼).
웹훅 먼저 오고 리턴URL 늦음:
상관없음. 웹훅이 진실이라 상태는 이미 PAID.
금액 불일치:
즉시 실패 처리 + 관리자 알림(의도적 변조 가능성).
PG 타임아웃/500:
재시도 정책(ex. 3회 지수백오프). 단, 생성계열은 idempotencyKey 유지해서 중복생성 방지.
웹훅 서명 불일치:
403로 거절 + 원문 보관 + IP/서명키 점검.
HTTPS 필수, 서버시간 NTP 동기화.
시크릿/키는 환경변수로(PG_SECRET).
PG 송신 IP 화이트리스트(가능하면 WAF).
서명 문자열 규칙은 PG 문서를 정확히 따를 것(구분자, 정렬, 타임스탬프 포함 여부).
Idempotency-Key 헤더/필드 지원 시 반드시 사용(세션 생성·환불).
감사 로그 보존 주기(법/약관 준수).
테스트 케이스: 승인성공/거절/사용자취소/서명오류/금액불일치/중복웹훅/부분환불.