Next.js Server Actions와 Prisma 트랜잭션 처리하기
Next.js Server Actions에서 Prisma를 활용하여 안전한 데이터베이스 트랜잭션을 구현하는 방법을 알아봅니다.
Next.js Server Actions와 Prisma 트랜잭션 처리하기
Introduction
Server Actions은 서버에서 폼 데이터를 안전하게 처리하는 강력한 기능입니다. Prisma와 결합하면 데이터베이스 트랜잭션을 안전하게 관리할 수 있습니다. 이 글에서는 트랜잭션 패턴과 에러 처리 방법을 다룹니다.
Environment
# 프로젝트 구조
my-app/
├── app/
│ ├── actions.ts
│ ├── checkout/
│ │ └── page.tsx
│ └── dashboard/
│ └── page.tsx
├── lib/
│ └── prisma.ts
├── prisma/
│ └── schema.prisma
└── package.json
# 기술 스택
Next.js: 14.2.5
Prisma: 5.19.1
PostgreSQL: 15.4# Prisma 스키마 예시
npx prisma initProblem
복수의 데이터베이스 작업을 트랜잭션 없이 처리하면 데이터 무결성 문제가 발생합니다:
// ❌ 트랜잭션 없는 처리
'use server';
export async function createOrder(formData: FormData) {
const userId = formData.get('userId') as string;
const productId = formData.get('productId') as string;
const quantity = Number(formData.get('quantity'));
// 주문 생성
const order = await prisma.order.create({
data: {
userId,
status: 'pending',
},
});
// 재고 감소 (실패 시 주문은 남음!)
await prisma.product.update({
where: { id: productId },
data: {
stock: { decrement: quantity },
},
});
// 문제: 재고 업데이트 실패 시 주문은 유지됨
}Analysis
Prisma 트랜잭션의 장점:
- 원자성: 모든 작업이 성공하거나 모두 실패
- 격리성: 동시 트랜잭션 간 간섭 방지
- 지속성: 커밋 후 데이터 영구 저장
- 일관성: 트랜잭션 시작 전 상태로 롤백 가능
Solution
1. 기본 트랜잭션 사용
// app/actions.ts
'use server';
import { prisma } from '@/lib/prisma';
export async function createOrder(formData: FormData) {
const userId = formData.get('userId') as string;
const productId = formData.get('productId') as string;
const quantity = Number(formData.get('quantity'));
try {
// Prisma 트랜잭션
const result = await prisma.$transaction(async (tx) => {
// 1. 상품 정보 조회
const product = await tx.product.findUnique({
where: { id: productId },
});
if (!product) {
throw new Error('상품을 찾을 수 없습니다.');
}
if (product.stock < quantity) {
throw new Error('재고가 부족합니다.');
}
// 2. 주문 생성
const order = await tx.order.create({
data: {
userId,
items: {
create: {
productId,
quantity,
price: product.price,
},
},
},
include: {
items: true,
},
});
// 3. 재고 감소
await tx.product.update({
where: { id: productId },
data: {
stock: { decrement: quantity },
},
});
// 4. 재고 이력 기록
await tx.stockHistory.create({
data: {
productId,
change: -quantity,
reason: 'order',
orderId: order.id,
},
});
return order;
});
return {
success: true,
orderId: result.id,
message: '주문이 성공적으로 생성되었습니다.',
};
} catch (error) {
console.error('주문 생성 실패:', error);
return {
success: false,
message: error instanceof Error ? error.message : '주문 생성에 실패했습니다.',
};
}
}2. 중첩 트랜잭션 패턴
// lib/transactions/order-transaction.ts
import { PrismaClient, Prisma } from '@prisma/client';
type PrismaTransaction = Prisma.TransactionClient;
export class OrderTransaction {
constructor(private prisma: PrismaClient) {}
async createOrderWithPayment(
userId: string,
items: Array<{ productId: string; quantity: number }>,
paymentMethod: string
) {
return this.prisma.$transaction(async (tx) => {
// 1. 사용자 정보 확인
const user = await tx.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error('사용자를 찾을 수 없습니다.');
}
// 2. 상품 검증 및 총 금액 계산
let totalAmount = 0;
const orderItems = [];
for (const item of items) {
const product = await tx.product.findUnique({
where: { id: item.productId },
});
if (!product) {
throw new Error(`상품 ${item.productId}를 찾을 수 없습니다.`);
}
if (product.stock < item.quantity) {
throw new Error(`상품 ${product.name}의 재고가 부족합니다.`);
}
totalAmount += product.price * item.quantity;
orderItems.push({
productId: item.productId,
quantity: item.quantity,
price: product.price,
});
}
// 3. 주문 생성
const order = await tx.order.create({
data: {
userId,
totalAmount,
status: 'pending',
items: {
create: orderItems,
},
},
});
// 4. 결제 정보 생성
const payment = await tx.payment.create({
data: {
orderId: order.id,
amount: totalAmount,
method: paymentMethod,
status: 'pending',
},
});
// 5. 재고 업데이트 (배치 처리)
await Promise.all(
items.map(item =>
tx.product.update({
where: { id: item.productId },
data: {
stock: { decrement: item.quantity },
},
})
)
);
// 6. 재고 이력 기록
await tx.stockHistory.createMany({
data: items.map(item => ({
productId: item.productId,
change: -item.quantity,
reason: 'order',
orderId: order.id,
})),
});
return { order, payment };
});
}
}3. 분산 트랜잭션 (Sagas 패턴)
// lib/transactions/saga.ts
import { prisma } from '@/lib/prisma';
interface SagaStep {
execute: () => Promise;
compensate: () => Promise;
}
export class OrderSaga {
private steps: SagaStep[] = [];
addStep(step: SagaStep) {
this.steps.push(step);
}
async execute() {
const completedSteps: SagaStep[] = [];
try {
for (const step of this.steps) {
await step.execute();
completedSteps.push(step);
}
return { success: true };
} catch (error) {
// 실패한 경우 보상 트랜잭션 실행
console.error('사가 실행 실패, 보상 트랜잭션 시작...');
for (const step of completedSteps.reverse()) {
try {
await step.compensate();
} catch (compensateError) {
console.error('보상 트랜잭션 실패:', compensateError);
}
}
throw error;
}
}
}
// 사용 예시
export async function createOrderWithSaga(
userId: string,
productId: string,
quantity: number
) {
const saga = new OrderSaga();
let orderId: string | null = null;
saga.addStep({
execute: async () => {
const order = await prisma.order.create({
data: { userId, status: 'pending' },
});
orderId = order.id;
},
compensate: async () => {
if (orderId) {
await prisma.order.delete({ where: { id: orderId } });
}
},
});
saga.addStep({
execute: async () => {
await prisma.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } },
});
},
compensate: async () => {
await prisma.product.update({
where: { id: productId },
data: { stock: { increment: quantity } },
});
},
});
await saga.execute();
return { orderId };
} 4. Optimistic Locking 구현
// app/actions.ts
'use server';
import { prisma } from '@/lib/prisma';
export async function updateProductStock(
productId: string,
quantity: number,
expectedVersion: number
) {
try {
const result = await prisma.$transaction(async (tx) => {
// 버전 확인
const product = await tx.product.findUnique({
where: { id: productId },
});
if (!product) {
throw new Error('상품을 찾을 수 없습니다.');
}
if (product.version !== expectedVersion) {
throw new Error('다른 사용자가 이미 수정했습니다. 새로고침 후 다시 시도해주세요.');
}
// 재고 업데이트 및 버전 증가
const updated = await tx.product.update({
where: {
id: productId,
version: expectedVersion, // 낙관적 잠금
},
data: {
stock: { decrement: quantity },
version: { increment: 1 },
},
});
return updated;
});
return {
success: true,
newVersion: result.version,
message: '재고가 업데이트되었습니다.',
};
} catch (error) {
if (error instanceof Error && error.message.includes('다른 사용자가')) {
return {
success: false,
conflict: true,
message: error.message,
};
}
throw error;
}
}5. 타임아웃 처리
// lib/transactions/timeout.ts
import { prisma } from '@/lib/prisma';
export async function createOrderWithTimeout(
userId: string,
productId: string,
quantity: number,
timeoutMs: number = 5000
) {
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('주문 생성 시간이 초과되었습니다.'));
}, timeoutMs);
});
const orderPromise = prisma.$transaction(async (tx) => {
// 주문 생성 로직
const order = await tx.order.create({
data: {
userId,
status: 'pending',
items: {
create: {
productId,
quantity,
},
},
},
});
// 재고 업데이트
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } },
});
return order;
});
try {
const order = await Promise.race([orderPromise, timeoutPromise]);
return {
success: true,
orderId: (order as any).id,
};
} catch (error) {
return {
success: false,
message: error instanceof Error ? error.message : '주문 생성 실패',
};
}
}Lessons Learned
- 트랜잭션 범위 최소화: 필요한 작업만 트랜잭션에 포함
- 에러 처리 상세화: 트랜잭션 실패 시 사용자에게 명확한 메시지 전달
- 성능 고려: 장시간 실행되는 트랜잭션은 분리 처리
- 테스트 필수: 동시성 테스트로 데이터 무결성 검증
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.