Prisma ORM이란
Prisma는 Node.js/TypeScript 생태계의 차세대 ORM이다. 스키마 파일(schema.prisma)로 데이터 모델을 선언하면 타입 안전한 클라이언트가 자동 생성되어, 쿼리 작성 시 IDE 자동완성과 컴파일 타임 타입 체크를 받을 수 있다. TypeORM이나 MikroORM과 달리 데코레이터 기반이 아닌 스키마 우선(Schema-first) 접근을 취한다.
Prisma Client, Prisma Migrate, Prisma Studio 세 가지 핵심 도구로 구성되며, PostgreSQL, MySQL, SQLite, MongoDB, SQL Server를 지원한다.
Schema 설계
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["fullTextSearch", "metrics"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// 관계
orders Order[]
profile Profile?
@@map("users") // 테이블 이름
@@index([email])
@@index([createdAt(sort: Desc)]) // 정렬 인덱스
}
model Order {
id String @id @default(cuid())
status OrderStatus @default(PENDING)
total Decimal @db.Decimal(10, 2)
note String? @db.Text
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items OrderItem[]
createdAt DateTime @default(now()) @map("created_at")
@@map("orders")
@@index([userId, status]) // 복합 인덱스
@@index([createdAt(sort: Desc)])
}
model OrderItem {
id String @id @default(cuid())
quantity Int
price Decimal @db.Decimal(10, 2)
orderId String @map("order_id")
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String @map("product_id")
product Product @relation(fields: [productId], references: [id])
@@map("order_items")
@@unique([orderId, productId]) // 주문당 상품 중복 방지
}
model Product {
id String @id @default(cuid())
name String
price Decimal @db.Decimal(10, 2)
stock Int @default(0)
description String? @db.Text
tags String[] // PostgreSQL 배열 타입
items OrderItem[]
@@map("products")
@@index([name])
}
model Profile {
id String @id @default(cuid())
bio String? @db.Text
avatar String?
userId String @unique @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("profiles")
}
enum Role {
USER
ADMIN
MODERATOR
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
NestJS 통합 설정
// prisma.service.ts — 수명 주기 관리
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super({
log: [
{ level: 'query', emit: 'event' },
{ level: 'error', emit: 'stdout' },
{ level: 'warn', emit: 'stdout' },
],
});
}
async onModuleInit() {
await this.$connect();
// 쿼리 로깅 (개발 환경)
if (process.env.NODE_ENV === 'development') {
this.$on('query' as any, (e: any) => {
console.log(`Query: ${e.query} — ${e.duration}ms`);
});
}
}
async onModuleDestroy() {
await this.$disconnect();
}
}
// prisma.module.ts — 글로벌 모듈
import { Global, Module } from '@nestjs/common';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
CRUD 쿼리 패턴
@Injectable()
export class OrderService {
constructor(private readonly prisma: PrismaService) {}
// 생성 — 중첩 관계까지 한 번에
async create(userId: string, dto: CreateOrderDto) {
return this.prisma.order.create({
data: {
userId,
total: dto.total,
items: {
create: dto.items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
price: item.price,
})),
},
},
include: {
items: { include: { product: true } },
user: { select: { id: true, name: true, email: true } },
},
});
}
// 조회 — 필터 + 페이지네이션 + 정렬
async findAll(params: OrderListParams) {
const { userId, status, page = 1, limit = 20 } = params;
const where = {
...(userId && { userId }),
...(status && { status }),
};
const [orders, total] = await Promise.all([
this.prisma.order.findMany({
where,
include: {
items: { include: { product: true } },
user: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.order.count({ where }),
]);
return {
data: orders,
meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
};
}
// 수정 — upsert 패턴
async upsertProfile(userId: string, dto: UpsertProfileDto) {
return this.prisma.profile.upsert({
where: { userId },
create: { userId, bio: dto.bio, avatar: dto.avatar },
update: { bio: dto.bio, avatar: dto.avatar },
});
}
}
트랜잭션 패턴
Prisma는 두 가지 트랜잭션 방식을 지원한다. 복잡한 비즈니스 로직에는 Interactive Transaction을 사용한다.
// 1. Sequential Transaction — 단순 배치
async transferPoints(fromId: string, toId: string, amount: number) {
return this.prisma.$transaction([
this.prisma.user.update({
where: { id: fromId },
data: { points: { decrement: amount } },
}),
this.prisma.user.update({
where: { id: toId },
data: { points: { increment: amount } },
}),
]);
}
// 2. Interactive Transaction — 조건부 로직
async placeOrder(userId: string, dto: CreateOrderDto) {
return this.prisma.$transaction(async (tx) => {
// 재고 확인 + 차감 (같은 트랜잭션)
for (const item of dto.items) {
const product = await tx.product.findUniqueOrThrow({
where: { id: item.productId },
});
if (product.stock < item.quantity) {
throw new BadRequestException(
`${product.name} 재고 부족 (남은 수량: ${product.stock})`,
);
}
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } },
});
}
// 주문 생성
const order = await tx.order.create({
data: {
userId,
total: dto.total,
items: {
create: dto.items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
price: item.price,
})),
},
},
include: { items: true },
});
return order;
}, {
maxWait: 5000, // 트랜잭션 시작까지 최대 대기
timeout: 10000, // 트랜잭션 실행 제한 시간
isolationLevel: 'Serializable', // 격리 수준
});
}
Raw Query와 고급 쿼리
// Raw SQL — 복잡한 집계
async getOrderStats(userId: string) {
return this.prisma.$queryRaw<OrderStats[]>`
SELECT
DATE_TRUNC('month', created_at) AS month,
COUNT(*)::int AS count,
SUM(total)::float AS revenue,
AVG(total)::float AS avg_order_value
FROM orders
WHERE user_id = ${userId}
AND status != 'CANCELLED'
GROUP BY DATE_TRUNC('month', created_at)
ORDER BY month DESC
LIMIT 12
`;
}
// groupBy — ORM 레벨 집계
async getStatusDistribution() {
return this.prisma.order.groupBy({
by: ['status'],
_count: { id: true },
_sum: { total: true },
orderBy: { _count: { id: 'desc' } },
});
}
// 전문 검색 (PostgreSQL)
async searchProducts(query: string) {
return this.prisma.product.findMany({
where: {
OR: [
{ name: { search: query } },
{ description: { search: query } },
],
},
orderBy: {
_relevance: {
fields: ['name', 'description'],
search: query,
sort: 'desc',
},
},
});
}
Prisma Migrate 운영
# 개발 환경: 마이그레이션 생성 + 즉시 적용
npx prisma migrate dev --name add_order_note
# 운영 환경: 마이그레이션만 적용 (생성은 개발에서)
npx prisma migrate deploy
# 마이그레이션 상태 확인
npx prisma migrate status
# 클라이언트 재생성 (스키마 변경 후)
npx prisma generate
# DB 시드
npx prisma db seed
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.user.upsert({
where: { email: 'admin@example.com' },
update: {},
create: {
email: 'admin@example.com',
name: 'Admin',
role: 'ADMIN',
profile: {
create: { bio: 'System Administrator' },
},
},
});
const products = [
{ name: 'NestJS 입문서', price: 35000, stock: 100 },
{ name: 'TypeScript 실전', price: 42000, stock: 50 },
];
for (const p of products) {
await prisma.product.upsert({
where: { id: p.name }, // 실제로는 고유 필드 사용
update: {},
create: p,
});
}
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
Middleware: 쿼리 가로채기
Prisma Middleware로 소프트 삭제, 감사 로그, 쿼리 타이밍 등을 전역 적용할 수 있다.
// 소프트 삭제 미들웨어
this.$use(async (params, next) => {
// delete → update (deletedAt 설정)
if (params.action === 'delete') {
params.action = 'update';
params.args['data'] = { deletedAt: new Date() };
}
if (params.action === 'deleteMany') {
params.action = 'updateMany';
params.args['data'] = { deletedAt: new Date() };
}
// findMany, findFirst에 deletedAt 필터 자동 추가
if (['findMany', 'findFirst', 'count'].includes(params.action)) {
if (!params.args) params.args = {};
if (!params.args.where) params.args.where = {};
params.args.where.deletedAt = null;
}
return next(params);
});
// 쿼리 실행 시간 로깅
this.$use(async (params, next) => {
const start = Date.now();
const result = await next(params);
const duration = Date.now() - start;
if (duration > 1000) {
console.warn(
`Slow query: ${params.model}.${params.action} — ${duration}ms`,
);
}
return result;
});
성능 최적화
| 문제 | 해결 |
|---|---|
| N+1 쿼리 | include/select로 관계 즉시 로딩 |
| 불필요한 필드 조회 | select로 필요한 필드만 지정 |
| 대량 데이터 처리 | cursor 기반 페이지네이션 |
| 커넥션 풀 고갈 | connection_limit 파라미터 조정 |
| 복잡한 집계 | $queryRaw로 네이티브 SQL 사용 |
// cursor 기반 페이지네이션 (대량 데이터에 효율적)
async findAllCursor(cursor?: string, limit = 20) {
return this.prisma.order.findMany({
take: limit + 1, // 다음 페이지 존재 여부 확인
...(cursor && {
cursor: { id: cursor },
skip: 1,
}),
orderBy: { createdAt: 'desc' },
select: {
id: true,
status: true,
total: true,
createdAt: true,
user: { select: { name: true } },
},
});
}
TypeORM Transaction 심화와 비교하면, Prisma의 Interactive Transaction은 콜백 패턴으로 더 직관적이다. MikroORM Unit of Work 패턴과는 철학이 다르지만, 타입 안전성 면에서 Prisma가 가장 강력하다.
정리: Prisma 설계 체크리스트
- @@map으로 네이밍: 모델은 PascalCase, DB 테이블/컬럼은 snake_case
- select로 최적화: 불필요한 필드를 배제하여 전송량 절감
- Interactive Transaction: 조건부 로직이 있으면 콜백 방식 사용
- Middleware 활용: 소프트 삭제, 감사 로그 등 횡단 관심사 전역 적용
- migrate deploy: 운영 환경에서는 dev 대신 deploy만 사용
- cursor 페이지네이션: 대량 데이터에서 offset보다 cursor가 효율적
- 인덱스 명시: @@index로 자주 조회하는 필드에 인덱스 선언