Prisma upsert error code P2002 해결하기 (about race condition)

Jung Wish·2024년 3월 4일
0

오늘의_코딩

목록 보기
4/11

오류의 종류는 PrismaClientKnownRequestError, 오류 로그를 읽어보면 upsert문에서 발생한 것으로 error code는 2002, unique name constraints에 위배되는 작업을 했다고 한다.

즉 이미 저장하려고는 하는 name이 row에 존재하므로 create 작업을 할 수 없다는 이야기인데
Prisma에서 upsert는 create, update, where 필드를 지정하여 where 조건에 맞는 데이터가 존재하면 update를 없다면 create를 실행하는건데 어떻게 contraint 에러가 날 수 있는 거지?????라고 생각했다.

그래서 오늘도 공식문서와 구글링을 열심히 해봤는데 공식문서에 원인와 솔루션이 나와있었다.

문제 상황/전제 조건

  • 레코드가 아직 존재하지 않는데, multiple upsert를 동시에 진행하는 상황일 때 하나 이상의 작업에서 unique key constraint error가 발생할 수 있다.

원인

  • Prisma Client에서 upsert문을 실행할 때, 레코드가 이미 존재하는지 먼저 확인하는데 위에서 잠깐 언급했듯이 해당 read operation에서 레코드가 존재하면 update문을 그렇지 않으면 create문을 실행하게 된다. 그런데 여기서 동시에 같은 테이블에 여러개의 upsert문을 실행하게 되면 동시성 문제(race condition)가 발생한다. 두 개 이상의 upsert문에서 where clause(read 연산)를 수행할 때 같은 자원인 테이블에 읽기/쓰기 연산 순서를 진행하는 과정에서 레코드가 없다 판단할 가능성이 존재하고 이에 따라 동시에 create 연산을 진행하게 된다. 결과적으로 create가 2번 이상 실행되면서 다음과 같은 unique constraint 에러가 발생한다는 것이다.
  • 나의 케이스는 Promise.all로 multiple upsert를 transaction 연산을 통해 진행하고 있었는데 그러다 보니 내부 upsert문이 동시에 실행되는 상황이 존재했다. upsert문이 내부적으로 where clause(read) -> update/create(write) 순서 및 연산을 나누어 비동기로 진행되는 것으로 추측된다. 이에 따라 read문이 읽히는 순서에 따라 같은 레코드를 create하는 작업을 실행하게 되어 에러가 나온 것이다.

해결 방법

  • P2002 error code에 대한 error handling을 따로 해준다. 해당 error code를 catch하면 upsert문을 재실행하여 create가 아닌 update문을 실행할 수 있도록 한다.
  • 본문에 나온 해결방법에 따라 P2002 에러가 발생한 연산에 한하여 retry를 하도록 했다. (해당 에러는 여러번의 재시도를 할 필요가 없다. 일단 한번 레코드 작업이 성공하면 이후 where read 연산에서는 레코드가 있다고 판단하여 update문을 실행할 것이기 때문에 wrtie unique constraints 에러가 발생하지 않는다.)

BEFORE

// index.ts
const pushGymToDatabase = async (gym: RockTaGym["Gym"]) => {
  const { address, phone, brand, ...others } = gym;

  return prisma.$transaction(async (tx) => {
        const gymAddress = await tx.address.findFirst({
          where: {
            ...address,
          },
        });

        const gymInput = {
          ...others,
          address: {
            ...(gymAddress
              ? { connect: { id: gymAddress.id } }
              : { create: { ...address } }),
          },
          ...(!phone.phoneNumber
            ? {}
            : {
                phone: {
                  connectOrCreate: {
                    create: { phoneNumber: phone.phoneNumber },
                    where: {
                      phoneNumber: phone.phoneNumber,
                    },
                  },
                },
              }),
          brand: {
            connectOrCreate: {
              create: {
                name: brand.name,
                relatedBrand: brand.relatedBrand ?? null,
              },
              where: {
                name: brand.name,
              },
            },
          },
        };
		
        // 여기가 바로 문제의 upsert
        await tx.gym.upsert({
          create: gymInput,
          update: gymInput,
          where: {
            name: others.name,
          },
        });
      });
};

AFTER

// modules/retryCatch.ts
export async function retryCatch<T>(
  callback: () => Promise<T>, // 메인 콜백함수
  retryCondition?: (error: any) => boolean, // 조건 함수
  times = 1 // retry 횟수 (여기서는 1번만 필요하니 1로 기본값을 둔다.)
): Promise<T> {
  try {
    return await callback();
  } catch (error) { 
    // error retry times가 유효하고, retryCondition에 부합하는 에러이면 함수를 다시 실행한다.
    if (times > 0 && retryCondition?.(error)) {     
      return await retryCatch(callback, retryCondition, times - 1);
    } else {
      // 그 이외의 경우에는 error을 throw 한다.
      throw error;
    }
  }
}
// index.ts
import { retryCatch } from "modules/retryCatch";
...

const pushGymToDatabase = async (gym: RockTaGym["Gym"]) => {
  const { address, phone, brand, ...others } = gym;

  return retryCatch(
    () =>
      prisma.$transaction(async (tx) => {
        const gymAddress = await tx.address.findFirst({
          where: {
            ...address,
          },
        });

        const gymInput = {
          ...others,
          address: {
            ...(gymAddress
              ? { connect: { id: gymAddress.id } }
              : { create: { ...address } }),
          },
          ...(!phone.phoneNumber
            ? {}
            : {
                phone: {
                  connectOrCreate: {
                    create: { phoneNumber: phone.phoneNumber },
                    where: {
                      phoneNumber: phone.phoneNumber,
                    },
                  },
                },
              }),
          brand: {
            connectOrCreate: {
              create: {
                name: brand.name,
                relatedBrand: brand.relatedBrand ?? null,
              },
              where: {
                name: brand.name,
              },
            },
          },
        };

        await tx.gym.upsert({
          create: gymInput,
          update: gymInput,
          where: {
            name: others.name,
          },
        });
      }),
    // retry 여부를 판단하는 함수를 넣어주었다.
    // error 객체 code 값에 P2002를 넣으면 첫 번째 파라미터에 넣은 Promise 함수를 재실행한다.
    (error) => error?.code === "P2002" // race condition error
  );
};
profile
Frontend Developer, 올라운더가 되고싶은 잡부 개발자, ISTP, 겉촉속바 인간, 블로그 주제 찾아다니는 사람

0개의 댓글