테마 전환

Next.js + Prisma 완벽 입문 가이드: 설정부터 실전까지(연결 누수 해결법 포함)

Easton editorial illustration: cache waterfall instrument

터미널에 또다시 사람을 절망하게 만드는 빨간 문장이 나타났습니다. Error: Can't reach database server at localhost:5432입니다. 아니, 더 정확히는 FATAL: sorry, too many clients already였습니다.

데이터베이스는 분명 실행 중이고 연결 문자열에도 문제가 없는데 왜 연결되지 않을까요? 프로젝트를 막 시작했을 때는 괜찮았지만 코드를 몇 번 수정하고 나니 오류가 발생하기 시작했습니다. Next.js 개발 서버를 재시작해도 소용없었습니다. 데이터베이스를 재시작하면 잠시 회복되지만 금세 다시 멈췄습니다.

나중에야 이것이 Next.js + Prisma 개발에서 가장 대표적인 함정 중 하나인 핫 리로드로 인한 데이터베이스 연결 누수라는 사실을 알았습니다. 코드를 수정할 때마다 Next.js 핫 리로드가 새 Prisma 인스턴스를 만들지만 이전 연결은 자동으로 닫히지 않아 결국 연결 풀이 터지는 문제입니다.

비슷한 문제를 겪었을 수도 있습니다. Next.js 프로젝트에 데이터베이스를 붙이려다 Prisma, TypeORM, Drizzle 사이에서 오랫동안 고민했거나, 어렵게 Prisma를 골라 설정을 마쳤는데 온갖 오류가 나타났을 수 있습니다. Schema를 설계할 때 일대다와 다대다 관계를 어떻게 작성해야 할지 막막했을 수도 있고, 저처럼 개발 도중 갑자기 데이터베이스가 멈췄을 수도 있습니다.

사실 Prisma의 학습 곡선은 그렇게 가파르지 않고 설정도 복잡하지 않습니다. 중요한 것은 환경을 구성하는 법, 연결 누수를 피하는 법, Schema를 설계하는 법, CRUD를 작성하는 법이라는 몇 가지 핵심을 아는 것입니다. 이 글에서는 이 내용을 하나씩 정리해 시행착오를 줄일 수 있도록 돕겠습니다.

Prisma를 선택해야 하는 이유(TypeORM 및 원시 SQL과 비교)

Next.js에서 사용할 수 있는 데이터베이스 솔루션은 다양합니다. TypeORM은 오래되고 안정적이며, Drizzle은 새롭고 가볍고, 원시 SQL은 최고의 성능을 제공합니다. 그런데도 Prisma를 선택할 이유는 무엇일까요?

타입 안전성, 이름 그대로 안전합니다

Prisma가 자동 생성한 TypeScript 타입을 처음 봤을 때 정말 놀랐습니다. 단순히 ‘꽤 괜찮네’ 정도가 아니라 ‘이런 방식도 가능하구나’라는 생각이 들었습니다.

