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
웹훅 처리 시 고려사항:
- 서명 검증: 웹훅이 유효한지 확인
- 멱등성: 중복 요청 처리
- 에러 핸들링: 처리 실패 시 재시도
- 보안: 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
- 서명 검증 필수: 모든 웹훅은 서명 검증을 통해 유효성을 확인해야 합니다.
- 멱등성 보장: 동일한 웹훅이 여러 번 전송될 수 있으므로 멱등성 처리가 필요합니다.
- 에러 핸들링: 웹훅 처리 실패 시 적절한 에러 응답과 재시도 로직이 필요합니다.
- 로깅: 모든 웹훅 요청을 로깅하면 디버깅과 감사에 유용합니다.
- 보안: 웹훅 시크릿은 안전한 곳에 저장하고 공유하지 않아야 합니다.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.