본문 바로가기
Javascript, Typescript

[Prisma] updateMany를 일부러 쓰는 이유

by Hyun-danpung2 2026. 3. 19.
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)를 달성하기 위한 방법입니다.
    1. 먼저 데이터의 상태를 읽습니다.
    2. 수정(갱신) 시점에도 그 상태가 여전히 유지되고 있는지 다시 한번 확인하며 업데이트를 시도합니다.
    3. 상태가 일치할 때만 수정을 반영하고, 그렇지 않으면 다른 트랜잭션에 의해 선점된 것으로 간주하여 실패 처리합니다.
  • 낙관적 동시성 제어: 리소스에 락을 걸지 않고, 업데이트 수행 시점에 데이터가 변경되지 않았음을 검증함으로써 동시성을 제어하는 방법입니다.

4. 주요 기능

update vs updateMany의 주요 차이

  • update: "특정 식별자(Unique Key 등)를 가진 레코드를 무조건적으로 주어진 데이터로 수정한다."
    • 단점: 현재 데이터의 상태 변화(예: 삭제 상태에서 이미 복구됨)를 보장하지 못하며 동시성 문제가 발생할 수 있습니다.
  • updateMany: "조건을 만족하는 레코드를 갱신하되, 처리 결과로 업데이트된 행의 수(count)를 반환한다."
    • 장점: where 조건에 상태 검증을 함께 포함시킬 수 있습니다. 만약 업데이트된 count === 0이라면, 기대했던 상태가 이미 변했거나 삭제된 것을 의미하므로 충돌(Conflict) 코드로 안전하게 처리할 수 있습니다.

5. 동작 원리

문제 시나리오 (TOCTOU 문제)

트랜잭션 A와 트랜잭션 B가 동시에 실행된다고 가정할 때:

  1. 둘 다 조회 시점에는 isDeleted = true (삭제 상태)인 같은 레코드를 확인합니다.
  2. 둘 다 "복구가 가능하구나"라고 판단합니다.
  3. A가 먼저 update를 통해 데이터를 복구(isDeleted = false)합니다.
  4. 이어서 B가 update를 실행하면, 이미 A가 복구했음에도 불구하고 B의 수정 작업은 단순 덮어쓰기로 인식되어 그대로 성공해 버립니다. 충돌을 감지하지 못합니다.

해결 원리

이 과정을 해결하려면 진짜 의도인 "내가 조회했던 그 데이터가, 여전히 삭제 상태일 때만 복구한다"를 조건에 포함해야 합니다.

const restored = await tx.couponRequest.updateMany({
  where: {
    id: existing.id,
    isDeleted: true, // 상태 조건 추가
  },
  data: { /* ... */ }
});

if (restored.count === 0) {
  throw new ConflictException('이미 다른 요청이 먼저 처리했습니다.');
}
  1. 조회한 후 수정 쿼리를 날릴 때 where: { id: existing.id, isDeleted: true }와 같이 식별자와 상태를 동시에 검증합니다.
  2. 그 사이 다른 트랜잭션이 상태를 변경했다면, isDeleted = true 조건에 매칭되는 데이터가 없어 업데이트된 레코드 수 count는 0이 반환됩니다.
  3. 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
반응형