Prisma 트랜잭션의 종류
Prisma는 세 가지 트랜잭션 방식을 제공한다. Nested Writes(암묵적), Sequential Operations($transaction 배열), Interactive Transactions($transaction 콜백)이다. 각각 적합한 사용 사례가 다르며, 잘못 선택하면 데이터 정합성 문제나 성능 저하가 발생한다.
Nested Writes: 암묵적 트랜잭션
// 관계 데이터를 한 번에 생성 — 자동으로 트랜잭션 처리
const order = await prisma.order.create({
data: {
userId: user.id,
totalAmount: 50000,
status: 'PENDING',
// 관계 데이터 동시 생성
items: {
create: [
{ productId: 1, quantity: 2, price: 15000 },
{ productId: 3, quantity: 1, price: 20000 },
],
},
payment: {
create: {
method: 'CARD',
amount: 50000,
status: 'AUTHORIZED',
},
},
shippingAddress: {
connect: { id: addressId }, // 기존 주소 연결
},
},
include: {
items: true,
payment: true,
},
});
// Nested Write + upsert 조합
const user = await prisma.user.update({
where: { id: userId },
data: {
profile: {
upsert: {
create: { bio: 'New bio', avatar: null },
update: { bio: 'Updated bio' },
},
},
posts: {
updateMany: {
where: { status: 'DRAFT' },
data: { status: 'ARCHIVED' },
},
},
},
});
Nested Write는 단일 쿼리로 관계 데이터를 조작할 때 가장 효율적이다. Prisma가 자동으로 트랜잭션을 관리하므로 별도 트랜잭션 코드가 필요 없다.
Sequential Operations: $transaction 배열
// 독립적인 여러 쿼리를 한 트랜잭션으로 묶기
const [updatedOrder, newLog, deletedCart] = await prisma.$transaction([
prisma.order.update({
where: { id: orderId },
data: { status: 'CONFIRMED' },
}),
prisma.auditLog.create({
data: {
action: 'ORDER_CONFIRMED',
entityId: orderId,
userId: currentUser.id,
},
}),
prisma.cartItem.deleteMany({
where: { userId: currentUser.id },
}),
]);
// 배치 업데이트: 여러 레코드를 개별적으로 업데이트
const updates = products.map((product) =>
prisma.product.update({
where: { id: product.id },
data: { price: product.newPrice },
})
);
const results = await prisma.$transaction(updates);
$transaction 배열은 쿼리 간 의존성이 없을 때 사용한다. 모든 쿼리가 성공하거나 모두 롤백된다. 단, 이전 쿼리의 결과를 다음 쿼리에서 참조할 수 없다.
Interactive Transactions: 핵심 패턴
// 쿼리 간 의존성이 있을 때 — 콜백 방식
async function transferFunds(
fromAccountId: string,
toAccountId: string,
amount: number,
) {
return prisma.$transaction(async (tx) => {
// 1. 출금 계좌 조회
const sender = await tx.account.findUniqueOrThrow({
where: { id: fromAccountId },
});
if (sender.balance < amount) {
throw new Error('잔액 부족'); // 자동 롤백
}
// 2. 출금
const updatedSender = await tx.account.update({
where: { id: fromAccountId },
data: { balance: { decrement: amount } },
});
// 3. 입금
const updatedReceiver = await tx.account.update({
where: { id: toAccountId },
data: { balance: { increment: amount } },
});
// 4. 거래 기록
await tx.transaction.create({
data: {
fromAccountId,
toAccountId,
amount,
type: 'TRANSFER',
},
});
return { sender: updatedSender, receiver: updatedReceiver };
}); // 여기서 자동 COMMIT 또는 에러 시 ROLLBACK
}
타임아웃과 격리 수준 설정
// 트랜잭션 옵션
const result = await prisma.$transaction(
async (tx) => {
// 장시간 작업...
const orders = await tx.order.findMany({
where: { status: 'PENDING', createdAt: { lt: oneHourAgo } },
});
for (const order of orders) {
await tx.order.update({
where: { id: order.id },
data: { status: 'EXPIRED' },
});
await tx.notification.create({
data: { userId: order.userId, type: 'ORDER_EXPIRED' },
});
}
return orders.length;
},
{
maxWait: 5000, // 트랜잭션 시작 대기 최대 5초
timeout: 30000, // 트랜잭션 실행 최대 30초
isolationLevel: 'Serializable', // 격리 수준
}
);
| 격리 수준 | Dirty Read | Non-Repeatable | Phantom | 사용 사례 |
|---|---|---|---|---|
ReadCommitted |
✅ 방지 | ❌ | ❌ | 일반 CRUD (기본값) |
RepeatableRead |
✅ | ✅ 방지 | ❌ | 잔액 조회+수정 |
Serializable |
✅ | ✅ | ✅ 방지 | 송금, 재고 차감 |
낙관적 동시성 제어
// 버전 필드로 낙관적 락 구현
async function updateProductPrice(
productId: string,
newPrice: number,
expectedVersion: number,
) {
const updated = await prisma.product.updateMany({
where: {
id: productId,
version: expectedVersion, // 버전 일치 확인
},
data: {
price: newPrice,
version: { increment: 1 }, // 버전 증가
},
});
if (updated.count === 0) {
throw new ConflictError(
'다른 사용자가 이미 수정했습니다. 새로고침 후 재시도하세요.'
);
}
}
// 재시도 래퍼
async function withOptimisticRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (error instanceof ConflictError && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
continue;
}
throw error;
}
}
throw new Error('최대 재시도 초과');
}
트랜잭션 에러 처리 패턴
import { Prisma } from '@prisma/client';
async function createOrder(data: CreateOrderDto) {
try {
return await prisma.$transaction(async (tx) => {
// 재고 차감
const product = await tx.product.update({
where: { id: data.productId },
data: { stock: { decrement: data.quantity } },
});
if (product.stock < 0) {
throw new InsufficientStockError(data.productId);
}
return tx.order.create({ data: { ... } });
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case 'P2002': // Unique 제약 위반
throw new DuplicateOrderError();
case 'P2025': // 레코드 없음
throw new ProductNotFoundError(data.productId);
case 'P2034': // 트랜잭션 충돌 (write conflict)
throw new TransactionConflictError();
}
}
if (error instanceof Prisma.PrismaClientUnknownRequestError) {
// DB 연결 문제 등
throw new DatabaseConnectionError();
}
throw error;
}
}
P2034는 Interactive Transaction에서 write conflict가 발생했을 때 던져진다. Prisma Client Extensions로 자동 재시도 로직을 확장에 구현할 수도 있다.
NestJS 서비스 레이어 통합
// NestJS에서 트랜잭션 클라이언트를 서비스에 주입
@Injectable()
export class OrderService {
constructor(private readonly prisma: PrismaService) {}
async checkout(userId: string, cartItems: CartItem[]) {
return this.prisma.$transaction(async (tx) => {
// 여러 서비스 로직을 트랜잭션 안에서 실행
const order = await this.createOrder(tx, userId, cartItems);
await this.deductStock(tx, cartItems);
await this.clearCart(tx, userId);
await this.createPaymentIntent(tx, order);
return order;
}, {
timeout: 15000,
isolationLevel: 'RepeatableRead',
});
}
// tx를 매개변수로 받아 같은 트랜잭션에서 실행
private async createOrder(
tx: Prisma.TransactionClient,
userId: string,
items: CartItem[],
) {
return tx.order.create({
data: {
userId,
items: {
create: items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
price: item.price,
})),
},
},
include: { items: true },
});
}
private async deductStock(
tx: Prisma.TransactionClient,
items: CartItem[],
) {
for (const item of 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} 재고가 부족합니다.`
);
}
}
}
}
Prisma.TransactionClient 타입을 사용하면 서비스 메서드가 트랜잭션 컨텍스트 안팎 모두에서 재사용할 수 있다. NestJS Dynamic Module로 PrismaService를 전역 모듈로 구성하면 모든 서비스에서 일관된 트랜잭션 관리가 가능하다.
선택 가이드
| 방식 | 적합한 상황 | 주의점 |
|---|---|---|
| Nested Write | 관계 데이터 CRUD | 단일 모델 연산만 가능 |
$transaction([]) |
독립적 배치 작업 | 쿼리 간 결과 참조 불가 |
$transaction(fn) |
조건부 로직, 의존적 쿼리 | 타임아웃·격리 수준 설정 필수 |
| 낙관적 락 | 낮은 경합 환경 | 재시도 로직 직접 구현 |