Use Cases (업데이트: 2026. 6. 2.)

Claude Code로 SaaS 보일러플레이트 만들기: Next.js 인증, 결제, 테넌트, 테스트

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-SaaSOAuth 로그인, 개인 플랜, 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 checklistCLAUDE.md 예시, env 목록, 배포 전 체크아이디어를 검증하는 개인 개발자
Starter templateNext.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 보일러플레이트의 가치는 기능 개수가 아니라 경계, 테스트, 운영 문서에서 결정됩니다.

#Claude Code #SaaS #boilerplate #Next.js #Prisma #Stripe
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.