NestJS + Prisma 통합 심화

왜 NestJS + Prisma인가?

NestJS의 모듈 시스템과 Prisma의 타입 안전한 쿼리 빌더는 궁합이 뛰어납니다. TypeORM이나 MikroORM과 달리 Prisma는 스키마 파일에서 타입을 자동 생성하므로 런타임 에러를 컴파일 타임에 잡을 수 있고, 마이그레이션도 선언적으로 관리됩니다. 이 글에서는 단순 연동을 넘어 모듈 설계, 트랜잭션 전파, 테스트 전략, 멀티 데이터베이스까지 실전 패턴을 다룹니다.

PrismaModule 설계

NestJS에서 Prisma를 사용할 때 가장 중요한 것은 PrismaService를 전역 모듈로 설계하는 것입니다:

// 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: [
        { emit: 'event', level: 'query' },
        { emit: 'stdout', level: 'error' },
        { emit: 'stdout', level: 'warn' },
      ],
    });
  }

  async onModuleInit() {
    await this.$connect();

    // 쿼리 로깅 (개발 환경)
    if (process.env.NODE_ENV === 'development') {
      this.$on('query' as never, (e: any) => {
        console.log(`Query: ${e.query}`);
        console.log(`Duration: ${e.duration}ms`);
      });
    }
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
// prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Global()   // 전역 모듈: 다른 모듈에서 import 없이 사용 가능
@Module({
  providers: [PrismaService],
  exports: [PrismaService],
})
export class PrismaModule {}
// app.module.ts
@Module({
  imports: [PrismaModule, OrderModule, UserModule],
})
export class AppModule {}

Repository 패턴으로 추상화

PrismaService를 직접 서비스에 주입하면 비즈니스 로직이 Prisma에 강하게 결합됩니다. Repository 계층으로 추상화하면 테스트가 쉬워지고 ORM 교체도 가능해집니다:

// base.repository.ts — 공통 CRUD 추상 클래스
export abstract class BaseRepository<
  T,
  CreateInput,
  UpdateInput,
  WhereUnique,
  WhereInput
> {
  constructor(protected readonly prisma: PrismaService) {}

  abstract get model(): any;

  async findById(where: WhereUnique): Promise<T | null> {
    return this.model.findUnique({ where });
  }

  async findMany(params: {
    where?: WhereInput;
    skip?: number;
    take?: number;
    orderBy?: any;
  }): Promise<T[]> {
    return this.model.findMany(params);
  }

  async create(data: CreateInput): Promise<T> {
    return this.model.create({ data });
  }

  async update(where: WhereUnique, data: UpdateInput): Promise<T> {
    return this.model.update({ where, data });
  }

  async delete(where: WhereUnique): Promise<T> {
    return this.model.delete({ where });
  }
}
// order.repository.ts
@Injectable()
export class OrderRepository extends BaseRepository<
  Order,
  Prisma.OrderCreateInput,
  Prisma.OrderUpdateInput,
  Prisma.OrderWhereUniqueInput,
  Prisma.OrderWhereInput
> {
  constructor(prisma: PrismaService) {
    super(prisma);
  }

  get model() {
    return this.prisma.order;
  }

  // 도메인 특화 쿼리
  async findWithItems(orderId: string): Promise<Order | null> {
    return this.prisma.order.findUnique({
      where: { id: orderId },
      include: {
        items: { include: { product: true } },
        customer: true,
      },
    });
  }

  async findPendingOrders(customerId: string): Promise<Order[]> {
    return this.prisma.order.findMany({
      where: {
        customerId,
        status: 'PENDING',
        createdAt: { gte: subDays(new Date(), 30) },
      },
      orderBy: { createdAt: 'desc' },
    });
  }
}

트랜잭션 처리 패턴

Prisma의 Interactive Transaction을 NestJS 서비스에서 활용하는 세 가지 패턴입니다:

@Injectable()
export class OrderService {
  constructor(private readonly prisma: PrismaService) {}

  // 패턴 1: Interactive Transaction
  async createOrder(dto: CreateOrderDto): Promise<Order> {
    return this.prisma.$transaction(async (tx) => {
      // 재고 확인 및 차감
      for (const item of dto.items) {
        const product = await tx.product.update({
          where: { id: item.productId },
          data: { stock: { decrement: item.quantity } },
        });
        if (product.stock < 0) {
          throw new BadRequestException(
            `재고 부족: ${product.name}`
          );
        }
      }

      // 주문 생성
      return tx.order.create({
        data: {
          customerId: dto.customerId,
          status: 'PENDING',
          items: {
            create: dto.items.map((item) => ({
              productId: item.productId,
              quantity: item.quantity,
              price: item.price,
            })),
          },
        },
        include: { items: true },
      });
    }, {
      maxWait: 5000,        // 트랜잭션 대기 최대 5초
      timeout: 10000,       // 트랜잭션 타임아웃 10초
      isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
    });
  }

  // 패턴 2: 중첩 서비스 호출 시 tx 전달
  async processOrderWithPayment(
    dto: CreateOrderDto,
  ): Promise<Order> {
    return this.prisma.$transaction(async (tx) => {
      const order = await this.createOrderInTx(tx, dto);
      await this.paymentService.chargeInTx(tx, order);
      await this.notificationService.sendInTx(tx, order);
      return order;
    });
  }
}

Soft Delete 미들웨어

Prisma Client Extensions로 소프트 삭제를 투명하게 구현합니다:

// prisma.extensions.ts
import { PrismaClient } from '@prisma/client';

export function withSoftDelete(prisma: PrismaClient) {
  return prisma.$extends({
    query: {
      $allModels: {
        // delete → soft delete로 변환
        async delete({ model, args, query }) {
          return (prisma as any)[model].update({
            ...args,
            data: { deletedAt: new Date() },
          });
        },
        // deleteMany → soft delete로 변환
        async deleteMany({ model, args, query }) {
          return (prisma as any)[model].updateMany({
            ...args,
            data: { deletedAt: new Date() },
          });
        },
        // findMany → deletedAt IS NULL 자동 필터
        async findMany({ args, query }) {
          args.where = { ...args.where, deletedAt: null };
          return query(args);
        },
        // findFirst → deletedAt IS NULL 자동 필터
        async findFirst({ args, query }) {
          args.where = { ...args.where, deletedAt: null };
          return query(args);
        },
      },
    },
  });
}

// prisma.service.ts에 적용
@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit {

  private _extended: ReturnType<typeof withSoftDelete>;

  get extended() {
    if (!this._extended) {
      this._extended = withSoftDelete(this);
    }
    return this._extended;
  }
}

Graceful Shutdown과 커넥션 관리

NestJS 종료 시 Prisma 커넥션을 안전하게 정리하는 것이 중요합니다. NestJS Graceful Shutdown 가이드와 함께 적용하세요:

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // Shutdown hooks 활성화
  app.enableShutdownHooks();

  // Prisma shutdown hook
  const prisma = app.get(PrismaService);
  process.on('beforeExit', async () => {
    await prisma.$disconnect();
  });

  await app.listen(3000);
}
# schema.prisma — 커넥션 풀 설정
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
  // ?connection_limit=10&pool_timeout=30
}

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["fullTextSearch", "metrics"]
}

