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. 문제 상황
증상
여러 사용자가 동시에 "일괄 상태 변경" 버튼을 클릭했을 때:
- A 사용자: 10건 선택 → "확정" 클릭
- B 사용자: 같은 10건 중 5건 선택 → "미확정" 클릭
- 결과: 어떤 상태가 최종인지 예측 불가
재현 시나리오
시간 사용자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. 참고 자료
- Prisma Interactive Transactions
- PostgreSQL Transaction Isolation
- TOCTOU Race Condition
- Optimistic vs Pessimistic Locking
8. 다음 단계
트랜잭션으로 동시성 이슈를 해결했다면, Decimal 타입 변환 시 발생하는 silent failure도 방지해보세요.
시리즈 목차:
- Prisma N+1 쿼리 성능 문제 해결하기 (50% 속도 개선)
- Prisma 일괄 업데이트에서 동시성 이슈 해결하기 ← 현재 글
- Prisma Decimal to Number 변환 시 silent failure 방지하기