PortOne V2 기반 결제 시스템 설계 및 검증 로직 구현기 - Workaway
사용자의 결제 경험에 더 높은 안정성을 제공하기 위해, 기존 방식에서 웹훅(Webhook) 기반 결제 검증 방식으로 전환하였습니다.
이 방식은 사용자가 결제 창을 중간에 닫거나, 네트워크 문제로 인해 결제 ID를 수신하지 못하는 경우,
서버에서 결제 정보를 확인할 수 없어 결제 완료 여부 판단이 불가능하다는 단점이 있었습니다.
결제 완료 시 PortOne에서 서버로 직접 결제 정보를 전달받는 웹훅 방식을 도입함으로써,
클라이언트 상태와 무관하게 결제 데이터를 수신하고 검증할 수 있게 되어, 예약/결제 시스템의 신뢰성과 안정성을 크게 향상시킬 수 있었습니다.
또한, PortOne 측에서도 다음과 같이 Webhook 사용을 권장하고 있습니다


웹훅을 수신받을 EndPoint URL을 입력하고, 웹훅 시크릿을 서버에 저장합니다.
EndPoint는 localhost를 사용할 수 없기 때문에 테스트환경에서는 ngrok과 같이 외부에서 로컬로 접속이 가능하게 만드는 도구를 사용해야합니다.
https://<ngrok_url>/valid와 같이 설정하면 됩니다.
@PostMapping("/valid")
public ResponseEntity<String> handlePortOneWebhook(
@RequestBody String payload,
@RequestHeader("webhook-id") String webhookId,
@RequestHeader("webhook-signature") String webhookSignature,
@RequestHeader("webhook-timestamp") String webhookTimestamp
) {
paymentService.verifyWebhook(payload, webhookId, webhookSignature, webhookTimestamp);
return ResponseEntity.ok("Webhook Processed Successfully");
}
}