테스트 전략

PrismaService를 모킹하여 단위 테스트하는 패턴:

// test/utils/prisma-mock.ts
import { DeepMockProxy, mockDeep } from 'jest-mock-extended';
import { PrismaClient } from '@prisma/client';

export type MockPrismaService = DeepMockProxy<PrismaClient>;
export const createMockPrisma = () => mockDeep<PrismaClient>();

// order.service.spec.ts
describe('OrderService', () => {
  let service: OrderService;
  let prisma: MockPrismaService;

  beforeEach(async () => {
    const module = await Test.createTestingModule({
      providers: [
        OrderService,
        { provide: PrismaService, useValue: createMockPrisma() },
      ],
    }).compile();

    service = module.get(OrderService);
    prisma = module.get(PrismaService);
  });

  it('should create order', async () => {
    const mockOrder = { id: '1', status: 'PENDING' } as Order;
    prisma.order.create.mockResolvedValue(mockOrder);

    const result = await service.createOrder(dto);
    expect(result.status).toBe('PENDING');
    expect(prisma.order.create).toHaveBeenCalledWith(
      expect.objectContaining({
        data: expect.objectContaining({
          customerId: dto.customerId,
        }),
      }),
    );
  });
});

통합 테스트에는 Testcontainers와 유사하게 Docker 기반 테스트 DB를 활용하세요:

// test/setup.ts
import { execSync } from 'child_process';

beforeAll(async () => {
  process.env.DATABASE_URL =
    'postgresql://test:test@localhost:5433/testdb';
  execSync('npx prisma migrate deploy', {
    env: { ...process.env },
  });
});

afterAll(async () => {
  // 테스트 DB 정리
  const prisma = new PrismaClient();
  const tables = await prisma.$queryRaw<{ tablename: string }[]>`
    SELECT tablename FROM pg_tables WHERE schemaname='public'`;
  for (const { tablename } of tables) {
    await prisma.$executeRawUnsafe(
      `TRUNCATE TABLE "${tablename}" CASCADE`);
  }
  await prisma.$disconnect();
});

마치며

NestJS와 Prisma의 조합은 타입 안전성, 개발 생산성, 스키마 관리 측면에서 강력합니다. Global PrismaModule로 기본 설정을 잡고, Repository 패턴으로 추상화하며, Client Extensions로 Soft Delete 같은 횡단 관심사를 처리하세요. Interactive Transaction의 타임아웃·격리 수준 설정을 잊지 말고, jest-mock-extended로 단위 테스트 커버리지를 확보하면 프로덕션 수준의 NestJS + Prisma 아키텍처를 갖출 수 있습니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux