Prisma로 타입 안전한 DB 접근: 스키마, 마이그레이션, 관계 쿼리, 트랜잭션, 블로그 예제

이 글의 핵심

Prisma는 schema.prisma 한 파일에서 모델을 정의하고, 그로부터 타입이 붙은 클라이언트와 SQL 마이그레이션을 생성하는 TypeScript ORM입니다. 스키마·마이그레이션·관계 쿼리·트랜잭션을 블로그 예제로 따라가며, include가 실제로 어떤 쿼리를 만드는지, 대화형 트랜잭션의 5초 타임아웃, 서버리스 연결 풀 고갈, 되돌리기(down) 마이그레이션이 없다는 점처럼 운영에서 부딪히는 제약을 함께 짚습니다.

Prisma란?

Prisma는 Node.js와 TypeScript용 ORM(Object-Relational Mapping) 도구 모음입니다. TypeORM이나 Sequelize가 클래스나 모델 정의에서 출발하는 것과 달리, Prisma는 schema.prisma라는 전용 언어로 작성한 스키마를 원본으로 삼고, 거기서 TypeScript 타입이 붙은 클라이언트 코드와 SQL 마이그레이션을 생성합니다. 그래서 prisma.user.findMany({ where: { emial: ... } })처럼 필드 이름을 잘못 쓰면 실행 전에 컴파일 에러가 나고, select로 고른 필드에 따라 반환 타입까지 정확히 좁혀집니다.

대가도 있습니다. 스키마를 바꿀 때마다 prisma generate로 클라이언트를 다시 만들어야 하고, Prisma의 쿼리 API로 표현하기 어려운 SQL(윈도 함수, 복잡한 CTE, DB 고유 기능)은 $queryRaw로 직접 써야 합니다. 쿼리 빌더에 가까운 Drizzle이나 Kysely는 SQL을 더 직접적으로 드러내는 대신 스키마 중심의 자동화는 덜합니다. 팀이 SQL보다 모델 중심으로 생각하고, 대부분의 쿼리가 CRUD와 관계 조회라면 Prisma의 장점이 크게 느껴집니다.

핵심 구성 요소

  1. Prisma Schema

    • 선언적 데이터 모델
    • 단일 진실 공급원
    • 마이그레이션 생성
  2. Prisma Client

    • 자동 생성 쿼리 빌더
    • 완벽한 타입 안전성
    • 자동완성 지원
  3. Prisma Migrate

    • 스키마 변경을 SQL 마이그레이션 파일로 생성
    • 마이그레이션 이력 관리
    • 되돌리기(down) 마이그레이션은 자동 생성하지 않음
  4. Prisma Studio

    • GUI 데이터 브라우저
    • 데이터 편집
    • 시각적 탐색

TypeORM vs Sequelize vs Prisma 비교

항목TypeORMSequelizePrisma
언어TypeScriptJavaScript/TSTypeScript
스키마 정의데코레이터 클래스모델 클래스Schema 파일
타입 안전성좋음보통매우 좋음
마이그레이션CLICLIMigrate CLI
Raw SQL지원지원지원
관계 쿼리Eager/LazyIncludeInclude
관계 로딩 방식JOIN (eager) / 지연 로딩include (JOIN)include (관계별 배치 쿼리, 선택적으로 JOIN)
GUI❌❌Prisma Studio
학습 곡선중간중간낮음

TypeORM의 지연 로딩(lazy relation)은 관계 속성에 접근할 때마다 쿼리를 보내므로 반복문 안에서 쓰면 N+1이 쉽게 생기지만, Prisma에는 지연 로딩이 없고 관계는 항상 include나 select로 명시적으로 요청해야 합니다. 이 차이 덕분에 “모르는 사이에 쿼리가 수백 번 나가는” 상황은 줄어들지만, 반복문 안에서 직접 findUnique를 호출하면 Prisma에서도 N+1은 그대로 생깁니다.

프로젝트 설정

Prisma를 시작하려면 먼저 프로젝트에 필요한 패키지를 설치해야 합니다. Prisma는 개발 의존성(devDependencies)으로 설치하며, Prisma Client는 런타임에 필요하므로 일반 의존성(dependencies)으로 설치합니다.

설치 후 npx prisma init 명령어를 실행하면 prisma 폴더와 schema.prisma 파일이 자동으로 생성됩니다. 이 파일이 데이터베이스 스키마를 정의하는 중심 파일입니다. 데이터베이스 종류에 따라 적절한 provider를 선택할 수 있습니다.

# Prisma 설치
npm install -D prisma
npm install @prisma/client

# Prisma 초기화
npx prisma init

# PostgreSQL 사용 시
npx prisma init --datasource-provider postgresql

# MySQL 사용 시
npx prisma init --datasource-provider mysql

초기화 후 .env 파일에 DATABASE_URL을 설정해야 합니다. 이 URL은 데이터베이스 연결 정보를 담고 있으며, 환경에 따라 다른 값을 사용할 수 있습니다.

디렉터리 구조

my-project/
├── prisma/
│   ├── schema.prisma     # 스키마 정의
│   └── migrations/       # 마이그레이션 파일
├── src/
│   └── index.ts
├── .env                  # DATABASE_URL
└── package.json

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   // unique 제약이 곧 인덱스
  name      String?
  age       Int?
  isActive  Boolean   @default(true)
  role      Role      @default(USER)
  posts     Post[]
  comments  Comment[]
  profile   Profile?
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  @@map("users")  // 테이블명 지정
}

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

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

  @@index([authorId])
  @@index([published])
}

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

  @@index([postId])
}

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

enum Role {
  USER
  ADMIN
  MODERATOR
}

관계는 양쪽 모델에 모두 선언합니다. Post.author처럼 @relation(fields: [authorId], references: [id])가 붙은 쪽이 실제 외래 키 컬럼(authorId)을 가진 쪽이고, User.posts는 데이터베이스에 컬럼이 생기지 않는 “반대편 탐색용” 필드입니다. 한쪽만 적으면 prisma format이나 validate가 “The relation field … is missing an opposite relation field” 에러를 내며, prisma format을 실행하면 빠진 반대편 필드를 자동으로 채워 줍니다. Post.tags와 Tag.posts처럼 양쪽이 모두 배열인 관계는 Prisma가 _PostToTag 같은 이름의 숨은 조인 테이블을 만들어 관리합니다. 조인 테이블에 “태그를 붙인 시각” 같은 추가 컬럼이 필요해지면 이 암시적 관계를 명시적인 모델로 바꿔야 하는데, 그 전환은 데이터 이전이 필요한 마이그레이션이 되므로 처음 설계할 때 고려해 두는 것이 좋습니다.

@unique는 그 자체로 유니크 인덱스를 만들기 때문에 같은 컬럼에 @@index를 또 걸 필요가 없습니다. 원래 예제에 있던 @@index([email])은 같은 컬럼에 인덱스를 하나 더 만들어 쓰기 비용만 늘립니다. 반대로 PostgreSQL은 외래 키 컬럼에 인덱스를 자동으로 만들지 않으므로, authorId, postId처럼 조인과 필터에 쓰이는 외래 키에는 @@index를 명시해야 합니다(MySQL은 외래 키에 인덱스를 자동으로 만듭니다).

마이그레이션

# 마이그레이션 생성 및 적용
npx prisma migrate dev --name init

# 프로덕션 적용
npx prisma migrate deploy

# 마이그레이션 상태 확인
npx prisma migrate status

# 마이그레이션 리셋 (개발 전용)
npx prisma migrate reset

# Prisma Client 재생성
npx prisma generate

migrate dev와 migrate deploy는 하는 일이 다릅니다. migrate dev는 개발용으로, 스키마와 현재 마이그레이션 이력을 비교해 새 마이그레이션 SQL을 만들고, 이를 검증하려고 임시 “섀도 데이터베이스”를 만들었다 지웁니다. 그래서 DB 계정에 데이터베이스 생성 권한이 없으면 P3014 에러(“Prisma Migrate could not create the shadow database”)가 나며, 관리형 DB에서는 shadowDatabaseUrl을 따로 지정해야 합니다. 또 개발 DB가 마이그레이션 이력과 어긋나 있으면(누군가 직접 테이블을 고친 경우) 초기화를 제안하면서 데이터를 지우겠다고 묻습니다. migrate deploy는 운영용으로, 새 마이그레이션을 만들지 않고 아직 적용되지 않은 파일만 순서대로 실행하므로 CI/CD에서 쓰기에 안전합니다.

Prisma Migrate는 되돌리기(down) 마이그레이션을 만들지 않습니다. 배포한 마이그레이션을 되돌리려면 반대 방향의 변경을 담은 새 마이그레이션을 만들거나, 수동으로 SQL을 실행한 뒤 prisma migrate resolve --rolled-back 마이그레이션이름으로 이력을 맞춰야 합니다. 배포 중 마이그레이션이 실패하면 이력에 “실패” 상태로 남아 이후 migrate deploy가 P3009 에러로 막히는데, 이때도 원인을 고친 뒤 migrate resolve로 상태를 정리해야 합니다.

Prisma Client 사용

import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// Create
const user = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: '홍길동',
    posts: {
      create: [
        { title: '첫 번째 글' },
        { title: '두 번째 글' }
      ]
    }
  }
});

// Read
const users = await prisma.user.findMany();
const user = await prisma.user.findUnique({
  where: { email: '[email protected]' }
});

// Include 관계
const userWithPosts = await prisma.user.findUnique({
  where: { id: 1 },
  include: {
    posts: true,
    profile: true
  }
});

// Update
const user = await prisma.user.update({
  where: { id: 1 },
  data: { name: '김철수' }
});

// Delete
await prisma.user.delete({
  where: { id: 1 }
});

findUnique의 where에는 @id나 @unique가 붙은 필드만 쓸 수 있습니다. 이름처럼 유니크하지 않은 필드로 한 건을 찾으려면 findFirst를 써야 하고, findUnique({ where: { name: '홍길동' } })은 타입 에러가 납니다. 결과가 없을 때 findUnique는 null을 돌려주고, update와 delete는 대상이 없으면 P2025(“An operation failed because it depends on one or more records that were required but not found”) 예외를 던진다는 차이도 알아 둬야 합니다. 대상이 없어도 괜찮은 삭제라면 deleteMany를 쓰면 예외 대신 { count: 0 }이 돌아옵니다.

위 스키마에서 Post.author에는 onDelete를 지정하지 않았으므로, 글이 있는 사용자를 delete하면 외래 키 제약 위반(P2003)으로 실패합니다. 사용자를 지울 때 글도 함께 지울지, 글의 작성자를 비울지(authorId를 선택 필드로 두고 onDelete: SetNull), 삭제를 막을지는 서비스 정책이므로 스키마에 명시적으로 적어 두는 편이 좋습니다.

고급 쿼리

필터링

// AND 조건
const users = await prisma.user.findMany({
  where: {
    AND: [
      { age: { gte: 20 } },
      { role: 'USER' }
    ]
  }
});

// OR 조건
const users = await prisma.user.findMany({
  where: {
    OR: [
      { email: { contains: 'gmail.com' } },
      { email: { contains: 'naver.com' } }
    ]
  }
});

// NOT 조건
const users = await prisma.user.findMany({
  where: {
    NOT: { role: 'ADMIN' }
  }
});

관계 필터링

// 포스트가 있는 사용자만
const users = await prisma.user.findMany({
  where: {
    posts: {
      some: {
        published: true
      }
    }
  }
});

// 특정 태그가 있는 포스트
const posts = await prisma.post.findMany({
  where: {
    tags: {
      some: {
        name: 'typescript'
      }
    }
  },
  include: {
    author: true,
    tags: true
  }
});

집계

// 카운트
const userCount = await prisma.user.count();
const activeUsers = await prisma.user.count({
  where: { isActive: true }
});

// 그룹화 집계
const stats = await prisma.user.groupBy({
  by: ['role'],
  _count: { id: true },
  _avg: { age: true }
});

트랜잭션

// 연속 트랜잭션 (배열 안의 쿼리를 순서대로 한 트랜잭션에서 실행)
const [user, post] = await prisma.$transaction([
  prisma.user.create({ data: { email: '[email protected]' } }),
  prisma.post.create({
    data: {
      title: 'Hello',
      author: { connect: { email: '[email protected]' } }  // 앞에서 만든 사용자에 연결
    }
  })
]);

// 대화형 트랜잭션
await prisma.$transaction(async (tx) => {
  const user = await tx.user.create({
    data: { email: '[email protected]' }
  });
  
  await tx.post.create({
    data: {
      title: 'Hello',
      authorId: user.id
    }
  });
  
  // 에러 발생 시 자동 롤백
});

두 형태는 쓰임이 다릅니다. 배열 형태는 쿼리를 미리 다 만들어 둔 다음 한 번에 보내므로, 앞 쿼리의 결과(user.id)를 뒤 쿼리에 쓸 수 없습니다. 원래 예제의 post.create({ data: { title: 'Hello' } })는 필수 관계인 author가 빠져 타입 에러가 나는 코드였고, 위처럼 connect로 유니크한 이메일을 가리키게 하면 순서가 보장되는 배열 트랜잭션 안에서 해결됩니다. 앞 결과에 따라 분기해야 한다면 대화형 트랜잭션을 씁니다.

대화형 트랜잭션은 편리하지만 조심할 점이 있습니다. 콜백 안에서는 반드시 인자로 받은 tx를 써야 하고, 실수로 바깥의 prisma를 쓰면 그 쿼리는 트랜잭션 밖에서 실행되어 롤백되지 않습니다. 타입이 같아서 컴파일러가 잡아 주지 않는 실수입니다. 또 기본 제한 시간이 5초라서, 콜백 안에서 결제 API 호출처럼 느린 외부 작업을 기다리면 Transaction already closed: A query cannot be executed on an expired transaction 에러와 함께 롤백됩니다. 트랜잭션은 DB 연결 하나와 행 락을 붙잡고 있으므로, 외부 호출은 트랜잭션 전후로 빼는 것이 원칙입니다. 동시에 같은 행을 수정하는 경쟁이 우려된다면 isolationLevel 옵션이나, 버전 컬럼을 where에 넣어 수정하는 낙관적 잠금을 함께 씁니다.

성능 최적화

Prisma는 기본적으로 성능이 우수하지만, 대규모 애플리케이션에서는 추가 최적화가 필요합니다. 가장 중요한 것은 필요한 데이터만 조회하는 것입니다.

Select를 사용하면 필요한 필드만 조회해 네트워크 전송량과 메모리 사용량을 줄일 수 있습니다. 특히 BLOB 필드나 긴 텍스트 필드가 있는 경우 큰 차이가 납니다.

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

// 페이지네이션
const users = await prisma.user.findMany({
  skip: 10,
  take: 10
});

// 커서 기반 페이지네이션
const users = await prisma.user.findMany({
  take: 10,
  cursor: {
    id: lastUserId
  },
  skip: 1,               // 커서 자신(이전 페이지의 마지막 행)은 제외
  orderBy: { id: 'asc' } // 커서 필드로 정렬해야 순서가 보장됨
});

페이지네이션은 두 가지 방식이 있습니다. Offset 기반(skip/take)은 구현이 간단하지만 데이터가 많으면 느려집니다. 커서 기반은 일관된 성능을 제공하지만 이전 페이지로 이동하기 어렵습니다.

Offset이 느려지는 이유는 skip: 100000이 SQL의 OFFSET 100000으로 바뀌고, 데이터베이스가 건너뛸 10만 행을 실제로 읽은 뒤 버리기 때문입니다. 또 페이지를 넘기는 사이 앞쪽에 새 행이 추가되면 같은 항목이 두 페이지에 걸쳐 보이는 문제도 있습니다. 커서 방식은 WHERE id > 마지막id로 바뀌어 인덱스로 바로 위치를 찾으므로 몇 번째 페이지든 속도가 같습니다. 커서에는 유니크하고 정렬 순서가 고정된 필드를 써야 하며, createdAt처럼 중복될 수 있는 필드로 정렬한다면 orderBy: [{ createdAt: 'desc' }, { id: 'desc' }]처럼 유니크한 필드를 뒤에 붙여야 같은 시각의 행이 누락되지 않습니다.

실전 사례: 블로그 시스템 구축

실무에서 Prisma를 사용해 블로그 시스템을 구축하는 과정을 살펴보겠습니다. 사용자, 포스트, 댓글, 태그의 관계를 효율적으로 모델링하고 쿼리하는 방법을 다룹니다.

관계 로딩과 N+1

전통적인 ORM에서는 N+1 문제가 자주 발생합니다. 예를 들어 10개의 포스트를 조회하고 각 포스트의 작성자를 조회하면 총 11번의 쿼리가 실행됩니다(1번: 포스트 목록, 10번: 각 작성자). Prisma는 include로 요청한 관계를 관계마다 한 번의 IN 쿼리로 모아서 가져오므로, 포스트가 10개든 1,000개든 쿼리 수가 늘어나지 않습니다.

// Prisma는 자동으로 최적화된 쿼리 생성
const posts = await prisma.post.findMany({
  include: {
    author: {
      select: {
        name: true,
        email: true
      }
    },
    tags: true
  }
});

이 쿼리는 기본적으로 포스트 목록, 작성자, 태그를 각각 조회하는 여러 개의 쿼리로 실행되고, Prisma가 결과를 메모리에서 합칩니다. 최신 버전에서는 relationLoadStrategy: 'join'을 지정해 데이터베이스 JOIN 한 번으로 가져오게 할 수도 있습니다. 어느 쪽이 빠른지는 데이터 모양에 따라 다릅니다. JOIN은 왕복이 한 번이지만 1:N 관계에서 부모 행이 자식 수만큼 반복되어 전송량이 늘고, 배치 쿼리는 왕복이 여러 번이지만 중복 데이터가 없습니다.

“자동으로 최적화된다”를 과신하면 안 되는 경우가 있습니다. include를 여러 단계로 중첩하면(posts → comments → author) 단계마다 쿼리가 추가되고, 부모가 수만 개면 IN 절에 수만 개의 ID가 들어갑니다. 또 for (const post of posts) { await prisma.user.findUnique(...) }처럼 반복문 안에서 직접 조회하면 Prisma도 N+1을 막아 주지 않습니다. 실제로 어떤 SQL이 나가는지는 new PrismaClient({ log: ['query'] })로 쿼리 로그를 켜서 확인하는 것이 가장 확실합니다. 처음 Prisma를 도입한 프로젝트에서 목록 API가 느리다면, 대개 이 로그에 예상보다 훨씬 많은 쿼리가 찍혀 있습니다.

복잡한 필터링 구현

실무에서는 여러 조건을 조합한 복잡한 검색 기능이 자주 필요합니다. Prisma의 조합 연산자(AND, OR, NOT)를 사용하면 복잡한 쿼리도 타입 안전하게 작성할 수 있습니다.

// 게시된 포스트 중 특정 태그가 있거나 제목에 키워드가 포함된 포스트
const posts = await prisma.post.findMany({
  where: {
    AND: [
      { published: true },
      {
        OR: [
          {
            tags: {
              some: {
                name: { in: ['typescript', 'prisma'] }
              }
            }
          },
          {
            title: {
              contains: '튜토리얼',
              mode: 'insensitive'  // 대소문자 무시
            }
          }
        ]
      }
    ]
  },
  include: {
    author: true,
    tags: true,
    _count: {
      select: { comments: true }
    }
  },
  orderBy: {
    createdAt: 'desc'
  }
});

