PortOne 결제 시스템의 Webhook 전환기 - Workaway

chaean·2025년 8월 2일

Workaway

목록 보기
10/11

개요

PortOne V2 기반 결제 시스템 설계 및 검증 로직 구현기 - Workaway

사용자의 결제 경험에 더 높은 안정성을 제공하기 위해, 기존 방식에서 웹훅(Webhook) 기반 결제 검증 방식으로 전환하였습니다.

PortOne - 웹훅 연동하기

기존 방식의 흐름

  • 사용자가 결제 완료 후, 클라이언트에 전달된 결제 ID를 서버로 전송
  • 서버는 해당 ID를 기준으로 결제 상세 정보를 조회하여 검증

기존 방식의 한계

이 방식은 사용자가 결제 창을 중간에 닫거나, 네트워크 문제로 인해 결제 ID를 수신하지 못하는 경우,
서버에서 결제 정보를 확인할 수 없어 결제 완료 여부 판단이 불가능하다는 단점이 있었습니다.

Webhook 전환

결제 완료 시 PortOne에서 서버로 직접 결제 정보를 전달받는 웹훅 방식을 도입함으로써,
클라이언트 상태와 무관하게 결제 데이터를 수신하고 검증할 수 있게 되어, 예약/결제 시스템의 신뢰성과 안정성을 크게 향상시킬 수 있었습니다.

또한, PortOne 측에서도 다음과 같이 Webhook 사용을 권장하고 있습니다

구현

1. PortOne 설정

웹훅을 수신받을 EndPoint URL을 입력하고, 웹훅 시크릿을 서버에 저장합니다.
EndPoint는 localhost를 사용할 수 없기 때문에 테스트환경에서는 ngrok과 같이 외부에서 로컬로 접속이 가능하게 만드는 도구를 사용해야합니다.

https://<ngrok_url>/valid와 같이 설정하면 됩니다.

2. Webhook 이벤트 수신

@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를 헤더로 보내줍니다.
이후에 있을 시그니처 검증에 필요하니 데이터를 들고있어야 합니다.

3. Webhook 검증

웹훅 수신 주소는 공개된 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 문자열이기때문에, 파싱해주어야 합니다.

Timestamp 유효성 검증

/**
 * 타임스탬프 검증
 * 요청이 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] 유효하지 않은 타임스탬프입니다.");
    }
}
  • 5분이상 차이가 날 경우 무효화되도록 설정하였습니다.


시그니처 검증

/**
 * 시그니처 비교
 * 생성된 시그니처와 포트원에서 제공된 시그니처가 동일한지 확인
 */
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);
    }
}
  • 포트원에서 제공한 Webhook V2 Secret 키를 사용하여, HMAC-SMA256 기반 시그니처를 생성하고, 요청 헤더인 webhook-signature과 동일한지 비교

포트원 문서에서는 Standard Webhooks에 따른다고 되어 있습니다.

4. 결제 데이터 검증

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

profile
백엔드 개발자

0개의 댓글