Prisma ORM 완전 정복: Next.js 프로젝트에서 왜 Prisma를 써야 하는가
SQL을 직접 쓰면 유연하지만 타입이 없고, 기존 ORM은 TypeScript에서 타입이 새거나 설정이 무겁습니다. TypeScript 풀스택 환경에서 빠르게 표준이 되고 있는 Prisma ORM을 스키마 파일부터 마이그레이션, Next.js App Router 통합까지 깊게 다룹니다.
Prisma란
Prisma는 Node.js와 TypeScript를 위한 차세대 ORM입니다. 2019년에 등장해 2021년 v1.0을 릴리즈했고, GitHub 스타 4만 개가 넘는 프로젝트입니다. 도구 하나가 아니라 세 가지 도구의 묶음입니다.
| 도구 | 역할 |
|---|---|
| Prisma Client | 자동 생성되는, 타입이 맞는 쿼리 빌더 |
| Prisma Migrate | 선언적 데이터 마이그레이션 관리 도구 |
| Prisma Studio | 데이터베이스를 눈으로 탐색하는 GUI |
지원하는 데이터베이스는 PostgreSQL, MySQL과 MariaDB, SQLite, SQL Server, CockroachDB, 그리고 프리뷰 단계인 MongoDB입니다.
기존 ORM과 무엇이 다른가
TypeORM이나 Sequelize 같은 전통적 ORM에는 세 가지 한계가 있습니다.
- 관계 데이터의 타입이 불안전합니다.
relations로 조인한 데이터의 타입이 런타임까지 보장되지 않습니다. - N+1 문제가 생기기 쉽습니다. 관계 데이터를 명시적으로 다루지 않으면 쿼리가 폭발합니다.
- 스키마와 코드가 어긋납니다. 모델 정의가 흩어져 있어 DB의 실제 상태와 코드가 따로 놉니다.
Prisma는 schema.prisma라는 단일 진실 공급원(single source of truth)에서 출발합니다. 마이그레이션도, 클라이언트도, GUI도 이 파일 하나에서 파생됩니다.
schema.prisma
│
├──► Prisma Migrate → SQL 마이그레이션 파일
├──► Prisma Client → 타입이 맞는 쿼리 빌더
└──► Prisma Studio → DB GUI
핵심 개념: Prisma Schema
모든 것은 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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
posts Post[]
profile Profile?
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
createdAt DateTime @default(now())
author User @relation(fields: [authorId], references: [id])
authorId Int
categories Category[]
}
model Profile {
id Int @id @default(autoincrement())
bio String?
user User @relation(fields: [userId], references: [id])
userId Int @unique
}
model Category {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
}
주요 어트리뷰트
| 어트리뷰트 | 설명 | 예시 |
|---|---|---|
@id | 기본 키 | @id |
@default() | 기본값 | @default(now()), @default(autoincrement()) |
@unique | 유니크 제약 | @unique |
@updatedAt | 수정 시각 자동 갱신 | @updatedAt |
@relation | 관계 정의 | @relation(fields: [...], references: [...]) |
@@index | 복합 인덱스 | @@index([email, name]) |
Next.js 프로젝트 셋업
설치
npm install prisma @prisma/client npx prisma init
prisma init은 스키마 파일 prisma/schema.prisma와, DATABASE_URL이 들어갈 .env를 만들어 줍니다.
환경변수 설정
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
마이그레이션
# 개발 환경: 마이그레이션 파일 생성 + DB 적용 npx prisma migrate dev --name init # 프로덕션: 마이그레이션만 적용 npx prisma migrate deploy
실행하면 prisma/migrations/ 아래에 버전별 SQL 파일이 생깁니다. 이 폴더는 git으로 관리합니다.
prisma/
└── migrations/
├── 20250301000000_init/
│ └── migration.sql
└── 20250310000000_add_profile/
└── migration.sql
Prisma Client 싱글톤
Next.js 개발 서버는 핫 리로드 때마다 모듈을 다시 평가합니다. 그때마다 Prisma Client가 새로 만들어지지 않도록 전역에 하나만 두는 패턴을 씁니다.
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
export default prisma
Prisma Client CRUD 패턴
조회
import { prisma } from '@/lib/prisma'
// 단일 레코드
const user = await prisma.user.findUnique({
where: { id: 1 },
})
// null 대신 에러를 던지고 싶을 때
const admin = await prisma.user.findUniqueOrThrow({
where: { email: 'admin@example.com' },
})
// 조건 검색
const activeUsers = await prisma.user.findMany({
where: {
posts: {
some: { published: true }
}
},
orderBy: { createdAt: 'desc' },
take: 10,
skip: 0,
})
// 관계 데이터 함께 조회 (include)
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
},
profile: true,
},
})
// 필드 선택 (select): 필요한 컬럼만 가져온다
const userNames = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
_count: {
select: { posts: true }
}
}
})
생성
// 단일 생성, 관계까지 한 번에 (nested create)
const newUser = await prisma.user.create({
data: {
email: 'sangwook@example.com',
name: '김상욱',
profile: {
create: {
bio: '개발팀 3팀 차장'
}
}
},
include: { profile: true }
})
// 다중 생성 (batch)
await prisma.post.createMany({
data: [
{ title: '첫 번째 포스트', authorId: 1 },
{ title: '두 번째 포스트', authorId: 1 },
],
skipDuplicates: true,
})
수정
// 단일 업데이트, 관계 데이터도 같이
const updatedUser = await prisma.user.update({
where: { id: 1 },
data: {
name: '김상욱 (수정됨)',
posts: {
updateMany: {
where: { published: false },
data: { published: true }
}
}
}
})
// upsert: 없으면 만들고, 있으면 고친다
const user = await prisma.user.upsert({
where: { email: 'sangwook@example.com' },
update: { name: '김상욱' },
create: {
email: 'sangwook@example.com',
name: '김상욱',
}
})
삭제
// 단일 삭제
await prisma.user.delete({
where: { id: 1 }
})
// 조건 삭제
await prisma.post.deleteMany({
where: {
published: false,
createdAt: { lt: new Date('2024-01-01') }
}
})
필터링
where 절은 SQL의 거의 모든 연산을 타입이 맞는 형태로 표현합니다.
// 복합 조건
const posts = await prisma.post.findMany({
where: {
AND: [
{ published: true },
{ createdAt: { gte: new Date('2025-01-01') } },
{
OR: [
{ title: { contains: 'Prisma', mode: 'insensitive' } },
{ content: { startsWith: '오늘' } }
]
},
{ author: { NOT: { name: null } } }
]
}
})
// 목록 필터
const picked = await prisma.post.findMany({
where: {
id: { in: [1, 2, 3] },
}
})
// 관계 필터
const usersWithManyPosts = await prisma.user.findMany({
where: {
posts: { some: { published: true } },
_count: { posts: { gt: 5 } } // Prisma 5.x부터
}
})
트랜잭션 처리
정합성이 중요한 작업은 트랜잭션으로 묶습니다. 배열 방식과 인터랙티브 방식 두 가지가 있고, 중간에 검증 로직이 끼어야 하면 인터랙티브 방식입니다.
// 방법 1: $transaction 배열 (여러 쿼리를 한 트랜잭션으로)
const [newUser, newPost] = await prisma.$transaction([
prisma.user.create({ data: { email: 'test@test.com', name: 'Test' } }),
prisma.post.create({ data: { title: 'Test Post', authorId: 1 } }),
])
// 방법 2: interactive transaction (권장, 중간에 롤백 가능)
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: { email: 'new@example.com', name: '신규유저' }
})
// 비즈니스 로직 검증
if (user.id > 1000) {
throw new Error('사용자 수 한도 초과') // 던지면 자동 롤백
}
const post = await tx.post.create({
data: {
title: '첫 포스트',
authorId: user.id
}
})
return { user, post }
})
Next.js App Router와의 통합
Server Component에서 직접 쓰기
import { prisma } from '@/lib/prisma'
export default async function UsersPage() {
// 서버 컴포넌트에서는 API 없이 바로 DB를 읽는다
const users = await prisma.user.findMany({
include: {
_count: { select: { posts: true } }
},
orderBy: { createdAt: 'desc' }
})
return (
<div>
{users.map(user => (
<div key={user.id}>
<h2>{user.name}</h2>
<p>포스트: {user._count.posts}개</p>
</div>
))}
</div>
)
}
Route Handler
import { prisma } from '@/lib/prisma'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') ?? '1')
const limit = parseInt(searchParams.get('limit') ?? '10')
const [posts, total] = await prisma.$transaction([
prisma.post.findMany({
skip: (page - 1) * limit,
take: limit,
include: { author: { select: { name: true } } },
orderBy: { createdAt: 'desc' }
}),
prisma.post.count()
])
return NextResponse.json({
data: posts,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) }
})
}
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await prisma.post.create({
data: {
title: body.title,
content: body.content,
authorId: body.authorId,
}
})
return NextResponse.json(post, { status: 201 })
}
Server Action
'use server'
import { prisma } from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await prisma.post.create({
data: { title, content, authorId: 1 }
})
revalidatePath('/posts') // 캐시 갱신
}
타입 시스템 활용
Prisma가 생성하는 타입을 그대로 쓰면 코드 전체에서 타입이 한 곳에서 나옵니다. include나 select 구조까지 정확히 반영된 타입을 뽑아낼 수 있습니다.
import { Prisma, User, Post } from '@prisma/client'
// include 구조를 그대로 반영한 타입
type UserWithPosts = Prisma.UserGetPayload<{
include: {
posts: { include: { categories: true } }
profile: true
}
}>
function renderUser(user: UserWithPosts) {
console.log(user.profile?.bio) // Profile | null
console.log(user.posts[0].categories) // Category[]
}
// 입력 타입
type CreateUserInput = Prisma.UserCreateInput
type UpdatePostInput = Prisma.PostUpdateInput
async function updatePost(id: number, data: UpdatePostInput) {
return prisma.post.update({ where: { id }, data })
}
Migrate 전략과 시딩
개발 환경
# 스키마를 고친 뒤 마이그레이션 생성 + 적용 npx prisma migrate dev --name add_user_role # DB를 스키마에 맞게 강제 리셋 (데이터 초기화) npx prisma migrate reset
프로덕션 환경
# 마이그레이션 상태 확인 npx prisma migrate status # 아직 적용되지 않은 파일만 실행 npx prisma migrate deploy
package.json 스크립트
{
"scripts": {
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"db:generate": "prisma generate",
"postinstall": "prisma generate"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
시딩: 초기 데이터 넣기
upsert로 쓰면 몇 번을 실행해도 같은 결과가 나옵니다.
import { prisma } from '../lib/prisma'
async function main() {
// 카테고리
const categories = await Promise.all([
prisma.category.upsert({
where: { name: 'Technology' },
update: {},
create: { name: 'Technology' }
}),
prisma.category.upsert({
where: { name: 'DevOps' },
update: {},
create: { name: 'DevOps' }
})
])
// 관리자 계정
const admin = await prisma.user.upsert({
where: { email: 'admin@example.com' },
update: {},
create: {
email: 'admin@example.com',
name: '관리자',
profile: { create: { bio: '시스템 관리자' } },
posts: {
create: [
{
title: 'Prisma ORM 시작하기',
content: '이 포스트는 시드 데이터입니다.',
published: true,
categories: { connect: [{ id: categories[0].id }] }
}
]
}
}
})
console.log(`시드 완료: 관리자(${admin.email}) 생성됨`)
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())
성능 최적화 팁
select로 필요한 필드만
// 모든 필드 조회 (비효율)
const users = await prisma.user.findMany()
// 필요한 필드만 선택
const slimUsers = await prisma.user.findMany({
select: { id: true, name: true, email: true }
})
페이지네이션
// 커서 기반 페이지네이션 (대용량 데이터에 적합)
const posts = await prisma.post.findMany({
take: 10,
cursor: { id: lastPostId }, // 마지막으로 조회한 ID
skip: 1, // cursor 자체는 건너뛴다
orderBy: { id: 'asc' }
})
복잡한 쿼리는 $queryRaw
// 복잡한 집계나 raw SQL이 필요할 때
const result = await prisma.$queryRaw<{ count: bigint; month: string }[]>`
SELECT
DATE_TRUNC('month', created_at) as month,
COUNT(*) as count
FROM posts
WHERE published = true
GROUP BY month
ORDER BY month DESC
LIMIT 12
`
인덱스
model Post {
id Int @id @default(autoincrement())
title String
authorId Int
createdAt DateTime @default(now())
@@index([authorId]) // 단일 인덱스
@@index([authorId, createdAt]) // 복합 인덱스
}
Prisma를 써야 하는 이유
TypeScript 타입 안전성
쿼리 결과에 include와 select 구조까지 정확히 반영된 타입이 자동으로 생성됩니다. IDE 자동완성이 끝까지 따라오고, 런타임 에러를 컴파일 타임에 잡습니다.
단일 진실 공급원
DB 스키마, 타입, 마이그레이션이 모두 schema.prisma 하나에서 파생됩니다. 코드와 DB가 어긋날 길이 없습니다.
직관적이고 선언적인 API
SQL을 몰라도 복잡한 관계 쿼리를 자연스럽게 씁니다. 중첩 생성(create), 연결(connect), 끊기(disconnect) 같은 관계 조작이 특히 그렇습니다.
자동화된 마이그레이션
스키마를 고치면 마이그레이션 파일이 생성되고 버전 관리까지 따라옵니다. Flyway 같은 별도 도구를 관리할 필요가 없습니다.
Next.js App Router와의 궁합
Server Component, Server Action, Route Handler 어디서든 바로 씁니다. Vercel의 공식 Next.js 예시도 Prisma를 기본 ORM으로 씁니다.
Prisma Studio
npx prisma studio
로컬에서 도는 GUI로 DB를 눈으로 보고 고칠 수 있습니다. 개발 중 데이터 확인이 눈에 띄게 빨라집니다.
경쟁 도구 비교
| 기준 | Prisma | TypeORM | Drizzle ORM | Sequelize |
|---|---|---|---|---|
| TypeScript 지원 | 매우 좋음 | 보통 | 매우 좋음 | 약함 |
| 학습 곡선 | 낮음 | 중간 | 낮음 | 중간 |
| 마이그레이션 | 자동화 | 수동/자동 | 수동 | 수동 |
| 번들 사이즈 | 중간 | 큼 | 매우 작음 | 큼 |
| 생태계 | 매우 활발 | 활발 | 성장 중 | 오래됨 |
| Raw SQL | 지원 | 지원 | 우수 | 지원 |
| GUI 도구 | Studio 내장 | 없음 | 없음 | 없음 |
주의사항과 알려진 한계
- Prisma Client 번들 사이즈. 런타임 의존성이 있어 Vercel Edge Runtime이나 Cloudflare Workers에서는
@prisma/adapter-neon같은 별도 어댑터가 필요합니다. - 대용량 batch 작업.
createMany,updateMany는returning절 지원이 제한적입니다. 대용량 배치에는$executeRaw를 고려하세요. - 스키마 변경은 신중하게.
migrate dev는 기존 데이터를 바꾸거나 지울 수 있습니다. 프로덕션에서는 반드시migrate deploy를 쓰고, 마이그레이션 파일을 git으로 관리하세요.
마치며
Prisma는 TypeScript와 Node.js 기반 풀스택 환경에서 데이터베이스 작업을 가장 안전하고 생산적으로 처리하는 도구입니다. 특히 Next.js App Router의 Server Component, Server Action과 조합하면 별도 API 레이어 없이도 타입이 맞는 채로 DB를 다룰 수 있습니다.
팀 프로젝트에서는 스키마 기반 단일 진실 공급원, 자동화된 마이그레이션, Studio의 조합이 팀원의 온보딩 시간과 디버깅 시간을 크게 줄여 줍니다. 처음에는 스키마 파일이나 마이그레이션 개념이 낯설 수 있지만, 한 번 익숙해지면 그 전으로 돌아가기 어렵습니다. 아직 써 보지 않았다면 새 Next.js 프로젝트에 바로 적용해 보길 권합니다.