Schema 파일을 작성하고 npx prisma generate를 실행하면 모든 모델의 타입 정의가 생성됩니다. 단순한 인터페이스가 아니라 자동 완성을 지원하는 완전한 타입입니다. 예를 들어 prisma.user.findUnique({ where: { id: 까지 입력하면 VSCode가 id의 타입과 where에 사용할 수 있는 다른 필드를 자동으로 알려 줍니다.

비교해 보겠습니다.

  • TypeORM: @Entity(), @Column() 같은 데코레이터를 직접 많이 작성해야 하고, 타입 정의와 데이터베이스 schema가 분리되어 쉽게 어긋납니다.
  • 원시 SQL: 조회 결과가 any이므로 인터페이스를 직접 작성해야 하며, 테이블 구조를 바꾸면 타입도 수동으로 갱신해야 합니다.
  • Prisma: Schema가 단일 사실 원천입니다. 타입이 자동으로 동기화되므로 Schema를 수정한 뒤 generate만 다시 실행하면 됩니다.

이는 단순히 편리함의 문제가 아닙니다. 타입 안전성이 있으면 런타임에 문제가 터진 뒤에야 알아차리는 대신 코드를 작성하는 단계에서 오류를 발견할 수 있습니다.

개발 경험은 세부 사항에서 드러납니다

Prisma에는 특히 만족스러운 기능이 몇 가지 있습니다.

Prisma Studio: 시각적 데이터베이스 관리 화면입니다. npx prisma studio를 실행하면 브라우저에서 데이터를 조회하고 편집할 수 있습니다. TablePlus를 설치하거나 SQL 쿼리를 작성할 필요가 없어 개발할 때 매우 편리합니다.

마이그레이션 체계: prisma migrate dev만으로 마이그레이션 파일을 생성하고 적용할 수 있어 TypeORM보다 훨씬 간단합니다. Schema를 수정하면 변경 사항을 자동으로 감지하고 마이그레이션을 생성할지 묻습니다. SQL 마이그레이션 스크립트를 직접 작성할 필요도, 마이그레이션 순서 오류를 걱정할 필요도 없습니다.

쿼리 문법: Prisma의 쿼리 작성 방식은 JavaScript 관습과 잘 맞습니다. 다음 코드를 보세요.

const users = await prisma.user.findMany({
  where: { email: { contains: '@gmail.com' } },
  include: { posts: true },
  orderBy: { createdAt: 'desc' }
})

매우 직관적이지 않나요? SQL을 작성하거나 수많은 데코레이터를 기억할 필요 없이 일반 객체와 메서드 호출만 사용하면 됩니다.

TypeORM의 QueryBuilder와 비교해 보겠습니다.

const users = await userRepository.createQueryBuilder("user")
  .where("user.email LIKE :email", { email: "%@gmail.com%" })
  .leftJoinAndSelect("user.posts", "posts")
  .orderBy("user.createdAt", "DESC")
  .getMany()

기능은 같지만 Prisma 쪽이 더 명확하고 오류 가능성도 낮습니다.

성능과 생태계

누군가는 ‘ORM은 성능이 떨어지지 않나요? 프로덕션에서는 원시 SQL을 써야 하지 않나요?‘라고 말할 수 있습니다.

솔직히 이는 오해입니다. Prisma에 일정한 성능 오버헤드가 있는 것은 사실이지만 대부분의 프로젝트에서는 충분히 감수할 수 있습니다. Prisma 자체도 여러 최적화를 제공합니다.

  • N+1 문제 자동 처리: include로 관계를 조회할 때 Prisma가 쿼리를 합쳐 중복 요청을 줄입니다.
  • 연결 풀 관리: 기본 연결 수인 num_cpus * 2 + 1도 합리적으로 설정되어 있습니다.
  • 쿼리 최적화: 필요한 필드만 조회하며 select를 사용하면 데이터 전송량을 더 줄일 수 있습니다.

실제로 성능 병목이 생기면 Prisma에서도 원시 SQL을 사용할 수 있습니다.

const result = await prisma.$queryRaw`SELECT * FROM User WHERE id = ${userId}`

ORM의 편리함을 유지하면서 중요한 부분에서는 원시 SQL로 최적화할 수 있어 두 장점을 모두 얻을 수 있습니다.

생태계 측면에서도 Prisma는 GitHub에서 38k+ star를 받았고 Vercel이 공식 추천하며 문서가 매우 충실합니다. 문제가 생겨도 대체로 해결책을 찾을 수 있습니다. Next.js 공식 문서에도 Prisma 통합 가이드가 별도로 있으므로 확실한 주류 선택지입니다.

Prisma가 완벽하다는 뜻은 아닙니다. 다만 대부분의 Next.js 풀스택 프로젝트에서는 단점보다 장점이 훨씬 큽니다. 타입 안전성, 개발 경험, 생태계 지원이라는 세 가지 이유만으로도 충분합니다.

환경 구성: 처음부터 Prisma 설정하기

Prisma를 선택했다면 이제 환경을 구성할 차례입니다. 이 단계는 빠르면 10분 안에 끝낼 수 있습니다.

의존성 설치 및 초기화

이미 Next.js 프로젝트가 있다고 가정하겠습니다. 없다면 먼저 npx create-next-app@latest를 실행하세요.

Prisma를 설치합니다.

npm install prisma @prisma/client

두 패키지의 역할은 다음과 같습니다.

  • prisma: 초기화, 마이그레이션 생성, Studio 실행에 사용하는 CLI 도구
  • @prisma/client: 실제 데이터베이스 조회에 사용하는 클라이언트

설치가 끝나면 초기화합니다.

npx prisma init

이 명령은 두 가지 작업을 수행합니다.

  1. prisma/schema.prisma 파일 생성(핵심 설정 파일)
  2. DATABASE_URL 환경 변수가 들어 있는 .env 파일 생성

데이터베이스 연결 설정

.env 파일을 열면 다음 내용이 보입니다.

DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

PostgreSQL 연결 문자열 예시이며 형식은 다음과 같습니다.

postgresql://사용자명:비밀번호@호스트:포트/데이터베이스명?schema=public

로컬 개발에는 PostgreSQL을 권장합니다. 기능이 풍부하고 Prisma 지원도 가장 좋으며 프로덕션 환경에서도 사용할 가능성이 크기 때문입니다.

PostgreSQL을 설치하지 않았다면 Docker를 사용하는 것이 가장 빠릅니다.

docker-compose.yml을 만듭니다.

version: '3.8'
services:
  postgres:
    image: postgres:15
    restart: always
    environment:
      POSTGRES_USER: myuser
      POSTGRES_PASSWORD: mypassword
      POSTGRES_DB: mydb
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

실행합니다.

docker-compose up -d

이제 데이터베이스가 실행됩니다. .env를 다음처럼 수정합니다.

DATABASE_URL="postgresql://myuser:mypassword@localhost:5432/mydb?schema=public"

중요: .env 파일을 Git에 커밋하지 마세요. .gitignore에 들어 있는지 확인하세요.

.env
.env.local

그렇지 않으면 비밀번호가 유출됩니다.

MySQL이나 SQLite를 사용한다면 연결 문자열 형식이 조금 다릅니다.

MySQL:

DATABASE_URL="mysql://root:password@localhost:3306/mydb"

SQLite(로컬 파일 데이터베이스, 소규모 프로젝트에 적합):

DATABASE_URL="file:./dev.db"

Prisma Client 생성

연결 설정이 끝나면 클라이언트를 생성합니다.

npx prisma generate

이 명령은 schema.prisma를 읽고 TypeScript 타입과 쿼리 메서드를 생성해 node_modules/@prisma/client에 저장합니다.

앞으로 Schema를 수정할 때마다 npx prisma generate를 실행해 타입을 갱신해야 합니다. 그렇지 않으면 TypeScript가 타입 불일치 오류를 표시합니다.

이제 환경 구성이 끝났습니다. 패키지 설치 → 초기화 → 데이터베이스 설정 → 클라이언트 생성의 네 단계뿐이라 복잡하지 않습니다.

다음은 많은 개발자가 어려움을 겪는 핵심 주제인 핫 리로드 연결 누수 해결법입니다.

핫 리로드 연결 누수 해결(핵심 장)

이제 수많은 개발자를 괴롭힌 FATAL: sorry, too many clients already 문제를 살펴보겠습니다.

정확히 어떤 문제인가요?

Next.js 개발 모드에는 Hot Module Replacement(HMR), 즉 핫 리로드 기능이 있습니다. 코드를 수정하고 저장하면 서버를 직접 다시 시작하지 않아도 페이지가 자동으로 새로 고쳐져 매우 편리합니다.

하지만 이 기능이 Prisma와 만나면 문제가 생깁니다.

코드를 수정할 때마다 Next.js는 모듈을 다시 불러옵니다. API Route나 Server Component에서 new PrismaClient()를 직접 호출하면 핫 리로드 때마다 새 Prisma 인스턴스가 만들어집니다.

새 인스턴스는 새 데이터베이스 연결을 열지만 이전 연결은 자동으로 닫히지 않고 그대로 남습니다.

PostgreSQL은 기본적으로 최대 100개 연결을 지원합니다(변경할 수 있지만 보통은 필요하지 않습니다). 코드를 몇 번 수정하면 연결 수가 10개, 20개, 50개, 100개로 늘어납니다. 한도에 도달하면 데이터베이스가 새 연결을 거부하고 too many clients already 오류를 냅니다.

처음 이 문제를 겪었을 때는 원인을 전혀 알 수 없었습니다. 데이터베이스는 멀쩡한데 왜 갑자기 연결되지 않는지 이해하기 어려웠습니다. 개발 서버를 재시작하면 잠시 회복되지만 이내 다시 오류가 났습니다. Prisma GitHub Issues를 찾아보고 나서야 Issue #10247에서 같은 문제를 호소하는 사람이 많다는 사실을 알았습니다.

싱글턴 패턴으로 한 번에 해결하기

해결책은 간단합니다. 싱글턴 패턴으로 애플리케이션 전체에서 PrismaClient 인스턴스를 한 번만 생성하면 됩니다.

구체적으로는 Prisma 인스턴스를 globalThis 객체에 저장합니다. globalThis는 JavaScript 전역 객체라 핫 리로드 때 초기화되지 않습니다. 처음 인스턴스를 만든 뒤에는 이후 핫 리로드에서도 같은 인스턴스를 재사용하므로 새 연결이 만들어지지 않습니다.

코드는 다음과 같습니다.

프로젝트 루트에 lib/prisma.ts를 만듭니다.

import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined
}

export const prisma = globalForPrisma.prisma || new PrismaClient({
  log: ['query', 'error', 'warn'], // 개발 중 모든 쿼리 확인
})

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

설명하면 다음과 같습니다.

  1. globalForPrisma는 TypeScript가 인식할 수 있도록 globalThis에 타입을 추가한 값입니다.
  2. 핵심은 export const prisma = globalForPrisma.prisma || new PrismaClient()입니다. globalThis.prisma에 값이 있으면 재사용하고, 없으면 새로 만듭니다.
  3. if (process.env.NODE_ENV !== 'production')은 개발 환경에서만 인스턴스를 globalThis에 저장하게 합니다. 프로덕션 환경은 배포할 때마다 완전히 새로 시작하고 핫 리로드가 없으므로 필요하지 않습니다.

앞으로는 모든 곳에서 이 파일의 prisma를 가져옵니다.

**App Router(Next.js 13+)**의 Server Component:

// app/users/page.tsx
import { prisma } from '@/lib/prisma'

export default async function UsersPage() {
  const users = await prisma.user.findMany()
  
  return (
    <div>
      {users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  )
}

API Route:

// app/api/users/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

export async function GET() {
  const users = await prisma.user.findMany()
  return NextResponse.json(users)
}

**Pages Router(Next.js 12 이하)**의 API Route:

// pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const users = await prisma.user.findMany()
  res.status(200).json(users)
}

핵심은 어떤 파일에서도 new PrismaClient()를 직접 호출하지 말고 항상 lib/prisma.ts에서 가져오는 것입니다.

프로덕션 환경에서는 걱정하지 않아도 됩니다

프로덕션 환경에서도 같은 방식이 필요한지 궁금할 수 있습니다.

필요하지 않습니다. 프로덕션 환경(예: Vercel 배포)에서는 각 요청이 독립적이고 핫 리로드도 없습니다. 코드도 빌드된 정적 파일이므로 자주 변경되지 않습니다.

따라서 if (process.env.NODE_ENV !== 'production') 조건이 개발 환경에서는 싱글턴을 사용하고 프로덕션 환경에서는 정상적으로 인스턴스를 만들도록 보장합니다.

이는 Prisma가 공식적으로 권장하는 방법이며 문서에서도 별도의 절로 설명합니다. 그대로 적용하면 대부분 문제가 생기지 않습니다.

이 함정 때문에 많은 개발자가 고생한 것은 사실이지만 원리를 알고 나면 코드 한 줄로 해결할 수 있습니다. lib/prisma.ts를 한 번 설정해 두면 이후에는 연결 누수를 걱정하지 않아도 됩니다.

Schema 설계 모범 사례

환경을 구성하고 연결 누수까지 해결했다면 이제 데이터베이스 테이블을 설계할 차례입니다. Prisma의 Schema 파일은 prisma/schema.prisma이며 모든 테이블 구조와 관계를 이곳에서 정의합니다.

기본 모델 정의

블로그 시스템의 User와 Post 모델을 간단한 예로 살펴보겠습니다.

// prisma/schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String?
  password  String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

몇 가지 핵심을 정리하면 다음과 같습니다.

  • @id: 기본 키
  • @default(autoincrement()): 자동 증가 ID
  • @unique: 고유 제약 조건. 예를 들어 email의 중복을 방지합니다.
  • String?: 물음표는 선택 필드이며 null일 수 있다는 뜻입니다.
  • @default(now()): 생성할 때 현재 시간을 자동으로 설정합니다.
  • @updatedAt: 수정할 때마다 시간을 자동으로 갱신합니다.

명명 규칙은 다음과 같습니다.

  • 모델명은 대문자 카멜 케이스를 사용합니다: User, Post, UserProfile
  • 필드명은 소문자 카멜 케이스를 사용합니다: createdAt, authorId
  • Prisma는 데이터베이스 테이블명을 자동으로 소문자 복수형으로 변환합니다. 예: Userusers, Postposts

관계 설계: 일대다, 다대다, 일대일

관계 설계는 Schema의 핵심이며 초보자가 가장 혼란스러워하는 부분이기도 합니다. 하나씩 살펴보겠습니다.

일대다(One-to-Many)

한 사용자는 여러 게시글을 작성할 수 있고 한 게시글은 한 사용자에게만 속합니다. 이것이 일대다 관계입니다.

위의 User와 Post가 바로 이 관계입니다.

  • User 모델의 posts Post[]는 한 사용자가 여러 Post를 가진다는 뜻입니다.
  • Post 모델의 author UserauthorId Int는 한 Post가 한 User에게 속한다는 뜻입니다.

@relation(fields: [authorId], references: [id])은 외래 키를 정의합니다.

  • fields: [authorId]는 현재 모델(Post)의 authorId 필드를 뜻합니다.
  • references: [id]User 모델의 id 필드를 참조한다는 뜻입니다.

다대다(Many-to-Many)

한 게시글에는 여러 태그가 붙을 수 있고 한 태그는 여러 게시글에 속할 수 있습니다. 이것이 다대다 관계입니다.

Prisma는 암시적 관계와 명시적 조인 테이블이라는 두 가지 방식을 지원합니다.

암시적 관계(간단하며 Prisma가 자동 처리):

model Post {
  id    Int    @id @default(autoincrement())
  title String
  tags  Tag[]
}

model Tag {
  id    Int    @id @default(autoincrement())
  name  String
  posts Post[]
}

Prisma가 _PostToTag 조인 테이블을 자동으로 만들기 때문에 직접 관리할 필요가 없습니다. 조회할 때 include: { tags: true }를 사용하면 관계가 있는 태그를 바로 가져올 수 있습니다.

명시적 조인 테이블(유연하며 조인 테이블에 추가 필드를 둘 수 있음):

model Post {
  id       Int        @id @default(autoincrement())
  title    String
  postTags PostTag[]
}

model Tag {
  id       Int        @id @default(autoincrement())
  name     String
  postTags PostTag[]
}

model PostTag {
  id        Int      @id @default(autoincrement())
  postId    Int
  tagId     Int
  post      Post     @relation(fields: [postId], references: [id])
  tag       Tag      @relation(fields: [tagId], references: [id])
  createdAt DateTime @default(now())
  
  @@unique([postId, tagId]) // 중복 관계 방지
}

명시적 방식은 PostTag 조인 테이블에 태그를 붙인 시간을 기록하는 createdAt 같은 필드를 추가할 수 있다는 장점이 있습니다. 추가 필드가 필요 없다면 암시적 방식이 더 간단합니다.

일대일(One-to-One)

한 사용자는 하나의 프로필을 가지고 하나의 프로필은 한 사용자에게만 속합니다.

model User {
  id      Int      @id @default(autoincrement())
  email   String   @unique
  profile Profile?
}

model Profile {
  id     Int    @id @default(autoincrement())
  bio    String?
  userId Int    @unique
  user   User   @relation(fields: [userId], references: [id])
}

핵심은 userId@unique를 추가해 한 User가 Profile을 하나만 가질 수 있도록 보장하는 것입니다.

고급 기법

열거형(Enum)

한 필드가 정해진 몇 가지 값만 가질 수 있다면 열거형을 사용합니다.

enum Role {
  USER
  ADMIN
  MODERATOR
}

model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  role  Role   @default(USER)
}

복합 고유 인덱스

여러 필드의 조합이 고유해야 할 때 사용합니다.

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  authorId Int
  slug     String
  
  @@unique([authorId, slug]) // 같은 작성자 안에서 slug 중복 방지
}

관계 모호성 제거

두 모델 사이에 여러 관계가 있다면 name 매개변수를 사용해야 합니다.

model User {
  id             Int    @id @default(autoincrement())
  writtenPosts   Post[] @relation("PostAuthor")
  favoritePosts  Post[] @relation("PostFavorites")
}

model Post {
  id          Int    @id @default(autoincrement())
  title       String
  authorId    Int
  author      User   @relation("PostAuthor", fields: [authorId], references: [id])
  favoritedBy User[] @relation("PostFavorites")
}

name을 지정하지 않으면 Prisma는 어떤 관계가 서로 대응하는지 알 수 없어 오류를 냅니다.

전체 예제: 블로그 시스템

앞의 내용을 모두 연결한 완전한 블로그 시스템 Schema는 다음과 같습니다.

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

enum Role {
  USER
  ADMIN
}

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String?
  password  String
  role      Role      @default(USER)
  posts     Post[]
  comments  Comment[]
  profile   Profile?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

model Profile {
  id     Int     @id @default(autoincrement())
  bio    String?
  avatar String?
  userId Int     @unique
  user   User    @relation(fields: [userId], references: [id])
}

model Post {
  id        Int       @id @default(autoincrement())
  title     String
  content   String?
  published Boolean   @default(false)
  authorId  Int
  author    User      @relation(fields: [authorId], references: [id])
  comments  Comment[]
  tags      Tag[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  postId    Int
  post      Post     @relation(fields: [postId], references: [id])
  authorId  Int
  author    User     @relation(fields: [authorId], references: [id])
  createdAt DateTime @default(now())
}

model Tag {
  id    Int    @id @default(autoincrement())
  name  String @unique
  posts Post[]
}

Schema를 작성했다면 npx prisma migrate dev --name init을 실행해 마이그레이션을 생성하고 데이터베이스에 적용합니다. Prisma가 테이블, 인덱스, 외래 키 제약 조건을 자동으로 만듭니다.

Schema 설계는 생각만큼 어렵지 않습니다. 일대다, 다대다, 일대일 관계를 이해하고 열거형과 고유 인덱스 같은 기법을 더하면 대부분의 요구 사항을 처리할 수 있습니다.

CRUD 작업 실습

Schema를 설계했으니 이제 코드로 데이터를 다뤄 보겠습니다. Prisma의 쿼리 API는 매우 직관적이라 한 번 보면 사용법을 대체로 이해할 수 있습니다.

모든 작업에서는 lib/prisma.tsprisma 인스턴스를 가져와야 합니다. 직접 new PrismaClient()를 호출하지 마세요.

데이터 생성(Create)

레코드 한 건 생성:

import { prisma } from '@/lib/prisma'

const user = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: 'Alice',
    password: 'hashed_password_here'
  }
})

일괄 생성:

const users = await prisma.user.createMany({
  data: [
    { email: '[email protected]', name: 'Bob', password: 'pass1' },
    { email: '[email protected]', name: 'Charlie', password: 'pass2' }
  ],
  skipDuplicates: true // 이미 존재하는 email 건너뛰기
})

console.log(`${users.count}명의 사용자를 생성했습니다`)

중첩 생성(사용자와 Profile을 동시에 생성):

const user = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: 'Dave',
    password: 'pass',
    profile: {
      create: {
        bio: '프로그래밍을 좋아하는 개발자'
      }
    }
  },
  include: {
    profile: true // 반환 결과에 profile 포함
  }
})

데이터 조회(Read)

한 건 조회:

// 고유 필드로 조회
const user = await prisma.user.findUnique({
  where: { email: '[email protected]' }
})

// 조건에 맞는 첫 번째 레코드 조회
const firstPost = await prisma.post.findFirst({
  where: { published: true },
  orderBy: { createdAt: 'desc' }
})

여러 건 조회:

const users = await prisma.user.findMany({
  where: {
    email: {
      contains: '@gmail.com' // email에 @gmail.com 포함
    }
  },
  orderBy: { createdAt: 'desc' },
  take: 10, // 10건만 가져오기
  skip: 0   // 0건 건너뛰기(페이지네이션용)
})

관계 조회:

include로 관계 데이터를 조회합니다.

const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: true,      // 사용자의 모든 게시글 포함
    profile: true     // 사용자의 profile 포함
  }
})

select로 필요한 필드만 조회하면 성능을 높일 수 있습니다.

const user = await prisma.user.findUnique({
  where: { id: 1 },
  select: {
    id: true,
    email: true,
    posts: {
      select: {
        id: true,
        title: true
      }
    }
  }
})
// 결과에는 id, email, posts(posts는 id와 title만)만 포함됨

include는 모든 필드를 가져오고 select는 지정한 필드만 가져옵니다. 데이터가 많을 때 select를 사용하면 상당한 대역폭을 아낄 수 있습니다.

필터 조건:

Prisma는 다양한 필터를 지원합니다.

const posts = await prisma.post.findMany({
  where: {
    OR: [
      { title: { contains: 'Next.js' } },
      { content: { contains: 'Prisma' } }
    ],
    AND: [
      { published: true },
      { authorId: { not: 1 } } // 작성자 ID가 1인 게시글 제외
    ]
  }
})

자주 쓰는 필터 작업은 다음과 같습니다.

  • equals: 같음
  • not: 같지 않음
  • in: 배열 안에 있음(in: [1, 2, 3])
  • notIn: 배열 안에 없음
  • contains: 포함(문자열)
  • startsWith: 특정 문자열로 시작
  • endsWith: 특정 문자열로 끝남
  • gt/gte: 초과/이상
  • lt/lte: 미만/이하

데이터 수정(Update)

한 건 수정:

const user = await prisma.user.update({
  where: { id: 1 },
  data: { name: 'Alice Updated' }
})

여러 건 수정:

const result = await prisma.user.updateMany({
  where: { email: { contains: '@gmail.com' } },
  data: { role: 'ADMIN' }
})

console.log(`${result.count}명의 사용자를 수정했습니다`)

Upsert(있으면 수정하고 없으면 생성):

const user = await prisma.user.upsert({
  where: { email: '[email protected]' },
  update: { name: 'Alice Updated' },
  create: {
    email: '[email protected]',
    name: 'Alice',
    password: 'pass'
  }
})

먼저 조회한 뒤 생성과 수정을 따로 판단할 필요가 없어 특히 유용합니다.

데이터 삭제(Delete)

한 건 삭제:

const user = await prisma.user.delete({
  where: { id: 1 }
})

여러 건 삭제:

const result = await prisma.user.deleteMany({
  where: {
    createdAt: {
      lt: new Date('2023-01-01') // 2023년 이전에 생성된 사용자 삭제
    }
  }
})

console.log(`${result.count}명의 사용자를 삭제했습니다`)

트랜잭션 처리

여러 작업이 모두 성공하거나 모두 실패하도록 보장해야 할 때가 있습니다. 예를 들어 송금에서는 A 계좌에서 돈을 빼고 B 계좌에 더하는 작업이 반드시 함께 성공해야 합니다.

일괄 트랜잭션(서로 독립적인 여러 작업):

const [user, post] = await prisma.$transaction([
  prisma.user.create({ data: { email: '[email protected]', password: 'pass' } }),
  prisma.post.create({ data: { title: 'Test Post', authorId: 1 } })
])

두 작업은 모두 성공하거나 모두 실패하고 롤백됩니다.

대화형 트랜잭션(작업 사이에 의존성이 있음):

const transferMoney = await prisma.$transaction(async (tx) => {
  // A 계좌에서 금액 차감
  const accountA = await tx.account.update({
    where: { id: 1 },
    data: { balance: { decrement: 100 } }
  })
  
  if (accountA.balance < 0) {
    throw new Error('잔액이 부족합니다')
  }
  
  // B 계좌에 금액 추가
  const accountB = await tx.account.update({
    where: { id: 2 },
    data: { balance: { increment: 100 } }
  })
  
  return { accountA, accountB }
})

도중에 오류가 발생하면 전체 트랜잭션이 롤백되어 두 계좌의 잔액이 모두 그대로 유지됩니다.

전체 예제: Next.js API Route

앞의 내용을 연결한 완전한 CRUD API는 다음과 같습니다.

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

// GET /api/posts - 게시글 목록 조회
export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url)
    const page = parseInt(searchParams.get('page') || '1')
    const limit = parseInt(searchParams.get('limit') || '10')
    
    const posts = await prisma.post.findMany({
      where: { published: true },
      include: {
        author: {
          select: { id: true, name: true, email: true }
        },
        tags: true
      },
      orderBy: { createdAt: 'desc' },
      skip: (page - 1) * limit,
      take: limit
    })
    
    const total = await prisma.post.count({ where: { published: true } })
    
    return NextResponse.json({ posts, total, page, limit })
  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch posts' }, { status: 500 })
  }
}

// POST /api/posts - 게시글 생성
export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { title, content, authorId, tagIds } = body
    
    const post = await prisma.post.create({
      data: {
        title,
        content,
        authorId,
        tags: {
          connect: tagIds.map((id: number) => ({ id })) // 기존 태그 연결
        }
      },
      include: { tags: true }
    })
    
    return NextResponse.json(post, { status: 201 })
  } catch (error) {
    return NextResponse.json({ error: 'Failed to create post' }, { status: 500 })
  }
}
// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'

// GET /api/posts/:id - 게시글 한 건 조회
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const post = await prisma.post.findUnique({
      where: { id: parseInt(params.id) },
      include: {
        author: { select: { id: true, name: true } },
        tags: true,
        comments: {
          include: {
            author: { select: { id: true, name: true } }
          }
        }
      }
    })
    
    if (!post) {
      return NextResponse.json({ error: 'Post not found' }, { status: 404 })
    }
    
    return NextResponse.json(post)
  } catch (error) {
    return NextResponse.json({ error: 'Failed to fetch post' }, { status: 500 })
  }
}

// PATCH /api/posts/:id - 게시글 수정
export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const body = await request.json()
    const post = await prisma.post.update({
      where: { id: parseInt(params.id) },
      data: body
    })
    
    return NextResponse.json(post)
  } catch (error) {
    return NextResponse.json({ error: 'Failed to update post' }, { status: 500 })
  }
}

// DELETE /api/posts/:id - 게시글 삭제
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    await prisma.post.delete({
      where: { id: parseInt(params.id) }
    })
    
    return NextResponse.json({ message: 'Post deleted' })
  } catch (error) {
    return NextResponse.json({ error: 'Failed to delete post' }, { status: 500 })
  }
}

CRUD 작업은 이 정도입니다. create, findUnique, findMany, update, delete와 필터, 관계 조회, 트랜잭션 처리를 익히면 대부분의 상황을 해결할 수 있습니다.

고급 기법과 자주 묻는 문제

기본 CRUD를 익힌 뒤에도 실제 프로젝트에서는 몇 가지 주의할 점이 있습니다. 이 절에서는 성능 최적화, 마이그레이션 관리, 디버깅 기법을 살펴보겠습니다.

성능 최적화

select로 조회 필드 줄이기

Prisma는 기본적으로 모든 필드를 조회합니다. 테이블에 필드가 많거나 큰 텍스트 필드가 있다면 select로 필요한 필드만 가져오세요.

// 좋지 않음: 크기가 클 수 있는 content를 포함해 모든 필드 조회
const posts = await prisma.post.findMany()

// 좋음: 필요한 필드만 조회
const posts = await prisma.post.findMany({
  select: {
    id: true,
    title: true,
    createdAt: true,
    author: {
      select: { name: true }
    }
  }
})

특히 목록 페이지에서는 게시글 전체 내용이 필요하지 않으므로 제목과 요약만 조회해도 충분합니다.

N+1 문제 방지

N+1은 ORM에서 흔한 문제입니다. N개의 레코드를 조회한 다음 각 레코드의 관계 데이터를 한 번씩 더 조회해 총 N+1번의 쿼리가 실행되는 현상입니다.

Prisma에서는 include로 자동 해결할 수 있습니다.

// 좋지 않음: N+1 쿼리
const users = await prisma.user.findMany()
for (const user of users) {
  user.posts = await prisma.post.findMany({ where: { authorId: user.id } })
}

// 좋음: 한 번에 조회
const users = await prisma.user.findMany({
  include: { posts: true }
})

Prisma가 쿼리를 지능적으로 합쳐 JOIN 또는 일괄 쿼리로 처리하므로 데이터베이스에 중복 요청을 보내지 않습니다.

연결 풀 설정

Prisma의 기본 연결 풀 크기는 num_cpus * 2 + 1입니다. 대부분 충분하지만 서버리스 환경(예: Vercel)이나 동시 요청이 많은 환경에서는 조정해야 할 수 있습니다.

DATABASE_URL에 매개변수를 추가합니다.

DATABASE_URL="postgresql://user:password@localhost:5432/mydb?connection_limit=5"

서버리스 환경에서는 1부터 시작해 점차 늘리는 것이 좋습니다. 너무 크게 설정하면 데이터베이스 연결이 쉽게 고갈됩니다.

Vercel에 배포한다면 Prisma Accelerate를 공식적으로 권장합니다. HTTP 연결 풀과 전역 캐시를 제공하며 서버리스 환경에 맞게 최적화되어 있습니다.

마이그레이션 관리

개발 환경

Schema를 수정한 뒤 prisma migrate dev를 실행합니다.

npx prisma migrate dev --name add-user-role

이 명령은 다음 작업을 수행합니다.

  1. Schema 변경 사항 감지
  2. SQL 마이그레이션 파일 생성
  3. 데이터베이스에 적용
  4. Prisma Client 재생성

--name은 마이그레이션 이름입니다. add-user-role, create-post-table처럼 내용을 설명하는 이름을 권장합니다.

프로덕션 환경

프로덕션 환경에서는 데이터 손실 위험이 있으므로 절대로 prisma migrate dev를 사용하면 안 됩니다. 대신 prisma migrate deploy를 사용하세요.

npx prisma migrate deploy

이 명령은 기존 마이그레이션 파일만 적용하며 새 파일을 만들지 않습니다.

