Prisma 트랜잭션 패턴 심화

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) 조건부 로직, 의존적 쿼리 타임아웃·격리 수준 설정 필수
낙관적 락 낮은 경합 환경 재시도 로직 직접 구현
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux