키모스토리

#35. Next.js 16(App Router) + Prisma v7 + SQLite 본문

Web Devlopment/NextJs

#35. Next.js 16(App Router) + Prisma v7 + SQLite

키모형 2026. 1. 1. 12:20
반응형

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 권장

Next.js+4Prisma+4Prisma+4

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}`)   // 상세 데이터 즉시 최신화
}

Next.js+1

참고: 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>
  );
}
로딩/Not Found (선택)

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) 실행/테스트 순서 체크리스트

 
pnpm prisma migrate dev --name init pnpm prisma generate pnpm prisma db seed pnpm dev

브라우저:

  • http://localhost:3000/product-db
    • 목록 확인
    • Add Product로 추가
    • 삭제
  • /product-db/1
    • 수정/삭제 동작 확인

14) 캐시/ISR(revalidate) 전략 + Data Fetching 패턴 비교

A. 캐시/ISR 전략 3가지 (Next 16 기준)

  1. 항상 최신(SSR처럼)
    export const dynamic = "force-dynamic";
    → 요청마다 실행(간단/확실). Next.js
  2. ISR(시간 기반 갱신)
    export const revalidate = 60;
    → 60초 단위로 재검증(카탈로그/목록에 적합). Next.js
  3. 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
반응형