Prisma 패턴
TypeScript 백엔드에서 Prisma ORM을 사용하기 위한 프로덕션 패턴과 잘 알려지지 않은 함정.
패턴을 적용하기 전에 버전을 확인하세요. Prisma API는 주요 릴리스에 걸쳐 변경되었습니다:
npx prisma --version버전 간 주요 API 차이점:
relationJoins은 관계를 별도의 쿼리 대신 JOIN을 통해 로드할 수 있지만, 큰 1:N 관계나 깊은include에서 행 폭발을 일으킬 수 있음 — 두 접근 방식을 벤치마킹하세요omit필드 수정자와prisma.$extends클라이언트 확장 API가 추가됨- 최신 설치: 패키지 이름이
@prisma/client대신prisma일 수 있음;PrismaClient는 드라이버 어댑터(예:@prisma/adapter-pg)가 필요할 수 있음;datasource.url이schema.prisma대신prisma.config.ts에 있을 수 있음- CLI 명령어(
migrate dev,migrate deploy,generate)는 버전에 관계없이 동일
활성화 시점
- Prisma 스키마 모델과 관계를 설계하거나 수정할 때
- 쿼리, 트랜잭션 또는 페이지네이션 로직을 작성할 때
updateMany,deleteMany또는 벌크 작업을 사용할 때- 데이터베이스 마이그레이션을 실행하거나 계획할 때
- 서버리스 환경(Vercel, Lambda, Cloudflare Workers)에 배포할 때
- 소프트 삭제 또는 멀티테넌트 행 필터링을 구현할 때
핵심 개념
ID 전략
| 전략 | 사용 시점 | 피해야 할 시점 |
|---|---|---|
@default(cuid()) | 기본 선택 — URL 안전, 정렬 가능, 충돌 없음 | 외부 시스템에 순차적 ID 필요함 |
@default(uuid()) | Prisma 외부 시스템과의 상호 운용성 필요 | 쓰기가 많은 테이블(랜덤 UUID는 B-트리 인덱스를 조각냄) |
@default(autoincrement()) | 내부 조인 테이블, 감사 로그 | 공개용 ID(레코드 수 노출) |
스키마 기본값
model User {
id String @id @default(cuid())
email String @unique // @unique는 이미 인덱스를 생성 — @@index 불필요
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@@index([createdAt])
@@index([deletedAt, createdAt]) // 소프트 삭제 + 정렬 쿼리를 위한 복합 인덱스
}
WHERE또는ORDER BY에 사용되는 모든 외래 키와 열에@@index를 추가하세요.- 소프트 삭제가 예상되는 요구사항이라면 미리
deletedAt DateTime?을 선언하세요 — 나중에 추가하려면 라이브 테이블에 마이그레이션이 필요합니다. updatedAt @updatedAt는update및upsert에서만 Prisma에 의해 자동으로 설정됩니다(벌크 업데이트 함정에 대한 안티패턴 참조).
include vs select
include | select | |
|---|---|---|
| 반환 | 모든 스칼라 필드 + 지정된 관계 | 지정된 필드만 |
| 사용 시점 | 대부분의 필드와 관계가 필요할 때 | 핫 경로, 큰 테이블, 과다 페치 방지 |
| 성능 | 넓은 테이블에서 과다 페치 가능 | 최소 페이로드, 큰 데이터셋에서 더 빠름 |
| Prisma 5 참고 | 기본적으로 JOIN 사용(relationJoins) | 동일 |
// include — 모든 열 + 관계
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { select: { id: true, title: true } } },
});
// select — 명시적 허용 목록
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true },
});
API 응답에서 Prisma 엔티티를 직접 반환하지 마세요 — 노출되는 필드를 제어하기 위해 응답 DTO에 매핑하세요:
// 나쁨: passwordHash, deletedAt, 내부 필드가 유출됨
return await prisma.user.findUniqueOrThrow({ where: { id } });
// 좋음: 명시적 DTO 매핑
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return { id: user.id, name: user.name, email: user.email };
트랜잭션 형식 선택
| 상황 | 사용 |
|---|---|
| 독립적인 작업, 상호 의존성 없음 | 배열 형식 |
| 이후 단계가 이전 결과에 의존 | 인터랙티브 형식 |
| 외부 호출(이메일, HTTP) 포함 | 트랜잭션 외부에서 완전히 처리 |
// 배열 형식 — 한 번의 왕복으로 일괄 처리
const [user, post] = await prisma.$transaction([
prisma.user.update({ where: { id }, data: { name } }),
prisma.post.create({ data: { title, authorId: id } }),
]);
// 인터랙티브 형식 — tx 클라이언트만 사용, 외부 prisma 클라이언트는 절대 사용하지 않음
const post = await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
if (user.role !== 'ADMIN') throw new Error('Forbidden');
return tx.post.create({ data: { title, authorId: user.id } });
});
PrismaClient 싱글톤
각 PrismaClient 인스턴스는 자체 연결 풀을 엽니다. 한 번만 인스턴스화하세요.
// lib/prisma.ts
// 옵션 A — 어댑터 기반 초기화 (최신 Prisma 설치에 필요)
import { PrismaClient } from '@prisma/client'; // 또는 설정에 맞는 생성된 클라이언트 경로
import { PrismaPg } from '@prisma/adapter-pg';
function createPrismaClient() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
});
}
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// 옵션 B — 직접 초기화 (이전 설치, 어댑터 불필요)
// import { PrismaClient } from '@prisma/client';
// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... });
Prisma 설치가 PrismaClient 생성자에 adapter 인수를 요구하는 경우 옵션 A를 사용하세요.
new PrismaClient()가 인수 없이 작동하는 경우 옵션 B를 사용하세요. 컴파일러가 올바른 것을 알려줄 것입니다.
globalThis 패턴은 핫 리로드(Next.js, nodemon, ts-node-dev) 중 중복 인스턴스를 방지합니다.
N+1 문제
루프 내에서 관계를 로드하면 행당 하나의 쿼리가 실행됩니다.
// 나쁨: N+1 — 사용자당 하나의 추가 쿼리
const users = await prisma.user.findMany();
for (const user of users) {
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
}
// 좋음: 단일 쿼리
const users = await prisma.user.findMany({ include: { posts: true } });
Prisma 5+ relationJoins를 사용하면 include 형식이 단일 JOIN을 사용합니다. 큰 1:N 집합에서는 결과 집합 크기가 증가할 수 있습니다 — 관계가 부모당 많은 행을 반환할 수 있는 경우 두 접근 방식을 벤치마킹하세요.
코드 예제
커서 페이지네이션 (피드 및 대규모 데이터셋에 선호)
async function getPosts(cursor?: string, limit = 20) {
const items = await prisma.post.findMany({
where: { published: true },
orderBy: [
{ createdAt: 'desc' },
{ id: 'desc' }, // 보조 정렬은 중복 타임스탬프에서 불안정한 페이지네이션 방지
],
take: limit + 1,
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
const hasNextPage = items.length > limit;
if (hasNextPage) items.pop();
return { items, nextCursor: hasNextPage ? items[items.length - 1].id : null };
}
limit + 1을 가져오고 pop — 추가 count 쿼리 없이 hasNextPage를 감지하는 표준 방법입니다. 여러 행이 동일한 타임스탬프를 공유할 때 불안정한 페이지네이션을 방지하기 위해 항상 고유 필드(예: id)를 보조 orderBy로 포함하세요. 사용자가 임의 페이지로 이동해야 하는 경우(관리자 테이블)에만 오프셋 페이지네이션을 사용하세요.
소프트 삭제
// 항상 명시적으로 필터링 — 미들웨어에 의존하지 마세요 (동작을 숨기고 디버그가 어려움)
const activeUsers = await prisma.user.findMany({ where: { deletedAt: null } });
await prisma.user.update({ where: { id }, data: { deletedAt: new Date() } });
await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // 복원
오류 처리
import { Prisma } from '@prisma/client'; // 또는 설정에 맞는 생성된 클라이언트 경로
try {
await prisma.user.create({ data: { email } });
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError) {
if (e.code === 'P2002') throw new ConflictError('Email already exists');
if (e.code === 'P2025') throw new NotFoundError('Record not found');
if (e.code === 'P2003') throw new BadRequestError('Referenced record does not exist');
}
throw e;
}
일반 코드: P2002 고유성 위반 · P2025 찾을 수 없음 · P2003 외래 키 위반.
서비스 경계에서 포착하여 도메인 오류로 변환하세요. API 소비자에게 원시 Prisma 메시지를 노출하지 마세요.
연결 풀 — 서버리스
연결 매개변수를 DATABASE_URL에 직접 포함시키세요 — 문자열 연결은 URL에 이미 쿼리 매개변수(예: ?schema=public)가 있으면 깨집니다:
# .env — 권장: URL에 매개변수 포함
DATABASE_URL="postgresql://user:pass@host/db?connection_limit=1&pool_timeout=20"
# 외부 풀러(PgBouncer, Supabase pooler) 사용 시
DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1"
// Vercel, AWS Lambda 및 유사한 서버리스 런타임:
// 인스턴스당 풀을 1로 제한; connection_limit 및 pool_timeout은 DATABASE_URL을 통해 제어
// 어댑터 기반 설정 (Prisma 설치에 어댑터가 필요한 경우):
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
// 직접 설정 (Prisma 설치에 어댑터가 필요하지 않은 경우):
// const prisma = new PrismaClient();
안티패턴
updateMany는 레코드가 아닌 count를 반환
// 나쁨: 결과는 { count: 2 } — users[0]은 undefined
const users = await prisma.user.updateMany({ where: { role: 'GUEST' }, data: { role: 'USER' } });
// 좋음: 먼저 ID를 캡처한 다음 업데이트, 마지막으로 영향을 받은 행만 가져오기
const targets = await prisma.user.findMany({
where: { role: 'GUEST' },
select: { id: true },
});
const ids = targets.map((u) => u.id);
await prisma.user.updateMany({ where: { id: { in: ids } }, data: { role: 'USER' } });
const updated = await prisma.user.findMany({ where: { id: { in: ids } } });
deleteMany에도 동일하게 적용 — { count: n }를 반환하며, 삭제된 행을 반환하지 않습니다.
$transaction 인터랙티브 형식이 5초 후 타임아웃
// 나쁨: 트랜잭션 내부의 외부 호출이 기본 5초 초과 → "Transaction already closed"
await prisma.$transaction(async (tx) => {
const user = await tx.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email); // 외부 호출
await tx.user.update({ where: { id }, data: { emailSent: true } });
});
// 좋음: 외부 호출은 트랜잭션 외부에서
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
await sendWelcomeEmail(user.email);
await prisma.user.update({ where: { id }, data: { emailSent: true } });
// 벌크 처리가 실제로 필요한 경우에만 타임아웃 증가
await prisma.$transaction(async (tx) => { ... }, { timeout: 30_000 });
migrate dev가 데이터베이스를 리셋할 수 있음
migrate dev는 스키마 드리프트를 감지하고 DB를 리셋하라는 메시지를 표시하여 모든 데이터를 삭제할 수 있습니다.
# 절대 공유 개발, 스테이징 또는 프로덕션에서 사용하지 마세요
npx prisma migrate dev --name add_column
# 로컬 단독 개발 이외의 모든 곳에서 안전
npx prisma migrate deploy
# 적용하지 않고 드리프트 확인
npx prisma migrate diff \
--from-migrations ./prisma/migrations \
--to-schema-datamodel ./prisma/schema.prisma \
--shadow-database-url "$SHADOW_DATABASE_URL"
마이그레이션 파일을 수동으로 편집하면 향후 배포가 중단됨
Prisma는 모든 마이그레이션 파일을 체크섬합니다. 적용 후 편집하면 원본이 이미 실행된 모든 환경에서 P3006 checksum mismatch가 발생합니다. 대신 새 마이그레이션을 생성하세요.
스키마 변경 사항을 깨뜨리려면 다단계 마이그레이션이 필요함
기존 열에 NOT NULL을 추가하거나 열 이름을 한 번의 마이그레이션으로 변경하면 테이블이 잠기거나 데이터가 손실됩니다. 확장-축소 패턴을 사용하세요:
# 1단계: 로컬에서 마이그레이션 생성, 배포
npx prisma migrate dev --name add_new_column # 로컬 전용
npx prisma migrate deploy # 스테이징 / 프로덕션
// 2단계: 데이터 백필 (스크립트 또는 마이그레이션 작업에서 실행, 쉘에서는 실행하지 마세요)
await prisma.user.updateMany({ data: { newColumn: derivedValue } });
# 3단계: 로컬에서 NOT NULL 제약 조건 마이그레이션 생성, 배포
npx prisma migrate dev --name make_new_column_required # 로컬 전용
npx prisma migrate deploy # 스테이징 / 프로덕션
@updatedAt이 updateMany에서 작동하지 않음
@updatedAt는 update 및 upsert에서만 자동으로 설정됩니다. 벌크 쓰기는 이를 만료된 상태로 둡니다.
// 나쁨: updatedAt이 이전 값으로 유지됨
await prisma.post.updateMany({ where: { authorId }, data: { published: true } });
// 좋음
await prisma.post.updateMany({
where: { authorId },
data: { published: true, updatedAt: new Date() },
});
소프트 삭제 + findUniqueOrThrow가 삭제된 레코드를 유출함
findUniqueOrThrow는 DB에 행이 존재하지 않을 때만 P2025를 발생시킵니다. 소프트 삭제된 행은 여전히 존재하며 오류 없이 반환됩니다.
findUniqueOrThrow는 where에 고유 제약 조건 필드가 필요합니다 — id 옆에 deletedAt: null을 추가하면 { id, deletedAt }이 복합 고유 제약 조건이 아니므로 타입이 깨집니다. 대신 findFirstOrThrow를 사용하세요.
// 나쁨: 소프트 삭제된 사용자 반환
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
// 나쁨: Prisma 타입 오류 — { id, deletedAt }은 고유 제약 조건이 아님
const user = await prisma.user.findUniqueOrThrow({ where: { id, deletedAt: null } });
// 좋음: findFirstOrThrow는 임의의 where 조건 지원
const user = await prisma.user.findFirstOrThrow({ where: { id, deletedAt: null } });
deleteMany에 where가 없으면 모든 행이 삭제됨
// 나쁨: 조용히 테이블을 비움
await prisma.post.deleteMany();
// 좋음
await prisma.post.deleteMany({ where: { authorId: userId } });
모범 사례
| 규칙 | 이유 |
|---|---|
CI/CD에서 migrate deploy, 로컬에서만 migrate dev | migrate dev는 드리프트 시 DB를 리셋할 수 있음 |
| 엔터티를 응답 DTO에 매핑 | 내부 필드 유출 방지 |
고유 필드를 보조 orderBy로 포함 | 중복 타임스탬프에서 안정적인 페이지네이션 보장 |
소프트 삭제 시 findUniqueOrThrow 대신 findFirstOrThrow 사용 | 삭제된 레코드가 쿼리를 통해 유출되는 것을 방지 |
연결 문자열 외부에서 connection_limit 및 pool_timeout 직접 설정 | URL에 ?가 이미 있으면 문자열 연결이 깨짐 |
| Prisma 오류를 서비스 경계에서 도메인 오류로 변환 | API 소비자에게 내부 세부 사항 숨김 |
include가 관계를 반환해야 하는 경우 열을 명시적으로 선택(중첩 select 사용) | 과다 페치 방지 |
| 벌크 업데이트 전에 항상 ID를 먼저 가져오기 | updateMany가 레코드를 반환하지 않기 때문 |