payload에서 위와 같은 데이터를 보내준다고 합니다.
추가적으로 Timestamp와 시그니처, webhook-id를 헤더로 보내줍니다.
이후에 있을 시그니처 검증에 필요하니 데이터를 들고있어야 합니다.
웹훅 수신 주소는 공개된 URL로 설정해야하기 때문에, 기본적으로 수신한 웹훅 메시지의 내용을 신뢰할 수 없습니다.
만약 누군가가 악의적으로 똑같은 내용으로 데이터를 보낼 수 있습니다.
따라서 웹훅 메시지를 반드시 검증해야 합니다.
@Override
public void 웹훅_검증(String payload, String webhookId, String webhookSignature, String webhookTimestamp) {
// 웹훅 타임스탬프 검증
verifyTimestamp(webhookTimestamp);
// 웹훅 시그니처 검증
String expectedSignature = generateSignature(webhookId, webhookTimestamp, payload);
verifySignature(expectedSignature, webhookSignature);
PortOneWebhookReqDTO webhookReqDTO = parsePayload(payload, objectMapper);
String type = webhookReqDTO.getType();
String paymentId = webhookReqDTO.getData().getPaymentId();
switch (type) {
case "Transaction.Ready":
log.info("[PortOne Webhook] 결제창 오픈 이벤트.");
break;
case "Transaction.Paid":
log.info("[PortOne Webhook] 결제 완료 이벤트 처리. paymentId = {}", paymentId);
if (!paymentRepository.existsByPaymentIdAndPaymentStatus(paymentId, PaymentStatus.PAID)) {
// 결제 데이터 검증
verifyPaymentForWebhook(paymentId, ...);
}
break;
case "Transaction.Cancelled":
log.info("[PortOne Webhook] 결제 취소 이벤트. paymentId = {}", paymentId);
break;
default:
log.warn("[PortOne Webhook] 알 수 없는 이벤트 타입: {}", type);
throw new BusinessException(ErrorCode.VALIDATION_FAILED, "알 수 없는 이벤트 타입입니다.");
}
}
payload데이터는 Json 문자열이기때문에, 파싱해주어야 합니다.
/**
* 타임스탬프 검증
* 요청이 5분 이상 경과한 경우 유효하지 않다고 판단
*/
private void 타임스탬프_검증(String timestamp) {
long now = System.currentTimeMillis() / 1000; // 현재 시간 (seconds)
long requestTimestamp = Long.parseLong(timestamp);
if (Math.abs(now - requestTimestamp) > 300) { // 5분
log.info("[PortOne Webhook] 유효하지 않은 타임스탬프: {}", timestamp);
throw new BusinessException(ErrorCode.VALIDATION_FAILED, "[PortOne Webhook] 유효하지 않은 타임스탬프입니다.");
}
}
/**
* 시그니처 비교
* 생성된 시그니처와 포트원에서 제공된 시그니처가 동일한지 확인
*/
private void 시그니처_검증(String expectedSignature, String webhookSignature) {
boolean equal = expectedSignature.equals(webhookSignature);
if (!equal) {
log.warn("[PortOne Webhook] 시그니처 검증 실패: expected={}, webhookSignature={}", expectedSignature, webhookSignature);
throw new BusinessException(ErrorCode.VALIDATION_FAILED, "[PortOne Webhook] 유효하지 않은 시그니처입니다.");
}
}
@Value("${portone.webhook.secret-key}")
private String WEBHOOK_SECRET;
private final String HMAC_SHA256 = "HmacSHA256";
private static final String SECRET_PREFIX = "whsec_";
/**
* 요청 데이터 기반 시그니처 생성
*/
private String 시그니처_생성(String webhookId, String timestamp, String payload) {
try {
String sec = WEBHOOK_SECRET;
if (sec.startsWith(SECRET_PREFIX)) {
sec = sec.substring(SECRET_PREFIX.length());
}
byte[] decodedKey = Base64.getDecoder().decode(sec);
SecretKeySpec keySpec = new SecretKeySpec(decodedKey, HMAC_SHA256);
String toSign = webhookId + "." + timestamp + "." + payload;
Mac mac = Mac.getInstance(HMAC_SHA256);
mac.init(keySpec);
byte[] macData = mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8));
return "v1," + Base64.getEncoder().encodeToString(macData);
} catch (Exception e) {
throw new RuntimeException("시그니처 생성 중 오류 발생", e);
}
}
포트원 문서에서는 Standard Webhooks에 따른다고 되어 있습니다.
@Override
public void 결제데이터_검증(String paymendId, ...) {
// PortOne API를 통해 결제 단건 조회
결제데이터 data = PortOne_결제_단건_조회_API(payment);
try {
예약상태_검증(...);
결제상태_검증(...);
가격_검증(...);
중복결졔_검증(...);
사용자_검증(...);
TTL_검증(...);
} catch (BusinessException e) {
결제취소데이터 failData = PortOne_결제_취소_API(...);
throw e;
} finally {
// TODO: 예약, 결제 로그 이벤트 발행 등...
}
}
간략한 내용은 위와 같습니다.
PortOne API를 통해 결제에 대한 데이터를 조회한 뒤, 서비스 요구사항에 따라 다양한 유효성 검사를 진행하고, 검증에 실패할 시 환불하는 로직을 구축하면됩니다.

저희 서비스에서는 결제 검증 및 중복결제를 막을 Lock Key로 사용하기 위해 예약 ID가 필요하였고, 위와 같이 필요한 데이터들을 customData로 보낼 수 있도록 클라이언트를 구축하였습니다.

사용하게된다면, customData는 실제로는 Json 문자열로 반환되기때문에, 이 또한 파싱해주어야 합니다.
Webhook 시스템으로 전환하면서 기존 방식보다 결제 안정성을 향상시킬 수 있었습니다.
PortOne에서 이러한 과정들을 편하게 구현할 수 있도록 JVM SDK로 제공하고 있지만,
PortOne에서 제공하는 다양한 기능 중 일부 API만 사용하는 구조였기 때문에, 전체 SDK를 도입하기보다는 필요한 API를 직접 REST 방식으로 연동하여 의존성과 복잡도를 최소화하였습니다.
JavaScript SDK - JS/TS
Python SDK
JVM SDK - Java/Kotlin/Scala 등