findUnique → create의 함정: Prisma get-or-create 패턴의 P2002 레이스 컨디션과 3줄짜리 해법

'없으면 만들어'는 간단해 보이지만 동시 요청 2개가 들어오면 둘 다 'findUnique가 없다'를 거쳐 둘 다 create를 호출합니다. unique constraint에 걸려 P2002가 터지는 전형적인 get-or-create 레이스. try/catch 3줄로 우아하게 풀었습니다.

1. 문제 상황

1.1 PR 리뷰에서 나온 한 줄

주간 리포트 기능의 PR을 올렸더니, 레포에 붙여둔 Claude Code Review 워크플로우(.github/workflows/claude-code-review.yml)가 PR에 이런 코멘트를 남겼습니다.

getOrCreateReportfindUnique → create는 원자적이지 않습니다. 동시 요청이 들어오면 P2002가 터질 수 있어요.

당시 코드는 이랬습니다:

// ❌ Before: 레이스 컨디션 취약
export async function getOrCreateReport(
  organizationId: string,
  year: number,
  week: number,
) {
  let report = await prisma.weeklyReport.findUnique({
    where: { organizationId_year_week: { organizationId, year, week } },
    include: { entries: true },
  });

  if (!report) {
    report = await prisma.weeklyReport.create({
      data: { organizationId, year, week, startDate, endDate, entries: {...} },
      include: { entries: true },
    });
  }

  return report;
}

"없으면 만든다" — 얼핏 맞아 보이는 이 2단계 구조에 틈이 있다는 지적입니다.

1.2 구체적인 재현 시나리오

월요일 아침, 두 사용자가 동시에 주간 리포트 페이지를 열었다고 상상해봅시다. 그 주의 리포트는 아직 DB에 없습니다.

시간   요청 A                          DB                      요청 B
───────────────────────────────────────────────────────────────────────
T1     findUnique(o,2026,15) ──────► null 반환
T2                                                      ◄── findUnique(o,2026,15)
T3                                   ───► null 반환
T4     !report → create 진입
T5                                                      !report → create 진입
T6     create(...) ────────────────► INSERT 성공
T7                                                      ◄── create(...)
T8                                   ◄── INSERT 시도 ────
T9                                   P2002 unique constraint 위반!

A는 정상 응답, B는 PrismaClientKnownRequestError: Unique constraint failed on the fields: (organizationId, year, week). 사용자에게는 500 에러.

1.3 이게 얼마나 자주 터지나?

  • F5 연타 한 번만으로도 재현됩니다.
  • 월요일 아침에 팀 전체가 자동화 대시보드를 여는 순간 병렬 요청이 겹칩니다.
  • 슬랙 프리뷰 크롤러 + 실제 사용자 접근이 같은 순간에 발생하는 경우.
  • E2E 테스트에서 병렬 spec 실행 시 간헐적 실패.

"간헐적"이어서 더 위험합니다. 로컬과 스테이징에서는 잘 안 재현되고, 프로덕션 트래픽에서 가끔 터지는 500 에러는 원인 파악이 어렵습니다.

2. 원인 분석

2.1 TOCTOU — 이번에도 같은 구조

이 패턴은 이전 글 Prisma 일괄 업데이트에서 동시성 이슈 해결하기에서 다룬 TOCTOU(Time of Check to Time of Use) 취약점의 또 다른 모습입니다.

  • Time of Check: findUnique() — "이 row가 있나?"
  • Time of Use: create() — "없으니 만든다"

체크와 사용 사이에 다른 프로세스가 같은 row를 만들어버리면 check 결과가 더 이상 유효하지 않습니다. 이전 글은 "조회 → 업데이트" 패턴이었고, 이번은 "조회 → 생성" 패턴. 해법도 다릅니다.

2.2 왜 $transaction만으로는 안 되나?

첫 번째 유혹은 이전 글처럼 $transaction으로 묶는 것입니다.

// ⚠️ 이 방법은 Prisma/PostgreSQL 기본 격리 수준에서 효과 없음
await prisma.$transaction(async (tx) => {
  let report = await tx.weeklyReport.findUnique(...);
  if (!report) {
    report = await tx.weeklyReport.create(...);
  }
  return report;
});

Prisma + PostgreSQL의 기본 격리 수준은 Read Committed입니다. 이 수준에서는 phantom read — 다른 트랜잭션이 커밋한 새 row를 볼 수 있습니다. 즉:

  • 트랜잭션 A의 findUnique: null
  • 트랜잭션 B가 같은 row를 커밋
  • 트랜잭션 A의 create: 여전히 P2002

Serializable 격리 수준으로 올리면 막히지만, 그건 "망치로 파리 잡기"입니다. 게다가 Prisma에서 Serializable 격리는 설정 번거롭고 성능 비용이 있습니다.

2.3 UPSERT는 어떨까?

await prisma.weeklyReport.upsert({
  where: { organizationId_year_week: { organizationId, year, week } },
  create: { organizationId, year, week, startDate, endDate, entries: {...} },
  update: {},
});

upsert는 DB 레벨 원자성을 보장하므로 이론상 완벽합니다. 하지만 이번 경우에는 문제가 있었습니다:

  • **nested entries: { create: [...] }**의 동작이 create 브랜치에서만 실행되는지 update 브랜치에서도 실행되는지 명확하지 않음.
  • 기존 리포트가 있을 때 "보충 로직"(missing entries만 추가)이 필요한데, upsertupdate: {}로는 표현하기 어려움.
  • 에러 케이스의 흐름이 create 분기와 다르게 나뉘어 로깅/디버깅이 복잡해짐.

그래서 **"낙관적 create + P2002 catch"**라는 더 단순한 패턴을 선택했습니다.

3. 해결 방법: try/catch 3줄

3.1 핵심 아이디어

"먼저 만들어보고, 이미 있으면 그때 조회한다."

// ✅ After: 낙관적 create + P2002 handling
import { Prisma } from "@prisma/client";

export async function getOrCreateReport(
  organizationId: string,
  year: number,
  week: number,
  timezone: string,
) {
  const { startDate, endDate } = getWeekRange(year, week, timezone);
  const activeProjectIds = await getActiveProjectIdsInWeek(
    prisma, organizationId, startDate, endDate,
  );

  let report = await prisma.weeklyReport.findUnique({
    where: { organizationId_year_week: { organizationId, year, week } },
    include: { entries: { include: ENTRY_INCLUDE } },
  });

  if (!report) {
    try {
      report = await prisma.weeklyReport.create({
        data: {
          organizationId, year, week, startDate, endDate,
          entries: { create: activeProjectIds.map((id) => ({ projectId: id })) },
        },
        include: { entries: { include: ENTRY_INCLUDE } },
      });
    } catch (e) {
      if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
        // 다른 요청이 먼저 생성함 → 재조회 후 보충 로직으로 fallthrough
        report = await prisma.weeklyReport.findUniqueOrThrow({
          where: { organizationId_year_week: { organizationId, year, week } },
          include: { entries: { include: ENTRY_INCLUDE } },
        });
      } else {
        throw e;
      }
    }
  }

  // 보충 로직: 해당 주 active 과제 중 entry 없는 과제 추가
  // → create 또는 catch 분기 모두 이 로직을 거침
  if (report) {
    const existingIds = new Set(report.entries.map((e) => e.projectId));
    const missing = activeProjectIds.filter((id) => !existingIds.has(id));
    if (missing.length > 0) {
      await prisma.reportEntry.createMany({
        data: missing.map((id) => ({ reportId: report!.id, projectId: id })),
      });
      report = await prisma.weeklyReport.findUniqueOrThrow({
        where: { id: report.id },
        include: { entries: { include: ENTRY_INCLUDE } },
      });
    }
  }

  return report;
}

3.2 핵심 3줄

실제로 레이스 컨디션을 해결한 부분은 다음 3줄입니다:

if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
  report = await prisma.weeklyReport.findUniqueOrThrow({ where: ... });
}
  • P2002 — Prisma의 unique constraint violation 에러 코드
  • findUniqueOrThrow — 이 시점엔 분명히 존재해야 하므로 null이면 예외
  • 그 외 에러는 throw로 재던지기

3.3 왜 "보충 로직"을 공통 경로로 뒀나?

create가 성공한 경우와 catch에 들어간 경우 모두, 결국 동일한 if (report) 블록을 거칩니다. 이유:

  • create 성공 시: active 과제가 새로 추가됐다면 해당 entry를 추가해야 함 (주 중반에 과제 승격)
  • catch 성공 시: 다른 요청이 만든 리포트는 자신이 보던 시점의 active 목록과 다를 수 있음 → 역시 보충 필요

이렇게 두면 "누가 먼저 생성했든" 최종 상태는 동일합니다. 멱등성이 이 패턴의 진짜 가치입니다.

4. 세부 설계 결정

4.1 find 먼저? create 먼저?

get-or-create 패턴에는 두 가지 전략이 있습니다:

