prisma orm 완전 정복: next.js 프로젝트에서 왜 prisma를 써야 하는가

·13 min read·3·
Prisma ORM 완전 정복: Next.js 프로젝트에서 왜 Prisma를 써야 하는가

Prisma ORM 완전 정복: Next.js 프로젝트에서 왜 Prisma를 써야 하는가

작성일: 2025년 3월


cover

들어가며

백엔드 개발에서 데이터베이스 연동은 피할 수 없는 과제다. SQL을 직접 작성하면 유연하지만 타입 안전성이 없고, 기존 ORM들은 타입 지원이 부족하거나 설정이 복잡하다. 그렇다면 TypeScript 기반의 현대적인 풀스택 환경에서 최선의 선택은 무엇일까?

이 글에서는 최근 Next.js 생태계에서 빠르게 자리를 잡아가고 있는 Prisma ORM을 깊이 있게 살펴보고, 왜 Next.js 프로젝트에서 Prisma가 사실상의 표준(de facto standard)이 되어가고 있는지를 실제 코드와 함께 설명한다.


Prisma란 무엇인가?

Prisma는 Node.js 및 TypeScript 환경을 위한 차세대 ORM(Object-Relational Mapper)이다. 2019년 처음 등장해 2021년 v1.0을 릴리즈한 이후, GitHub Star 40,000개를 넘으며 빠르게 성장한 오픈소스 프로젝트다.

Prisma는 단순한 ORM이 아니라 3가지 핵심 도구의 모음으로 이루어져 있다.

도구역할
Prisma Client자동 생성되는 타입 안전(Type-safe) 쿼리 빌더
Prisma Migrate선언적 데이터 마이그레이션 관리 도구
Prisma Studio데이터베이스를 시각적으로 탐색하는 GUI

지원 데이터베이스

  • PostgreSQL
  • MySQL / MariaDB
  • SQLite
  • SQL Server
  • MongoDB (Preview)
  • CockroachDB

기존 ORM과 무엇이 다른가?

전통적인 ORM의 문제점

TypeORM, Sequelize 같은 전통적 ORM들은 오랜 역사를 가지고 있지만, TypeScript 환경에서는 다음과 같은 한계를 가진다.

// TypeORM 예시 - 반환 타입이 불분명하다
const users = await userRepository.find({
  where: { active: true },
  relations: ['posts']
});
// users의 타입? User[] 이지만 posts가 실제로 로드됐는지는 런타임에서만 알 수 있다
  • 관계 데이터의 타입 불안전: relations로 조인한 데이터의 타입이 런타임까지 보장되지 않는다.
  • N+1 문제 발생 가능성: 관계 데이터를 명시적으로 처리하지 않으면 쿼리가 폭발적으로 증가한다.
  • 스키마와 코드의 불일치: 모델 정의가 분산되어 있어 DB 실제 상태와 코드가 어긋나기 쉽다.

Prisma의 접근 방식

Prisma는 schema.prisma라는 단일 진실 공급원(Single Source of Truth) 에서 출발한다. 이 파일 하나로부터 타입, 마이그레이션, 클라이언트가 모두 생성된다.

schema.prisma
     │
     ├──► Prisma Migrate → SQL 마이그레이션 파일
     ├──► Prisma Client  → 완벽히 타입화된 쿼리 빌더
     └──► Prisma Studio  → DB GUI

핵심 개념: Prisma Schema

모든 것은 prisma/schema.prisma 파일에서 시작된다.

// 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[]
}

Schema의 주요 어트리뷰트

어트리뷰트설명예시
@id기본 키 지정@id
@default()기본값 설정@default(now()), @default(autoincrement())
@unique유니크 제약@unique
@updatedAt자동 업데이트 타임스탬프@updatedAt
@relation관계 정의@relation(fields: [...], references: [...])
@@index복합 인덱스@@index([email, name])

Next.js 프로젝트 셋업 가이드

1단계: 설치

npm install prisma @prisma/client
npx prisma init

prisma init 실행 시 다음 파일들이 생성된다:

  • prisma/schema.prisma — 스키마 정의 파일
  • .env — 환경변수 파일 (DATABASE_URL 포함)

2단계: 환경변수 설정

# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"

3단계: 마이그레이션

스키마를 작성한 후 DB에 반영한다.

# 개발환경: 마이그레이션 파일 생성 + DB 적용
npx prisma migrate dev --name init

# 프로덕션: 마이그레이션만 적용
npx prisma migrate deploy

마이그레이션 실행 시 prisma/migrations/ 폴더에 버전별 SQL 파일이 자동 생성된다.

prisma/
└── migrations/
    ├── 20250301000000_init/
    │   └── migration.sql
    └── 20250310000000_add_profile/
        └── migration.sql

4단계: Prisma Client 싱글톤 설정

Next.js에서는 개발 환경에서 Hot Reload로 인해 Prisma Client 인스턴스가 중복 생성되는 문제가 발생할 수 있다. 이를 방지하는 싱글톤 패턴을 사용한다.

// 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

export default prisma

Prisma Client 주요 CRUD 패턴

데이터 조회 (Read)

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

// 단일 레코드 조회
const user = await prisma.user.findUnique({
  where: { id: 1 },
})

// null 반환 대신 에러를 던지고 싶을 때
const user = 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 }
    }
  }
})

데이터 생성 (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,
})

데이터 수정 (Update)

// 단일 업데이트
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: '김상욱',
  }
})

데이터 삭제 (Delete)

// 단일 삭제
await prisma.user.delete({
  where: { id: 1 }
})

// 조건 삭제
await prisma.post.deleteMany({
  where: {
    published: false,
    createdAt: { lt: new Date('2024-01-01') }
  }
})

강력한 필터링 시스템

Prisma의 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 recentPosts = 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에서 직접 사용

// app/users/page.tsx (Server Component)
import { prisma } from '@/lib/prisma'

export default async function UsersPage() {
  // 서버 컴포넌트에서 직접 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 (API Route)

// app/api/posts/route.ts
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에서 사용

// app/actions/post.ts
'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의 타입 시스템 활용

Prisma가 생성하는 타입을 활용하면 코드 전반에 걸쳐 일관된 타입을 유지할 수 있다.

import { Prisma, User, Post } from '@prisma/client'

// 자동 생성된 타입 직접 사용
type UserWithPosts = Prisma.UserGetPayload<{
  include: {
    posts: { include: { categories: true } }
    profile: true
  }
}>

// 이 타입은 include 구조를 정확히 반영한다
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 })
}

Prisma 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"
  }
}

시딩(Seeding): 초기 데이터 삽입

// prisma/seed.ts
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())
// package.json
{
  "prisma": {
    "seed": "tsx prisma/seed.ts"
  }
}

성능 최적화 팁

1. select로 필요한 필드만 조회

// ❌ 모든 필드 조회 (비효율)
const users = await prisma.user.findMany()

// ✅ 필요한 필드만 선택
const users = await prisma.user.findMany({
  select: { id: true, name: true, email: true }
})

2. 페이지네이션 적용

// 커서 기반 페이지네이션 (대용량 데이터에 적합)
const posts = await prisma.post.findMany({
  take: 10,
  cursor: { id: lastPostId },  // 마지막 조회된 ID
  skip: 1,                     // cursor 자체는 스킵
  orderBy: { id: 'asc' }
})

3. $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
`

4. 인덱스 설정

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 자동완성이 완벽하게 동작하며, 런타임 에러를 컴파일 타임에 잡을 수 있다.

✅ 단일 진실 공급원 (schema.prisma)

DB 스키마, 타입, 마이그레이션이 모두 하나의 파일에서 파생된다. 코드와 DB가 절대 어긋나지 않는다.

✅ 직관적이고 선언적인 API

SQL을 몰라도 복잡한 관계 쿼리를 자연스럽게 표현할 수 있다. 중첩 생성(nested 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 vs 경쟁 도구 비교

기준PrismaTypeORMDrizzle ORMSequelize
TypeScript 지원⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐
학습 곡선낮음중간낮음중간
마이그레이션자동화수동/자동수동수동
번들 사이즈중간매우 작음
생태계/커뮤니티매우 활발활발성장 중오래됨
Raw SQL 지원지원지원우수지원
GUI 도구Studio 내장없음없음없음

Drizzle ORM도 최근 빠르게 성장하는 강력한 대안이지만, 마이그레이션 자동화와 Studio 같은 편의 기능은 Prisma가 앞선다. 번들 사이즈가 중요한 Edge Runtime 환경이라면 Drizzle을 고려할 만하다.


주의사항 및 알려진 한계

1. Prisma Client 번들 사이즈 Prisma Client는 런타임 의존성이 있어 Vercel Edge Runtime이나 Cloudflare Workers 같은 환경에서는 @prisma/adapter-neon 같은 별도 어댑터가 필요하다.

2. 대용량 Batch 작업 createMany, updateManyreturning 절 지원이 제한적이다. 대용량 배치에는 $executeRaw를 고려하자.

3. Schema 변경 시 신중함 필요 migrate dev는 기존 데이터를 변경하거나 삭제할 수 있다. 프로덕션에서는 반드시 migrate deploy를 사용하고 마이그레이션 파일을 git으로 관리해야 한다.


마치며

Prisma는 "TypeScript + Node.js 기반 풀스택" 환경에서 데이터베이스 작업을 가장 안전하고 생산적으로 처리할 수 있는 도구다. 특히 Next.js App Router의 Server Component, Server Action과 조합하면 별도의 API 레이어 없이도 타입 안전하게 DB를 다룰 수 있다.

팀 프로젝트에서는 스키마 기반의 단일 진실 공급원, 자동화된 마이그레이션, Studio 도구의 조합이 팀원들의 온보딩 시간과 디버깅 시간을 크게 줄여준다.

처음에는 스키마 파일이나 마이그레이션 개념이 낯설게 느껴질 수 있지만, 한번 익숙해지면 그 전으로는 돌아갈 수 없다. 아직 Prisma를 써보지 않았다면, 지금 당장 새 Next.js 프로젝트에 적용해보길 강력히 권한다.


🔗 참고 자료

// tags