Stripe API로 결제를 연동했는데 Webhook 요청이 자꾸 거부되거나, 서명 검증에 실패해서 결제 완료 후 주문이 등록되지 않는 경험을 해봤을까. 다만 대부분의 개발자들은 Webhook의 정체, 왜 서명 검증이 필요한지, 그리고 구체적으로 어떻게 구현해야 하는지 정확히 모르고 인터넷에서 가져온 코드를 무작정 복사해 붙인다. 이번에는 Stripe Webhook 메커니즘이 정확히 뭔지, 왜 필요한지, 그리고 PHP에서 서명 검증부터 결제 이벤트 처리까지 완벽하게 구현하는 방법을 정리해서 소개하겠다.
Stripe Webhook은 결제 완료, 환불, 청금 실패 같은 결제 이벤트가 발생했을 때 Stripe 서버에서 당신의 서버로 POST 요청을 보내는 메커니즘이다. 예를 들어 고객이 결제하면 Stripe는 즉시 응답해주지만, 내부적으로 비동기 작업(사기 탐지, 3D 보안 등)을 거친 후 최종 결과를 Webhook으로 통보한다.
그런데 문제는 누구나 당신의 Webhook URL을 알면 가짜 Webhook 요청을 보낼 수 있다는 것이다. 공격자가 payment_intent.succeeded 이벤트를 위조해서 실제 결제가 없었는데도 주문을 등록하도록 속일 수 있다. 이걸 방지하기 위해 Stripe는 Webhook 요청에 HMAC SHA256 서명을 붙인다. 당신의 서버는 이 서명을 Stripe의 서명 비밀(Webhook Secret)로 검증해서 정말 Stripe에서 온 요청인지 확인해야 한다.
먼저 Stripe 대시보드에서 Webhook 설정을 해야 한다. Developers > Webhooks 섹션으로 이동해서 새 엔드포인트를 추가한다. 당신의 Webhook URL(예: https://yoursite.com/stripe-webhook.php)을 입력하고, payment_intent.succeeded, payment_intent.payment_failed, charge.refunded 같은 이벤트를 선택한다.
엔드포인트 추가 후 Stripe는 Signing secret을 생성한다. 이 값을 복사해서 안전하게 보관하면 된다(개발 환경과 라이브 환경의 시크릿이 다르므로 주의). 이 시크릿이 바로 서명을 검증할 때 쓸 키다.
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');
}
?>
위의 모든 단계를 통합한 실제 사용 가능한 코드다.
<?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 같은 터널링 도구 사용 |
로컬 환경에서 개발할 때는 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
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 결제를 처리할 수 있을 것이다.