전략 흐름 언제?
Read-first findUnique → 없으면 create 대부분 이미 존재 (hot path 최적화)
Write-first create 시도 → P2002면 findUnique 대부분 없음 (hot path 최적화)

이번 경우 주간 리포트는 사용자가 처음 방문할 때만 없고, 이후 모든 요청은 이미 존재하는 리포트를 읽습니다. 그래서 read-first가 hot path입니다. 단, 최초 생성 순간에 레이스가 생길 뿐. 그 순간만 write 실패 → refetch로 방어합니다.

write-first를 택할 수도 있었지만, 매 GET마다 create attempt + rollback 비용을 치르는 건 과합니다. Read-first + catch가 균형이 좋습니다.

4.2 왜 findUniqueOrThrow인가?

catch 안에서는 이 시점에 반드시 row가 존재해야 합니다. 방금 P2002가 떴다는 건 누군가 create했다는 증거이니까요. 그래서 findUnique(null 허용) 대신 findUniqueOrThrow(예외 throw)를 씁니다.

만약 여기서도 null이 나온다면 그건 다른 오류의 신호입니다:

  • 외부 프로세스가 P2002 직후 즉시 DELETE — 이론상 가능하지만 매우 드문 경합
  • Prisma 클라이언트 버그

둘 다 복구 불가능한 상황이므로 예외를 던져 fail-fast가 옳습니다.

4.3 ENTRY_INCLUDE 상수 재사용

findUniquefindUniqueOrThrow를 두 번 호출하는 자리에서 같은 include 옵션이 중복됩니다. 모듈 상단에 상수로 추출:

const ENTRY_INCLUDE = {
  project: { select: { id: true, name: true, requestDept: true, status: true } },
  comments: {
    include: { author: { select: { id: true, name: true, email: true, image: true } } },
    orderBy: { createdAt: "asc" as const },
  },
} as const;

양쪽에서 같은 include를 쓰지 않으면 catch 경로의 report 객체에 entries.comments가 빠지는 식의 미묘한 불일치가 생깁니다. 타입 시그니처가 같아지는 것이 중요합니다.

5. 핵심 개념 정리

5.1 Prisma 에러 코드 요약

Prisma는 DB 에러를 구조화된 코드로 반환합니다. 자주 쓰는 것들:

코드 의미 해결
P2002 Unique constraint 위반 재조회 or upsert
P2003 Foreign key constraint 위반 참조 무결성 복구
P2025 레코드 없음 (update/delete 대상) 사전 검증 or catch
P2014 Required relation 위반 부모 먼저 생성
import { Prisma } from "@prisma/client";

try {
  await prisma.x.create(...);
} catch (e) {
  if (e instanceof Prisma.PrismaClientKnownRequestError) {
    switch (e.code) {
      case "P2002": /* duplicate */ break;
      case "P2003": /* fk violation */ break;
      default: throw e;
    }
  } else {
    throw e;
  }
}

5.2 get-or-create 패턴의 4가지 구현

// 1. 순진한 구현 (❌ 레이스 취약)
const x = (await prisma.x.findUnique(...)) ?? await prisma.x.create(...);

// 2. Transaction + Read Committed (❌ phantom read로 여전히 P2002 가능)
await prisma.$transaction(async (tx) => {...});

// 3. Upsert (✅ DB 레벨 원자성, 단 "생성 vs 조회" 구분이 흐릿)
await prisma.x.upsert({ where, create, update: {} });

// 4. Read-first + P2002 catch (✅ 이 글의 접근, 멱등한 보충 로직 가능)
let x = await prisma.x.findUnique(...);
if (!x) {
  try { x = await prisma.x.create(...); }
  catch (e) { if (e.code === "P2002") x = await prisma.x.findUniqueOrThrow(...); }
}

5.3 언제 어떤 패턴을 쓸까?

상황 추천
단순 get-or-create, 생성 후 추가 작업 없음 upsert
생성 후 조건부 보충/업데이트 필요 read-first + P2002 catch
hot path가 write 쪽 (대부분 생성) write-first + P2002 catch
엄격한 순차성 필요 (결제 등) advisory lock + transaction

6. 베스트 프랙티스

6.1 체크리스트

  • [ ] findUnique → create가 있는 모든 자리를 재검토 — 대부분 레이스 취약합니다
  • [ ] 에러 타입 체크는 instanceof Prisma.PrismaClientKnownRequestError로 (instanceof 없이 .code만 검사하면 타입이 unknown이라 불안정)
  • [ ] catch 안의 fallback은 findUniqueOrThrow로 fail-fast
  • [ ] 후속 로직(이 예제의 보충 블록)을 catch/try 공통 경로에 두어 멱등성 확보
  • [ ] 테스트에서 Promise.all([getOrCreate(), getOrCreate()])로 병렬 시나리오 검증

