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을 추가하거나 열 이름을 한 번의 마이그레이션으로 변경하면 테이블이 잠기거나 데이터가 삭제됩니다. 확장-축소(expand-and-contract)를 사용하세요:
# 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 without where는 모든 행을 삭제함
// 나쁨: 조용히 테이블을 비움
await prisma.post.deleteMany();
// 좋음
await prisma.post.deleteMany({ where: { authorId: userId } });
모범 사례
| 규칙 | 이유 |
|---|---|
CI/CD에서는 migrate deploy, 로컬에서만 migrate dev | migrate dev는 드리프트 시 DB를 초기화할 수 있음 |
| 엔터티를 응답 DTO에 매핑 | 내부 필드 누출 방지 |
항상 limit + cursor 기반 페이지네이션 사용 | 오프셋 기반은 대규모 데이터셋에서 느리고 불안정함 |
findUniqueOrThrow 대신 findFirstOrThrow(소프트 삭제 시) | findUniqueOrThrow는 고유 제약 조건 where가 필요하며 deletedAt과 호환되지 않음 |
| 트랜잭션 내부에서 외부 호출(이메일, HTTP) 금지 | 5초 기본 타임아웃으로 인해 트랜잭션이 중단됨 |
| 트랜잭션 배열 형태가 가능하면 선호 | 대화형 형태보다 성능이 좋고 명시적임 |
서버리스의 경우 모든 인스턴스에서 connection_limit=1 | 연결 고갈 방지, 각 함수 호출은 자체 풀을 가짐 |
| 마이그레이션 파일을 절대 편집하지 말고 새 마이그레이션을 생성 | Prisma 체크섬이 적용된 마이그레이션을 무효화함 |