결제 구현하기-01

Kingyj·2025년 10월 10일

  • 이 포스트는 지금 작업중인 프로젝트의 결제 진행 전 이론 정리용
    (LIFE IN SEOUL)

🐤 결제 ?

결제는 “돈 이동을 안전하게 약속/기록”하는 절차.

사용자가 결제 버튼을 누르면, 우리 서버가 결제대행사(PG) 와 대화하고, PG가 카드사/은행과 정산을 약속·확인하며, 결과를 우리에게 알려준다. 우리는 그 결과를 주문 상태로 반영한다.


🐤 결제 구현 개념 정리

결제의 기본 구조

사용자 → 상점서버 → 결제대행사(PG사) → 카드사/은행

사용자(User) : 결제 요청을 보냄 (ex. “구매하기” 클릭)
상점서버(Server) : 주문 생성, 금액 검증, 결제요청 데이터 구성
PG사(Payment Gateway) : 카드·계좌 결제 중계 (토스페이먼츠, 이니시스, 카카오페이 등)
카드사/은행 : 실제 승인 및 금액 이동


🐤 결제의 주요 단계

[클라이언트] 결제하기 클릭
   ↓
[서버] 주문 생성(Order)  ──> [PG] 결제요청(금액·주문번호·서명)(리다이렉트)
[사용자] 카드/간편결제 승인
   ↓
[PG] 결과 통지 ①리턴URL(브라우저) ②웹훅(서버로)
   ↓
[서버] 금액/서명 검증 → 결제확정(PAID) → 후처리(재고/영수증)
  1. 주문 생성(Order Create)
    상품, 금액, 유저 정보 저장

  2. 결제 요청(Payment Request)
    PG사의 SDK나 API 호출

  3. 승인/실패 결과 수신(Callback or Webhook)
    PG사가 상점서버에 결제 성공/실패 결과 전달

  4. 결제 검증(Verification)
    서버에서 금액·상태 재확인 (위변조 방지)

  5. 주문 상태 변경(Order Update)
    성공 → 결제완료, 실패 → 결제취소


🐤 핵심 개념

용어의미
PG(Payment Gateway)결제 요청을 대신 처리해주는 중간 플랫폼
결제토큰(Token)민감한 결제정보를 암호화한 임시키
WebhookPG → 서버로 보내는 결제결과 통지
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가 구현체 자동 생성.

유틸(HMAC/HTTP 클라)

@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.

DTO

@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; }

PG 클라이언트

@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 : 환불


🐤각 단계의 “검증 로직”이 필요한 이유

  1. 서명(HMAC)
    목적: 파라미터 위변조 방지(금액·주문번호 바꾸기 방어).
    방식: 서버만 아는 secret으로 데이터 문자열을 HMAC → PG/서버가 서로 비교.

  2. 서버↔서버 재조회
    목적: 리턴URL(브라우저 경유) 신뢰 불가. PG 원장에게 “정말 승인됐냐?” 확인.
    확인 항목: 승인여부, 금액/통화, 결제수단, 거래 ID.

  3. 멱등성(pgTxnId 유니크)
    목적: 중복 웹훅/중복요청으로 2번 처리되는 사고 방지.
    구현: payments.pgTxnId에 유니크 인덱스 + findByPgTxnId 체크.

  4. 상태머신(전이만 허용)
    목적: 비정상 상태 “점프” 방지.
    예: PENDING → PAID → REFUNDED(OK) / PENDING → REFUNDED(NG).

  5. 로깅(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 헤더/필드 지원 시 반드시 사용(세션 생성·환불).
감사 로그 보존 주기(법/약관 준수).
테스트 케이스: 승인성공/거절/사용자취소/서명오류/금액불일치/중복웹훅/부분환불.


0개의 댓글