콘텐츠로 건너뛰기
모든 글

Next.js에서 구현하는 멱등성 있는 Stripe 및 Lemon Squeezy 웹훅

원시 웹훅 서명을 검증하고, 재전송된 이벤트를 데이터베이스 경계에서 중복 제거하며, 중복 구독으로 인해 유료 액세스 권한이 취소되는 문제를 방지하는 방법.

supastack 팀읽기 3분

How do I handle Stripe and Lemon Squeezy webhooks idempotently in Next.js?

It depends on your billing providers, but the core architecture is identical: verify the raw payload signature before parsing JSON, normalize provider events into a shared internal contract, and enforce idempotency at the database boundary using the provider's event or order ID. Never grant product entitlements from a client-side checkout redirect; let the verified webhook transactionally update subscription state and entitlements once. supastack prewires this pipeline for Stripe, Lemon Squeezy, and mobile RevenueCat, though single-plan apps can start with a simpler handler.

JSON을 파싱하기 전에 원시 페이로드 서명을 검증하세요

모든 결제 제공업체는 네트워크로 전송하는 정확한 바이트 시퀀스에 서명합니다. 라우트 핸들러가 await request.json()으로 파싱한 뒤 서명을 확인하기 전에 객체를 다시 직렬화하면 키 순서나 공백 차이로 인해 정상적인 웹훅이 거부되거나, 스테이징 환경에서 서명 검증을 건너뛰는 유혹에 빠지기 쉽습니다.

Next.js App Router의 POST 핸들러에서 await request.text()로 원시 본문을 한 번만 읽고, 서버 전용 웹훅 시크릿으로 암호화 서명을 검증한 뒤, 단 하나의 데이터베이스 쿼리도 실행하기 전에 실패한 요청을 HTTP 400으로 거부하세요. 빌링 모듈 가이드는 supastack이 단일 BFF 진입점 뒤에서 서명 검증을 어떻게 분리하는지 보여줍니다.

export async function POST(request: Request) {
  const rawBody = await request.text();
  const signature = request.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  const event = stripe.webhooks.constructEvent(
    rawBody,
    signature,
    env.STRIPE_WEBHOOK_SECRET,
  );
  await handleNormalizedBillingEvent(event);
  return Response.json({ received: true });
}
  • request.text()로 원시 요청 본문을 읽고, 파싱된 JSON을 다시 문자열로 변환하지 마세요.
  • STRIPE_WEBHOOK_SECRET 및 Lemon Squeezy 서명 시크릿은 서버 전용 설정에만 보관하세요.
  • HMAC 서명을 직접 검증할 때는 상수 시간 비교(crypto.timingSafeEqual)를 사용하세요.

메모리가 아닌 데이터베이스 경계에서 중복을 제거하세요

Stripe와 Lemon Squeezy는 배포가 재시작되거나 서버리스 함수가 타임아웃되거나 네트워크 구간에서 응답이 유실될 때마다 웹훅을 재전송합니다. 프로세스 로컬 Set이나 짧은 캐시 키는 인스턴스 간에 사라지므로, 동시에 도착한 두 번의 재시도가 중복 라이선스를 발급하거나 영수증 이메일을 두 번 보내는 것을 막지 못합니다.

구독 액세스 권한을 변경하는 동일한 Postgres 트랜잭션 안에서 제공업체의 이벤트 ID나 주문 정산 상태를 기록하세요. 원자적인 INSERT ... ON CONFLICT DO NOTHING이나 status != 'paid' 조건부 업데이트를 사용하면 동시 재전송을 안전한 no-op으로 처리할 수 있습니다. 웹훅이 처리할 요금제를 설계할 때는 SaaS 요금제 페이지 생성기로 티어와 좌석 규칙을 미리 모델링할 수 있습니다.

