architecture2025-03-05·9 min·159/348

Vercel에서 webhook 수신 설정

Vercel에서 웹훅을 수신하고 처리하는 방법을 알아봅니다.

Vercel에서 webhook 수신 설정

Introduction

웹훅은 외부 서비스에서 이벤트를 수신하는 데 유용한 메커니즘입니다. Vercel에서 웹훅을 수신하고 처리하는 방법을 알아보겠습니다.

Environment

  • Vercel 프로젝트 (Next.js 14+)
  • Stripe, GitHub, Slack 등 외부 서비스
  • Node.js 18+
  • TypeScript

Problem

웹훅 처리 시 발생하는 문제:

// 웹훅 서명 검증 없이 처리
export async function POST(request) {
  const data = await request.json();
  
  // 보안 문제: 서명 검증 없이 처리
  // 악의적인 요청을 처리할 수 있음
}

Analysis

웹훅 처리 시 고려사항:

  1. 서명 검증: 웹훅이 유효한지 확인
  2. 멱등성: 중복 요청 처리
  3. 에러 핸들링: 처리 실패 시 재시도
  4. 보안: API 키 관리

Solution

1. 기본 웹훅 엔드포인트

// app/api/webhooks/stripe/route.js
import { NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(request) {
  const body = await request.text();
  const sig = request.headers.get('stripe-signature');
  
  let event;
  
  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }
  
  // 이벤트 처리
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(event.data.object);
      break;
    case 'payment_intent.payment_failed':
      await handlePaymentFailure(event.data.object);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }
  
  return NextResponse.json({ received: true });
}

2. GitHub 웹훅 처리

// app/api/webhooks/github/route.js
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request) {
  const body = await request.text();
  const signature = request.headers.get('x-hub-signature-256');
  
  // 서명 검증
  const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
  const digest = 'sha256=' + hmac.update(body).digest('hex');
  
  if (signature !== digest) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }
  
  const event = JSON.parse(body);
  
  // 이벤트 처리
  switch (request.headers.get('x-github-event')) {
    case 'push':
      await handlePush(event);
      break;
    case 'pull_request':
      await handlePullRequest(event);
      break;
    case 'issues':
      await handleIssues(event);
      break;
  }
  
  return NextResponse.json({ received: true });
}

3. Slack 웹훅 처리

// app/api/webhooks/slack/route.js
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export async function POST(request) {
  const body = await request.text();
  const signature = request.headers.get('x-slack-signature');
  const timestamp = request.headers.get('x-slack-request-timestamp');
  
  // 서명 검증
  const baseString = `v0:${timestamp}:${body}`;
  const hmac = crypto.createHmac('sha256', process.env.SLACK_SIGNING_SECRET);
  const mySignature = 'v0=' + hmac.update(baseString).digest('hex');
  
  if (signature !== mySignature) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
  }
  
  const payload = JSON.parse(body);
  
  // 슬랙 인터랙션 처리
  if (payload.type === 'url_verification') {
    return NextResponse.json({ challenge: payload.challenge });
  }
  
  // 이벤트 처리
  if (payload.event) {
    await handleSlackEvent(payload.event);
  }
  
  return NextResponse.json({ ok: true });
}

4. 웹훅 핸들러 클래스

// lib/webhook-handler.js
import crypto from 'crypto';

export class WebhookHandler {
  constructor(secret) {
    this.secret = secret;
    this.handlers = new Map();
  }
  
  on(event, handler) {
    this.handlers.set(event, handler);
    return this;
  }
  
  verify(signature, body) {
    const hmac = crypto.createHmac('sha256', this.secret);
    const digest = hmac.update(body).digest('hex');
    return signature === `sha256=${digest}`;
  }
  
  async handle(event, body) {
    const handler = this.handlers.get(event);
    if (!handler) {
      console.log(`No handler for event: ${event}`);
      return { success: true };
    }
    
    try {
      await handler(body);
      return { success: true };
    } catch (error) {
      console.error(`Handler error for ${event}:`, error);
      return { success: false, error: error.message };
    }
  }
}

// 사용 예시
const webhookHandler = new WebhookHandler(process.env.WEBHOOK_SECRET);

webhookHandler.on('payment.success', async (data) => {
  // 결제 성공 처리
});

webhookHandler.on('user.created', async (data) => {
  // 사용자 생성 처리
});

5. 웹훅 로깅

// lib/webhook-logger.js
import { Redis } from '@upstash/redis';

const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL,
  token: process.env.UPSTASH_REDIS_REST_TOKEN,
});

export async function logWebhook(source, event, payload) {
  const logEntry = {
    id: crypto.randomUUID(),
    source,
    event,
    timestamp: Date.now(),
    payload: JSON.stringify(payload),
  };
  
  // Redis에 로그 저장
  await redis.lpush('webhook-logs', JSON.stringify(logEntry));
  
  // 로그 개수 제한
  await redis.ltrim('webhook-logs', 0, 999);
  
  return logEntry.id;
}

export async function getWebhookLogs(limit = 50) {
  const logs = await redis.lrange('webhook-logs', 0, limit - 1);
  return logs.map(log => JSON.parse(log));
}

6. 웹훅 재시도 로직

// lib/webhook-retry.js
export async function withRetry(fn, maxRetries = 3, delay = 1000) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      
      console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
      delay *= 2; // 지수 백오프
    }
  }
}

Lessons Learned

  1. 서명 검증 필수: 모든 웹훅은 서명 검증을 통해 유효성을 확인해야 합니다.
  2. 멱등성 보장: 동일한 웹훅이 여러 번 전송될 수 있으므로 멱등성 처리가 필요합니다.
  3. 에러 핸들링: 웹훅 처리 실패 시 적절한 에러 응답과 재시도 로직이 필요합니다.
  4. 로깅: 모든 웹훅 요청을 로깅하면 디버깅과 감사에 유용합니다.
  5. 보안: 웹훅 시크릿은 안전한 곳에 저장하고 공유하지 않아야 합니다.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.