CI/CD 과정에서는 배포 전에 prisma migrate deploy를 실행해 데이터베이스 구조와 코드가 일치하도록 합니다.

마이그레이션 롤백

Prisma에는 내장 롤백 명령이 없지만 수동으로 처리할 수 있습니다.

  1. 마이그레이션 기록 확인:
npx prisma migrate status
  1. 롤백이 필요하면 변경 사항을 되돌리는 SQL을 직접 작성하거나 데이터베이스 스냅샷을 복원합니다.

프로덕션 환경에서는 데이터베이스를 백업해 두어 문제가 생겼을 때 빠르게 복원할 수 있도록 하는 것이 좋습니다.

디버깅 기법

쿼리 로그 활성화

Prisma가 실제로 어떤 SQL을 실행하는지 보고 싶다면 로그를 켭니다.

// lib/prisma.ts
export const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error']
})

개발 중에는 각 쿼리의 SQL, 실행 시간, 매개변수를 볼 수 있어 특히 유용합니다.

프로덕션 환경에서는 error 수준만 활성화합니다.

log: ['error']

로그가 지나치게 많아 성능에 영향을 주는 것을 방지할 수 있습니다.

Prisma Studio 사용

Prisma Studio는 시각적 데이터베이스 관리 도구입니다.

npx prisma studio

브라우저에 다음 작업을 수행할 수 있는 화면이 열립니다.

  • 모든 테이블과 데이터 조회
  • 레코드 직접 추가, 수정, 삭제
  • 관계 테스트

개발할 때 SQL 클라이언트로 전환할 필요가 없어 매우 편리합니다.

자주 발생하는 오류 해결

P2002: Unique constraint failed
→ email 중복 등 고유 제약 조건을 위반한 것입니다. 데이터가 이미 존재하는지 확인하세요.

P2025: Record not found
→ 수정하거나 삭제할 레코드가 없습니다. 먼저 findUnique로 확인하세요.

P1001: Can't reach database server
→ 데이터베이스 연결에 실패했습니다. .envDATABASE_URL이 올바른지, 데이터베이스가 실행 중인지 확인하세요.

Vercel 배포 시 주의 사항

Vercel에 배포할 때는 package.jsonbuild 스크립트에 prisma generate를 추가해야 합니다.

{
  "scripts": {
    "build": "prisma generate && next build"
  }
}

그러면 빌드할 때마다 최신 Prisma Client가 생성되어 타입과 Schema가 일치합니다.

환경 변수 DATABASE_URL은 코드에 직접 작성하지 말고 Vercel 프로젝트 설정에서 구성해야 합니다.

PostgreSQL을 사용한다면 무료 사용량이 있고 설정이 간단한 Vercel Postgres나 Supabase를 권장합니다.

핵심 요약

고급 내용의 핵심은 다음과 같습니다.

  • 성능 최적화: select로 필드를 줄이고 include로 N+1을 방지하며 연결 풀을 적절히 설정합니다.
  • 마이그레이션 관리: 개발에서는 migrate dev, 프로덕션에서는 migrate deploy를 사용하고 둘을 혼동하지 않습니다.
  • 디버깅 기법: 로그를 켜고 Prisma Studio를 사용하며 자주 발생하는 오류 코드를 이해합니다.
  • 배포 주의 사항: build 전에 prisma generate를 실행하고 환경 변수를 올바르게 설정합니다.

이 내용을 익히면 Prisma를 훨씬 매끄럽게 사용할 수 있습니다.

결론

이 글에서는 환경 구성부터 CRUD 실습까지, 연결 누수부터 Schema 설계까지 Next.js + Prisma의 전체 흐름을 살펴봤습니다.

가장 중요한 내용을 다시 강조하겠습니다.

연결 누수 문제: lib/prisma.ts를 만들고 싱글턴 패턴을 적용한 뒤 모든 곳에서 이 파일의 인스턴스를 가져오세요. 개발할 때 반드시 필요한 설정이며, 적용하지 않으면 언젠가 데이터베이스 연결이 고갈됩니다.

Schema 설계: 일대다, 다대다, 일대일이라는 세 가지 관계를 이해하고 @relation 사용법을 익히세요. 명명 규칙을 통일하고 열거형도 적절히 활용하세요.

CRUD 작업: create, findMany, update, delete라는 핵심 메서드와 include, select의 차이를 익히세요. $transaction으로 트랜잭션을 처리해 데이터 일관성을 보장하세요.

성능과 배포: select로 조회 필드를 줄이고 include로 N+1을 피하세요. 환경에 맞는 마이그레이션 명령을 사용하고 Vercel 배포 시 prisma generate를 잊지 마세요.

Prisma는 원시 SQL보다 학습 곡선이 완만하고 타입 안전성과 개발 경험도 뛰어납니다. 물론 완벽한 도구는 아닙니다. 일정한 성능 오버헤드가 있고 복잡한 쿼리에서는 여전히 원시 SQL이 필요할 수 있습니다. 하지만 대부분의 Next.js 풀스택 프로젝트에서는 장점이 충분히 큽니다.

다음 단계로 아래 작업을 해 볼 수 있습니다.

  • 간단한 Next.js + Prisma 프로젝트를 직접 만들어 이 작업을 실습합니다.
  • Prisma 공식 문서에서 더 많은 고급 기능을 자세히 알아봅니다.
  • Prisma Studio로 데이터베이스를 시각적으로 관리해 봅니다.
  • Prisma GitHub Discussions를 살펴봅니다. 커뮤니티가 활발해 대부분의 문제에 대한 답을 찾을 수 있습니다.

궁금한 점이 있으면 댓글로 이야기하거나 Prisma를 사용하며 겪은 시행착오를 공유해 주세요. 여러분의 경험이 다른 개발자에게 도움이 될 수 있습니다.

Next.js + Prisma 전체 설정 과정

설치부터 연결 누수 해결, Schema 설계, CRUD 작업까지 이어지는 전체 단계

⏱️ Estimated time: 4 hr

  1. 1

    Step 1: Prisma 설치 및 초기화

    의존성 설치:
    • npm install prisma @prisma/client
    • npx prisma init

    초기화하면 다음 파일이 생성됩니다:
    • prisma/schema.prisma: Schema 정의 파일
    • .env: 환경 변수 파일(DATABASE_URL 포함)

    데이터베이스 연결 설정:
    • .env에 DATABASE_URL 설정
    • 형식: postgresql://user:password@localhost:5432/dbname
  2. 2

    Step 2: 연결 누수 문제 해결

    싱글턴 패턴 생성:
    • lib/prisma.ts 파일 생성
    • 개발 환경에서는 globalThis로 인스턴스 캐시
    • 프로덕션 환경에서는 인스턴스를 그대로 내보내기

    코드:
    const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
    export const prisma = globalForPrisma.prisma || new PrismaClient()
    if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

    이렇게 하면 핫 리로드가 여러 연결을 생성하는 것을 막을 수 있습니다.
  3. 3

    Step 3: Schema 설계

    모델 정의:
    • model 키워드로 테이블 정의
    • @id로 기본 키 표시
    • @default로 기본값 설정
    • @relation으로 관계 정의

    관계 유형:
    • 일대일: @relation(fields, references)
    • 일대다: 한 모델에는 @relation이 있고 다른 모델에는 없음
    • 다대다: 조인 테이블(@relation 테이블) 사용

    마이그레이션 생성:
    • npx prisma migrate dev --name init
  4. 4

    Step 4: CRUD 작업 구현

    데이터 생성:
    • prisma.user.create({ data: { name, email } })

    데이터 조회:
    • prisma.user.findMany(): 여러 건 조회
    • prisma.user.findUnique({ where: { id } }): 한 건 조회
    • prisma.user.findFirst({ where: { ... } }): 첫 번째 항목 조회

    데이터 수정:
    • prisma.user.update({ where: { id }, data: { name } })

    데이터 삭제:
    • prisma.user.delete({ where: { id } })
  5. 5

    Step 5: 관계 조회 처리

    include 사용:
    • prisma.user.findMany({ include: { posts: true } })
    • 사용자와 사용자의 모든 게시글을 함께 반환

    select 사용:
    • prisma.user.findMany({ select: { id: true, name: true } })
    • 지정한 필드만 반환해 조회 데이터 감소

    N+1 문제 방지:
    • include로 관계 데이터를 한 번에 조회
    • 바깥쪽 반복문에서 관계 데이터를 따로 조회하지 않기
  6. 6

    Step 6: 배포 및 마이그레이션

    프로덕션 환경 배포:
    • DATABASE_URL 환경 변수 설정
    • npx prisma generate를 실행해 클라이언트 생성
    • npx prisma migrate deploy를 실행해 마이그레이션 적용

    Vercel 배포:
    • Vercel Dashboard에서 환경 변수 설정
    • build 명령에 prisma generate 추가
    • prisma migrate deploy로 마이그레이션 적용

    주의: 프로덕션 환경에서 migrate dev를 실행하지 마세요.

FAQ

'too many clients already' 오류가 발생하는 이유는 무엇인가요?
Next.js 핫 리로드로 생기는 연결 누수 문제입니다.

코드를 수정할 때마다 Next.js가 새 PrismaClient 인스턴스를 만들지만 이전 연결은 자동으로 닫히지 않아 결국 연결 풀이 고갈됩니다.

개발 환경에서 globalThis에 PrismaClient 인스턴스를 캐시하는 싱글턴 패턴으로 해결할 수 있습니다.
Prisma는 TypeORM, Drizzle과 어떻게 다른가요?
Prisma:
• 타입 안전성이 가장 뛰어나고 개발 경험이 좋음
• 일정한 성능 오버헤드가 있음

TypeORM:
• 기능이 강력하고 복잡한 쿼리를 지원함
• 설정이 복잡함

Drizzle:
• 가볍고 성능이 좋음
• 타입 안전성은 Prisma보다 약함

Next.js 프로젝트에서는 Prisma의 사용 편의성과 타입 안전성이 뚜렷한 장점입니다.
일대다 관계는 어떻게 설계하나요?
'다' 쪽에 외래 키 필드를 추가하고 @relation으로 표시합니다.

예: User 한 명이 여러 Post를 가질 때
• Post 모델에 userId 필드와 @relation(fields: ['userId'], references: [id]) 추가
• User 모델에 posts Post[] 필드 추가
include와 select의 차이는 무엇인가요?
include:
• 관계 조회에 사용하며 관계 데이터를 반환함
• 조회 데이터가 늘어남

select:
• 필드를 선택하는 데 사용하며 지정한 필드만 반환함
• 조회 데이터가 줄어듦

둘을 함께 사용할 수도 있습니다: { include: { posts: true }, select: { id: true, name: true } }
N+1 쿼리 문제를 어떻게 피하나요?
바깥쪽 반복문에서 관계 데이터를 조회하지 말고 include로 한 번에 조회하세요. 예를 들어 prisma.user.findMany({ include: { posts: true } })는 사용자마다 게시글을 한 번씩 조회하지 않고 모든 사용자와 게시글을 한 번에 조회합니다.
Prisma는 트랜잭션을 지원하나요?
지원합니다. prisma.$transaction([...])으로 여러 작업을 실행하면 모두 성공하거나 모두 실패합니다. 예: await prisma.$transaction([prisma.user.create(...), prisma.post.create(...)])
Vercel에 Prisma를 어떻게 배포하나요?
단계:
1) Vercel Dashboard에서 DATABASE_URL 환경 변수 설정
2) package.json의 build 명령에 prisma generate 추가
3) prisma migrate deploy로 마이그레이션 적용(migrate dev는 사용하지 않음)

프로덕션 데이터베이스에 정상적으로 연결되는지 확인하세요.

9분 읽기 · 게시일: 2025년 12월 20일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog