본문으로 건너뛰기
Previous
Next
Prisma 완벽 가이드 | ORM·Schema·Migration·Query·타입 안전성·실전 활용

Prisma 완벽 가이드 | ORM·Schema·Migration·Query·타입 안전성·실전 활용

Prisma 완벽 가이드 | ORM·Schema·Migration·Query·타입 안전성·실전 활용

이 글의 핵심

Prisma ORM 완벽 가이드. 스키마 정의·마이그레이션·타입 안전 쿼리·관계·트랜잭션까지 Node.js·TypeScript 백엔드 DB 연동 실전 정리. PostgreSQL·MySQL 성능 최적화 포함.

Prisma 마이그레이션 지옥… 한 번은 겪어봐야 밈처럼 말이 나온다. migrate dev 돌리다가 쉐도 DB 권한 없어서 터지고, 누군가 로컬에서 스키마만 고치고 SQL 안 올리고, CI에서는 deploy만 돌리는데 스테이징이 꼬여 있고, “왜 prod만 컬럼이 없죠?” 이러면서 새벽에 prisma db pull 찍는 그림. Prisma 잘못됐다기보다 팀이 마이그레이션을 ‘파일’이 아니라 ‘습관’으로 안 묶었을 때 나는 소음이다. 난 이 지옥을 피하려고 아예 “마이그레이션 = PR”, “migrate deploy는 배포 전 필수” 같은 걸 룰로 박아두는 편이다.

그렇다고 Prisma를 버릴 생각은 없다. TypeORM 갔다가 돌아온 팀이 많은 이유가, 그냥 타입이 끊기지 않는다는 점 하나로 설득이 되거든. 쿼리 쓰는 시간이 줄어드는 건 체감이고, 다만 N+1 조심하세요. include로 관계 쫙 펼쳤다가 리스트 API 하나에 쿼리가 수십·수백 개 나가는 건 “Prisma가 느려요”가 아니라 “내가 include를 느슨하게 쓴 것”인 경우가 태반이니까. select로 좁히거나, 끊어서 findMany + 별도 배치, 아니면 아예 그 구간은 $queryRaw로 가는 쪽이 낫다. 이건 반복해서 말해도 덜 먹힌다. 로그에 query 켜고 한 번 봐라.

런타임 쪽을 한 줄로 잡으면, Prisma는 스키마 → DMMF(메타) → generate로 타입 + @prisma/client, 실제 SQL은 Rust 쿼리 엔진이 런타임에 짠다. “스키마가 곧 SQL”이 아니다. 그래서 CI에서 prisma generate 빼먹으면 “로컬에선 됐는데?” 같은 구린 장면이 나온다. Docker 멀티 스테이지면 빌드 스테이지에서 generate 박고 이미지에 같이 싣는 패턴이 당연하다.

커넥션은… 서버리스 가면 끝없이 말해도 모자라다. 람다/워커마다 풀 하나씩 생기면 DB max_connections가 터진다. connection_limit을 아주 작게 잡거나, 외부 풀(Accelerate 같은 거) + 호스팅 조합을 본다. PgBouncer 쓰면 Transaction 모드에서 prepared statement 꼬이는 케이스도 있다. “문서 한 번”이 아니라, 우리 환경에서 실제로 어떤 모드 쓰는지부터 맞춰라.

설치는 밋밋하게 이렇게.

npm install prisma @prisma/client
npx prisma init

.env는 예를 들어 이렇게.

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

스키마는 대충 이런 느낌. User–Post–Profile까지 relations 걸어서 나중에 include 지옥(아 네, N+1 조심하세요)의 재료를 만든다.

// 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?
  posts     Post[]
  profile   Profile?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
model Profile {
  id     Int     @id @default(autoincrement())
  bio    String?
  user   User    @relation(fields: [userId], references: [id])
  userId Int     @unique
}
npx prisma migrate dev --name init
npx prisma generate

CRUD는 문서에 나온 대로 쓰면 된다. 생성부터.

import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

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

const userWithPost = await prisma.user.create({
  data: {
    email: '[email protected]',
    name: 'Jane',
    posts: {
      create: [
        { title: 'First Post', content: 'Hello World' },
        { title: 'Second Post', content: 'Another Post' },
      ],
    },
  },
  include: { posts: true },
});

읽을 때 include를 남발하면 N+1 조심하세요 (세 번째다. 기억해라). 리스트 + 관계는 설계를 같이 봐야 한다.

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

const users = await prisma.user.findMany({
  where: { email: { contains: '@example.com' } },
  orderBy: { createdAt: 'desc' },
  take: 10,
});

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

업데이트/삭제도 패턴은 단순하다.

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

await prisma.user.updateMany({
  where: { email: { contains: '@example.com' } },
  data: { name: 'Updated' },
});

await prisma.user.delete({ where: { id: 1 } });
await prisma.user.deleteMany({ where: { email: { contains: '@test.com' } } });

집계·그룹·트랜잭션은 “복잡한 보고 쿼리”가 Prisma API로 뻑나면 그냥 Raw로 가는 팀이 많다. 고집 부리지 마라.

const agg = await prisma.post.aggregate({
  _count: { id: true },
  _avg: { authorId: true },
  _sum: { authorId: true },
  _min: { createdAt: true },
  _max: { createdAt: true },
});

const grouped = await prisma.post.groupBy({
  by: ['authorId'],
  _count: { id: true },
  having: { id: { _count: { gt: 5 } } },
});

const [u, p] = await prisma.$transaction([
  prisma.user.create({ data: { email: '[email protected]', name: 'U' } }),
  prisma.post.create({ data: { title: 'P', authorId: 1 } }),
]);

await prisma.$transaction(async (tx) => {
  const u2 = await tx.user.create({ data: { email: '[email protected]', name: 'J' } });
  await tx.post.create({ data: { title: 'Post', authorId: u2.id } });
});

npx prisma studio는 디버그용으로는 좋다. 프로덕션에서 막 쓰진 말고.

Next 같은 데서는 PrismaClient요청마다 new 하지 말고 싱글톤/글로벌 캐시 패턴 쓰는 이유는, 풀 중복 = 연결 고갈이니까.

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

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

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  });

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

솔직히 내 의견으로 끊자면: Prisma는 DX는 좋은데, SQL을 안 보이게 해서 오히려 성능 감각이 무뎌진다는 면이 있다. log: ['query']라도 켜 보고, 느리면 실행 계획은 결국 DBA/개발이 같이 봐야 한다. 마이그레이션은 migrate dev로 장난치고, 올릴 땐 migrate deploy. 파괴적 변경(컬럼 타입, 이름)은 확장/수축으로 쪼개는 건 Prisma가 대신해 주지 않는다. 팀 룰이다.

TypeORM 쓰던 시절이 그리우면 비교 글 보면 되고, Drizzle 쪽 냄새 맡고 싶으면 여기다. Nest·Next·폼 쪽은 각각 NestJS, App Router, RHF 쯤. 면접용으로 N+1이랑 트랜잭션 질문 나올 때 대비는 기술 면접 가이드이력서도 같이 보면 됨.

Prisma, ORM, TypeScript, Database, PostgreSQL, MySQL, Backend