팀 프로젝트인 배달 커머스 백엔드 프로젝트의 핵심 도메인인 결제(Payment) 시스템을 설계하고 포트원(PortOne) API 연동 흐름을 학습했다.
단순히 API 문서를 보고 따라 치는 것이 아니라, "결제 요청의 주체는 누구인가?", "웹훅은 정확히 어느 타이밍에 동작하는가?"에 대한 근본적인 오해를 바로잡고 견고한 비즈니스 로직을 세울 수 있었다.
처음 결제 시스템을 설계할 때 가장 착각하기 쉬운 부분이 바로 "누가 결제를 시도하는가?"이다.
❌ 나의 오해: 백엔드가 주문 정보를 모아 PG사(포트원)에 결제를 요청하고, PG사가 고객에게 결제를 받는다.
✅ 실제 실무 흐름: 프론트엔드가 PG사 SDK를 이용해 결제창을 띄우고 고객과 직접 결제를 진행한다. 결제가 완료되면 프론트엔드가 백엔드에 결제 고유번호(imp_uid)를 넘겨주고, 백엔드는 PG사 서버를 찔러 '이 결제가 조작되지 않은 진짜 결제인지' 검증(Validation)만 수행한다.
즉, 백엔드의 핵심 역할은 결제 '요청'이 아니라, 보안을 위한 '위변조 검증'과 '상태 동기화'이다.
프론트엔드가 결제 완료 후 백엔드에 알려주면 끝나는 것 아닌가? 왜 굳이 PG사 서버가 우리 백엔드를 또 호출하는 웹훅(Webhook)이 필요할까?
가장 큰 이유는 프론트엔드 환경은 절대 신뢰할 수 없고, 언제든 끊길 수 있기 때문이다.
고객의 브라우저 이탈 방어: 고객이 결제를 끝마친 직후, 우리 앱으로 화면이 돌아오기 0.1초 전에 브라우저를 닫거나 인터넷이 끊기면? 고객 돈은 나갔지만 우리 DB는 영원히 결제 대기 상태가 된다.
비동기 결제 지원: 무통장 입금(가상계좌)처럼 고객이 나중에 ATM에서 입금하는 경우, 프론트엔드는 이미 종료되었으므로 PG사 서버가 직접 우리 백엔드로 입금 완료를 알려줘야 한다.
결국 웹훅은 프론트엔드의 결함이나 이탈을 막아주는 최후의 백업 요원이다.
가장 헷갈렸던 "웹훅은 프론트엔드 검증 과정 중간에 쓰이는가, 아니면 다 끝난 후에 쓰이는가?"에 대한 답은 둘 다 아니다 였다.
프론트엔드의 결제 완료 API 요청과 웹훅 요청은 서로 종속되지 않은 독립적이고 병렬적인 레이스를 펼친다.
우리의 백엔드에는 결제 승인 로직을 처리하는 '하나의 방(Service)'이 있고, 이 방으로 들어오는 '두 개의 문(Controller Endpoint)'이 있는 것과 같다.
1번 문: 프론트엔드가 들어오는 문 (/api/v1/payments/complete)
2번 문: 포트원 웹훅이 들어오는 문 (/api/v1/payments/webhook)
누가 먼저 도착하든 상관없다. 먼저 도착한 요청이 DB를 검증하고 주문 상태를 PAID로 바꾼다.
그다음 늦게 도착한 요청은 DB를 확인해 보고 "어? 이미 결제 완료 처리되어 있네?" 하고 무시(200 OK 반환)하면 그만이다.
이것이 바로 결제 시스템에서 가장 중요한 멱등성(Idempotency) 보장 로직이다.
위의 개념을 바탕으로 스프링 백엔드의 Controller와 Service 구조를 아래와 같이 잡을 수 있다.
@RestController
@RequestMapping("/api/v1/payments")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
// 1번 문: 프론트엔드 요청
@PostMapping("/complete")
public ResponseEntity<Void> completePayment(@RequestBody PaymentRequest req) {
paymentService.verifyAndProcess(req.getImpUid(), req.getOrderId());
return ResponseEntity.ok().build();
}
// 2번 문: 포트원 웹훅 요청
@PostMapping("/webhook")
public ResponseEntity<String> webhook(@RequestBody WebhookRequest req) {
paymentService.verifyAndProcess(req.getImpUid(), req.getMerchantUid());
// 포트원 서버에는 무조건 200 OK를 반환하여 재시도를 멈추게 한다.
return ResponseEntity.ok("success");
}
}
---
@Service
@RequiredArgsConstructor
public class PaymentService {
@Transactional
public void verifyAndProcess(String impUid, Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow(...);
// [중요] 멱등성 보장 로직: 이미 결제 완료된 주문이면 로직 종료
if (order.getStatus() == OrderStatus.PAID) {
return;
}
// 1. 포트원 API 호출하여 실제 결제 금액 조회
// 2. DB 주문 금액과 포트원 결제 금액 비교 (위변조 검증)
// 3. 일치하면 DB Payment 저장 및 Order 상태를 PAID로 변경
}
}