본문 바로가기
튜토리얼/웹

[Next.js] Prisma + PostgreSQL Todo 앱 만들기 (Server Actions)

by 루까(Luka) 2026. 3. 2.
반응형

Next.js + Prisma + PostgreSQL Todo 앱 만들기

왜 만들었나

튜토리얼 시리즈 15번째 주제는 DB 연동이다. React 상태로만 관리하는 Todo 앱은 새로고침하면 사라진다. 실제 서비스처럼 데이터를 영구 저장하려면 데이터베이스가 필요하다.

ORM으로 Prisma, DB로 PostgreSQL을 선택했다. Prisma는 타입 안전한 쿼리를 제공하고, 스키마에서 DB와 TypeScript 타입이 함께 생성된다. PostgreSQL은 Neon의 무료 서버리스 인스턴스를 사용한다.

Next.js 15의 Server Actions로 API 엔드포인트 없이 서버 로직을 처리한다.


기술 상세

프로젝트 구조

15-nextjs-prisma-todo/
├── prisma/
│   └── schema.prisma        ← Todo 모델 정의
├── prisma.config.ts         ← Prisma 7 설정 (DATABASE_URL)
├── lib/
│   └── prisma.ts            ← PrismaClient 싱글턴
├── app/
│   ├── actions.ts           ← Server Actions (CRUD)
│   ├── components/
│   │   ├── AddTodoForm.tsx  ← 입력 폼 (Client Component)
│   │   └── TodoItem.tsx     ← 할일 아이템 (Client Component)
│   └── page.tsx             ← 메인 페이지 (Server Component)
└── .env                     ← DATABASE_URL

핵심 패키지

패키지 역할
prisma Prisma CLI + 스키마 관리
@prisma/client 타입 안전한 DB 클라이언트
@prisma/adapter-pg PostgreSQL 어댑터 (Prisma 7)
pg Node.js PostgreSQL 드라이버

아키텍처

page.tsx (Server Component)
  └─ getTodos()  ← Server Action → Prisma → PostgreSQL (Neon)
  └─ <AddTodoForm>  ← addTodo() → revalidatePath('/')
  └─ <TodoItem>     ← toggleTodo() / deleteTodo() → revalidatePath('/')

클라이언트 컴포넌트에서 Server Action을 직접 호출한다. 완료 후 revalidatePath('/')로 캐시를 무효화하면 서버 컴포넌트가 다시 렌더링되면서 최신 DB 데이터를 보여준다.

Prisma 5 vs Prisma 7

Prisma 5 Prisma 7
클라이언트 임포트 @prisma/client ./app/generated/prisma/client
설정 파일 schema.prisma datasource url prisma.config.ts
생성 위치 node_modules/@prisma/client 프로젝트 내 지정 경로
초기화 new PrismaClient() new PrismaClient({ adapter })
DB 어댑터 내장 명시적 (@prisma/adapter-pg 등)

Prisma 7은 어댑터를 명시적으로 주입해야 한다. new PrismaClient()는 허용되지 않는다.


소스 코드

prisma/schema.prisma

generator client {
  provider = "prisma-client"
  output   = "../app/generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model Todo {
  id        String   @id @default(cuid())
  text      String
  done      Boolean  @default(false)
  createdAt DateTime @default(now())
}

lib/prisma.ts — 싱글턴

import { PrismaClient } from '@/app/generated/prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };

function createPrismaClient() {
  const adapter = new PrismaPg({
    connectionString: process.env.DATABASE_URL,
  });
  return new PrismaClient({ adapter });
}

export const prisma = globalForPrisma.prisma || createPrismaClient();

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

Next.js dev 서버는 hot reload 때마다 모듈이 재실행된다. globalThis에 인스턴스를 저장해 PrismaClient가 중복 생성되지 않게 한다.

app/actions.ts — Server Actions

'use server';

import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';

export async function getTodos() {
  return prisma.todo.findMany({ orderBy: { createdAt: 'desc' } });
}

export async function addTodo(text: string) {
  if (!text.trim()) return;
  await prisma.todo.create({ data: { text: text.trim() } });
  revalidatePath('/');
}

export async function toggleTodo(id: string, done: boolean) {
  await prisma.todo.update({ where: { id }, data: { done: !done } });
  revalidatePath('/');
}

export async function deleteTodo(id: string) {
  await prisma.todo.delete({ where: { id } });
  revalidatePath('/');
}

