Claude Code를 활용한 Webhook 처리: 서명 검증, 멱등성 및 재시도 안전성
I’m happy to translate the article for you, but I’ll need the full text of the post (the portions you want translated) in order to do so. Could you please paste the content here? Once I have it, I’ll provide a Korean translation while preserving the original formatting, markdown, and any code blocks or URLs unchanged.
Webhook 수신 규칙
보안 (필수)
- 모든 들어오는 웹훅에 대해 서명을 검증합니다 (유효한 서명이 없으면 거부).
- Stripe: 웹훅 시크릿을 사용해
stripe-signature헤더를 검증합니다. - GitHub:
X-Hub-Signature-256을 HMAC‑SHA256으로 검증합니다. - 중요: 파싱된 JSON이 아니라 원시 요청 본문을 기준으로 검증합니다 (파싱 시 바이트가 변함).
멱등성 (필수)
- 모든 웹훅 핸들러는 멱등해야 합니다 (같은 이벤트를 두 번 받아도 안전).
- 멱등성 키 형식:
{provider}-{eventId}로WebhookEvent테이블에 저장합니다. - 이미 처리된 이벤트: 즉시
200을 반환하고 추가 처리를 건너뜁니다.
응답 시간
- 5초 이내에
200을 반환합니다 (응답이 없으면 제공자가 재시도합니다). - 무거운 처리는 큐(예: BullMQ) 로 위임한 뒤 반환합니다.
재시도 안전성
- 멱등성 테이블이 재시도 시 중복 처리를 방지합니다.
- 재시도 로직은 전송 제공자가 자체적으로 관리하도록 합니다.
Stripe 웹훅 구현
엔드포인트 (src/webhooks/stripeWebhook.ts)
import Stripe from 'stripe';
import express, { Router } from 'express';
import { prisma } from '../prisma'; // adjust import as needed
import { webhookQueue } from '../queues'; // BullMQ queue
import { logger } from '../logger';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export const stripeWebhookRouter = Router();
// IMPORTANT: Use express.raw() — not express.json()
// Stripe signature verification requires the raw request body
stripeWebhookRouter.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
logger.warn({ err }, 'Stripe webhook signature failed');
return res.status(400).send('Signature verification failed');
}
// Idempotency check
const alreadyProcessed = await prisma.webhookEvent.findUnique({
where: { id: `stripe-${event.id}` },
});
if (alreadyProcessed) {
logger.info({ eventId: event.id }, 'Already processed, skipping');
return res.status(200).json({ received: true });
}
// Record as processed (before processing to prevent race conditions)
await prisma.webhookEvent.create({
data: {
id: `stripe-${event.id}`,
type: event.type,
processedAt: new Date(),
},
});
// Return 200 immediately — don't block on processing
res.status(200).json({ received: true });
// Queue for async processing
await webhookQueue.add(event.type, event);
}
);
핸들러 (src/webhooks/handlers/paymentHandler.ts)
import Stripe from 'stripe';
import { prisma } from '../../prisma';
import { emailQueue } from '../../queues';
import { logger } from '../../logger';
export async function handlePaymentIntentSucceeded(
event: Stripe.Event
): Promise {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const orderId = paymentIntent.metadata.orderId;
if (!orderId) {
logger.warn(
{ paymentIntentId: paymentIntent.id },
'No orderId in metadata'
);
return;
}
await prisma.order.update({
where: { id: orderId },
data: {
status: 'completed',
paidAt: new Date(),
paymentIntentId: paymentIntent.id,
},
});
await emailQueue.add('order-confirmation', { orderId });
logger.info({ orderId }, 'Order completed via webhook');
}
export async function handlePaymentIntentFailed(
event: Stripe.Event
): Promise {
const paymentIntent = event.data.object as Stripe.PaymentIntent;
const orderId = paymentIntent.metadata.orderId;
if (!orderId) {
logger.warn(
{ paymentIntentId: paymentIntent.id },
'No orderId in metadata'
);
return;
}
await prisma.order.update({
where: { id: orderId },
data: {
status: 'failed',
failedAt: new Date(),
},
});
logger.info({ orderId }, 'Order marked as failed via webhook');
}
export async function handleSubscriptionDeleted(
event: Stripe.Event
): Promise {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await prisma.subscription.updateMany({
where: { stripeCustomerId: customerId },
data: { active: false, cancelledAt: new Date() },
});
logger.info(
{ customerId },
'Subscription deactivated via webhook'
);
}
GitHub Webhook 구현 (src/webhooks/githubWebhook.ts)
import crypto from 'crypto';
import express, { Router } from 'express';
import { githubQueue } from '../queues';
import { logger } from '../logger';
const router = Router();
function verifyGitHubSignature(body: Buffer, signature: string): boolean {
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET!);
hmac.update(body);
const expected = `sha256=${hmac.digest('hex')}`;
// Timing‑safe comparison prevents timing attacks
try {
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
} catch {
return false; // Different lengths
}
}
router.post(
'/webhooks/github',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-hub-signature-256'] as string;
if (!signature || !verifyGitHubSignature(req.body, signature)) {
logger.warn('Invalid GitHub webhook signature');
return res.status(401).send('Invalid signature');
}
const event = req.headers['x-github-event'] as string;
const payload = JSON.parse(req.body.toString());
// Return fast, process async
res.status(200).json({ received: true });
await githubQueue.add(event, payload);
}
);
export default router;
Webhook 이벤트 모델 (Prisma)
model WebhookEvent {
id String @id // "{provider}-{eventId}"
type String
processedAt DateTime
@@index([processedAt])
}
유지보수
- 예약된 크론 작업을 사용하여 30일 후에 오래된 이벤트를 정리합니다.
웹훅 보안 모범 사례에 대한 자세한 내용은 가이드를 에서 확인하십시오.