Docs
PayPal
웹훅 API

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은 연관된 앱에서 생성된 모든 구독 이벤트에 대해 메시지를 보내기 시작합니다.

웹훅 메시지 처리 단계

  1. 메시지 수신
  2. 메시지 검증

메시지 수신 요구사항

  • 수신 엔드포인트가 HTTP 200 또는 기타 2xx 상태 코드로 응답해야 함
  • 2xx가 아닌 상태 코드는 PayPal이 3일 동안 최대 25회까지 재시도
  • 3일 후 실패하면 배송이 실패로 표시됨 (웹훅 이벤트 대시보드에서 수동으로 재전송 가능)

메시지 검증 방법

다음 두 가지 방법 중 하나 사용 가능:

  1. 메시지의 CRC32를 계산하고 서명 확인
  2. 메시지, 저장된 웹훅 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"
    }
  ]
}

요청 매개변수

매개변수타입필수설명
urlstring필수들어오는 POST 알림 메시지를 수신할 URL (최대 2048자)
event_typesarray필수구독할 이벤트 배열 (최대 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_typestringanchor_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_idstring필수세부 정보를 조회할 웹훅의 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_idstring필수업데이트할 웹훅의 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_idstring필수삭제할 웹훅의 ID (최대 50자)

응답 (204 No Content)

성공적인 요청은 HTTP 204 No Content 상태 코드를 반환하며 JSON 응답 본문은 없습니다.

웹훅 이벤트 구독 목록 조회 (List Event Subscriptions)

GET /v1/notifications/webhooks/{webhook_id}/event-types

ID로 웹훅의 이벤트 구독을 조회합니다.

경로 매개변수

매개변수타입필수설명
webhook_idstring필수구독을 조회할 웹훅의 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_sizeinteger페이지당 항목 수 (기본값: 10)
start_timestring시작 시간 (ISO 8601 형식)
end_timestring종료 시간 (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과의 연동에서 발생할 수 있는 다양한 상황들을 원활하게 처리할 수 있습니다.