SaaS 개발 가이드 에이전트 - 단계별 코드 생성 및 설명
Generates production-ready SaaS code with authentication, payments, and database setup.
/plugin marketplace add johunsang/kreatsaas/plugin install kreatsaas@kreatsaas-marketplace프로덕션 수준의 SaaS 코드를 단계별로 생성하고 설명하는 전문 에이전트입니다.
npx create-next-app@latest my-saas --typescript --tailwind --eslint --app
cd my-saas
필수 패키지 설치:
# DB & ORM
npm install @supabase/supabase-js
# 또는
npm install prisma @prisma/client
# 인증
npm install next-auth @auth/prisma-adapter
# 또는
npm install @clerk/nextjs
# 결제
npm install stripe @stripe/stripe-js
# UI
npm install @radix-ui/react-* class-variance-authority clsx tailwind-merge
npm install lucide-react
# 폼 & 유효성
npm install react-hook-form zod @hookform/resolvers
# 유틸
npm install date-fns
# .env.local
DATABASE_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
Prisma 스키마 예시:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
emailVerified DateTime?
accounts Account[]
sessions Session[]
memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
memberships Membership[]
subscription Subscription?
createdAt DateTime @default(now())
}
model Membership {
id String @id @default(cuid())
role String @default("member")
user User @relation(fields: [userId], references: [id])
userId String
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String
@@unique([userId, organizationId])
}
model Subscription {
id String @id @default(cuid())
stripeSubscriptionId String @unique
stripePriceId String
stripeCustomerId String
status String
currentPeriodStart DateTime
currentPeriodEnd DateTime
organization Organization @relation(fields: [organizationId], references: [id])
organizationId String @unique
}
NextAuth.js 설정:
// lib/auth.ts
import { PrismaAdapter } from "@auth/prisma-adapter"
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import { prisma } from "./db"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
session: async ({ session, user }) => {
if (session.user) {
session.user.id = user.id
}
return session
},
},
})
디자인 시스템 기반 컴포넌트:
app/(marketing)/page.tsx)app/(auth)/login/page.tsx)app/(auth)/signup/page.tsx)app/(dashboard)/page.tsx)app/(dashboard)/settings/page.tsx)app/(dashboard)/billing/page.tsx)// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
})
// 체크아웃 세션 생성
export async function createCheckoutSession(priceId: string, customerId: string) {
return stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=true`,
})
}
// 고객 포털 세션 생성
export async function createPortalSession(customerId: string) {
return stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
})
}
// app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe'
import { prisma } from '@/lib/db'
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
// 구독 상태 업데이트
break
case 'customer.subscription.deleted':
// 구독 취소 처리
break
}
return new Response('OK')
}
// middleware.ts
import { auth } from "@/lib/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth
const isAuthPage = req.nextUrl.pathname.startsWith('/login') ||
req.nextUrl.pathname.startsWith('/signup')
const isDashboard = req.nextUrl.pathname.startsWith('/dashboard')
if (isDashboard && !isLoggedIn) {
return Response.redirect(new URL('/login', req.nextUrl))
}
if (isAuthPage && isLoggedIn) {
return Response.redirect(new URL('/dashboard', req.nextUrl))
}
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
npm run lint
npm run build
npm run test
// lib/auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "./db"
import bcrypt from "bcryptjs"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
pages: {
signIn: "/login",
signUp: "/signup",
error: "/auth/error",
},
providers: [
// 소셜 로그인
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
// 이메일/비밀번호 로그인
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error("이메일과 비밀번호를 입력하세요")
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
})
if (!user || !user.hashedPassword) {
throw new Error("계정을 찾을 수 없습니다")
}
const isValid = await bcrypt.compare(
credentials.password,
user.hashedPassword
)
if (!isValid) {
throw new Error("비밀번호가 일치하지 않습니다")
}
if (!user.emailVerified) {
throw new Error("이메일 인증이 필요합니다")
}
return user
}
})
],
callbacks: {
async jwt({ token, user, trigger, session }) {
if (user) {
token.id = user.id
token.role = user.role
}
// 세션 업데이트 처리
if (trigger === "update" && session) {
token.name = session.name
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
session.user.role = token.role as string
}
return session
}
}
})
// app/api/auth/signup/route.ts
import { prisma } from "@/lib/db"
import bcrypt from "bcryptjs"
import { sendVerificationEmail } from "@/lib/email"
import { generateToken } from "@/lib/tokens"
export async function POST(req: Request) {
try {
const { email, password, name } = await req.json()
// 유효성 검사
if (!email || !password || password.length < 8) {
return Response.json(
{ error: "유효하지 않은 입력입니다" },
{ status: 400 }
)
}
// 중복 확인
const exists = await prisma.user.findUnique({ where: { email } })
if (exists) {
return Response.json(
{ error: "이미 가입된 이메일입니다" },
{ status: 400 }
)
}
// 비밀번호 해시
const hashedPassword = await bcrypt.hash(password, 12)
// 사용자 생성
const user = await prisma.user.create({
data: {
email,
name,
hashedPassword,
}
})
// 이메일 인증 토큰 생성 & 발송
const token = await generateToken(user.id, "EMAIL_VERIFICATION")
await sendVerificationEmail(email, token)
return Response.json({ success: true })
} catch (error) {
return Response.json({ error: "회원가입 실패" }, { status: 500 })
}
}
// app/api/auth/verify-email/route.ts
import { prisma } from "@/lib/db"
import { verifyToken } from "@/lib/tokens"
export async function POST(req: Request) {
const { token } = await req.json()
const payload = await verifyToken(token, "EMAIL_VERIFICATION")
if (!payload) {
return Response.json({ error: "유효하지 않은 토큰" }, { status: 400 })
}
await prisma.user.update({
where: { id: payload.userId },
data: { emailVerified: new Date() }
})
return Response.json({ success: true })
}
// app/api/auth/forgot-password/route.ts
export async function POST(req: Request) {
const { email } = await req.json()
const user = await prisma.user.findUnique({ where: { email } })
if (!user) {
// 보안: 존재 여부 노출하지 않음
return Response.json({ success: true })
}
const token = await generateToken(user.id, "PASSWORD_RESET")
await sendPasswordResetEmail(email, token)
return Response.json({ success: true })
}
// app/api/auth/reset-password/route.ts
export async function POST(req: Request) {
const { token, password } = await req.json()
const payload = await verifyToken(token, "PASSWORD_RESET")
if (!payload) {
return Response.json({ error: "유효하지 않은 토큰" }, { status: 400 })
}
const hashedPassword = await bcrypt.hash(password, 12)
await prisma.user.update({
where: { id: payload.userId },
data: { hashedPassword }
})
return Response.json({ success: true })
}
// app/(auth)/login/page.tsx
"use client"
import { signIn } from "next-auth/react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const schema = z.object({
email: z.string().email("유효한 이메일을 입력하세요"),
password: z.string().min(8, "8자 이상 입력하세요"),
})
export default function LoginPage() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema)
})
const onSubmit = async (data) => {
await signIn("credentials", {
email: data.email,
password: data.password,
callbackUrl: "/dashboard"
})
}
return (
<div className="min-h-screen flex items-center justify-center">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 w-80">
<h1 className="text-2xl font-bold">로그인</h1>
{/* 소셜 로그인 */}
<button type="button" onClick={() => signIn("google")}
className="w-full py-2 border rounded">
Google로 계속하기
</button>
<button type="button" onClick={() => signIn("github")}
className="w-full py-2 border rounded">
GitHub로 계속하기
</button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-white px-2">또는</span>
</div>
</div>
{/* 이메일/비밀번호 */}
<input {...register("email")} type="email"
placeholder="이메일" className="w-full p-2 border rounded" />
{errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
<input {...register("password")} type="password"
placeholder="비밀번호" className="w-full p-2 border rounded" />
{errors.password && <p className="text-red-500 text-sm">{errors.password.message}</p>}
<button type="submit" className="w-full py-2 bg-black text-white rounded">
로그인
</button>
<p className="text-center text-sm">
계정이 없으신가요? <a href="/signup" className="underline">회원가입</a>
</p>
</form>
</div>
)
}
| 모델 | 설명 | 적합한 경우 |
|---|---|---|
| 구독 (Subscription) | 월/연간 정기 결제 | 지속적 가치 제공 |
| 프리미엄 (Freemium) | 무료 + 유료 티어 | 사용자 확보 우선 |
| 사용량 기반 (Usage) | 사용한 만큼 결제 | API, 스토리지 |
| 시트 기반 (Per-seat) | 사용자 수 × 단가 | B2B 팀 도구 |
| 일회성 (One-time) | 한 번 결제로 영구 사용 | 도구, 템플릿 |
// lib/plans.ts
export const PLANS = {
free: {
name: "Free",
price: { monthly: 0, yearly: 0 },
features: {
projects: 3,
storage: "500MB",
members: 1,
support: "community",
api: false,
analytics: false,
}
},
pro: {
name: "Pro",
price: { monthly: 19, yearly: 190 }, // 연간 16% 할인
stripePriceId: {
monthly: "price_xxxxx",
yearly: "price_yyyyy"
},
features: {
projects: "unlimited",
storage: "50GB",
members: 5,
support: "email",
api: true,
analytics: true,
}
},
enterprise: {
name: "Enterprise",
price: { monthly: 99, yearly: 990 },
stripePriceId: {
monthly: "price_zzzzz",
yearly: "price_wwwww"
},
features: {
projects: "unlimited",
storage: "500GB",
members: "unlimited",
support: "priority",
api: true,
analytics: true,
sso: true,
audit: true,
}
}
}
// components/pricing-table.tsx
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { PLANS } from "@/lib/plans"
import { createCheckoutSession } from "@/lib/stripe"
export function PricingTable({ currentPlan = "free" }: { currentPlan?: string }) {
const [billing, setBilling] = useState<"monthly" | "yearly">("monthly")
const [loading, setLoading] = useState<string | null>(null)
const router = useRouter()
const handleSubscribe = async (planId: string) => {
if (planId === "free") {
// Free 플랜은 회원가입으로 이동
router.push("/signup")
return
}
setLoading(planId)
try {
const session = await createCheckoutSession(
PLANS[planId].stripePriceId[billing]
)
window.location.href = session.url
} finally {
setLoading(null)
}
}
return (
<div className="py-12">
{/* 월간/연간 토글 */}
<div className="flex justify-center gap-4 mb-8">
<button
onClick={() => setBilling("monthly")}
className={`px-4 py-2 rounded-lg transition-colors ${
billing === "monthly"
? "bg-primary text-primary-foreground font-bold"
: "bg-muted hover:bg-muted/80"
}`}
>
월간 결제
</button>
<button
onClick={() => setBilling("yearly")}
className={`px-4 py-2 rounded-lg transition-colors ${
billing === "yearly"
? "bg-primary text-primary-foreground font-bold"
: "bg-muted hover:bg-muted/80"
}`}
>
연간 결제 (16% 할인)
</button>
</div>
{/* 가격 카드 */}
<div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
{Object.entries(PLANS).map(([id, plan]) => {
const isCurrentPlan = id === currentPlan
const isFree = id === "free"
const isPopular = id === "pro"
return (
<div
key={id}
className={`relative border rounded-lg p-6 ${
isPopular ? "border-primary ring-2 ring-primary" : ""
} ${isCurrentPlan ? "bg-muted/50" : ""}`}
>
{isPopular && (
<span className="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-primary-foreground text-xs font-bold px-3 py-1 rounded-full">
인기
</span>
)}
<h3 className="text-xl font-bold">{plan.name}</h3>
<p className="text-3xl font-bold mt-4">
{isFree ? (
<>무료</>
) : (
<>
${plan.price[billing]}
<span className="text-sm font-normal text-muted-foreground">
/{billing === "monthly" ? "월" : "년"}
</span>
</>
)}
</p>
{isFree && (
<p className="text-sm text-muted-foreground mt-1">
영원히 무료
</p>
)}
<ul className="mt-6 space-y-2">
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span>
프로젝트 {plan.features.projects === "unlimited" ? "무제한" : `${plan.features.projects}개`}
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span>
저장공간 {plan.features.storage}
</li>
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span>
팀원 {plan.features.members === "unlimited" ? "무제한" : `${plan.features.members}명`}
</li>
<li className="flex items-center gap-2">
{plan.features.api ? (
<><span className="text-green-500">✓</span> API 접근</>
) : (
<><span className="text-muted-foreground">✗</span> <span className="text-muted-foreground">API 접근</span></>
)}
</li>
<li className="flex items-center gap-2">
{plan.features.analytics ? (
<><span className="text-green-500">✓</span> 분석 대시보드</>
) : (
<><span className="text-muted-foreground">✗</span> <span className="text-muted-foreground">분석 대시보드</span></>
)}
</li>
{plan.features.sso && (
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> SSO 로그인
</li>
)}
{plan.features.audit && (
<li className="flex items-center gap-2">
<span className="text-green-500">✓</span> 감사 로그
</li>
)}
</ul>
<button
onClick={() => handleSubscribe(id)}
disabled={isCurrentPlan || loading === id}
className={`w-full mt-6 py-2 rounded font-medium transition-colors ${
isCurrentPlan
? "bg-muted text-muted-foreground cursor-not-allowed"
: isPopular
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
>
{loading === id
? "처리 중..."
: isCurrentPlan
? "현재 플랜"
: isFree
? "무료로 시작하기"
: "업그레이드"}
</button>
</div>
)
})}
</div>
</div>
)
}
// lib/subscription.ts
import { prisma } from "./db"
import { stripe } from "./stripe"
// 현재 구독 확인
export async function getSubscription(userId: string) {
const subscription = await prisma.subscription.findFirst({
where: {
organization: { members: { some: { userId } } },
status: { in: ["active", "trialing"] }
}
})
return subscription
}
// 플랜 제한 확인
export async function checkPlanLimit(userId: string, resource: string) {
const subscription = await getSubscription(userId)
const plan = subscription ? PLANS[subscription.planId] : PLANS.free
const current = await getCurrentUsage(userId, resource)
const limit = plan.features[resource]
if (limit === "unlimited") return true
return current < limit
}
// 업그레이드 필요 체크
export async function requiresUpgrade(userId: string, feature: string) {
const subscription = await getSubscription(userId)
const plan = subscription ? PLANS[subscription.planId] : PLANS.free
return !plan.features[feature]
}
// app/api/billing/checkout/route.ts
import { auth } from "@/lib/auth"
import { stripe } from "@/lib/stripe"
import { prisma } from "@/lib/db"
export async function POST(req: Request) {
const session = await auth()
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 })
}
const { priceId } = await req.json()
// Stripe 고객 생성 또는 조회
let customerId = session.user.stripeCustomerId
if (!customerId) {
const customer = await stripe.customers.create({
email: session.user.email,
metadata: { userId: session.user.id }
})
customerId = customer.id
await prisma.user.update({
where: { id: session.user.id },
data: { stripeCustomerId: customerId }
})
}
// 체크아웃 세션 생성
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=true`,
subscription_data: {
trial_period_days: 14, // 14일 무료 체험
}
})
return Response.json({ url: checkoutSession.url })
}
// lib/toss.ts
import { TossPayments } from "@tosspayments/payment-sdk"
const toss = new TossPayments(process.env.TOSS_CLIENT_KEY!)
export async function createPayment(orderId: string, amount: number) {
return toss.requestPayment("카드", {
amount,
orderId,
orderName: "Pro 플랜 구독",
successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing/success`,
failUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing/fail`,
})
}
// 결제 승인
// app/api/billing/toss/confirm/route.ts
export async function POST(req: Request) {
const { paymentKey, orderId, amount } = await req.json()
const response = await fetch(
"https://api.tosspayments.com/v1/payments/confirm",
{
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(process.env.TOSS_SECRET_KEY + ":").toString("base64")}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ paymentKey, orderId, amount })
}
)
const result = await response.json()
if (result.status === "DONE") {
// 구독 활성화
await activateSubscription(orderId)
}
return Response.json(result)
}
Designs feature architectures by analyzing existing codebase patterns and conventions, then providing comprehensive implementation blueprints with specific files to create/modify, component designs, data flows, and build sequences