Stripe API로 결제를 연동했는데 Webhook 요청이 자꾸 거부되거나, 서명 검증에 실패해서 결제 완료 후 주문이 등록되지 않는 경험을 해봤을까. 다만 대부분의 개발자들은 Webhook의 정체, 왜 서명 검증이 필요한지, 그리고 구체적으로 어떻게 구현해야 하는지 정확히 모르고 인터넷에서 가져온 코드를 무작정 복사해 붙인다. 이번에는 Stripe Webhook 메커니즘이 정확히 뭔지, 왜 필요한지, 그리고 PHP에서 서명 검증부터 결제 이벤트 처리까지 완벽하게 구현하는 방법을 정리해서 소개하겠다.

 

Stripe Webhook과 서명 검증이 필요한 이유

Stripe Webhook은 결제 완료, 환불, 청금 실패 같은 결제 이벤트가 발생했을 때 Stripe 서버에서 당신의 서버로 POST 요청을 보내는 메커니즘이다. 예를 들어 고객이 결제하면 Stripe는 즉시 응답해주지만, 내부적으로 비동기 작업(사기 탐지, 3D 보안 등)을 거친 후 최종 결과를 Webhook으로 통보한다.

그런데 문제는 누구나 당신의 Webhook URL을 알면 가짜 Webhook 요청을 보낼 수 있다는 것이다. 공격자가 payment_intent.succeeded 이벤트를 위조해서 실제 결제가 없었는데도 주문을 등록하도록 속일 수 있다. 이걸 방지하기 위해 Stripe는 Webhook 요청에 HMAC SHA256 서명을 붙인다. 당신의 서버는 이 서명을 Stripe의 서명 비밀(Webhook Secret)로 검증해서 정말 Stripe에서 온 요청인지 확인해야 한다.

 

Stripe 대시보드에서 Webhook 엔드포인트 및 서명 비밀 확보