'use server' 지시자로 서버에서만 실행되는 함수임을 선언한다. 클라이언트 번들에 포함되지 않는다.

app/components/AddTodoForm.tsx

'use client';

import { useRef, useTransition } from 'react';
import { addTodo } from '../actions';

export function AddTodoForm() {
  const ref = useRef<HTMLFormElement>(null);
  const [pending, startTransition] = useTransition();

  return (
    <form
      ref={ref}
      action={(formData) => {
        const text = formData.get('text') as string;
        startTransition(async () => {
          await addTodo(text);
          ref.current?.reset();
        });
      }}
    >
      <input name="text" disabled={pending} />
      <button type="submit" disabled={pending}>
        {pending ? '추가 중...' : '추가'}
      </button>
    </form>
  );
}

React 19의 form action 문법을 사용한다. useTransition으로 대기 상태를 처리하고, 폼 제출 후 ref.current.reset()으로 입력창을 비운다.

app/components/TodoItem.tsx

'use client';

import { useTransition } from 'react';
import { toggleTodo, deleteTodo } from '../actions';

export function TodoItem({ todo }) {
  const [pending, startTransition] = useTransition();

  return (
    <li style={{ opacity: pending ? 0.5 : 1 }}>
      <button onClick={() => startTransition(() => toggleTodo(todo.id, todo.done))}>
        {todo.done ? '✓' : '○'}
      </button>
      <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
        {todo.text}
      </span>
      <button onClick={() => startTransition(() => deleteTodo(todo.id))}>×</button>
    </li>
  );
}

app/page.tsx — Server Component

import { getTodos } from './actions';
import { AddTodoForm } from './components/AddTodoForm';
import { TodoItem } from './components/TodoItem';

export const dynamic = 'force-dynamic';

export default async function Home() {
  const todos = await getTodos();

  return (
    <main>
      <AddTodoForm />
      <ul>
        {todos.map((todo) => (
          <TodoItem key={todo.id} todo={todo} />
        ))}
      </ul>
    </main>
  );
}

force-dynamic으로 요청마다 DB에서 최신 데이터를 가져온다. 빌드 타임에 DB를 호출하지 않는다.


삽질 기록

new PrismaClient() 빌드 에러

Prisma 7에서 인수 없이 생성하면 타입 에러가 난다.

Expected 1 arguments, but got 0.

Prisma 7은 어댑터를 명시적으로 넘겨야 한다. @prisma/adapter-pg를 설치하고 어댑터를 주입했다.

// Before (Prisma 5/6)
new PrismaClient()

// After (Prisma 7)
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
new PrismaClient({ adapter })

임포트 경로 — @/app/generated/prisma가 없음

Module not found: Can't resolve '@/app/generated/prisma'

Prisma 7은 생성된 클라이언트에 index.ts가 없다. 직접 client.ts를 임포트해야 한다.

// 틀린 경로
import { PrismaClient } from '@/app/generated/prisma';

// 올바른 경로
import { PrismaClient } from '@/app/generated/prisma/client';

Vercel 배포 — prisma generate 누락

Vercel은 빌드 시 node_modules를 재설치한다. app/generated/prisma.gitignore에 포함되어 있으므로 postinstall에서 생성해야 한다.

"scripts": {
  "postinstall": "prisma generate"
}

마이그레이션 실행 — Neon에서

로컬에 PostgreSQL 없이 Neon DATABASE_URL로 직접 실행한다.

DATABASE_URL="postgresql://..." npx prisma migrate dev --name init

또는 Vercel 환경변수 설정 후 Neon 콘솔에서 직접 실행해도 된다.


마무리

Server Actions는 API 엔드포인트 없이 서버 로직을 처리할 수 있어 풀스택 코드가 단순해진다. Prisma의 타입 안전한 쿼리와 revalidatePath의 캐시 무효화 조합이 핵심이다.

Prisma 7은 어댑터 방식으로 바뀌어 설정이 조금 더 복잡해졌지만, 기본 개념은 동일하다.


기술 스택: Next.js 16 · TypeScript · Tailwind CSS · Prisma 7 · PostgreSQL · Neon

소스 코드: https://github.com/lukaPlayground/tutorial/tree/main/15-nextjs-prisma-todo

데모: https://tutorial-nextjs-prisma-todo.vercel.app

반응형