728x90
반응형
Prisma에서 updateMany를 일부러 쓰는 이유: update 대신 updateMany가 더 안전한 순간
1. 개요
Prisma를 사용할 때 단건 데이터 수정에는 보통 update 메서드를 사용하지만, 동시성 제어와 조건부 상태 전이를 처리해야 하는 특정 상황에서는 의도적으로 updateMany를 사용하는 것이 더 안전하고 적절합니다. 이번 글에서는 어떠한 경우에 updateMany가 유용하며, 왜 단순한 update 대신 이것을 선택해야 하는지를 다뤄보겠습니다.
2. 목표
- 동시 요청 상황에서 발생할 수 있는 TOCTOU(Time Of Check To Time Of Use) 문제를 이해하고 방지합니다.
- 단순한 데이터 수정이 아닌 '조건부 상태 전이'를 모델링하고 표현하는 방법을 학습합니다.
updateMany의 특성(count반환 및 조건 갱신)을 활용하여 어플리케이션 레벨의 비교-교환(Compare-and-Set) 알고리즘을 구현합니다.
3. 코어 컨셉
- Compare-and-Set (CAS) 패턴: 데이터베이스 관점에서 낙관적 동시성 제어(Optimistic Concurrency Control)를 달성하기 위한 방법입니다.
- 먼저 데이터의 상태를 읽습니다.
- 수정(갱신) 시점에도 그 상태가 여전히 유지되고 있는지 다시 한번 확인하며 업데이트를 시도합니다.
- 상태가 일치할 때만 수정을 반영하고, 그렇지 않으면 다른 트랜잭션에 의해 선점된 것으로 간주하여 실패 처리합니다.
- 낙관적 동시성 제어: 리소스에 락을 걸지 않고, 업데이트 수행 시점에 데이터가 변경되지 않았음을 검증함으로써 동시성을 제어하는 방법입니다.
4. 주요 기능
update vs updateMany의 주요 차이
update: "특정 식별자(Unique Key 등)를 가진 레코드를 무조건적으로 주어진 데이터로 수정한다."- 단점: 현재 데이터의 상태 변화(예: 삭제 상태에서 이미 복구됨)를 보장하지 못하며 동시성 문제가 발생할 수 있습니다.
updateMany: "조건을 만족하는 레코드를 갱신하되, 처리 결과로 업데이트된 행의 수(count)를 반환한다."- 장점:
where조건에 상태 검증을 함께 포함시킬 수 있습니다. 만약 업데이트된count === 0이라면, 기대했던 상태가 이미 변했거나 삭제된 것을 의미하므로 충돌(Conflict) 코드로 안전하게 처리할 수 있습니다.
- 장점:
5. 동작 원리
문제 시나리오 (TOCTOU 문제)
트랜잭션 A와 트랜잭션 B가 동시에 실행된다고 가정할 때:
- 둘 다 조회 시점에는
isDeleted = true(삭제 상태)인 같은 레코드를 확인합니다. - 둘 다 "복구가 가능하구나"라고 판단합니다.
- A가 먼저
update를 통해 데이터를 복구(isDeleted = false)합니다. - 이어서 B가
update를 실행하면, 이미 A가 복구했음에도 불구하고 B의 수정 작업은 단순 덮어쓰기로 인식되어 그대로 성공해 버립니다. 충돌을 감지하지 못합니다.
해결 원리
이 과정을 해결하려면 진짜 의도인 "내가 조회했던 그 데이터가, 여전히 삭제 상태일 때만 복구한다"를 조건에 포함해야 합니다.
const restored = await tx.couponRequest.updateMany({
where: {
id: existing.id,
isDeleted: true, // 상태 조건 추가
},
data: { /* ... */ }
});
if (restored.count === 0) {
throw new ConflictException('이미 다른 요청이 먼저 처리했습니다.');
}
- 조회한 후 수정 쿼리를 날릴 때
where: { id: existing.id, isDeleted: true }와 같이 식별자와 상태를 동시에 검증합니다. - 그 사이 다른 트랜잭션이 상태를 변경했다면,
isDeleted = true조건에 매칭되는 데이터가 없어 업데이트된 레코드 수count는 0이 반환됩니다. count === 0일 때 예외를 던져 덮어쓰기를 방지하고 안전하게 경합 충돌을 알릴 수 있습니다.
6. 사용 예시
쿠폰을 발급하되, 기존에 소프트 삭제(isDeleted)된 쿠폰이라면 복구하는 전체 비즈니스 로직 작성 예시입니다. 동시 접속 시 발생할 수 있는 중복 요청을 조건 기반 updateMany로 제어합니다.
모델 정의 (Prisma Schema)
model CouponRequest {
id BigInt @id @default(autoincrement())
eventId BigInt
userId BigInt
isDeleted Boolean @default(false)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime
@@unique([eventId, userId])
}
비즈니스 로직 (트랜잭션 내 처리)
await prisma.$transaction(async (tx) => {
// 1. (eventId, userId)에 해당하는 기존 요청 조회
const existing = await tx.couponRequest.findFirst({
where: {
eventId,
userId,
},
select: {
id: true,
isDeleted: true,
},
});
// 2. 이미 활성 상태인 요청이 있다면 새로 요청하거나 복구할 수 없으므로 충돌 예외 발생
if (existing && !existing.isDeleted) {
throw new ConflictException('이미 요청이 존재합니다.');
}
if (existing && existing.isDeleted) {
// 3. 기존에 삭제된 요청이 있다면 데이터 복구 시도 (Compare-and-Set)
const restored = await tx.couponRequest.updateMany({
where: {
id: existing.id,
isDeleted: true, // 반드시 현재 상태 검증을 where 조건에 포함해야 함
},
data: {
isDeleted: false, // 활성 상태로 복원
deletedAt: null,
updatedAt: new Date(),
},
});
// 4. count가 0이라면 조건에 부합하는 데이터가 없음을 의미.
// 즉, 나와 동시에 처리된 다른 요청에 의해 상태가 이미 변경되었으므로 예외 처리
if (restored.count === 0) {
throw new ConflictException('이미 다른 요청이 먼저 처리했습니다.');
}
return { id: existing.id, restored: true };
}
// 5. 기존 요청이 없다면 안전하게 새로 레코드 생성
const created = await tx.couponRequest.create({
data: {
eventId,
userId,
isDeleted: false,
updatedAt: new Date(),
},
select: {
id: true,
},
});
return { id: created.id, restored: false };
});
7. 레퍼런스
728x90
반응형
'Javascript, Typescript' 카테고리의 다른 글
| [Node.js] Node.js는 싱글 스레드인가? (4) | 2025.08.05 |
|---|---|
| [타입스크립트] 타입스크립트에 타입 지정하기 (2) | 2024.09.25 |
| [타입스크립트] 타입스크립트의 타입, 클래스와 인터페이스 (0) | 2022.12.20 |