먼저 Stripe 대시보드에서 Webhook 설정을 해야 한다. Developers > Webhooks 섹션으로 이동해서 새 엔드포인트를 추가한다. 당신의 Webhook URL(예: https://yoursite.com/stripe-webhook.php)을 입력하고, payment_intent.succeeded, payment_intent.payment_failed, charge.refunded 같은 이벤트를 선택한다.

엔드포인트 추가 후 Stripe는 Signing secret을 생성한다. 이 값을 복사해서 안전하게 보관하면 된다(개발 환경과 라이브 환경의 시크릿이 다르므로 주의). 이 시크릿이 바로 서명을 검증할 때 쓸 키다.

 

PHP에서 Webhook 서명 검증 구현

Stripe Webhook의 서명은 HTTP 헤더 Stripe-Signature에 담긴다. 형식은 t=<timestamp>,v1=<signature>,v1=<signature> 같이 타임스탬프와 여러 버전의 서명을 포함한다. 서명 검증은 다음 순서로 진행된다.

1단계: Raw Request Body 확보
PHP는 기본으로 요청 바디를 파싱해버려서 나중에 검증하기 힘들다. Webhook 핸들러 맨 앞에 php://input을 읽어서 원본 바디를 확보해야 한다.

<?php
// ✗ 잘못된 방법
$data = $_POST; // 이미 파싱되어 있어서 서명 검증 불가능

// ✓ 올바른 방법
$payload = file_get_contents('php://input');
$body = json_decode($payload, true);
?>

2단계: 타임스탬프 검증으로 리플레이 공격 방지
Webhook 요청이 오래되었으면 리플레이 공격일 가능성이 있다. Stripe-Signature 헤더에서 타임스탬프를 추출해서 현재 시간과 비교한다. 보통 5분 이상 차이나면 거부한다.

<?php
// Stripe-Signature 헤더 파싱
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
if (!$sig_header) {
    http_response_code(400);
    exit('No signature header');
}

// 타임스탬프,v1=서명 형식 파싱
$parts = array_reduce(
    explode(',', $sig_header),
    function($carry, $part) {
        [$key, $value] = explode('=', $part, 2);
        $carry[$key] = $value;
        return $carry;
    },
    []
);

$timestamp = $parts['t'] ?? null;
$signatures = [];
foreach ($parts as $key => $value) {
    if (strpos($key, 'v') === 0) {
        $signatures[] = $value;
    }
}

// 타임스탬프 검증 (5분 이내)
if (!$timestamp || abs(time() - (int)$timestamp) > 300) {
    http_response_code(400);
    exit('Timestamp outside tolerance window');
}
?>

3단계: HMAC SHA256 서명 검증
Stripe는 "타임스탐프.원본바디" 문자열을 Webhook Secret으로 HMAC SHA256 암호화한 후 시그니처에 담는다. 당신도 같은 방식으로 계산해서 비교하면 된다.

<?php
$payload = file_get_contents('php://input');
$secret = 'whsec_test_1234567890abcdef'; // Stripe 대시보드에서 복사한 비밀

// Stripe 서명 생성 규칙: timestamp.body를 secret으로 HMAC SHA256 암호화
$signed_content = $timestamp . '.' . $payload;
$expected_sig = hash_hmac('sha256', $signed_content, $secret);

// ✗ 잘못된 방법: 단순 ==로 비교 (타이밍 공격 취약)
if ($expected_sig == $signatures[0]) {
    // 처리
}

// ✓ 올바른 방법: hash_equals로 타이밍 공격 방지
if (!hash_equals($expected_sig, $signatures[0] ?? '')) {
    http_response_code(403);
    exit('Invalid signature');
}
?>

 

완전한 Webhook 핸들러 구현

위의 모든 단계를 통합한 실제 사용 가능한 코드다.

<?php
// stripe-webhook.php
require 'vendor/autoload.php';

// 환경 변수에서 Webhook Secret 로드
$webhook_secret = getenv('STRIPE_WEBHOOK_SECRET');
if (!$webhook_secret) {
    http_response_code(500);
    exit('Webhook secret not configured');
}

// 1. Raw 페이로드 확보
$payload = file_get_contents('php://input');
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';

if (!$sig_header) {
    http_response_code(400);
    exit('No signature header');
}

// 2. 서명 헤더 파싱
$parts = [];
foreach (explode(',', $sig_header) as $part) {
    if (strpos($part, '=') === false) continue;
    [$key, $value] = explode('=', $part, 2);
    $parts[$key] = $value;
}

$timestamp = $parts['t'] ?? null;
$signatures = [];
foreach ($parts as $key => $value) {
    if (strpos($key, 'v') === 0) {
        $signatures[] = $value;
    }
}

// 3. 타임스탬프 검증 (5분 이내)
if (!$timestamp || abs(time() - (int)$timestamp) > 300) {
    http_response_code(403);
    exit('Timestamp outside tolerance window');
}

// 4. HMAC SHA256 서명 검증
$signed_content = $timestamp . '.' . $payload;
$expected_sig = hash_hmac('sha256', $signed_content, $webhook_secret);

if (empty($signatures) || !hash_equals($expected_sig, $signatures[0])) {
    http_response_code(403);
    exit('Invalid signature');
}

// 5. 서명 검증 완료, 이벤트 처리
http_response_code(200);

$event = json_decode($payload, true);
if (!$event || !isset($event['type'])) {
    exit('Invalid event');
}

switch ($event['type']) {
    case 'payment_intent.succeeded':
        $payment_intent = $event['data']['object'];
        $order_id = $payment_intent['metadata']['order_id'] ?? null;
        // 주문 상태를 '결제 완료'로 업데이트
        // UPDATE orders SET status = 'paid' WHERE id = ?
        error_log("Order $order_id paid successfully");
        break;

    case 'payment_intent.payment_failed':
        $payment_intent = $event['data']['object'];
        $order_id = $payment_intent['metadata']['order_id'] ?? null;
        // 주문 상태를 '결제 실패'로 업데이트
        // UPDATE orders SET status = 'failed' WHERE id = ?
        error_log("Order $order_id payment failed: " . $payment_intent['last_payment_error']['message']);
        break;

    case 'charge.refunded':
        $charge = $event['data']['object'];
        $order_id = $charge['metadata']['order_id'] ?? null;
        // 주문 환불 처리
        error_log("Order $order_id refunded");
        break;

    default:
        error_log("Unhandled event type: " . $event['type']);
}
?>

 

흔한 실수와 해결책
❌ 문제 상황 ✓ 해결 방법
php://input 대신 $_POST로 읽음 php://input으로 원본 바디를 읽어야 서명 검증 가능. $_POST는 이미 파싱됨
hash_equals 대신 == 또는 strcmp 사용 hash_equals는 타이밍 공격(timing attack)을 방지하는 상수시간 비교 함수. 반드시 사용
Webhook Secret을 환경 변수 대신 코드에 하드코딩 AWS Secrets Manager, 환경 변수, .env 파일(git 무시)에 보관. 소스 코드에 노출되면 안됨
타임스탬프 검증 스킵 리플레이 공격으로 같은 Webhook이 여러 번 처리될 수 있음. 무조건 검증하기
Webhook 핸들러에서 예외 발생 후 500 반환 Stripe는 5xx 응답을 받으면 최대 3일간 재시도. 예외 로깅 후 항상 200 OK 반환해야 함
Webhook URL을 HTTPS 대신 HTTP로 설정 Stripe는 HTTPS만 지원. 개발 환경에서 테스트하려면 ngrok 같은 터널링 도구 사용

 

개발 환경에서 Webhook 테스트하기

로컬 환경에서 개발할 때는 localhost:8000이 인터넷에서 접근 불가능하므로 Webhook을 테스트할 수 없다. ngrok 같은 터널링 도구를 사용하면 된다.

# ngrok 설치 후 로컬 포트를 인터넷 주소로 노출
ngrok http 8000

# 출력 예시
# Forwarding https://abc123.ngrok.io -> http://localhost:8000

# Stripe 대시보드의 Webhook URL을
# https://abc123.ngrok.io/stripe-webhook.php 로 설정

또는 Stripe CLI를 사용해서 더 직접적으로 테스트할 수도 있다.

# Stripe CLI 설치
# https://stripe.com/docs/stripe-cli

# 로컬 환경으로 Webhook 포워딩 시작
stripe listen --forward-to localhost:8000/stripe-webhook.php

# 별도 터미널에서 test 이벤트 발생
stripe trigger payment_intent.succeeded

 

멱등성(Idempotency) 처리로 중복 처리 방지

Stripe가 Webhook을 재시도할 때 같은 이벤트가 여러 번 들어올 수 있다. 이벤트 ID로 이미 처리됐는지 확인해서 중복 처리를 방지해야 한다.

<?php
$event = json_decode($payload, true);
$event_id = $event['id']; // 고유 이벤트 ID

// 이미 처리한 이벤트인지 확인
$stmt = $pdo->prepare('SELECT id FROM webhook_events WHERE event_id = ?');
$stmt->execute([$event_id]);

if ($stmt->fetch()) {
    // 이미 처리한 이벤트
    http_response_code(200);
    exit('Event already processed');
}

// 이벤트 처리 전 기록
$stmt = $pdo->prepare('INSERT INTO webhook_events (event_id, event_type, processed_at) VALUES (?, ?, NOW())');
$stmt->execute([$event_id, $event['type']]);

// 실제 비즈니스 로직 처리
switch ($event['type']) {
    case 'payment_intent.succeeded':
        // 주문 처리
        break;
}

http_response_code(200);
?>

 

정리하고 다음 단계

Stripe Webhook 시그니처 검증은 결제 보안의 핵심이다. 서명을 검증하지 않으면 누구나 당신의 Webhook을 위조해서 무료 주문을 만들 수 있다. 이 글에서 소개한 4가지 단계(Raw 바디 확보, 타임스탬프 검증, HMAC SHA256 검증, 멱등성 처리)를 빠뜨리지 말고 반드시 구현하자. 특히 hash_equals() 함수 사용과 환경 변수 관리는 타이밍 공격과 소스 코드 노출을 방지하는 필수 보안 조치다. 이 글의 완전한 Webhook 핸들러 코드를 참고해서 실제 프로젝트에 적용하면, 위조된 결제 요청으로 인한 손실 없이 안전하게 Stripe 결제를 처리할 수 있을 것이다.