Prisma ORM 실전 심화 가이드

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로 자주 조회하는 필드에 인덱스 선언
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux