Prisma 일괄 업데이트에서 동시성 이슈 해결하기 ($transaction 활용)

두 사용자가 동시에 일괄 상태 변경을 실행했을 때 데이터가 꼬이는 버그를 발견했습니다. Prisma $transaction으로 TOCTOU 취약점을 해결한 방법을 공유합니다.

Prisma 일괄 업데이트에서 동시성 이슈 해결하기 ($transaction 활용)

"조회하고 업데이트"하는 패턴의 숨겨진 위험 - Race Condition을 트랜잭션으로 방지하는 방법

작성일: 2026-01-17
프로젝트: 사이드 프로젝트 (B2B SaaS)
기술 스택: TypeScript, Prisma, PostgreSQL, Next.js Server Actions
키워드: Prisma transaction, race condition, 동시성 제어, batch update, optimistic locking, 트랜잭션 락


1. 문제 상황

증상

여러 사용자가 동시에 "일괄 상태 변경" 버튼을 클릭했을 때:

  1. A 사용자: 10건 선택 → "확정" 클릭
  2. B 사용자: 같은 10건 중 5건 선택 → "미확정" 클릭
  3. 결과: 어떤 상태가 최종인지 예측 불가

재현 시나리오

시간   사용자A                    사용자B                    DB 상태
─────────────────────────────────────────────────────────────────────
T1     SELECT (10건 조회)                                   DRAFT
T2                                SELECT (5건 조회)         DRAFT
T3     UPDATE (10건 → CONFIRMED)                           CONFIRMED
T4                                UPDATE (5건 → DRAFT)     DRAFT (5건만)

기대: A의 10건이 모두 CONFIRMED
실제: 5건은 DRAFT, 5건은 CONFIRMED (혼합 상태)

발생 조건

  • 2명 이상의 사용자가 동시에 같은 데이터를 수정
  • "조회 → 검증 → 업데이트" 패턴을 사용
  • 트랜잭션 없이 각 단계가 개별 쿼리로 실행

영향 범위

  • 데이터 무결성 손상
  • 비즈니스 로직 오류 (확정되지 않은 데이터가 확정된 것처럼 처리)
  • 디버깅이 어려운 간헐적 버그

2. 원인 분석

기존 코드의 문제점

// ❌ Before: Race Condition 취약
export async function batchUpdateStatus(
  ids: string[],
  status: 'DRAFT' | 'CONFIRMED'
) {
  // Step 1: 조회
  const records = await prisma.record.findMany({
    where: { id: { in: ids } },
    include: { owner: { select: { companyId: true } } },
  });

  // ⚠️ 이 시점에 다른 요청이 같은 레코드를 수정할 수 있음!

  // Step 2: 권한 검증
  if (records.length === 0) {
    return { success: false, message: 'Not found' };
  }

  const unauthorized = records.some(
    (r) => r.owner.companyId !== session.user.companyId
  );
  if (unauthorized) {
    return { success: false, message: 'Unauthorized' };
  }

  // ⚠️ Step 1에서 조회한 데이터가 이미 변경되었을 수 있음!

  // Step 3: 업데이트
  const result = await prisma.record.updateMany({
    where: { id: { in: ids } },
    data: { status },
  });

  return { success: true, count: result.count };
}

왜 위험한가?

요청A                          DB                           요청B
───────────────────────────────────────────────────────────────────
findMany() ─────────────────► 10건 반환
                               │
                               │◄───────────────────── findMany()
                               │                        10건 반환
                               │
권한 검증 (OK)                 │
                               │                        권한 검증 (OK)
                               │
updateMany() ──────────────────┤
     10건 CONFIRMED            │
                               │◄───────────────────── updateMany()
                               │                        5건 DRAFT
                               ▼
              최종 상태: 5건 CONFIRMED, 5건 DRAFT (불일치!)

TOCTOU 문제

이 패턴은 TOCTOU (Time of Check to Time of Use) 취약점입니다:

  • Time of Check: findMany()로 데이터 확인
  • Time of Use: updateMany()로 데이터 사용

체크와 사용 사이에 데이터가 변경될 수 있습니다.


3. 해결 방법

핵심 원칙: 트랜잭션으로 원자적 처리

Prisma의 $transaction을 사용하여 조회와 업데이트를 하나의 원자적 단위로 묶습니다.

Step 1: 트랜잭션 래퍼 적용

// ✅ After: 트랜잭션으로 원자적 처리
export async function batchUpdateStatus(
  ids: string[],
  status: 'DRAFT' | 'CONFIRMED'
) {
  try {
    const session = await auth();
    if (!session?.user) {
      return { success: false, message: '인증이 필요합니다.' };
    }

    if (ids.length === 0) {
      return { success: false, message: '선택된 항목이 없습니다.' };
    }

    // 트랜잭션으로 조회와 업데이트를 원자적으로 처리
    const result = await prisma.$transaction(async (tx) => {
      // Step 1: 트랜잭션 내에서 조회 (락 획득)
      const records = await tx.record.findMany({
        where: { id: { in: ids } },
        include: { owner: { select: { companyId: true } } },
      });

      // Step 2: 존재 여부 확인
      if (records.length === 0) {
        throw new Error('NOT_FOUND');
      }

      // Step 3: 권한 확인
      const unauthorized = records.some(
        (r) => r.owner.companyId !== session.user.companyId
      );
      if (unauthorized) {
        throw new Error('UNAUTHORIZED');
      }

      // Step 4: 요청된 모든 ID가 존재하는지 확인
      if (records.length !== ids.length) {
        throw new Error('PARTIAL_NOT_FOUND');
      }

      // Step 5: 트랜잭션 내에서 업데이트
      return tx.record.updateMany({
        where: { id: { in: ids } },
        data: {
          status,
          ...(status === 'CONFIRMED' && {
            confirmedAt: new Date(),
            confirmedBy: session.user.id,
          }),
        },
      });
    });

    return {
      success: true,
      data: { count: result.count },
      message: `${result.count}건이 처리되었습니다.`,
    };
  } catch (error) {
    // 트랜잭션 내부에서 throw된 에러 처리
    if (error instanceof Error) {
      if (error.message === 'NOT_FOUND') {
        return { success: false, message: '항목을 찾을 수 없습니다.' };
      }
      if (error.message === 'UNAUTHORIZED') {
        return { success: false, message: '접근 권한이 없습니다.' };
      }
      if (error.message === 'PARTIAL_NOT_FOUND') {
        return { success: false, message: '일부 항목을 찾을 수 없습니다.' };
      }
    }
    console.error('Batch update error:', error);
    return { success: false, message: '서버 오류가 발생했습니다.' };
  }
}

Step 2: 에러 코드로 분기 처리

트랜잭션 내부에서 throw하면 자동으로 롤백됩니다. 에러 메시지로 상황을 구분합니다:

// 트랜잭션 내부
if (records.length === 0) {
  throw new Error('NOT_FOUND'); // ← 롤백 + 에러 전달
}

// 트랜잭션 외부
catch (error) {
  if (error instanceof Error) {
    switch (error.message) {
      case 'NOT_FOUND':
        return { success: false, message: '항목을 찾을 수 없습니다.' };
      case 'UNAUTHORIZED':
        return { success: false, message: '접근 권한이 없습니다.' };
      // ...
    }
  }
}

Step 3: 모든 ID 존재 확인 추가

// 요청된 모든 ID가 DB에 존재하는지 확인
if (records.length !== ids.length) {
  throw new Error('PARTIAL_NOT_FOUND');
}

이 검증이 중요한 이유:

  • 클라이언트가 존재하지 않는 ID를 포함할 수 있음
  • 조회와 업데이트 사이에 삭제될 수 있음
  • 부분 업데이트 방지

4. 핵심 개념 정리

Prisma $transaction의 두 가지 사용법

방식 사용 사례 예시
Sequential 여러 독립적 쿼리를 순차 실행 prisma.$transaction([query1, query2])
Interactive 조건부 로직이 필요한 경우 prisma.$transaction(async (tx) => {...})

우리 사례는 Interactive 트랜잭션이 필요합니다 (조회 결과에 따라 분기).

트랜잭션 격리 수준

Prisma + PostgreSQL 기본 격리 수준: Read Committed

격리 수준       Dirty Read   Non-repeatable Read   Phantom Read
──────────────────────────────────────────────────────────────────
Read Uncommitted   가능         가능                 가능
Read Committed     방지         가능                 가능      ← 기본
Repeatable Read    방지         방지                 가능
Serializable       방지         방지                 방지

Read Committed에서도 트랜잭션이 필요한 이유:

  • 트랜잭션 내 쿼리들이 같은 커넥션에서 실행됨
  • 롤백이 원자적으로 처리됨
  • 비즈니스 로직의 원자성 보장

WITH/WITHOUT 트랜잭션 비교

상황 트랜잭션 없음 트랜잭션 있음
동시 수정 Race condition 발생 순차 처리 보장
중간 에러 부분 업데이트 전체 롤백
권한 검증 실패 이미 일부 처리됨 아무것도 처리 안 됨
DB 연결 끊김 불일치 상태 롤백으로 일관성 유지

5. 베스트 프랙티스

체크리스트

  • [ ] "조회 → 검증 → 업데이트" 패턴에 $transaction 적용
  • [ ] 트랜잭션 내부에서 에러는 throw로 처리
  • [ ] 에러 메시지로 상황 구분 (NOT_FOUND, UNAUTHORIZED 등)
  • [ ] 요청된 모든 ID 존재 여부 확인
  • [ ] 타임아웃 설정 고려

트랜잭션 타임아웃 설정

const result = await prisma.$transaction(
  async (tx) => {
    // ...
  },
  {
    maxWait: 5000,  // 트랜잭션 시작 대기 최대 시간 (ms)
    timeout: 10000, // 트랜잭션 실행 최대 시간 (ms)
  }
);

언제 트랜잭션이 필요한가?

// ✅ 트랜잭션 필요
// 1. 조회 후 조건부 업데이트
const record = await tx.record.findUnique(...);
if (record.status === 'DRAFT') {
  await tx.record.update(...);
}

// 2. 여러 테이블 동시 수정
await tx.record.update(...);
await tx.audit.create(...);

// 3. 일괄 처리 중 하나라도 실패하면 전체 롤백
const results = await Promise.all(ids.map(id => tx.record.update(...)));
// ❌ 트랜잭션 불필요
// 1. 단순 조회
const records = await prisma.record.findMany(...);

// 2. 조건 없는 단일 업데이트
await prisma.record.update({ where: { id }, data: { name } });

// 3. 독립적인 쿼리들
await prisma.record.create(...);
await prisma.log.create(...); // 실패해도 record는 유지해야 함

Server Action 반환 타입 패턴

// Discriminated Union으로 타입 안전한 결과 반환
export type ActionState<T = void> =
  | { success: true; data: T; message?: string }
  | { success: false; message: string; errors?: Record<string, string[]> };

// 사용 예
export async function batchUpdate(ids: string[]): Promise<ActionState<{ count: number }>> {
  try {
    // ...
    return {
      success: true,
      data: { count: result.count },
      message: `${result.count}건이 처리되었습니다.`,
    };
  } catch (error) {
    return { success: false, message: '서버 오류가 발생했습니다.' };
  }
}

6. FAQ

Q: Prisma에서 SELECT ... FOR UPDATE를 사용할 수 있나요?

A: Prisma 자체는 FOR UPDATE 구문을 직접 지원하지 않습니다. 하지만 Interactive 트랜잭션 내에서 findMany()를 사용하면 PostgreSQL에서 암묵적으로 Row-level 락이 적용됩니다.

명시적인 FOR UPDATE가 필요하다면 Raw Query를 사용해야 합니다:

await prisma.$transaction(async (tx) => {
  await tx.$executeRaw`SELECT * FROM "Record" WHERE id IN (${Prisma.join(ids)}) FOR UPDATE`;
  // 이후 업데이트
});

Q: 트랜잭션 격리 수준을 변경할 수 있나요?

A: Prisma에서 직접 지원하지 않지만, PostgreSQL 연결 문자열에서 설정하거나 Raw Query로 설정할 수 있습니다:

await prisma.$transaction(async (tx) => {
  await tx.$executeRaw`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`;
  // 이후 쿼리
});

단, 높은 격리 수준은 성능에 영향을 줄 수 있습니다.

Q: 트랜잭션 내에서 외부 API를 호출해도 되나요?

A: 권장하지 않습니다. 트랜잭션 내에서 외부 API 호출은:

  • 트랜잭션 시간이 길어짐
  • 타임아웃 위험 증가
  • 외부 API 실패 시 DB 롤백되지만 API 호출은 취소 불가
// ❌ 안 좋은 패턴
await prisma.$transaction(async (tx) => {
  await tx.record.update(...);
  await sendEmail(...); // 외부 API 호출
});

// ✅ 좋은 패턴
const result = await prisma.$transaction(async (tx) => {
  return tx.record.update(...);
});
await sendEmail(...); // 트랜잭션 완료 후 호출

Q: 낙관적 락(Optimistic Locking)과 비관적 락(Pessimistic Locking)의 차이는?

A:

구분 낙관적 락 비관적 락
방식 버전 필드로 충돌 감지 DB 락으로 동시 접근 차단
장점 락 대기 없음, 높은 동시성 충돌 자체를 방지
단점 충돌 시 재시도 필요 락 대기로 성능 저하 가능
적합한 상황 충돌이 드문 경우 충돌이 잦은 경우

Prisma에서 낙관적 락:

model Record {
  id      String @id
  version Int    @default(0)
  // ...
}

// 업데이트 시 버전 확인
await prisma.record.update({
  where: { id, version: currentVersion },
  data: { ..., version: { increment: 1 } },
});

Q: batchUpdateMany의 성능은 어떤가요?

A: updateMany는 단일 SQL 쿼리로 실행되어 성능이 좋습니다:

UPDATE "Record" SET status = 'CONFIRMED' WHERE id IN ('id1', 'id2', ...)

단, 트랜잭션 내에서 findMany + updateMany는 2개의 쿼리가 필요합니다. 성능이 중요하다면:

// 권한 검증 없이 바로 업데이트 (권한은 WHERE로 처리)
const result = await prisma.record.updateMany({
  where: {
    id: { in: ids },
    owner: { companyId: session.user.companyId }, // 권한 필터
  },
  data: { status },
});

// 업데이트된 건수로 권한 검증
if (result.count !== ids.length) {
  // 일부 또는 전체가 권한 없음
}

7. 참고 자료


8. 다음 단계

트랜잭션으로 동시성 이슈를 해결했다면, Decimal 타입 변환 시 발생하는 silent failure도 방지해보세요.

시리즈 목차:

  1. Prisma N+1 쿼리 성능 문제 해결하기 (50% 속도 개선)
  2. Prisma 일괄 업데이트에서 동시성 이슈 해결하기 ← 현재 글
  3. Prisma Decimal to Number 변환 시 silent failure 방지하기