PayPal Webhook API - 웹훅 API
개요
PayPal REST API는 이벤트 알림을 위해 **웹훅(Webhooks)**을 사용합니다. 웹훅은 이벤트에 대한 알림 메시지를 받는 HTTP 콜백입니다. 웹훅은 여러분의 시스템이 PayPal을 호출하는 것과 반대 방향의 API 호출로 생각할 수 있습니다. PayPal이 여러분의 서버에 콜백을 보내는 것입니다.
웹훅은 다음과 같은 경우에 특히 유용합니다:
- 구독이 각 주기마다 처리될 때
- www.paypal.com (opens in a new tab) 비즈니스 대시보드에서 환불이 시작될 때
- 대체 결제 수단 체크아웃이 리디렉션된 구매자에 의해 승인되어 수집할 준비가 되었을 때
초기 설정
웹훅을 받기 위한 초기 설정은 특정 앱(REST 앱 또는 NVP/SOAP 웹훅 앱)에 대한 수신 URL을 구독하는 것입니다.
주요 특징
- 앱당 최대 10개의 웹훅 URL 구독 가능
- 각 URL에 대해 특정 웹훅 이벤트 타입 구독 가능
- 모든 이벤트 타입 구독시
*
와일드카드 사용 가능 - 특정 앱과 연관된 이벤트만 수신
- 계정이 같아도 다른 REST 앱의 이벤트는 수신되지 않음
웹훅 수신 및 처리
URL이 구독되면 PayPal은 연관된 앱에서 생성된 모든 구독 이벤트에 대해 메시지를 보내기 시작합니다.
웹훅 메시지 처리 단계
- 메시지 수신
- 메시지 검증
메시지 수신 요구사항
- 수신 엔드포인트가 HTTP 200 또는 기타 2xx 상태 코드로 응답해야 함
- 2xx가 아닌 상태 코드는 PayPal이 3일 동안 최대 25회까지 재시도
- 3일 후 실패하면 배송이 실패로 표시됨 (웹훅 이벤트 대시보드에서 수동으로 재전송 가능)
메시지 검증 방법
다음 두 가지 방법 중 하나 사용 가능:
- 메시지의 CRC32를 계산하고 서명 확인
- 메시지, 저장된 웹훅 ID, 헤더 정보를 PayPal의 서명 검증 엔드포인트에 전송하여 확인
중요: 메시지를 검증하지 않으면 발신자가 실제로 PayPal인지 확인할 수 없습니다.
웹훅 API 엔드포인트
웹훅 생성 (Create Webhook)
POST /v1/notifications/webhooks
웹훅 리스너를 이벤트에 구독시킵니다.
인증
- OAuth2 Bearer 토큰 필요
요청 본문
{
"url": "https://example.com/example_webhook",
"event_types": [
{
"name": "PAYMENT.AUTHORIZATION.CREATED"
},
{
"name": "PAYMENT.AUTHORIZATION.VOIDED"
}
]
}
요청 매개변수
매개변수 | 타입 | 필수 | 설명 |
---|---|---|---|
url | string | 필수 | 들어오는 POST 알림 메시지를 수신할 URL (최대 2048자) |
event_types | array | 필수 | 구독할 이벤트 배열 (최대 500개) |
응답 (201 Created)
{
"id": "0EH40505U7160970P",
"url": "https://example.com/example_webhook",
"event_types": [
{
"name": "PAYMENT.AUTHORIZATION.CREATED",
"description": "결제 승인이 생성되었습니다."
},
{
"name": "PAYMENT.AUTHORIZATION.VOIDED",
"description": "결제 승인이 무효화되었습니다."
}
],
"links": [
{
"href": "https://api-m.paypal.com/v1/notifications/webhooks/0EH40505U7160970P",
"rel": "self",
"method": "GET"
}
]
}
웹훅 목록 조회 (List Webhooks)
GET /v1/notifications/webhooks
앱의 웹훅 목록을 조회합니다.
쿼리 매개변수
매개변수 | 타입 | 설명 | 기본값 |
---|---|---|---|
anchor_type | string | anchor_id 엔티티 타입으로 필터링 | "APPLICATION" |
가능한 값: "APPLICATION", "ACCOUNT"
응답 (200 OK)
{
"webhooks": [
{
"id": "40Y916089Y8324740",
"url": "https://example.com/example_webhook",
"event_types": [
{
"name": "PAYMENT.AUTHORIZATION.CREATED",
"description": "결제 승인이 생성되었습니다."
}
],
"links": [
{
"href": "https://api-m.paypal.com/v1/notifications/webhooks/40Y916089Y8324740",
"rel": "self",
"method": "GET"
}
]
}
]
}
웹훅 상세 정보 조회 (Show Webhook Details)
GET /v1/notifications/webhooks/{webhook_id}
ID로 웹훅의 세부 정보를 조회합니다.
경로 매개변수
매개변수 | 타입 | 필수 | 설명 |
---|---|---|---|
webhook_id | string | 필수 | 세부 정보를 조회할 웹훅의 ID (최대 50자) |
응답 (200 OK)
{
"id": "0EH40505U7160970P",
"url": "https://example.com/example_webhook",
"event_types": [
{
"name": "PAYMENT.AUTHORIZATION.CREATED",
"description": "결제 승인이 생성되었습니다.",
"status": "ENABLED"
},
{
"name": "PAYMENT.AUTHORIZATION.VOIDED",
"description": "결제 승인이 무효화되었습니다.",
"status": "ENABLED"
}
],
"links": [
{
"href": "https://api-m.paypal.com/v1/notifications/webhooks/0EH40505U7160970P",
"rel": "self",
"method": "GET"
}
]
}
웹훅 업데이트 (Update Webhook)
PATCH /v1/notifications/webhooks/{webhook_id}
웹훅을 업데이트하여 웹훅 필드를 새 값으로 바꿉니다. replace
연산만 지원합니다.
경로 매개변수
매개변수 | 타입 | 필수 | 설명 |
---|---|---|---|
webhook_id | string | 필수 | 업데이트할 웹훅의 ID (최대 50자) |
요청 본문 (JSON Patch)
[
{
"op": "replace",
"path": "/url",
"value": "https://example.com/example_webhook_2"
},
{
"op": "replace",
"path": "/event_types",
"value": [
{
"name": "PAYMENT.SALE.REFUNDED"
}
]
}
]
패치 연산
연산 | 설명 |
---|---|
add | 배열에 새 값 삽입 또는 객체에 새 매개변수 추가 |
remove | 대상 위치의 값 제거 |
replace | 대상 위치의 값을 새 값으로 바꿈 |
move | 지정된 위치에서 값을 제거하고 대상 위치에 추가 |
copy | 지정된 위치의 값을 대상 위치로 복사 |
test | 대상 위치의 값이 지정된 값과 같은지 테스트 |
응답 (200 OK)
{
"id": "0EH40505U7160970P",
"url": "https://example.com/example_webhook_2",
"event_types": [
{
"name": "PAYMENT.SALE.REFUNDED",
"description": "판매 결제가 환불되었습니다."
}
],
"links": [
{
"href": "https://api-m.paypal.com/v1/notifications/webhooks/0EH40505U7160970P",
"rel": "self",
"method": "GET"
}
]
}
웹훅 삭제 (Delete Webhook)
DELETE /v1/notifications/webhooks/{webhook_id}
ID로 웹훅을 삭제합니다.
경로 매개변수
매개변수 | 타입 | 필수 | 설명 |
---|---|---|---|
webhook_id | string | 필수 | 삭제할 웹훅의 ID (최대 50자) |
응답 (204 No Content)
성공적인 요청은 HTTP 204 No Content 상태 코드를 반환하며 JSON 응답 본문은 없습니다.
웹훅 이벤트 구독 목록 조회 (List Event Subscriptions)
GET /v1/notifications/webhooks/{webhook_id}/event-types
ID로 웹훅의 이벤트 구독을 조회합니다.
경로 매개변수
매개변수 | 타입 | 필수 | 설명 |
---|---|---|---|
webhook_id | string | 필수 | 구독을 조회할 웹훅의 ID |
응답 (200 OK)
{
"event_types": [
{
"name": "PAYMENT.AUTHORIZATION.CREATED",
"description": "결제 승인이 생성되었습니다.",
"status": "ENABLED"
},
{
"name": "PAYMENT.AUTHORIZATION.VOIDED",
"description": "결제 승인이 무효화되었습니다.",
"status": "ENABLED"
},
{
"name": "RISK.DISPUTE.CREATED",
"description": "거래에 대해 분쟁이 제기되었습니다.",
"status": "DEPRECATED"
}
]
}
웹훅 조회 생성 (Create Webhook Lookup)
POST /v1/notifications/webhooks-lookup
웹훅 조회를 생성합니다. 웹훅 조회는 API 호출자의 REST API 앱을 주체 계정에 연결합니다.
응답 (201 Created)
{
"id": "0EH40505U7160970P",
"client_id": "ASknfhB5DtpICIHI7ZRvVStLDqVIg6mc_ETGcxjtEQkkgHrUU8IOLPUQFTq_",
"links": [
{
"href": "https://api-m.paypal.com/v1/notifications/webhooks-lookup/0EH40505U7160970P",
"rel": "self",
"method": "GET"
}
]
}
웹훅 조회 목록 조회 (List Webhook Lookups)
GET /v1/notifications/webhooks-lookup
웹훅 조회 목록을 조회합니다.
응답 (200 OK)
{
"id": "0EH40505U7160970P",
"client_id": "ASknfhB5DtpICIHI7ZRvVStLDqVIg6mc_ETGcxjtEQkkgHrUU8IOLPUQFTq_",
"account_number": "654839282",
"links": [
{
"href": "https://api-m.paypal.com/v1/notifications/webhooks-lookup/0EH40505U7160970P",
"rel": "self",
"method": "GET"
}
]
}
웹훅 서명 검증 (Verify Webhook Signature)
POST /v1/notifications/verify-webhook-signature
웹훅 서명을 검증합니다.
요청 본문
{
"transmission_id": "69cd13f0-d67a-11e5-baa3-778b53f4dc41",
"cert_id": "69cd13f0-d67a-11e5-baa3-778b53f4dc42",
"auth_algo": "SHA256withRSA",
"transmission_time": "2016-02-18T20:01:35Z",
"webhook_id": "1JE434234637435435",
"webhook_event": {
"id": "8PT597110X687430LKGECATA",
"create_time": "2013-06-25T21:41:28Z",
"resource_type": "authorization",
"event_type": "PAYMENT.AUTHORIZATION.CREATED",
"summary": "결제 승인이 생성되었습니다",
"resource": {
"id": "2DC87612EK520411B",
"create_time": "2013-06-25T21:39:15Z",
"update_time": "2013-06-25T21:39:17Z",
"state": "authorized",
"amount": {
"total": "7.47",
"currency": "USD"
}
}
}
}
응답
{
"verification_status": "SUCCESS"
}
사용 가능한 이벤트 목록 조회 (List Available Events)
GET /v1/notifications/webhooks-event-types
사용 가능한 모든 웹훅 이벤트 타입을 조회합니다.
응답 (200 OK)
{
"event_types": [
{
"name": "PAYMENT.AUTHORIZATION.CREATED",
"description": "결제 승인이 생성되었습니다."
},
{
"name": "PAYMENT.AUTHORIZATION.VOIDED",
"description": "결제 승인이 무효화되었습니다."
},
{
"name": "PAYMENT.CAPTURE.COMPLETED",
"description": "결제 수집이 완료되었습니다."
}
]
}
이벤트 알림 목록 조회 (List Event Notifications)
GET /v1/notifications/webhooks-events
이벤트 알림 목록을 조회합니다.
쿼리 매개변수
매개변수 | 타입 | 설명 |
---|---|---|
page_size | integer | 페이지당 항목 수 (기본값: 10) |
start_time | string | 시작 시간 (ISO 8601 형식) |
end_time | string | 종료 시간 (ISO 8601 형식) |
웹훅 이벤트 시뮬레이션 (Simulate Webhook Event)
POST /v1/notifications/simulate-event
웹훅 이벤트를 시뮬레이션합니다.
요청 본문
{
"url": "https://example.com/example_webhook",
"event_type": "PAYMENT.SALE.COMPLETED"
}
주요 웹훅 이벤트 타입
결제 관련 이벤트
Payments V2
이벤트 | 트리거 | 설명 |
---|---|---|
PAYMENT.AUTHORIZATION.CREATED | 결제 승인 생성됨 | 결제 승인이 생성, 승인, 실행되거나 미래 결제 승인이 생성됨 |
PAYMENT.AUTHORIZATION.VOIDED | 결제 승인 무효화됨 | 30일 유효기간 도달 또는 수동 무효화로 인한 승인 무효화 |
PAYMENT.CAPTURE.DECLINED | 결제 수집 거부됨 | 결제 수집이 거부됨 |
PAYMENT.CAPTURE.COMPLETED | 결제 수집 완료됨 | 결제 수집이 완료됨 |
PAYMENT.CAPTURE.PENDING | 결제 수집 대기중 | 결제 수집 상태가 대기중으로 변경됨 |
PAYMENT.CAPTURE.REFUNDED | 결제 수집 환불됨 | 판매자가 결제 수집을 환불함 |
PAYMENT.CAPTURE.REVERSED | 결제 수집 취소됨 | PayPal이 결제 수집을 취소함 |
구독 관련 이벤트
이벤트 | 트리거 | 설명 |
---|---|---|
BILLING.SUBSCRIPTION.CREATED | 구독 생성됨 | 구독이 생성됨 |
BILLING.SUBSCRIPTION.ACTIVATED | 구독 활성화됨 | 구독이 활성화됨 |
BILLING.SUBSCRIPTION.UPDATED | 구독 업데이트됨 | 구독이 업데이트됨 |
BILLING.SUBSCRIPTION.EXPIRED | 구독 만료됨 | 구독이 만료됨 |
BILLING.SUBSCRIPTION.CANCELLED | 구독 취소됨 | 구독이 취소됨 |
BILLING.SUBSCRIPTION.SUSPENDED | 구독 일시정지됨 | 구독이 일시정지됨 |
BILLING.SUBSCRIPTION.PAYMENT.FAILED | 구독 결제 실패 | 구독 결제가 실패함 |
분쟁 관련 이벤트
이벤트 | 트리거 | 설명 |
---|---|---|
CUSTOMER.DISPUTE.CREATED | 분쟁 생성됨 | 분쟁이 생성됨 |
CUSTOMER.DISPUTE.RESOLVED | 분쟁 해결됨 | 분쟁이 해결됨 |
CUSTOMER.DISPUTE.UPDATED | 분쟁 업데이트됨 | 분쟁이 업데이트됨 |
인보이스 관련 이벤트
이벤트 | 트리거 | 설명 |
---|---|---|
INVOICING.INVOICE.CREATED | 인보이스 생성됨 | 인보이스가 생성됨 |
INVOICING.INVOICE.PAID | 인보이스 결제됨 | 인보이스가 결제됨 |
INVOICING.INVOICE.CANCELLED | 인보이스 취소됨 | 인보이스가 취소됨 |
INVOICING.INVOICE.REFUNDED | 인보이스 환불됨 | 인보이스가 환불됨 |
결제 수단 토큰 관련 이벤트
이벤트 | 트리거 | 설명 |
---|---|---|
VAULT.PAYMENT-TOKEN.CREATED | 결제 토큰 생성됨 | 결제 수단 저장을 위한 결제 토큰이 생성됨 |
VAULT.PAYMENT-TOKEN.DELETED | 결제 토큰 삭제됨 | 결제 토큰이 삭제되어 결제 수단이 더 이상 저장되지 않음 |
VAULT.PAYMENT-TOKEN.DELETION-INITIATED | 결제 토큰 삭제 시작됨 | 결제 토큰 삭제 요청이 제출됨 |
웹훅 메시지 구조
웹훅 메시지는 다음과 같은 기본 구조를 가집니다:
{
"id": "WH-6RH64098EX891111R-7HV95734TV988632V",
"event_version": "1.0",
"create_time": "2018-08-17T21:41:28.032Z",
"resource_type": "sale",
"resource_version": "2.0",
"event_type": "PAYMENT.SALE.COMPLETED",
"summary": "결제가 완료되었습니다",
"resource": {
"id": "5O190127TN364715T",
"state": "completed",
"amount": {
"total": "20.00",
"currency": "USD"
},
"parent_payment": "PAY-17S8410768582940NKEE66EQ",
"create_time": "2018-08-17T21:39:15Z",
"update_time": "2018-08-17T21:39:17Z"
},
"links": [
{
"href": "https://api-m.paypal.com/v1/notifications/webhooks-events/WH-6RH64098EX891111R-7HV95734TV988632V",
"rel": "self",
"method": "GET"
}
]
}
주요 필드 설명
필드 | 설명 |
---|---|
id | 웹훅 이벤트의 고유 식별자 |
event_version | 이벤트 버전 |
create_time | 이벤트 생성 시간 |
resource_type | 리소스 타입 (sale, authorization, capture 등) |
event_type | 이벤트 타입 |
summary | 이벤트 요약 |
resource | 실제 이벤트 데이터 |
links | 관련 링크들 |
웹훅 핸들링 시스템 구축 방안
1. 아키텍처 설계
웹훅을 효과적으로 처리하기 위한 권장 아키텍처는 다음과 같습니다:
PayPal Webhook
↓
Load Balancer (nginx/AWS ALB)
↓
Webhook Receiver Service
↓
Message Queue (Redis/RabbitMQ/AWS SQS)
↓
Webhook Processor Workers
↓
Database & Business Logic
↓
Notification Service
2. 웹훅 수신 서비스 설계
2.1 기본 원칙
- 신속한 응답: 웹훅 수신 후 즉시 200 OK 응답
- 비동기 처리: 실제 처리는 큐를 통해 비동기로 수행
- 멱등성 보장: 동일한 이벤트 중복 처리 방지
- 보안: 서명 검증을 통한 요청 인증
2.2 수신 서비스 구현 예시 (Node.js)
const express = require('express');
const crypto = require('crypto');
const Redis = require('redis');
const app = express();
const redis = Redis.createClient();
// 웹훅 수신 엔드포인트
app.post('/webhook/paypal', express.raw({type: 'application/json'}), async (req, res) => {
try {
// 1. 서명 검증
const isValidSignature = await verifyWebhookSignature(req);
if (!isValidSignature) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. 웹훅 데이터 파싱
const webhookEvent = JSON.parse(req.body);
const eventId = webhookEvent.id;
const eventType = webhookEvent.event_type;
// 3. 중복 체크 (멱등성 보장)
const isDuplicate = await redis.exists(`webhook:${eventId}`);
if (isDuplicate) {
console.log(`Duplicate webhook event: ${eventId}`);
return res.status(200).json({ status: 'processed' });
}
// 4. 이벤트 큐에 추가
await redis.lpush('webhook_queue', JSON.stringify({
eventId,
eventType,
data: webhookEvent,
receivedAt: new Date().toISOString()
}));
// 5. 중복 방지를 위한 이벤트 ID 저장 (24시간 TTL)
await redis.setex(`webhook:${eventId}`, 86400, 'processed');
// 6. 즉시 응답
res.status(200).json({ status: 'received' });
// 7. 로깅
console.log(`Webhook received: ${eventType} - ${eventId}`);
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// 서명 검증 함수
async function verifyWebhookSignature(req) {
const headers = req.headers;
const transmissionId = headers['paypal-transmission-id'];
const certId = headers['paypal-cert-id'];
const authAlgo = headers['paypal-auth-algo'];
const transmissionTime = headers['paypal-transmission-time'];
const webhookId = process.env.PAYPAL_WEBHOOK_ID;
try {
const response = await fetch('https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${await getAccessToken()}`
},
body: JSON.stringify({
transmission_id: transmissionId,
cert_id: certId,
auth_algo: authAlgo,
transmission_time: transmissionTime,
webhook_id: webhookId,
webhook_event: JSON.parse(req.body)
})
});
const result = await response.json();
return result.verification_status === 'SUCCESS';
} catch (error) {
console.error('Signature verification error:', error);
return false;
}
}
3. 웹훅 처리 워커 구현
3.1 워커 서비스 예시
const Redis = require('redis');
const redis = Redis.createClient();
class WebhookProcessor {
constructor() {
this.handlers = new Map();
this.setupHandlers();
}
setupHandlers() {
// 결제 관련 핸들러
this.handlers.set('PAYMENT.SALE.COMPLETED', this.handlePaymentCompleted.bind(this));
this.handlers.set('PAYMENT.SALE.REFUNDED', this.handlePaymentRefunded.bind(this));
this.handlers.set('PAYMENT.CAPTURE.COMPLETED', this.handleCaptureCompleted.bind(this));
// 구독 관련 핸들러
this.handlers.set('BILLING.SUBSCRIPTION.CREATED', this.handleSubscriptionCreated.bind(this));
this.handlers.set('BILLING.SUBSCRIPTION.ACTIVATED', this.handleSubscriptionActivated.bind(this));
this.handlers.set('BILLING.SUBSCRIPTION.CANCELLED', this.handleSubscriptionCancelled.bind(this));
this.handlers.set('BILLING.SUBSCRIPTION.PAYMENT.FAILED', this.handleSubscriptionPaymentFailed.bind(this));
// 분쟁 관련 핸들러
this.handlers.set('CUSTOMER.DISPUTE.CREATED', this.handleDisputeCreated.bind(this));
this.handlers.set('CUSTOMER.DISPUTE.RESOLVED', this.handleDisputeResolved.bind(this));
}
async start() {
console.log('Webhook processor started');
while (true) {
try {
// 큐에서 웹훅 이벤트 가져오기
const eventData = await redis.brpop('webhook_queue', 10);
if (eventData) {
const webhookEvent = JSON.parse(eventData[1]);
await this.processEvent(webhookEvent);
}
} catch (error) {
console.error('Worker error:', error);
await new Promise(resolve => setTimeout(resolve, 5000)); // 5초 대기
}
}
}
async processEvent(webhookEvent) {
const { eventId, eventType, data } = webhookEvent;
try {
console.log(`Processing webhook: ${eventType} - ${eventId}`);
const handler = this.handlers.get(eventType);
if (handler) {
await handler(data);
console.log(`Successfully processed: ${eventType} - ${eventId}`);
} else {
console.log(`No handler for event type: ${eventType}`);
}
} catch (error) {
console.error(`Error processing webhook ${eventId}:`, error);
// 실패한 이벤트를 DLQ(Dead Letter Queue)로 이동
await redis.lpush('webhook_dlq', JSON.stringify({
...webhookEvent,
error: error.message,
failedAt: new Date().toISOString()
}));
}
}
// 결제 완료 핸들러
async handlePaymentCompleted(webhookData) {
const payment = webhookData.resource;
const paymentId = payment.id;
const amount = payment.amount.total;
const currency = payment.amount.currency;
// 데이터베이스 업데이트
await db.query(`
UPDATE payments
SET status = 'completed',
completed_at = NOW(),
amount = ?,
currency = ?
WHERE paypal_payment_id = ?
`, [amount, currency, paymentId]);
// 사용자에게 알림 발송
await sendPaymentConfirmationEmail(payment);
// 서비스 활성화 (구독 등)
await activateUserService(payment);
}
// 구독 생성 핸들러
async handleSubscriptionCreated(webhookData) {
const subscription = webhookData.resource;
const subscriptionId = subscription.id;
const planId = subscription.plan_id;
// 구독 정보 저장
await db.query(`
INSERT INTO subscriptions (
paypal_subscription_id,
plan_id,
status,
created_at
) VALUES (?, ?, 'CREATED', NOW())
`, [subscriptionId, planId]);
console.log(`Subscription created: ${subscriptionId}`);
}
// 구독 결제 실패 핸들러
async handleSubscriptionPaymentFailed(webhookData) {
const subscription = webhookData.resource;
const subscriptionId = subscription.id;
// 구독 상태 업데이트
await db.query(`
UPDATE subscriptions
SET status = 'PAYMENT_FAILED',
last_payment_failed_at = NOW()
WHERE paypal_subscription_id = ?
`, [subscriptionId]);
// 사용자에게 결제 실패 알림
await sendPaymentFailureNotification(subscription);
// 재시도 스케줄링
await schedulePaymentRetry(subscriptionId);
}
// 분쟁 생성 핸들러
async handleDisputeCreated(webhookData) {
const dispute = webhookData.resource;
const disputeId = dispute.dispute_id;
const transactionId = dispute.disputed_transactions[0].seller_transaction_id;
// 분쟁 정보 저장
await db.query(`
INSERT INTO disputes (
paypal_dispute_id,
transaction_id,
status,
amount,
currency,
reason,
created_at
) VALUES (?, ?, ?, ?, ?, ?, NOW())
`, [
disputeId,
transactionId,
dispute.status,
dispute.amount.value,
dispute.amount.currency_code,
dispute.reason
]);
// 관리자에게 알림
await sendDisputeAlert(dispute);
}
}
// 워커 실행
const processor = new WebhookProcessor();
processor.start();
4. 데이터베이스 스키마 설계
-- 웹훅 이벤트 로그 테이블
CREATE TABLE webhook_events (
id SERIAL PRIMARY KEY,
event_id VARCHAR(100) UNIQUE NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_version VARCHAR(20),
resource_type VARCHAR(50),
status VARCHAR(20) DEFAULT 'RECEIVED',
payload JSONB,
processed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event_type (event_type),
INDEX idx_event_id (event_id),
INDEX idx_created_at (created_at)
);
-- 결제 테이블
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
paypal_payment_id VARCHAR(50) UNIQUE,
user_id INTEGER REFERENCES users(id),
amount DECIMAL(10,2),
currency VARCHAR(3),
status VARCHAR(20) DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
INDEX idx_paypal_payment_id (paypal_payment_id),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
);
-- 구독 테이블
CREATE TABLE subscriptions (
id SERIAL PRIMARY KEY,
paypal_subscription_id VARCHAR(50) UNIQUE,
user_id INTEGER REFERENCES users(id),
plan_id VARCHAR(50),
status VARCHAR(20) DEFAULT 'PENDING',
next_billing_time TIMESTAMP,
last_payment_failed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_paypal_subscription_id (paypal_subscription_id),
INDEX idx_user_id (user_id),
INDEX idx_status (status)
);
-- 분쟁 테이블
CREATE TABLE disputes (
id SERIAL PRIMARY KEY,
paypal_dispute_id VARCHAR(50) UNIQUE,
transaction_id VARCHAR(50),
user_id INTEGER REFERENCES users(id),
amount DECIMAL(10,2),
currency VARCHAR(3),
status VARCHAR(20),
reason VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
INDEX idx_paypal_dispute_id (paypal_dispute_id),
INDEX idx_transaction_id (transaction_id),
INDEX idx_status (status)
);
-- 실패한 웹훅 처리 테이블
CREATE TABLE webhook_failures (
id SERIAL PRIMARY KEY,
event_id VARCHAR(100),
event_type VARCHAR(100),
error_message TEXT,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
next_retry_at TIMESTAMP,
payload JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_event_id (event_id),
INDEX idx_next_retry_at (next_retry_at)
);
5. 모니터링 및 알림 시스템
5.1 메트릭 수집
// Prometheus 메트릭 예시
const promClient = require('prom-client');
const webhookCounter = new promClient.Counter({
name: 'paypal_webhooks_received_total',
help: 'Total number of PayPal webhooks received',
labelNames: ['event_type', 'status']
});
const webhookProcessingDuration = new promClient.Histogram({
name: 'paypal_webhook_processing_duration_seconds',
help: 'Duration of webhook processing',
labelNames: ['event_type']
});
const webhookQueueSize = new promClient.Gauge({
name: 'paypal_webhook_queue_size',
help: 'Current size of webhook processing queue'
});
// 웹훅 수신시 메트릭 업데이트
function recordWebhookReceived(eventType, status) {
webhookCounter.labels(eventType, status).inc();
}
function recordProcessingTime(eventType, duration) {
webhookProcessingDuration.labels(eventType).observe(duration);
}
5.2 알림 설정
// Slack 알림 예시
async function sendSlackAlert(message, severity = 'warning') {
const webhook_url = process.env.SLACK_WEBHOOK_URL;
const payload = {
text: `PayPal Webhook Alert`,
attachments: [{
color: severity === 'error' ? 'danger' : 'warning',
text: message,
ts: Math.floor(Date.now() / 1000)
}]
};
await fetch(webhook_url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
// 웹훅 처리 실패 시 알림
async function alertWebhookFailure(eventId, eventType, error) {
const message = `웹훅 처리 실패\n이벤트 ID: ${eventId}\n이벤트 타입: ${eventType}\n오류: ${error}`;
await sendSlackAlert(message, 'error');
}
// 큐 크기 모니터링
setInterval(async () => {
const queueSize = await redis.llen('webhook_queue');
webhookQueueSize.set(queueSize);
if (queueSize > 1000) {
await sendSlackAlert(`웹훅 큐 크기 경고: ${queueSize}개 대기중`, 'warning');
}
}, 60000); // 1분마다 체크
6. 재시도 메커니즘
6.1 지수 백오프 재시도
class RetryManager {
constructor() {
this.maxRetries = 5;
this.baseDelay = 1000; // 1초
}
async processWithRetry(eventData, handler) {
let lastError;
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
await handler(eventData);
return; // 성공시 종료
} catch (error) {
lastError = error;
console.log(`Attempt ${attempt + 1} failed:`, error.message);
if (attempt < this.maxRetries - 1) {
const delay = this.calculateDelay(attempt);
await this.sleep(delay);
}
}
}
// 모든 재시도 실패
throw new Error(`Max retries exceeded. Last error: ${lastError.message}`);
}
calculateDelay(attempt) {
// 지수 백오프: 1초, 2초, 4초, 8초, 16초
return this.baseDelay * Math.pow(2, attempt);
}
sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// 사용 예시
const retryManager = new RetryManager();
async function processWebhookWithRetry(webhookEvent) {
try {
await retryManager.processWithRetry(webhookEvent, async (event) => {
const handler = this.handlers.get(event.eventType);
if (handler) {
await handler(event.data);
}
});
} catch (error) {
// DLQ로 이동
await redis.lpush('webhook_dlq', JSON.stringify({
...webhookEvent,
finalError: error.message,
maxRetriesExceeded: true,
failedAt: new Date().toISOString()
}));
}
}
7. 보안 고려사항
7.1 IP 화이트리스트
// PayPal IP 주소 범위 (예시 - 실제로는 PayPal 문서 확인 필요)
const paypalIpRanges = [
'173.0.84.0/24',
'173.0.82.0/24',
'216.113.188.0/24'
// PayPal에서 제공하는 실제 IP 범위 추가
];
function isPayPalIP(clientIP) {
return paypalIpRanges.some(range => {
return ipRangeCheck(clientIP, range);
});
}
// 미들웨어로 IP 체크
app.use('/webhook/paypal', (req, res, next) => {
const clientIP = req.ip || req.connection.remoteAddress;
if (!isPayPalIP(clientIP)) {
console.log(`Blocked request from unauthorized IP: ${clientIP}`);
return res.status(403).json({ error: 'Forbidden' });
}
next();
});
7.2 Rate Limiting
const rateLimit = require('express-rate-limit');
const webhookLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 1000, // 최대 1000개 요청
message: 'Too many webhook requests',
standardHeaders: true,
legacyHeaders: false
});
app.use('/webhook/paypal', webhookLimiter);
8. 테스트 전략
8.1 단위 테스트
// Jest 테스트 예시
describe('WebhookProcessor', () => {
let processor;
beforeEach(() => {
processor = new WebhookProcessor();
});
test('should handle payment completed event', async () => {
const mockWebhookData = {
id: 'WH-123',
event_type: 'PAYMENT.SALE.COMPLETED',
resource: {
id: 'PAY-123',
amount: { total: '10.00', currency: 'USD' },
state: 'completed'
}
};
const mockHandler = jest.fn();
processor.handlers.set('PAYMENT.SALE.COMPLETED', mockHandler);
await processor.processEvent({
eventId: 'WH-123',
eventType: 'PAYMENT.SALE.COMPLETED',
data: mockWebhookData
});
expect(mockHandler).toHaveBeenCalledWith(mockWebhookData);
});
test('should handle unknown event type gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'log');
await processor.processEvent({
eventId: 'WH-123',
eventType: 'UNKNOWN.EVENT.TYPE',
data: {}
});
expect(consoleSpy).toHaveBeenCalledWith('No handler for event type: UNKNOWN.EVENT.TYPE');
});
});
8.2 통합 테스트
// PayPal 웹훅 시뮬레이터를 사용한 통합 테스트
describe('Webhook Integration Tests', () => {
test('should receive and process webhook from PayPal simulator', async () => {
// PayPal 시뮬레이터에서 웹훅 발송
const response = await fetch('https://api-m.sandbox.paypal.com/v1/notifications/simulate-event', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
url: 'https://your-test-server.com/webhook/paypal',
event_type: 'PAYMENT.SALE.COMPLETED'
})
});
expect(response.status).toBe(202);
// 웹훅 처리 확인 (비동기이므로 잠시 대기)
await new Promise(resolve => setTimeout(resolve, 5000));
const processedEvent = await redis.get('test:webhook:processed');
expect(processedEvent).toBeTruthy();
});
});
9. 운영 및 유지보수
9.1 대시보드 구성
// Express 대시보드 예시
app.get('/admin/webhooks/dashboard', async (req, res) => {
const stats = {
totalReceived: await redis.get('stats:webhooks:total') || 0,
todayReceived: await redis.get(`stats:webhooks:${today}`) || 0,
queueSize: await redis.llen('webhook_queue'),
dlqSize: await redis.llen('webhook_dlq'),
recentEvents: await getRecentWebhookEvents(10)
};
res.render('webhook-dashboard', { stats });
});
// 웹훅 재처리 API
app.post('/admin/webhooks/:eventId/retry', async (req, res) => {
const { eventId } = req.params;
try {
const eventData = await db.query('SELECT * FROM webhook_events WHERE event_id = ?', [eventId]);
if (eventData.length === 0) {
return res.status(404).json({ error: 'Event not found' });
}
// 큐에 다시 추가
await redis.lpush('webhook_queue', JSON.stringify(eventData[0]));
res.json({ status: 'retry scheduled' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
9.2 성능 최적화
// 배치 처리를 통한 성능 최적화
class BatchWebhookProcessor {
constructor() {
this.batchSize = 10;
this.batchTimeout = 5000; // 5초
}
async startBatchProcessing() {
while (true) {
try {
const events = await this.getBatchEvents();
if (events.length > 0) {
await this.processBatch(events);
}
} catch (error) {
console.error('Batch processing error:', error);
}
}
}
async getBatchEvents() {
const events = [];
const startTime = Date.now();
while (events.length < this.batchSize &&
(Date.now() - startTime) < this.batchTimeout) {
const event = await redis.brpop('webhook_queue', 1);
if (event) {
events.push(JSON.parse(event[1]));
}
}
return events;
}
async processBatch(events) {
const promises = events.map(event => this.processEvent(event));
await Promise.allSettled(promises);
}
}
이러한 종합적인 웹훅 핸들링 시스템을 구축하면 PayPal의 모든 이벤트를 안정적이고 확장 가능한 방식으로 처리할 수 있습니다. 특히 구독 기반 서비스나 전자상거래 플랫폼에서는 웹훅을 통한 실시간 이벤트 처리가 사용자 경험과 비즈니스 운영에 매우 중요합니다.
핵심은 빠른 응답, 안정적인 처리, 완벽한 모니터링, 효과적인 오류 처리입니다. 이를 통해 PayPal과의 연동에서 발생할 수 있는 다양한 상황들을 원활하게 처리할 수 있습니다.