#35. Next.js 16(App Router) + Prisma v7 + SQLite
Next.js 16(App Router) + Prisma v7 + SQLite 기준으로
설치 → DB 생성(migrate) → seed
목록/상세 CRUD UI → 캐시/ISR(revalidate) 전략 + Data Fetching 패턴 비교까지 한 번에 들어있는 예제입니다.
Prisma v7 변경점: schema.prisma의 datasource.url 제거 + prisma.config.ts로 이동.
Next 16 변경점: revalidateTag(tag, profile) 형태로 2번째 인자가 필요
Server Action에서는 즉시 반영용 updateTag 권장
0) 전제
- Node 런타임에서 Prisma를 사용합니다(Edge 런타임 X).
- 패키지 매니저는 pnpm 기준으로 작성했습니다(npm도 동일 개념).
1) 설치
1-1) 패키지 설치
pnpm add @prisma/client
pnpm add -D prisma
pnpm add @prisma/adapter-better-sqlite3 better-sqlite3
pnpm add -D tsx
Prisma v7에서는 DB 연결을 위해 adapter(또는 Accelerate)가 필요하며, SQLite는 보통 better-sqlite3 어댑터를 사용합니다. Prisma+1
1-2) pnpm “Ignored build scripts …” 경고가 뜬다면
Prisma 엔진/네이티브 모듈 스크립트가 차단되어 추후 generate/빌드에서 문제가 날 수 있으니, 아래로 필요한 항목만 허용하세요.
pnpm approve-builds
# prisma, @prisma/engines, better-sqlite3 등을 선택(허용)
pnpm install
2) Prisma 초기화 & 설정 파일
2-1) init
pnpm dlx prisma init
2-2) .env
프로젝트 루트 .env:
DATABASE_URL="file:./prisma/app.db"
SQLite는 .db 파일로 동작합니다. Prisma
2-3) prisma.config.ts (Prisma v7 핵심)
프로젝트 루트 prisma.config.ts:
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
v7부터 schema의 datasource.url은 deprecated/금지이고, config 파일에서 설정합니다. Prisma+1
3) Prisma 스키마 작성
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
}
model Product {
id Int @id @default(autoincrement())
title String
price Int
description String?
}
4) DB 생성 (migrate) + Client 생성 (generate)
pnpm prisma migrate dev --name init
pnpm prisma generate
5) Seed 데이터 넣기
5-1) prisma/seed.ts
중요: “모듈 import 시점에 seed 실행”은 Next 빌드/프리렌더 시점에 DB 작업이 실행될 수 있어 비권장입니다. seed는 명령으로 1회 실행하는 방식이 안전합니다.
prisma/seed.ts
import "dotenv/config";
import { PrismaClient } from "@prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
const prisma = new PrismaClient({
adapter: new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! }),
});
async function main() {
const count = await prisma.product.count();
if (count === 0) {
await prisma.product.createMany({
data: [
{ title: "Product 1", price: 500, description: "Description 1" },
{ title: "Product 2", price: 700, description: "Description 2" },
{ title: "Product 3", price: 1000, description: "Description 3" },
],
});
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => prisma.$disconnect());
5-2) package.json에 seed 등록
{
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
5-3) seed 실행
pnpm prisma db seed
Prisma seed 워크플로우는 공식 문서에서도 이 방식으로 안내합니다. Prisma+1
6) PrismaClient 싱글톤 만들기 (Next.js 서버용)
lib/prisma.ts
import "server-only";
import { PrismaClient } from "@prisma/client";
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
adapter: new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! }),
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
7) 앱 코드: DB 접근 + 캐시/ISR 전략
7-1) DB 조회 레이어 + 캐시(태그) (app/product-db/data.ts)
DB는 fetch()가 아니므로, “DB 결과 캐시”를 하고 싶으면 unstable_cache를 사용합니다. Next.js
app/product-db/data.ts
import "server-only";
import { prisma } from "@/lib/prisma";
import { unstable_cache } from "next/cache";
export type ProductDTO = {
id: number;
title: string;
price: number;
description: string | null;
};
// 1) 캐시 없이 매 요청 DB 조회
export async function getProductsNoCache() {
return prisma.product.findMany({ orderBy: { id: "desc" } });
}
export async function getProductNoCache(id: number) {
return prisma.product.findUnique({ where: { id } });
}
export async function getProductCountNoCache() {
return prisma.product.count();
}
// 2) DB 결과 캐시(태그 + revalidate 60초)
// - tags는 Server Action에서 updateTag/revalidateTag로 무효화 가능
const _getProductsCached = unstable_cache(
async () => prisma.product.findMany({ orderBy: { id: "desc" } }),
["products:list"],
{ tags: ["products"], revalidate: 60 }
);
const _getCountCached = unstable_cache(
async () => prisma.product.count(),
["products:count"],
{ tags: ["products"], revalidate: 60 }
);
export async function getProductsCached() {
return _getProductsCached();
}
export async function getProductCountCached() {
return _getCountCached();
}
export async function getProductCached(id: number) {
const fn = unstable_cache(
async () => prisma.product.findUnique({ where: { id } }),
["products:detail", String(id)],
{ tags: ["products", `product:${id}`], revalidate: 60 }
);
return fn();
}
// ✅ 학습용 토글: 여기만 바꾸면 전체 전략 변경 가능
export const getProducts = getProductsCached; // or getProductsNoCache
export const getProduct = getProductCached; // or getProductNoCache
export const getProductCount = getProductCountCached; // or getProductCountNoCache
8) Server Actions: 생성/수정/삭제 + 캐시 무효화 (Next 16)
Next 16에서는 Server Action에서 “즉시 반영(read-your-writes)” 목적이면 updateTag()가 권장입니다. Next.js+1
(반면 revalidateTag(tag, "max")는 SWR 성격으로 “약간 늦어도 괜찮은 콘텐츠”에 권장됩니다. Next.js+1)
app/product-db/actions.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath, updateTag } from "next/cache";
function asInt(v: FormDataEntryValue | null) {
const n = Number(v);
return Number.isFinite(n) ? n : NaN;
}
export async function createProductAction(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
const price = asInt(formData.get("price"));
const description = String(formData.get("description") ?? "").trim();
if (!title) throw new Error("title is required");
if (!Number.isFinite(price) || price <= 0) throw new Error("price must be > 0");
await prisma.product.create({
data: { title, price, description: description || null },
});
// ✅ 즉시 반영(강추)
updateTag("products");
revalidatePath("/product-db");
}
export async function updateProductAction(formData: FormData) {
const id = asInt(formData.get("id"));
const title = String(formData.get("title") ?? "").trim();
const price = asInt(formData.get("price"));
const description = String(formData.get("description") ?? "").trim();
if (!Number.isFinite(id)) throw new Error("invalid id");
if (!title) throw new Error("title is required");
if (!Number.isFinite(price) || price <= 0) throw new Error("price must be > 0");
await prisma.product.update({
where: { id },
data: { title, price, description: description || null },
});
updateTag("products");
updateTag(`product:${id}`);
revalidatePath("/product-db");
revalidatePath(`/product-db/${id}`);
}
export async function deleteProductAction(formData: FormData) {
const id = asInt(formData.get("id"));
if (!Number.isFinite(id)) throw new Error("invalid id");
await prisma.product.delete({ where: { id } });
updateTag("products");
updateTag(`product:${id}`);
revalidatePath("/product-db");
}
참고: Route Handler 등 “Server Action 밖”에서 태그 무효화를 해야 하면 revalidateTag(tag, "max")를 사용합니다(Next 16에서는 2번째 인자가 필요). Next.js+1
updateTag 와 revalidatePath
둘 다 “캐시 무효화” 계열이지만 대상이 다릅니다.
- updateTag() → 데이터(태그) 캐시를 즉시 만료(Expired)
- revalidatePath() → 특정 경로(페이지/레이아웃) 캐시를 무효화(Invalidate)
공식 문서도 이 둘을 목적이 다른 API로 분리해서 설명합니다. Next.js+2Next.js+2
1) updateTag(tag)란?
핵심
- Server Action 안에서만 사용 가능
- 해당 tag로 캐싱된 데이터를 즉시 만료(expire) 시킵니다.
- read-your-own-writes 목적: “저장했는데 방금 저장한 데이터가 화면에 바로 보여야 한다”에 최적화
- 다음 요청/다음 fetch는 stale(이전 캐시)를 주지 않고, 신선한 데이터가 올 때까지 기다렸다가 내려줍니다. Next.js+2Next.js+2
언제 쓰나?
- 폼 저장/수정/삭제 직후 바로 최신 데이터가 보여야 하는 화면
- 같은 데이터를 여러 페이지가 공유하고(예: 상품 목록/대시보드/상세) 태그로 묶어서 한 번에 갱신하고 싶을 때
예시 (예제 맥락)
'use server'
import { updateTag, revalidatePath } from 'next/cache'
export async function updateProductAction(...) {
await prisma.product.update(...)
updateTag('products') // 목록 관련 데이터 즉시 최신화
updateTag(`product:${id}`) // 상세 데이터 즉시 최신화
}
참고: updateTag는 Route Handler(API route)에서는 사용할 수 없고, 그 경우 revalidateTag를 쓰라고 문서에 명시되어 있습니다. Next.js+1
2) revalidatePath(path, type?)란?
핵심
- 특정 “경로”의 캐시를 무효화
- 페이지(page) 또는 레이아웃(layout) 단위로 무효화 가능
- Server Action/서버 함수에서 호출하면 해당 경로를 보고 있는 경우 UI가 즉시 갱신될 수 있고,
Route Handler에서 호출하면 “다음 방문 때” 재생성되는 식으로 동작이 다릅니다. Next.js+1
시그니처(Next 16 문서 기준)
revalidatePath(path: string, type?: 'page' | 'layout'): void
- path에 동적 세그먼트 패턴(/product-db/[id])을 넣는다면 type이 필수입니다. Next.js
언제 쓰나?
- “이 페이지(/product-db) 결과를 다시 만들고 싶다(재렌더/재생성)”
즉, Full Route Cache(렌더 결과) 쪽을 확실히 새로 만들고 싶을 때
예시
import { revalidatePath } from 'next/cache'
revalidatePath('/product-db') // 목록 페이지 캐시 무효화
revalidatePath('/product-db/[id]', 'page') // 패턴 기반(여러 id 페이지)
3) 둘의 관계: “태그” vs “경로”
문서에서 정리한 차이 그대로 요약하면: Next.js+1
- revalidatePath: “특정 페이지/레이아웃”만 신선하게
- updateTag: “그 태그를 쓰는 모든 페이지의 데이터”를 신선하게
그래서 실무에서(그리고 지금 예제에서도) 쓰기 이후엔 보통 둘을 같이 씁니다.
왜 같이 쓰는가?
- updateTag('products')는 데이터 캐시는 즉시 최신으로 만들지만,
- 이미 만들어진 페이지 렌더 결과(경로 캐시) 가 남아있다면, 그 경로가 다시 렌더될 타이밍이 필요합니다.
- revalidatePath('/product-db')까지 해주면 **“이 경로를 다시 만들 기회”**도 확실히 줍니다. Next.js+1
4) (보너스) revalidateTag와 updateTag 차이 한 줄
- updateTag: 즉시 만료 + 다음 읽기는 fresh가 올 때까지 기다림 (Server Action 전용) Next.js+1
- revalidateTag(tag, "max"): 기본적으로 stale을 줄 수도 있고, 백그라운드 재검증(SWR) 성격 (Route Handler에서도 사용 가능) Next.js+1
9) UX: 제출 중 버튼 비활성화
app/product-db/components/SubmitButton.tsx
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
const { pending } = useFormStatus();
return (
<button
type="submit"
className={className}
disabled={pending}
aria-disabled={pending}
>
{pending ? "처리중..." : children}
</button>
);
}
10) 목록 페이지: 생성 + 삭제 + 상세 링크 (Parallel Fetching 포함)
app/product-db/page.tsx
import Link from "next/link";
import { getProducts, getProductCount } from "./data";
import { createProductAction, deleteProductAction } from "./actions";
import { SubmitButton } from "./components/SubmitButton";
export const runtime = "nodejs";
/**
* ✅ 캐시/ISR 전략 선택
*
* (A) 항상 최신(요청마다 실행)
* export const dynamic = "force-dynamic";
*
* (B) ISR: 60초마다 갱신
* export const revalidate = 60;
*
* (C) DB 쿼리 캐시(unstable_cache) + Server Action에서 updateTag로 즉시 반영
* -> data.ts + actions.ts 설정 그대로 쓰면 됨
*/
export default async function ProductDBPage() {
// ✅ Parallel Data Fetching (서로 의존 없는 쿼리)
const [products, total] = await Promise.all([getProducts(), getProductCount()]);
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
<header className="flex items-end justify-between">
<h1 className="text-2xl font-bold">Products</h1>
<div className="text-sm text-gray-500">Total: {total}</div>
</header>
{/* Create */}
<form action={createProductAction} className="bg-white shadow rounded p-4 space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<input name="title" className="border rounded p-2" placeholder="title" />
<input name="price" type="number" className="border rounded p-2" placeholder="price" />
<input name="description" className="border rounded p-2" placeholder="description (optional)" />
</div>
<SubmitButton className="border rounded px-4 py-2">Add Product</SubmitButton>
</form>
{/* List */}
<ul className="space-y-3">
{products.map((p) => (
<li key={p.id} className="bg-white shadow rounded p-4 flex items-start justify-between gap-3">
<div>
<div className="font-semibold">{p.title}</div>
<div className="text-sm text-gray-600">{p.price}원</div>
{p.description ? <div className="text-sm text-gray-500 mt-1">{p.description}</div> : null}
</div>
<div className="flex gap-2">
<Link className="border rounded px-3 py-2 text-sm" href={`/product-db/${p.id}`}>
상세/수정
</Link>
{/* Delete */}
<form action={deleteProductAction}>
<input type="hidden" name="id" value={p.id} />
<SubmitButton className="border rounded px-3 py-2 text-sm">삭제</SubmitButton>
</form>
</div>
</li>
))}
</ul>
</div>
);
}
11) 상세 페이지: 조회 + 수정 + 삭제 (Sequential/의존형 설명 포인트)
app/product-db/[id]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { getProduct } from "../data";
import { updateProductAction, deleteProductAction } from "../actions";
import { SubmitButton } from "../components/SubmitButton";
export const runtime = "nodejs";
export default async function ProductDetailPage({
params,
}: {
params: { id: string };
}) {
const productId = Number(params.id);
if (!Number.isFinite(productId)) notFound();
// ✅ Sequential Data Fetching 예시 포인트:
// "id로 상세를 먼저 가져온 뒤, 그 결과를 기반으로 다른 작업(추천/로그/권한 체크 등)을 이어가는 구조"에 적합
const product = await getProduct(productId);
if (!product) notFound();
return (
<div className="p-6 max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Product #{product.id}</h1>
<Link className="border rounded px-3 py-2 text-sm" href="/product-db">
← 목록
</Link>
</div>
<div className="bg-white shadow rounded p-4 space-y-1">
<div className="font-semibold">{product.title}</div>
<div className="text-sm text-gray-600">{product.price}원</div>
{product.description ? <div className="text-sm text-gray-500">{product.description}</div> : null}
</div>
{/* Update */}
<form action={updateProductAction} className="bg-white shadow rounded p-4 space-y-3">
<input type="hidden" name="id" value={product.id} />
<div className="grid grid-cols-1 gap-3">
<input name="title" className="border rounded p-2" defaultValue={product.title} />
<input name="price" type="number" className="border rounded p-2" defaultValue={product.price} />
<input
name="description"
className="border rounded p-2"
defaultValue={product.description ?? ""}
placeholder="description (optional)"
/>
</div>
<SubmitButton className="border rounded px-4 py-2">수정</SubmitButton>
</form>
{/* Delete (폼 중첩 금지: 별도 form) */}
<form action={deleteProductAction} className="bg-white shadow rounded p-4">
<input type="hidden" name="id" value={product.id} />
<SubmitButton className="border rounded px-4 py-2">삭제</SubmitButton>
</form>
</div>
);
}
app/product-db/[id]/loading.tsx
export default function Loading() {
return <div className="p-6 max-w-3xl mx-auto text-sm text-gray-500">Loading...</div>;
}
app/product-db/[id]/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div className="p-6 max-w-3xl mx-auto space-y-3">
<h1 className="text-xl font-bold">상품을 찾을 수 없습니다.</h1>
<Link className="border rounded px-3 py-2 inline-block text-sm" href="/product-db">
목록으로
</Link>
</div>
);
}
12) 빌드 실패 방지: build 전에 generate 강제
package.json scripts를 아래처럼 두면 @prisma/client did not initialize yet류 문제를 크게 줄일 수 있습니다.
{
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"lint": "next lint"
}
}
13) 실행/테스트 순서 체크리스트
브라우저:
- http://localhost:3000/product-db
- 목록 확인
- Add Product로 추가
- 삭제
- /product-db/1
- 수정/삭제 동작 확인
14) 캐시/ISR(revalidate) 전략 + Data Fetching 패턴 비교
A. 캐시/ISR 전략 3가지 (Next 16 기준)
- 항상 최신(SSR처럼)
export const dynamic = "force-dynamic";
→ 요청마다 실행(간단/확실). Next.js - ISR(시간 기반 갱신)
export const revalidate = 60;
→ 60초 단위로 재검증(카탈로그/목록에 적합). Next.js - On-demand(변경 시점 갱신)
- Server Action에서 즉시 반영: updateTag("products") (권장) Next.js+1
- SWR 성격(약간 지연 OK): revalidateTag("products", "max") Next.js+2Next.js+2
Next 16의 revalidateTag는 2번째 인자(profile)가 필수입니다. Next.js+1
B. Data Fetching 패턴 비교
- Parallel Data Fetching: 서로 의존 없는 데이터는 Promise.all로 병렬 (목록 + count 같은 케이스)
- Sequential Data Fetching: “상세 먼저 → 결과 기반으로 추가 쿼리/권한/로그 등”처럼 의존성이 있으면 순차
이 예제에서:
- 목록 페이지는 Promise.all([getProducts(), getProductCount()])로 병렬
- 상세 페이지는 getProduct(id)를 먼저 가져온 뒤(필수), 필요하면 그 결과로 다음 작업을 이어가는 구조로 확장 가능
왜 data.ts와 actions.ts로 나누나?
둘로 나누는 건 “필수 규칙”은 아니고, 역할/캐시/의존성 관리가 쉬워지기 때문에 실무에서 자주 쓰는 패턴입니다.
한 파일(action.ts)에 몰아도 동작은 합니다. 다만 장단점이 뚜렷합니다.
1) 책임 분리(읽기 vs 쓰기)
- data.ts: Read(조회) 전용 → 목록/상세 페이지에서 재사용
- actions.ts: Write(등록/수정/삭제) 전용 → 폼 submit / Server Action에서만 호출
읽기/쓰기는 요구사항이 달라요.
- 읽기: 캐시/ISR/태그 전략이 중요
- 쓰기: 검증/트랜잭션/에러 처리/권한 체크 + **무효화(updateTag, revalidatePath)**가 중요
분리하면 각 파일에 “관심사”가 깔끔하게 모입니다.
2) 캐시 전략을 ‘조회’에만 집중
data.ts는 보통 여기만 봐도 “이 화면은 캐시됨? revalidate 몇 초?” 같은 정책을 한눈에 알 수 있게 만들기 좋습니다.
반대로 actions.ts는 “쓰기 후 어떤 캐시를 깨는지”만 명확히 보이면 됩니다.
3) 의존성/번들 안전성
- actions.ts에는 "use server"가 들어가고, 폼에서만 불려야 합니다.
- 조회 함수는 "use server"가 없어도 Server Component에서 호출되지만, 그래도 보통 server-only로 서버 전용을 보장합니다.
이걸 섞어두면 나중에 파일을 Client Component가 실수로 import했을 때(혹은 팀원이 섞어서 쓰기 시작할 때) 문제 추적이 어려워질 수 있어요.
4) 테스트/확장 용이
- 조회 로직(필터/검색/페이징)은 계속 커지고,
- 쓰기 로직(검증/권한/감사로그/트랜잭션)도 계속 커집니다.
초기에 분리해두면 파일이 커져도 관리가 편합니다.
action.ts로 “몰아 넣어도” 되는 경우
아래 조건이면 한 파일에 합쳐도 괜찮습니다.
- 학습용/데모로 규모가 매우 작다
- 조회/쓰기 로직이 단순하고, 캐시 전략도 거의 없다
- 팀이 아니라 혼자 짧게 끝낼 프로젝트다
이때는 server.ts 같은 이름 하나로
- getProducts()
- createProductAction()
같이 같이 둬도 됩니다.
실무적으로 추천하는 기준
- CRUD + 캐시/ISR까지 다루는 학습이라면 → 분리 추천
- “진짜 작은 샘플”이라면 → 합쳐도 OK
- 규모가 커질 가능성이 있으면 → 처음부터 분리 추천
참고: 더 흔한 네이밍(실무 스타일)
- data.ts → queries.ts (조회)
- actions.ts → mutations.ts (변경)
- 또는 도메인별로:
- product.queries.ts
- product.mutations.ts