deep-dive2025-06-09·12 min·38/348

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 init

Problem

복수의 데이터베이스 작업을 트랜잭션 없이 처리하면 데이터 무결성 문제가 발생합니다:

// ❌ 트랜잭션 없는 처리
'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 트랜잭션의 장점:

  1. 원자성: 모든 작업이 성공하거나 모두 실패
  2. 격리성: 동시 트랜잭션 간 간섭 방지
  3. 지속성: 커밋 후 데이터 영구 저장
  4. 일관성: 트랜잭션 시작 전 상태로 롤백 가능

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

  1. 트랜잭션 범위 최소화: 필요한 작업만 트랜잭션에 포함
  2. 에러 처리 상세화: 트랜잭션 실패 시 사용자에게 명확한 메시지 전달
  3. 성능 고려: 장시간 실행되는 트랜잭션은 분리 처리
  4. 테스트 필수: 동시성 테스트로 데이터 무결성 검증

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.