이런 복잡한 쿼리도 타입 안전성이 보장되어 오타나 잘못된 필드명 사용을 컴파일 타임에 잡을 수 있습니다. 다만 mode: 'insensitive'는 PostgreSQL과 MongoDB에서만 지원되는 옵션입니다. MySQL과 SQL Server는 컬럼의 콜레이션(collation)이 대소문자 구분 여부를 정하므로 이 옵션 없이도 기본적으로 대소문자를 무시하고, SQLite에서는 이 옵션을 쓸 수 없습니다. 같은 코드가 데이터베이스에 따라 다르게 동작하는 몇 안 되는 지점이라, 개발은 SQLite로 하고 운영은 PostgreSQL로 하는 구성에서 자주 문제가 됩니다. contains 검색은 LIKE '%키워드%'로 바뀌어 일반 B-Tree 인덱스를 쓰지 못하므로, 데이터가 많아지면 전문 검색이나 pg_trgm 인덱스를 $queryRaw와 함께 쓰는 방향을 검토해야 합니다.

트랜잭션으로 데이터 일관성 보장

포스트를 삭제할 때 관련된 댓글과 태그 관계도 함께 삭제해야 합니다. 트랜잭션을 사용하면 모든 작업이 성공하거나 모두 실패하도록 보장할 수 있습니다.

async function deletePostWithRelations(postId: number) {
  return await prisma.$transaction(async (tx) => {
    // 댓글 삭제
    await tx.comment.deleteMany({
      where: { postId }
    });
    
    // 포스트 삭제 (태그 관계는 onDelete: Cascade로 자동 삭제)
    await tx.post.delete({
      where: { id: postId }
    });
    
    // 사용되지 않는 태그 정리
    await tx.tag.deleteMany({
      where: {
        posts: {
          none: {}
        }
      }
    });
  });
}

트랜잭션 중 에러가 발생하면 모든 변경사항이 자동으로 롤백됩니다.

위 스키마처럼 Comment.post에 onDelete: Cascade를 걸어 두면 데이터베이스가 포스트 삭제와 함께 댓글을 지워 주므로, 첫 번째 deleteMany는 사실 필요 없습니다. 어느 쪽에 맡길지는 선택의 문제입니다. 데이터베이스의 cascade는 누락될 일이 없지만 삭제 범위가 코드에 드러나지 않고, 애플리케이션에서 직접 지우면 명시적이지만 새 관계를 추가할 때 삭제 코드를 함께 고쳐야 합니다. 마지막의 “사용되지 않는 태그 정리”는 전체 태그를 훑는 쿼리라서, 태그가 많고 삭제가 잦다면 요청마다 실행하기보다 주기적인 배치 작업으로 옮기는 편이 낫습니다.

주의사항과 팁

연결 풀 관리

서버리스 환경(Lambda, Vercel)에서는 연결 풀이 금방 고갈될 수 있습니다. Prisma Accelerate(예전 Data Proxy의 후속 서비스)나 PgBouncer, 클라우드 제공자의 연결 풀러(RDS Proxy 등)를 앞단에 두는 것이 일반적입니다.

원인은 PrismaClient 인스턴스 하나가 자기만의 연결 풀을 갖는다는 데 있습니다. 함수 인스턴스가 수십 개로 늘어나면 풀도 그만큼 생겨 DB의 max_connections를 넘깁니다. 서버리스에서는 데이터베이스 URL에 connection_limit=1처럼 인스턴스당 연결 수를 작게 두고, 앞단의 풀러가 실제 연결을 나눠 쓰게 합니다. PgBouncer를 트랜잭션 모드로 쓴다면 URL에 pgbouncer=true를 붙여 Prisma가 prepared statement를 재사용하지 않게 해야 “prepared statement already exists” 에러를 피할 수 있고, 마이그레이션은 풀러를 거치지 않는 직접 연결(directUrl)로 실행합니다.

개발 환경에서는 반대 방향의 문제가 생깁니다. Next.js 개발 서버는 파일을 고칠 때마다 모듈을 다시 불러오는데, 모듈 최상위에서 new PrismaClient()를 하면 저장할 때마다 새 클라이언트와 새 풀이 생겨 곧 “too many connections”가 납니다. globalThis에 인스턴스를 보관해 재사용하는 패턴이 이 때문에 널리 쓰입니다.

import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;  // 개발 중 핫 리로드 대비

마이그레이션 전략

개발 환경에서는 prisma migrate dev를 사용하며, 프로덕션에서는 prisma migrate deploy를 사용하세요. 절대 프로덕션에서 migrate reset을 실행하면 안 됩니다.

타입 생성 자동화

스키마를 변경한 후 반드시 npx prisma generate를 실행해 타입을 재생성해야 합니다. CI/CD 파이프라인에 이 단계를 포함하세요.

Prisma Client는 스키마로부터 생성되는 코드이고, 실제 SQL은 런타임의 쿼리 엔진이 만듭니다. 그래서 Docker 멀티 스테이지 빌드에서 generate를 빼먹거나, 생성된 클라이언트(와 엔진 바이너리)를 최종 이미지로 복사하지 않으면 로컬에선 되는데 컨테이너에서만 @prisma/client did not initialize yet 에러가 납니다. 빌드 스테이지와 실행 스테이지의 OS가 다르면 schema.prisma의 binaryTargets도 맞춰야 합니다.

컬럼 이름·타입 변경은 두 번에 나눠서

prisma migrate dev는 컬럼 이름을 바꾸면 “기존 컬럼 삭제 + 새 컬럼 추가”로 마이그레이션을 만들기 쉽고, 그대로 배포하면 데이터가 사라집니다. 또 배포 중에는 옛 코드와 새 코드가 잠시 함께 돌기 때문에, 한 번에 이름을 바꾸면 옛 코드가 없는 컬럼을 찾다 실패합니다. 파괴적인 변경은 확장(expand) → 이전 → 수축(contract) 으로 나눕니다. 먼저 새 컬럼을 추가하고 코드가 양쪽에 쓰게 한 뒤 데이터를 옮기고, 모든 코드가 새 컬럼만 읽게 된 다음 배포에서 옛 컬럼을 지웁니다. 생성된 마이그레이션 SQL은 적용 전에 반드시 열어 보고, 필요하면 --create-only로 만든 뒤 직접 고쳐서 씁니다.

정리하면, Prisma의 강점은 스키마 한 곳에서 타입과 마이그레이션이 함께 나온다는 것이고, 그 덕분에 CRUD와 관계 조회가 대부분인 서비스에서 실수를 크게 줄여 줍니다. 대신 생성 단계(generate)가 빌드 파이프라인의 일부가 되고, 복잡한 SQL은 $queryRaw로 내려가야 하며, 마이그레이션 되돌리기와 연결 풀 관리는 직접 설계해야 합니다. 쿼리 로그를 켜서 실제로 나가는 SQL을 확인하는 습관이 이 중 대부분의 함정을 일찍 드러내 줍니다.


자주 묻는 질문 (FAQ)

Q. Next.js 개발 서버에서 too many connections 에러가 나는 이유는 무엇인가요?

A. 개발 서버는 파일을 저장할 때마다 모듈을 다시 불러오는데, 모듈 최상위에서 new PrismaClient()를 하면 그때마다 새 클라이언트와 새 연결 풀이 생기기 때문입니다. globalThis에 인스턴스를 보관해 두고 이미 있으면 재사용하는 패턴으로 해결합니다. 서버리스 운영 환경에서는 반대로 인스턴스마다 풀이 생기므로 connection_limit을 작게 두고 PgBouncer 같은 풀러를 앞단에 두는 것이 일반적입니다.


같이 보면 좋은 글