
Stripe 결제 연동: 돈을 받는 코드는 왜 이렇게 무서운가
SaaS에 결제를 붙이려고 Stripe을 연동했다. 결제 코드는 다른 코드와 차원이 다른 긴장감이 있었다. Checkout Session부터 Webhook까지 실전 경험.

SaaS에 결제를 붙이려고 Stripe을 연동했다. 결제 코드는 다른 코드와 차원이 다른 긴장감이 있었다. Checkout Session부터 Webhook까지 실전 경험.
결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

당신의 앱이 AWS나 Docker 환경에서 자꾸 죽는다면? Heroku 개발자들이 만든 '현대적인 앱을 위한 12가지 헌법'. 로컬호스트에서는 잘 되는데 배포만 하면 터지는 이유와 해결책.

AI가 파일을 읽고, DB를 조회하고, API를 호출할 수 있게 해주는 MCP. USB처럼 AI와 도구를 표준화된 방식으로 연결하는 프로토콜을 정리했다.

회원가입 확인 이메일을 보내야 하는데 HTML 이메일이 지옥이었다. React Email로 컴포넌트처럼 이메일을 만들고 Resend로 보내는 방법을 정리했다.

SaaS를 만들었다. 기능도 완성됐다. 랜딩 페이지도 올렸다. 회원가입도 된다. 그런데 돈을 받는 기능이 없었다. 무료로만 굴리는 건 의미가 없으니, 결제를 붙여야 했다.
Stripe을 선택했다. 개발자들 사이에서 평판이 좋고, 문서가 잘 되어 있다는 얘기를 들었다. 설레는 마음으로 Stripe 대시보드에 가입하고 API 키를 복사했다. 그런데 코드를 짜기 시작하자마자 이상한 기분이 들었다.
평소에 버그가 나도 "고치면 되지" 하는 편이다. UI가 깨지면 다시 그리면 되고, 쿼리가 느리면 인덱스 붙이면 된다. 그런데 결제 코드는 달랐다. 손이 약간 떨렸다. "만약 두 번 청구되면 어떡하지?", "결제는 됐는데 내 서버에서 처리가 안 되면?", "환불 요청이 왔을 때 데이터가 꼬이면?" 이런 생각들이 머리를 맴돌았다.
돈이 오가는 코드는 다른 코드와 차원이 다른 긴장감이 있다. 이 글은 그 긴장감을 안고 Stripe을 연동하면서 이해한 것들을 정리한 기록이다.
Stripe 문서는 방대하다. 처음엔 뭐가 뭔지 몰라서 헤맸다. 한참 읽다 보니 핵심은 세 가지였다.
Checkout Session이란 Stripe이 제공하는 호스팅 결제 페이지다. 내가 카드 입력 폼을 만들 필요가 없다. Stripe이 PCI-DSS를 준수하는 안전한 결제 페이지를 대신 보여준다.
이게 처음엔 이상하게 느껴졌다. "내 서비스인데 결제 페이지를 외부 URL로 보내버린다고?" 싶었다. 그런데 생각해보니 이게 오히려 합리적이다. 카드 번호, CVV 같은 민감한 정보를 내 서버에서 직접 다루지 않아도 되니까. PCI 컴플라이언스를 직접 구현하려면 수개월짜리 감사를 통과해야 한다. Checkout Session을 쓰면 그 부담을 Stripe에 완전히 넘길 수 있다.
비유하자면 백화점 문화상품권 교환 카운터와 비슷하다. 내 가게를 찾아온 손님을 백화점 공용 카운터로 잠깐 안내한다. 카운터 직원이 결제를 처리하고 영수증을 발급한다. 손님은 영수증을 들고 내 가게로 돌아온다. 내 가게는 카드 번호를 볼 일이 없다.
PaymentIntent는 "이 금액을 이 고객에게 청구하겠다"는 의도를 Stripe에 기록하는 객체다. Checkout Session을 만들면 내부적으로 PaymentIntent가 생성된다. 결제가 성공하면 PaymentIntent의 상태가 requires_payment_method → processing → succeeded로 바뀐다.
처음엔 "왜 이렇게 복잡하게 나눠놨지?" 싶었다. 결국 이해했다. 상태 기계를 명시적으로 만들어놓은 것이다. 결제는 단순히 "됐다/안 됐다"가 아니다. 카드 승인 대기 중일 수도 있고, 은행 3D Secure 인증을 기다리는 중일 수도 있고, 사기 의심으로 보류될 수도 있다. 이 복잡한 상태들을 PaymentIntent 하나로 관리한다.
Webhook이 가장 중요하다. 결제가 완료됐을 때 Stripe이 내 서버를 직접 호출해주는 기능이다.
"결제 성공 후에 클라이언트에서 /payment/success 페이지로 리다이렉트하면 그때 처리하면 되는 거 아닌가?"
처음에 이렇게 생각했다. 치명적인 오해였다. 클라이언트를 절대 믿으면 안 된다. 사용자가 결제 직후에 브라우저를 닫을 수도 있고, 네트워크가 끊길 수도 있고, 악의적인 사용자가 결제 없이 성공 URL을 직접 입력할 수도 있다. 리다이렉트에 의존하면 이런 케이스에서 결제는 됐는데 서비스가 활성화 안 되거나, 반대로 결제 안 했는데 서비스가 열리는 일이 생긴다.
Webhook은 배달 완료 문자와 같다. 택배를 주문했다고 해서 집에 도착한 건 아니다. 실제로 문 앞에 놓였을 때 배달 완료 문자가 온다. Stripe Webhook도 마찬가지다. 결제가 진짜로 완료됐을 때, Stripe이 내 서버에 직접 POST 요청을 보내준다. 이걸 받아서 처리해야 비로소 서비스가 활성화되어야 한다.
개념을 이해했으니 코드를 짰다. Next.js App Router 기준이다.
먼저 패키지 설치.
npm install stripe
Checkout Session을 만드는 API Route.
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
export async function POST(req: NextRequest) {
const { priceId, userId } = await req.json();
try {
const session = await stripe.checkout.sessions.create({
mode: 'subscription', // 구독 결제
payment_method_types: ['card'],
line_items: [
{
price: priceId, // Stripe 대시보드에서 생성한 Price ID
quantity: 1,
},
],
// 결제 완료/취소 후 돌아올 URL
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
// 나중에 Webhook에서 유저를 특정하기 위한 메타데이터
metadata: {
userId,
},
// 이미 Stripe Customer가 있으면 연결
customer_email: undefined, // 또는 customer: existingCustomerId
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error('Stripe session creation failed:', error);
return NextResponse.json(
{ error: 'Failed to create checkout session' },
{ status: 500 }
);
}
}
클라이언트에서는 이 API를 호출하고 session.url로 리다이렉트하면 된다. Stripe이 결제 페이지를 보여주고, 완료되면 success_url로 돌아온다.
// 클라이언트 컴포넌트
async function handleSubscribe(priceId: string) {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId, userId: currentUser.id }),
});
const { url } = await res.json();
window.location.href = url; // Stripe 결제 페이지로 이동
}
success_url에는 {CHECKOUT_SESSION_ID} 플레이스홀더를 넣었다. Stripe이 자동으로 실제 세션 ID로 채워준다. 이걸로 결제 완료 페이지에서 어떤 결제였는지 확인할 수 있다. 단, 이 세션 ID로 서비스를 활성화해선 안 된다. 그건 오직 Webhook에서만 해야 한다.
Webhook 처리가 결제 연동의 진짜 핵심이다. 여기서 실수하면 돈은 받았는데 서비스가 열리지 않거나, 반대의 상황이 생긴다.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text(); // raw body가 필요하다
const headersList = await headers();
const signature = headersList.get('stripe-signature')!;
let event: Stripe.Event;
try {
// 서명 검증: 이 요청이 진짜 Stripe에서 온 것인지 확인
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// 이벤트 타입에 따라 처리
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
const customerId = session.customer as string;
const subscriptionId = session.subscription as string;
if (!userId) break;
// DB에 구독 정보 저장
await activateSubscription({
userId,
stripeCustomerId: customerId,
stripeSubscriptionId: subscriptionId,
status: 'active',
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
// 구독 취소 처리
await deactivateSubscription({ stripeCustomerId: customerId });
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
// 결제 실패 처리 (이메일 알림, 유예 기간 설정 등)
await handlePaymentFailure({ stripeCustomerId: customerId });
break;
}
}
// Stripe에 200을 반환해야 한다. 안 하면 재시도한다.
return NextResponse.json({ received: true });
}
Webhook 처리에서 가장 중요한 두 가지가 있다.
첫째, 서명 검증은 절대 생략하지 않는다. stripe.webhooks.constructEvent()가 서명을 검증한다. 이게 실패하면 즉시 400을 반환하고 끝낸다. 서명 검증을 건너뛰면 누구나 내 서버에 가짜 결제 완료 요청을 보낼 수 있다.
둘째, raw body를 써야 한다. req.text()로 받아야 한다. req.json()으로 파싱하면 서명 검증이 실패한다. Stripe은 body를 그대로 해시해서 서명을 만들기 때문이다. Next.js App Router에서 이걸 놓쳐서 한참 헤맸다.
Webhook을 로컬에서 테스트하려면 Stripe CLI가 필수다. Stripe 서버에서 내 로컬 서버로 이벤트를 포워딩해준다.
# Stripe CLI 설치 (macOS)
brew install stripe/stripe-cli/stripe
# 로그인
stripe login
# 로컬 서버로 Webhook 포워딩
stripe listen --forward-to localhost:3000/api/webhooks/stripe
실행하면 Webhook Secret이 출력된다. 이걸 .env.local의 STRIPE_WEBHOOK_SECRET에 넣으면 된다.
테스트 이벤트를 수동으로 발생시킬 수도 있다.
# 결제 성공 이벤트 테스트
stripe trigger checkout.session.completed
# 구독 취소 이벤트 테스트
stripe trigger customer.subscription.deleted
# 결제 실패 이벤트 테스트
stripe trigger invoice.payment_failed
이게 정말 편하다. 실제 카드를 쓰지 않아도 모든 시나리오를 테스트할 수 있다. 테스트 카드 번호(4242 4242 4242 4242)를 써서 Checkout 플로우 전체를 돌려볼 수도 있다.
결제 코드에서 놓치기 쉬운 것들을 정리했다.
같은 요청이 두 번 처리되는 걸 막는 개념이다. 네트워크 오류로 요청이 재시도될 때 특히 중요하다. Stripe API 호출에 idempotencyKey를 넣으면, 같은 키로 들어온 요청은 한 번만 처리하고 이전 결과를 그대로 반환한다. 이중 청구를 막는 안전장치다.
await stripe.checkout.sessions.create(
{ /* ... */ },
{ idempotencyKey: `checkout-${userId}-${Date.now()}` }
);
Stripe은 Webhook을 여러 번 보낼 수 있다. 내 서버가 200을 반환하지 않으면 재시도한다. 그래서 같은 이벤트 ID의 Webhook이 중복으로 올 수 있다. DB에 처리한 이벤트 ID를 기록해두고, 이미 처리한 이벤트면 200만 반환하고 로직은 실행하지 않는다.
// Webhook 이벤트 중복 확인
const existingEvent = await db.stripeEvents.findUnique({
where: { stripeEventId: event.id },
});
if (existingEvent) {
return NextResponse.json({ received: true }); // 이미 처리함
}
// 처리 후 이벤트 ID 기록
await db.stripeEvents.create({
data: { stripeEventId: event.id, processedAt: new Date() },
});
구독 서비스에서 놓치기 쉬운 케이스다. 사용자 카드가 만료되거나 잔액 부족으로 월간 갱신이 실패할 수 있다. invoice.payment_failed 이벤트를 처리해서 사용자에게 알림을 보내고, 유예 기간을 주는 로직이 필요하다. Stripe 대시보드에서 재시도 횟수와 간격도 설정할 수 있다.
Stripe 말고도 선택지가 있다. 직접 고민한 결과를 정리해본다.
| 항목 | Stripe | Paddle | Lemon Squeezy |
|---|---|---|---|
| 수수료 | 2.9% + $0.30 | 5% + $0.50 | 5% + $0.50 |
| 세금 처리 | 직접 구현 | 자동 (MoR) | 자동 (MoR) |
| 한국 사업자 | 복잡 | 가능 | 가능 |
| 개발 유연성 | 최고 | 중간 | 중간 |
| 문서 품질 | 최고 | 좋음 | 좋음 |
Stripe은 가장 유연하고 개발자 경험이 좋다. 그런데 세금 처리를 직접 해야 한다. 글로벌로 팔면 EU VAT, 미국 Sales Tax 같은 걸 나라별로 챙겨야 한다. 세금 처리만으로 프로젝트 하나 규모의 작업이 된다.
Paddle과 Lemon Squeezy는 MoR(Merchant of Record) 방식이다. 기술적으로 판매자가 Paddle/LS이고 나는 그들의 리셀러가 되는 구조다. 세금 신고, 환불 처리, 각국 규제 대응을 다 알아서 해준다. 수수료가 좀 더 높지만, 인디 메이커 혼자서 세금 이슈까지 감당하기 어렵다면 훨씬 현명한 선택이다.
처음 SaaS를 만드는 사람이라면 Lemon Squeezy부터 시작하는 걸 권한다. Stripe보다 설정이 단순하고, 세금 걱정 없이 바로 팔 수 있다. 규모가 커지면 그때 Stripe로 마이그레이션하면 된다.
Checkout Session은 빌려 쓰는 결제 카운터다. 카드 정보를 내 서버에서 다룰 필요가 없다. PCI 컴플라이언스를 Stripe에 위임하는 것이 핵심이다.
클라이언트 리다이렉트를 절대 신뢰하지 않는다. 결제 완료 후 성공 URL로 돌아온 것만으로 처리하면 안 된다. 브라우저는 언제든 닫힐 수 있고, URL은 조작될 수 있다.
Webhook이 진실의 원천이다. Stripe이 서버를 직접 호출해주는 Webhook에서만 서비스를 활성화해야 한다. 서명 검증(constructEvent)은 절대 생략하지 않는다.
raw body를 써야 서명 검증이 된다. req.text()를 써야 한다. req.json()으로 파싱하면 서명 검증이 실패한다.
Stripe CLI로 로컬 테스트를 철저히 한다. stripe listen과 stripe trigger로 실제 카드 없이 모든 시나리오를 검증할 수 있다.
인디 SaaS 초기엔 Lemon Squeezy도 좋은 선택이다. 세금, 환불, 규제 처리까지 원스톱으로 해결된다.
결제 코드를 처음 짤 때 느꼈던 그 긴장감은 사실 좋은 신호였다. 그 긴장감이 더 꼼꼼하게 만들었다. 에러 케이스를 더 많이 생각하게 했고, 서명 검증 같은 보안을 빠뜨리지 않게 했다. 돈을 받는 코드가 무서운 건 당연하다. 그 무서움을 무시하지 말고, 제대로 된 검증 구조를 만드는 데 써야 한다.