14.5 NestJS + Prisma — 데이터베이스 통합
Prisma 설정
npm install prisma @prisma/client
npx prisma init
Prisma 스키마
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}
enum Role {
ADMIN
USER
GUEST
}
PrismaModule (전역 서비스)
// prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'
import { PrismaClient } from '@prisma/client'
@Injectable()
export class PrismaService extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
async onModuleInit() {
await this.$connect()
}
async onModuleDestroy() {
await this.$disconnect()
}
}
// prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common'
import { PrismaService } from './prisma.service'
@Global() // 전역 모듈 — imports 없이 어디서나 주입 가능
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
// app.module.ts
import { Module } from '@nestjs/common'
import { PrismaModule } from './prisma/prisma.module'
import { UsersModule } from './users/users.module'
@Module({
imports: [PrismaModule, UsersModule],
})
export class AppModule {}
Repository 패턴
// users/users.repository.ts
import { Injectable } from '@nestjs/common'
import { PrismaService } from '../prisma/prisma.service'
import { Prisma, User } from '@prisma/client'
@Injectable()
export class UsersRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } })
}
async findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } })
}
async findAll(params: {
skip?: number
take?: number
where?: Prisma.UserWhereInput
orderBy?: Prisma.UserOrderByWithRelationInput
}): Promise<User[]> {
return this.prisma.user.findMany(params)
}
async create(data: Prisma.UserCreateInput): Promise<User> {
return this.prisma.user.create({ data })
}
async update(id: string, data: Prisma.UserUpdateInput): Promise<User> {
return this.prisma.user.update({ where: { id }, data })
}
async delete(id: string): Promise<User> {
return this.prisma.user.delete({ where: { id } })
}
async count(where?: Prisma.UserWhereInput): Promise<number> {
return this.prisma.user.count({ where })
}
}
Service 계층
// users/users.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'
import { User } from '@prisma/client'
import { UsersRepository } from './users.repository'
import { CreateUserDto } from './dto/create-user.dto'
import { UpdateUserDto } from './dto/update-user.dto'
export type UserWithoutPassword = Omit<User, 'password'>
@Injectable()
export class UsersService {
constructor(private readonly usersRepository: UsersRepository) {}
async findAll(page = 1, limit = 10): Promise<{
data: UserWithoutPassword[]
total: number
page: number
limit: number
}> {
const skip = (page - 1) * limit
const [users, total] = await Promise.all([
this.usersRepository.findAll({ skip, take: limit }),
this.usersRepository.count(),
])
return {
data: users.map(({ password, ...user }) => user),
total,
page,
limit,
}
}
async findOne(id: string): Promise<UserWithoutPassword> {
const user = await this.usersRepository.findById(id)
if (!user) {
throw new NotFoundException(`사용자 ID ${id}를 찾을 수 없습니다.`)
}
const { password, ...result } = user
return result
}
async findByEmail(email: string): Promise<User | null> {
return this.usersRepository.findByEmail(email)
}
async create(createUserDto: CreateUserDto): Promise<UserWithoutPassword> {
const existing = await this.usersRepository.findByEmail(createUserDto.email)
if (existing) {
throw new ConflictException('이미 사용 중인 이메일입니다.')
}
const user = await this.usersRepository.create(createUserDto)
const { password, ...result } = user
return result
}
async update(id: string, updateUserDto: UpdateUserDto): Promise<UserWithoutPassword> {
await this.findOne(id) // 존재 여부 확인
const user = await this.usersRepository.update(id, updateUserDto)
const { password, ...result } = user
return result
}
async remove(id: string): Promise<void> {
await this.findOne(id) // 존재 여부 확인
await this.usersRepository.delete(id)
}
}
트랜잭션 처리
// 여러 작업을 원자적으로 처리
import { PrismaService } from '../prisma/prisma.service'
@Injectable()
export class OrderService {
constructor(private readonly prisma: PrismaService) {}
async createOrderWithItems(
userId: string,
items: Array<{ productId: string; quantity: number; price: number }>
) {
// $transaction: 하나라도 실패하면 전체 롤백
return this.prisma.$transaction(async (tx) => {
// 주문 생성
const order = await tx.order.create({
data: { userId, status: 'PENDING' },
})
// 주문 항목 생성
await tx.orderItem.createMany({
data: items.map(item => ({
orderId: order.id,
...item,
})),
})
// 재고 차감
for (const item of items) {
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } },
})
}
return tx.order.findUnique({
where: { id: order.id },
include: { items: true },
})
})
}
}
Prisma 타입 활용
import { Prisma, User } from '@prisma/client'
// Prisma가 자동 생성하는 유용한 타입들
// 생성 입력 타입
type CreateUserInput = Prisma.UserCreateInput
// 업데이트 입력 타입
type UpdateUserInput = Prisma.UserUpdateInput
// 특정 필드만 선택한 타입
type UserProfile = Prisma.UserGetPayload<{
select: { id: true; name: true; email: true; createdAt: true }
}>
// 관계 포함 타입
type UserWithPosts = Prisma.UserGetPayload<{
include: { posts: true }
}>
// 조건 타입
type UserWhereInput = Prisma.UserWhereInput
// 정렬 타입
type UserOrderBy = Prisma.UserOrderByWithRelationInput
고수 팁
소프트 삭제 (Soft Delete) 미들웨어
// prisma/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect()
// 소프트 삭제 미들웨어 — 실제로 삭제하지 않고 deletedAt 설정
this.$use(async (params, next) => {
if (params.action === 'delete') {
params.action = 'update'
params.args.data = { deletedAt: new Date() }
}
if (params.action === 'deleteMany') {
params.action = 'updateMany'
if (params.args.data !== undefined) {
params.args.data.deletedAt = new Date()
} else {
params.args.data = { deletedAt: new Date() }
}
}
return next(params)
})
// 삭제된 레코드 자동 필터링
this.$use(async (params, next) => {
const modelsWithSoftDelete = ['User', 'Post']
if (modelsWithSoftDelete.includes(params.model ?? '')) {
if (params.action === 'findUnique' || params.action === 'findFirst') {
params.action = 'findFirst'
params.args.where = { ...params.args.where, deletedAt: null }
}
if (params.action === 'findMany') {
params.args.where = { ...params.args.where, deletedAt: null }
}
}
return next(params)
})
}
}