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