6.2 테스트 패턴

import { describe, it, expect } from "vitest";
import { getOrCreateReport } from "@/app/api/reports/weekly/route";

describe("getOrCreateReport - 동시성", () => {
  it("같은 주차를 병렬로 호출해도 하나만 생성되고 둘 다 같은 ID를 반환", async () => {
    const [r1, r2] = await Promise.all([
      getOrCreateReport(orgId, 2026, 15, "Asia/Seoul"),
      getOrCreateReport(orgId, 2026, 15, "Asia/Seoul"),
    ]);
    expect(r1!.id).toBe(r2!.id);
    const count = await prisma.weeklyReport.count({
      where: { organizationId: orgId, year: 2026, week: 15 },
    });
    expect(count).toBe(1);
  });
});

이 테스트는 순진한 구현으로는 간헐적으로 실패하고, 수정 후에는 안정적으로 통과합니다. CI에서 반복 실행해서 flakiness를 확인하는 게 안전합니다.

6.3 관측 가능성

catch 안에서 재조회가 일어났다는 사실을 로깅해두면 레이스 빈도를 추적할 수 있습니다:

catch (e) {
  if (e.code === "P2002") {
    console.info("[getOrCreateReport] P2002 — concurrent creation detected", { orgId, year, week });
    report = await prisma.weeklyReport.findUniqueOrThrow(...);
  }
}

너무 자주 발생하면 캐시나 디바운싱 같은 상위 레이어 최적화를 고려할 신호입니다.

7. FAQ

Q. upsert로 하면 안 되나요?

A. 이 사례에서는 "생성 시 entries도 create" + "조회 시 보충 로직 실행"이라는 두 분기가 달라서 upsert는 create: {...}update: {}의 의미가 어색했습니다. 단순 get-or-create라면 upsert가 더 깔끔합니다. 판단 기준: "생성 브랜치와 업데이트 브랜치의 후속 로직이 동일한가?"

Q. 에러 코드 대신 메시지로 분기해도 되나요?

A. 권장하지 않습니다. Prisma 에러 메시지는 버전 간에 문구가 바뀔 수 있습니다. .code === "P2002"는 API의 명시적 계약입니다.

Q. SERIALIZABLE로 올리는 건?

A. 막히긴 합니다. 하지만 격리 수준 전체를 올리면 다른 쿼리들도 영향을 받고, 성능 비용이 큽니다. 또 Prisma + PostgreSQL에서 Serializable 격리는 설정이 번거롭고 에러 처리(serialization_failure)도 따로 필요합니다. 이 한 곳 때문에 전역 설정을 바꾸는 건 과한 해법입니다.

Q. 이 패턴은 MySQL에서도 동작하나요?

A. 네. MySQL의 unique violation도 Prisma가 P2002로 통일해서 반환합니다. PostgreSQL의 unique_violation, MySQL의 ER_DUP_ENTRY, SQLite의 SQLITE_CONSTRAINT_UNIQUE 모두 같은 P2002로 노출됩니다.

Q. create 안에서 nested write를 쓰는데 부분 실패 처리는?

A. Prisma의 nested write는 전체가 하나의 트랜잭션입니다. entries가 하나라도 실패하면 WeeklyReport 자체도 롤백됩니다. 따라서 catch 경로에서 재조회된 리포트는 "완전한 상태"라고 가정할 수 있습니다.

Q. 더 많은 에러를 한 번에 잡고 싶어요.

A. 에러 매칭 로직을 유틸로 추출할 수 있습니다:

function isPrismaError(e: unknown, code: string): e is Prisma.PrismaClientKnownRequestError {
  return e instanceof Prisma.PrismaClientKnownRequestError && e.code === code;
}

// 사용
catch (e) {
  if (isPrismaError(e, "P2002")) { /* ... */ }
  else { throw e; }
}

8. 참고 자료

9. 다음 단계

이 글의 catch 패턴이 "생성 후 후속 로직"을 공통 경로에 두는 이유를 다뤘다면, 서로 다른 테이블을 원자적으로 묶어야 하는 또 다른 케이스가 있습니다. prisma.project.createlogProjectCreated를 하나의 트랜잭션으로 묶으면서, Prisma의 $extends(테넌트 격리)가 트랜잭션 콜백 내에서 동작하지 않는 함정을 만났던 경험은 별도 글에서 다룹니다.