Claude Code로 SaaS 보일러플레이트 만들기: Next.js 인증, 결제, 테넌트, 테스트
Claude Code로 유료 SaaS 스타터를 구축하는 방법. Next.js, 인증, 결제, 테넌트, 감사 로그, 테스트까지 다룹니다.
SaaS 보일러플레이트는 유료 웹 제품을 만들 때 반복해서 필요한 기반 코드입니다. 인증, 결제, 테넌트, 역할, 이메일, 대시보드, 관리자 화면, 감사 로그, 환경 변수, 테스트, 문서, 배포 체크리스트를 하나의 재사용 가능한 출발점으로 묶습니다. Claude Code를 쓰면 이 기반을 빠르게 만들 수 있지만, 유료 제품으로 팔거나 실제 고객에게 제공하려면 데모보다 훨씬 엄격한 경계가 필요합니다.
가장 위험한 착각은 “앱이 실행된다”를 완료로 보는 것입니다. 테넌트는 하나의 애플리케이션 안에서 회사, 워크스페이스, 고객 계정을 분리하는 단위입니다. 테넌트 확인이 약하면 한 고객이 다른 고객의 데이터를 볼 수 있습니다. RBAC는 역할 기반 접근 제어입니다. OWNER, ADMIN, BILLING, MEMBER, VIEWER가 같은 권한을 가지면 안 됩니다. 감사 로그는 누가 언제 무엇을 했는지 남기는 기록입니다. 이 로그가 없으면 지원, 장애 분석, 기업 고객 대응이 어려워집니다.
이 글에서는 Next.js App Router, TypeScript, Prisma, Stripe, Resend를 기준으로 Claude Code에게 SaaS 보일러플레이트를 만들게 하는 방법을 정리합니다. 구현할 때는 공식 문서를 기준으로 확인하세요. 참고할 문서는 Claude Code docs, Next.js docs, Auth.js, Prisma schema docs, Stripe webhooks, Resend docs, OWASP Authentication Cheat Sheet입니다.
함께 보면 좋은 ClaudeCodeLab 글은 안전한 인증 구현, RBAC 구현, Zod 검증, Claude Code API 개발입니다.
유료 제품에서 거꾸로 설계하기
먼저 물어볼 질문은 “Claude Code가 어떤 컴포넌트를 만들까”가 아닙니다. “이 스타터를 산 사람이 어떤 제품을 출시할 수 있을까”입니다. 화면이 많아도 테넌트 경계가 약한 템플릿은 가치가 낮습니다. 범위가 작더라도 테스트, 운영 문서, 배포 기준이 명확한 템플릿이 더 팔기 쉽습니다.
| 유스 케이스 | 필요한 기반 | 수익화 경로 |
|---|---|---|
| 개인 micro-SaaS | OAuth 로그인, 개인 플랜, Stripe Checkout, 사용량 대시보드 | 낮은 진입 장벽의 월 구독 |
| B2B 팀 도구 | 테넌트, 초대, 역할, 결제 담당자, 감사 로그 | 좌석 수 또는 워크스페이스 단위 과금 |
| 멤버십 콘텐츠와 템플릿 포털 | 구매자 권한, 다운로드 기록, 이메일 알림, 관리자 화면 | 유료 템플릿 팩과 강의 |
| 사내 AI 업무 도구 | SSO, 승인, IP 또는 도메인 정책, 작업 로그 | 도입 컨설팅과 운영 지원 |
보일러플레이트가 법무, 세무, 개인정보, 보안 검토를 없애 준다고 말하면 안 됩니다. Stripe 세금 설정, 환불, 이용약관, 개인정보 처리방침, 데이터 보존, 고객 지원, 권한 점검은 사람이 확인해야 합니다. Claude Code는 구현 속도를 높이는 도구이지, 사업 책임을 대신 맡는 도구가 아닙니다.
flowchart LR
A["Marketing site"] --> B["Auth"]
B --> C["Tenant and roles"]
C --> D["Dashboard"]
C --> E["Billing"]
C --> F["Admin"]
D --> G["Audit logs"]
E --> G
F --> G
G --> H["Tests and release checklist"]
CLAUDE.md로 작업 계약을 만든다
Claude Code에게 “SaaS 앱 만들어 줘”라고만 요청하면 UI, API, 데이터 모델, 비즈니스 규칙이 큰 변경 하나로 섞이기 쉽습니다. CLAUDE.md는 작업 계약입니다. 여기서 harness는 “에이전트가 안전하게 작업하도록 만드는 발판”이라고 생각하면 됩니다.
# CLAUDE.md
## Product goal
Build a paid SaaS starter that can be reused for real products.
Do not claim the starter removes legal, tax, privacy, or security review.
## Stack
- Next.js App Router with TypeScript
- Prisma and PostgreSQL
- Auth.js for OAuth/session integration
- Stripe Checkout and billing webhooks
- Resend for transactional email
- Vitest and Playwright for acceptance tests
## Required boundaries
- Every business record belongs to a tenantId.
- Never trust tenantId from the browser without checking membership.
- Roles are OWNER, ADMIN, BILLING, MEMBER, VIEWER.
- Billing routes require OWNER or BILLING.
- Admin routes require OWNER or ADMIN.
- State-changing routes write an audit log.
- Secrets must be read through src/lib/env.ts and never hardcoded.
- Include tests for forbidden tenant access and webhook idempotency.
## Review output
After edits, list changed files, commands run, risks, and manual checks.
이 규칙은 초보자에게 특히 효과적입니다. 브라우저가 보낸 tenantId를 믿지 말 것, 결제 라우트는 OWNER 또는 BILLING만 허용할 것, 상태 변경은 감사 로그를 남길 것처럼 금지된 지름길을 먼저 지정하기 때문입니다.
책임별 폴더 구조로 나누기
작은 데모는 모든 코드를 한 라우트 폴더에 넣어도 됩니다. SaaS는 그렇지 않습니다. 인증, 결제, 팀 관리, 이메일, 감사 로그가 빠르게 얽히므로 폴더 구조에서 경계가 보여야 합니다.
src/
app/
(marketing)/
(auth)/
(dashboard)/dashboard/page.tsx
(admin)/admin/page.tsx
api/
billing/checkout/route.ts
billing/webhook/route.ts
tenants/invite/route.ts
components/
dashboard/
pricing/
ui/
lib/
auth.ts
env.ts
prisma.ts
tenant.ts
audit.ts
email.ts
stripe.ts
tests/
acceptance/saas.spec.ts
prisma/
schema.prisma
이 구조를 두면 Claude Code에게 “billing webhook과 관련 테스트만 수정해”라고 요청할 수 있습니다. 전체 앱을 다시 건드리게 하지 않아도 됩니다.
Prisma 스키마에서 테넌트를 중심에 둔다
아래 스키마는 유료 SaaS의 실용적인 최소 형태입니다. Tenant는 회사, 워크스페이스, 고객 계정입니다. Membership은 사용자가 어떤 테넌트에서 어떤 역할을 갖는지 나타냅니다. Subscription은 Stripe 상태를 저장하고, AuditLog는 중요한 변경을 기록합니다.
// prisma/schema.prisma
enum Role {
OWNER
ADMIN
BILLING
MEMBER
VIEWER
}
enum Plan {
FREE
STARTER
PRO
}
enum SubscriptionStatus {
TRIALING
ACTIVE
PAST_DUE
CANCELED
}
model User {
id String @id @default(cuid())
email String @unique
name String?
memberships Membership[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
}
model Tenant {
id String @id @default(cuid())
name String
slug String @unique
plan Plan @default(FREE)
memberships Membership[]
subscription Subscription?
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Membership {
id String @id @default(cuid())
userId String
tenantId String
role Role @default(MEMBER)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, tenantId])
@@index([tenantId, role])
}
model Subscription {
id String @id @default(cuid())
tenantId String @unique
stripeCustomerId String @unique
stripeSubscriptionId String? @unique
status SubscriptionStatus @default(TRIALING)
priceId String?
currentPeriodEnd DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt
}
model AuditLog {
id String @id @default(cuid())
tenantId String
actorId String?
action String
metadata Json?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
actor User? @relation(fields: [actorId], references: [id])
createdAt DateTime @default(now())
@@index([tenantId, createdAt])
}
실제 제품에서는 청구서, 주소, 사용량 카운터, API 키, 데이터 보존 정책, 고객 지원 메모가 더 필요할 수 있습니다. 첫날 모든 것을 모델링할 필요는 없지만, 테넌트 경계를 처음부터 무시할 수 없게 만들어야 합니다.
환경 변수와 시크릿을 검증한다
실제 시크릿은 코드, 스크린샷, 커밋 기록에 넣지 않습니다. .env.example에는 변수 이름만 쓰고 실제 값은 Vercel, Cloudflare, AWS, GitHub Actions 같은 시크릿 저장소에 둡니다. env.ts로 시작 시점에 검증하면 Webhook 서명이나 이메일 API 키 누락을 빨리 발견할 수 있습니다.
// src/lib/env.ts
import { z } from "zod";
export const env = z
.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
STRIPE_PRICE_STARTER: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
EMAIL_FROM: z.string().email(),
})
.parse(process.env);
테넌트와 역할은 서버에서 확인한다
브라우저가 tenantId를 보낼 수는 있지만, 서버가 그 값을 믿으면 안 됩니다. 데이터를 읽거나 바꾸기 전에 항상 서버에서 Membership을 조회합니다.
// src/lib/tenant.ts
import { Role } from "@prisma/client";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
const roleRank: Record<Role, number> = {
VIEWER: 1,
MEMBER: 2,
BILLING: 3,
ADMIN: 4,
OWNER: 5,
};
export async function requireTenant(tenantId: string, minimumRole: Role = "MEMBER") {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const membership = await prisma.membership.findUnique({
where: {
userId_tenantId: {
userId: session.user.id,
tenantId,
},
},
include: { tenant: true },
});
if (!membership || roleRank[membership.role] < roleRank[minimumRole]) {
throw new Error("Forbidden tenant access");
}
return {
userId: session.user.id,
tenant: membership.tenant,
role: membership.role,
};
}
자주 나오는 실패는 tenantId를 hidden input에 넣고 그대로 업데이트에 쓰는 것입니다. hidden input은 비밀이 아닙니다. 모든 쓰기 작업은 서버에서 Membership을 다시 확인해야 합니다.
Stripe Webhook은 반복될 수 있다고 보고 작성한다
Webhook은 외부 알림입니다. 같은 이벤트가 두 번 올 수도 있고 늦게 도착할 수도 있습니다. 제품용 스타터라면 서명 검증, metadata.tenantId 확인, upsert, 감사 로그를 기본으로 넣어야 합니다.
// src/app/api/billing/webhook/route.ts
import Stripe from "stripe";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { SubscriptionStatus } from "@prisma/client";
import { env } from "@/lib/env";
import { prisma } from "@/lib/prisma";
const stripe = new Stripe(env.STRIPE_SECRET_KEY);
function toStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
if (status === "active") return "ACTIVE";
if (status === "past_due") return "PAST_DUE";
if (status === "canceled") return "CANCELED";
return "TRIALING";
}
export async function POST(request: Request) {
const body = await request.text();
const signature = (await headers()).get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Missing Stripe signature" }, { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, env.STRIPE_WEBHOOK_SECRET);
} catch {
return NextResponse.json({ error: "Invalid Stripe signature" }, { status: 400 });
}
if (
event.type === "customer.subscription.created" ||
event.type === "customer.subscription.updated" ||
event.type === "customer.subscription.deleted"
) {
const subscription = event.data.object as Stripe.Subscription;
const tenantId = subscription.metadata.tenantId;
if (!tenantId || typeof subscription.customer !== "string") {
return NextResponse.json({ error: "Missing tenant metadata" }, { status: 400 });
}
await prisma.subscription.upsert({
where: { tenantId },
create: {
tenantId,
stripeCustomerId: subscription.customer,
stripeSubscriptionId: subscription.id,
status: toStatus(subscription.status),
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
update: {
stripeSubscriptionId: subscription.id,
status: toStatus(subscription.status),
priceId: subscription.items.data[0]?.price.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
await prisma.auditLog.create({
data: {
tenantId,
action: `stripe.${event.type}`,
metadata: { eventId: event.id, subscriptionId: subscription.id },
},
});
}
return NextResponse.json({ received: true });
}
이메일과 감사 로그를 공통 함수로 둔다
초대, 결제 실패, 비밀번호 재설정, 관리자 알림은 모두 이메일을 씁니다. 결제 변경, 역할 변경, 테넌트 설정 변경은 모두 감사 로그를 남겨야 합니다. 공통 helper를 두면 제품 리뷰가 쉬워집니다.
// src/lib/email.ts
import { Resend } from "resend";
import { env } from "@/lib/env";
const resend = new Resend(env.RESEND_API_KEY);
export async function sendTenantInviteEmail(input: {
to: string;
tenantName: string;
inviteUrl: string;
}) {
return resend.emails.send({
from: env.EMAIL_FROM,
to: input.to,
subject: `${input.tenantName} invited you`,
html: `<p>You were invited to ${input.tenantName}.</p><p><a href="${input.inviteUrl}">Accept invite</a></p>`,
});
}
// src/lib/audit.ts
import { prisma } from "@/lib/prisma";
export async function writeAuditLog(input: {
tenantId: string;
actorId?: string;
action: string;
metadata?: Record<string, unknown>;
}) {
return prisma.auditLog.create({
data: {
tenantId: input.tenantId,
actorId: input.actorId,
action: input.action,
metadata: input.metadata,
},
});
}
UI를 다듬기 전에 수용 테스트를 정한다
Acceptance test는 사용자의 관점에서 이 기능을 받아들일 수 있는지 확인하는 테스트입니다. 유료 SaaS 스타터에서는 거부 경로 테스트가 특히 중요합니다.
// tests/acceptance/saas.spec.ts
import { test, expect } from "@playwright/test";
test("member cannot open billing settings", async ({ page }) => {
await page.goto("/test-login?role=MEMBER");
await page.goto("/dashboard/acme/billing");
await expect(page.getByText("Forbidden")).toBeVisible();
});
test("billing user can open billing settings", async ({ page }) => {
await page.goto("/test-login?role=BILLING");
await page.goto("/dashboard/acme/billing");
await expect(page.getByRole("heading", { name: "Billing" })).toBeVisible();
});
test("tenant switch does not leak another tenant data", async ({ page }) => {
await page.goto("/test-login?tenant=acme");
await page.goto("/dashboard/other-team/settings");
await expect(page.getByText("Forbidden")).toBeVisible();
});
흔한 실수는 로그인, 워크스페이스 생성, 결제, 대시보드 표시 같은 정상 흐름만 테스트하는 것입니다. 다른 테넌트 접근 차단, 멤버의 결제 변경 차단, 중복 webhook 처리도 확인해야 합니다.
배포 전 체크리스트
공개하거나 판매하기 전에 최소한 다음 항목을 확인합니다.
- metadata, hero image, 공식 링크, 내부 링크, CTA가 있다
.env.example에는 이름만 있고 실제 시크릿은 없다- 모든 비즈니스 업데이트가 서버에서 테넌트 Membership을 확인한다
- OWNER, ADMIN, BILLING, MEMBER, VIEWER의 차이가 테스트된다
- Stripe Webhook 서명 검증과 멱등 처리가 있다
- 이메일 실패 시 재시도 또는 수동 재전송 경로가 있다
- 관리자 화면과 감사 로그가 프로덕션에서 동작한다
- 이용약관, 개인정보 처리방침, 환불 정책, 세금 설정, 데이터 보존은 사람이 검토했다
- README에 로컬 실행, seed, 테스트, 배포 절차가 있다
상품으로 팔 때는 패키지를 나눈다
SaaS 보일러플레이트를 판매하려면 코드만으로는 부족합니다. 구매자는 설정 문서, 환경 변수 표, Stripe 설정 설명, seed 데이터, 수용 테스트, 지원 범위를 함께 원합니다.
| 패키지 | 내용 | 적합한 사용자 |
|---|---|---|
| Free checklist | CLAUDE.md 예시, env 목록, 배포 전 체크 | 아이디어를 검증하는 개인 개발자 |
| Starter template | Next.js, Prisma, Auth.js, Stripe, Resend, 테스트 | 주말에 MVP를 만들고 싶은 사람 |
| Pro template | 관리자, 감사 로그, 사용량 과금, 초대, 문서 | 유료 SaaS 출시를 진지하게 준비하는 사람 |
| Team rollout | 저장소 리뷰, Claude Code 교육, 리뷰 규칙 | 팀에 Claude Code를 도입하는 회사 |
ClaudeCodeLab에는 무료 치트시트, Claude Code 제품과 템플릿, 교육 및 도입 상담이 있습니다. 무료 자료로 흐름을 확인하고, 실제 저장소에 맞춰야 할 때 템플릿이나 상담으로 확장하는 방식이 현실적입니다.
실제로 시도했을 때의 결과
Masa가 작은 검증 저장소에서 처음 “dashboard를 만들어 줘”라고 요청했을 때는 화면은 동작했지만, 다른 테넌트 URL 직접 접근이 막히지 않았고 Stripe Webhook 서명 검증도 빠졌습니다. 이후 CLAUDE.md에 테넌트 경계, 역할, 감사 로그, 수용 테스트를 먼저 적자 Claude Code의 변경 범위가 작아졌고 리뷰할 지점도 선명해졌습니다. 결론은 명확합니다. SaaS 보일러플레이트의 가치는 기능 개수가 아니라 경계, 테스트, 운영 문서에서 결정됩니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Obsidian 메모를 CLAUDE.md로 바꾸는 Claude Code 워크플로
Obsidian 작업 메모를 CLAUDE.md 운영 노트로 정리해 Claude Code 세션의 문맥 반복을 줄입니다.
Claude Code Revenue CTA Routing: 글에서 PDF, Gumroad, 상담으로 보내기
독자 의도에 따라 무료 PDF, Gumroad 상품, 상담으로 나누는 Claude Code CTA 설계입니다.
Claude Code 팀 인계 규칙: 리뷰 증거, 권한, 롤백, 수익 경로까지 넘기는 법
Claude Code 작업을 팀에 넘길 때 필요한 증거, 권한 규칙, 롤백, 무료 PDF, Gumroad, 상담 경로 체크리스트.