const settled = await db.transaction(async (tx) => {
  const [inserted] = await tx
    .insert(billingWebhookEvents)
    .values({ providerEventId: event.id, provider: "stripe" })
    .onConflictDoNothing({ target: billingWebhookEvents.providerEventId })
    .returning({ id: billingWebhookEvents.id });

  if (!inserted) return false; // Already processed
  await applyWorkspaceEntitlement(tx, event);
  return true;
});

순서가 바뀐 이벤트와 중복 구독을 안전하게 처리하세요

웹훅이 사용자가 클릭한 순서대로 도착한다는 보장은 없습니다. customer.subscription.updated가 checkout.session.completed보다 먼저 도착할 수도 있고, 실수로 결제 탭을 두 개 연 고객이 같은 워크스페이스에서 잠시 두 개의 구독을 보유할 수도 있습니다.

해지 및 결제 실패 처리는 해당 이벤트를 발생시킨 특정 구독 ID에 한정하세요. 워크스페이스의 유료 권한을 회수하기 전에 해당 워크스페이스를 유지하는 다른 활성 구독이 남아 있는지 확인해야 합니다. 그렇지 않으면 중복 구독을 해지했을 때 고객이 계속 비용을 지불 중인 액세스 권한까지 조용히 사라집니다.

  • 구독 상태를 workspaceId뿐만 아니라 providerSubscriptionId 기준으로 관리하세요.
  • 최소 하나 이상의 구독이 active 또는 trialing 상태인 동안에는 워크스페이스의 유료 권한을 유지하세요.
  • 일회성 주문을 처리하기 전에 결제 금액과 통화가 예상 SKU와 일치하는지 확인하세요.

SaaS 빌링 웹훅에 대해 자주 묻는 질문

샌드박스 결제 테스트에서 실제 프로덕션 트래픽으로 전환할 때마다 다음 질문들이 반복해서 등장합니다.

  • 결제 성공 리다이렉트 시점에 유료 기능을 바로 열어도 되나요? — 아니요. 리다이렉트 URL은 북마크하거나 공유할 수 있으며, 비동기 결제 수단이 정산되기 전에 방문될 수도 있습니다. 성공 페이지는 서버 상태를 폴링하는 용도로만 사용하고 실제 권한 부여는 검증된 웹훅에 맡기세요.
  • 중복 웹훅에는 어떤 상태 코드를 반환해야 하나요? — 데이터베이스에서 이미 처리된 이벤트임이 확인되면 200 OK를 반환하세요. 4xx나 5xx를 반환하면 제공업체가 계속 재전송합니다.
  • 로컬에서 웹훅 재시도를 어떻게 테스트하나요? — 완전히 동일한 서명된 페이로드를 두 번 전송하는 결정론적 단위 테스트를 작성하고, 라이선스·권한·트랜잭션 이메일이 딱 한 번만 생성되는지 검증하세요.
  • 모바일 인앱 결제는 같은 모델에 어떻게 맞추나요? — RevenueCat 서버 알림을 동일한 멱등성 권한 테이블로 라우팅하여 웹과 모바일 결제가 서로 덮어쓰지 않도록 하세요. 출시 전 점검 항목은 출시 체크리스트에서 확인할 수 있습니다.

자주 묻는 질문

질문 14개

supastack이란 무엇인가요?

supastack은 AI SaaS 제품을 구축하기 위한 모듈식 기반입니다. 공통 인프라를 모아두어 제품 고유의 기능에 집중할 수 있습니다.

웹과 모바일을 지원하나요?

네. supastack에는 웹과 모바일 경험의 출발점으로 Next.js 앱과 Expo 앱이 포함되어 있습니다.

어떤 인증이 포함되어 있나요?

Better Auth가 이메일/비밀번호, OTP, 소셜 로그인을 커버합니다. 개인 워크스페이스는 계정과 함께 프로비저닝되어 첫날부터 레코드가 경계 안에 유지됩니다.

AI는 어떻게 지원되나요?

supastack에는 구성 가능한 멀티 프로바이더 AI, 사용량 추적, 예산, 암호화된 자체 키(BYOK) 지원이 포함되어 있습니다.

구독은 어떻게 관리되나요?

이 키트 위에 구축된 제품에서는 Stripe 결제, 구독, 권한이 유료 액세스의 기반을 형성합니다. 프로바이더 시크릿은 서버 측에 유지됩니다.

제품 고유 코드는 어디에 두나요?

제품 고유 기능은 별도의 코드베이스에 두고, 제품이 supastack 기본 모듈을 구성하고 통합합니다.

supastack 가격은 어떻게 되나요?

키트는 평생 업데이트가 포함된 일회성 구매입니다: 싱글 스택 $249(Next.js(웹), Expo(iOS 및 Android), Tauri(macOS 및 Windows 데스크톱) 리포지토리 중 하나 선택), 듀얼 스택 $398(원하는 리포지토리 2개), 또는 풀 번들 $399(3개 리포지토리 모두와 전체 모노레포), 추가 개발자 시트당 $75. 키트 구독은 없습니다. 이 사이트의 체크아웃은 라이선스 키를 이메일로 전달하고 GitHub 초대로 리포지토리 액세스를 제공합니다.

워크스페이스는 내 데이터를 어떻게 경계 짓나요?

사용자 레코드는 workspaceId로 범위가 지정됩니다. 신원, 권한, AI 사용량, 결제 이벤트, 감사 항목은 개인 워크스페이스 경계 안에 유지됩니다.

백그라운드 작업은 어떻게 작동하나요?

Trigger.dev가 명시적인 작업 런타임입니다. JobRunner 계약은 enqueue, status, cancel만 약속하며 — 워크플로 의미는 Trigger.dev 고유이고 명시적입니다.

이메일과 아웃리치는 어떻게 다른가요?

트랜잭션 이메일은 작업 완료 알림 같은 제품 워크플로를 커버합니다. 콜드 아웃리치와 캠페인은 제품 자체에 속하며 이 키트의 이메일 모듈 밖입니다.

모바일 구매는 어떻게 작동하나요?

모바일은 RevenueCat 네이티브 구매와 복원을 지원하며 서버에서 접근 권한을 검증합니다. 출시 전에 스토어 계정을 설정하고 기기 테스트를 완료하세요. 웹 결제는 별도로 관리합니다.

AI 프로바이더 키는 어디에 있나요?

BYOK 자격 증명은 암호화되어 서버 측에 유지됩니다. 키는 로그에서 삭제되며, 사용량과 예산은 워크스페이스 수준에서 확인할 수 있습니다.

체크아웃에서 어떤 결제 수단을 지원하나요?

Stripe 호스팅 체크아웃은 카드를 받으며, Stripe 계정에서 지갑을 활성화하면 Alipay와 WeChat Pay도 제공할 수 있습니다 — 중국 구매자에게 유용합니다. 지갑 승인은 비동기로 확정되므로 결제가 완료되는 즉시 라이선스 페이지가 갱신됩니다.

환불을 받을 수 있나요?

저장소 접근 권한을 얻은 후에는 supastack이 영구적으로 귀하의 소유가 되므로 환불이 불가능합니다. 하지만 안심하세요. 평생 업데이트가 포함된 전체 소스 코드를 받아 자신이나 고객을 위한 제품을 무제한으로 구축하고 출시할 수 있습니다. 단, 코드를 재판매하거나 저장소를 스타터 키트 또는 템플릿으로 재배포할 수는 없습니다. 예외로 출시 캐시백이 있습니다. supastack으로 만든 실제 서비스를 출시해 공유하고 구매 후 60일 안에 오가닉 조회수 5,000회 또는 오가닉 좋아요 50개에 도달하면 결제 금액의 50%를 돌려받습니다. 자세한 내용은 이용약관을 참조하세요.

KO