TechFeedTechFeed
Backend

Prisma P2002, unique constraint, 중복 키 | 가입 버튼 두 번이 원인일까?

P2002는 유니크 칸이 이미 차서 넣기를 거절한 줄입니다. 가입 연타는 한 원인일 뿐입니다. meta.target과 메시지의 칸을 읽고, findUnique 다음 create를 끊고, 카카오 이메일은 있는 행으로 로그인합니다. 프리즈마, 넥스트, 슈퍼베이스, PostgreSQL 23505, 백엔드, 1인 개발자 기준. 2026년 9월 프리즈마·포스트그레스 공식 문서.

by

P2002는 테이블이 없어서가 아니라, 넣으려는 값이 이미 유니크 칸을 차지해서 납니다. 가입 버튼을 두 번 누른 게 한 원인이고, 유일한 원인은 아닙니다.


넥스트로 카카오 가입을 붙이다 보면, 로컬에선 되고 프리뷰만 Unique constraint failed가 뜨는 일이 있습니다. 제가 쓰는 슈퍼베이스 사용자 표도 이메일에 @unique를 걸어 두어서, 같은 주소로 create가 두 번 들어가면 바로 이 코드가 납니다.


포스트그레스는 23505 unique_violation을 던지고, 프리즈마 클라이언트가 그걸 P2002로 감쌉니다. 근거는 프리즈마 에러 참고 P2002예외 처리 문서, 포스트그레스 에러 코드 부록에 있습니다.


P2002는 중복 키이지 테이블 없음이 아니다

유니크 칸이 이미 차 있어서 넣기를 거절한 줄입니다. 프리즈마 6 참고는 메시지를 Unique constraint failed on the {constraint} 라고 적습니다. 표 이름 자체가 없다는 P2021과 코드가 다릅니다.


데이터베이스는 먼저 제약을 검사합니다. 이메일처럼 @unique가 붙은 칸, 또는 @@unique([authorId, slug])처럼 두 칸을 묶은 조합이 이미 있으면 삽입은 실패합니다. 포스트그레스 부록에서 이 조건의 SQLSTATE는 23505, 이름은 unique_violation입니다. 프리즈마는 그 응답을 클라이언트가 아는 코드 P2002로 바꿉니다.


그래서 마이그레이션을 다시 돌리거나 클라이언트를 다시 생성해도 같은 줄이 남습니다. 스키마 파일이 틀린 게 아니라, 지금 넣으려는 값이 이미 한 행을 차지하고 있다는 뜻이에요. 프리뷰 디비에 표가 없는 칸은 P2021 테이블 없음에서만 다룹니다.


먼저 기억할 것 | 코드가 P2002이면 migrate부터 돌리지 마세요. e.code와 메시지에 찍힌 칸을 읽고, 그 칸의 기존 행이 누구인지부터 찾습니다.


가입 버튼을 두 번 누르면 같은 줄이 나나

두 번 누르면 같은 이메일이 두 번 들어가서 P2002가 납니다. 다만 카카오 계정이 예전에 한 번 들어간 경우, 시드 데이터가 같은 주소를 가진 경우도 같은 코드입니다.


장면무엇이 겹치나먼저 할 일
가입 버튼 연타같은 요청이 거의 동시에 두 번대기 중 버튼을 잠그고, 서버는 P2002를 409로
카카오 이메일 재가입이미 있는 이메일로 create기존 행을 찾고 로그인으로 안내
슬러그 조합authorId + slug가 이미 있음메시지 칸이 배열인지 확인
시드·프리뷰 데이터로컬에 없는 행이 프리뷰에만 있음프리뷰 표를 SELECT로 확인
조회 후 넣기두 요청이 둘 다 빈 칸을 봄findUnique 다음 create를 끊기

버튼 연타는 화면에서 막는 일과 서버에서 잡는 일이 갈립니다. 화면 잠금은 폼 상태 훅이 담당하고, 여기서는 디비가 거절한 줄만 봅니다. 화면이 잠겨도 새로고침이나 두 탭이면 요청은 두 번 갑니다.


한국 1인 사이드 프로젝트에서 제가 자주 본 건 카카오 콜백입니다. 이메일로 먼저 가입해 둔 사용자가 같은 주소의 카카오 계정으로 들어오면, 콜백이 user.create를 한 번 더 호출합니다. 버튼이 한 번만 눌려도 P2002입니다.


가입 요청이 두 번 들어가 유니크 칸이 겹치는 서버실 작업 장면
같은 이메일로 create가 두 번 들어가면 디비가 유니크를 거절한다

에러에서 어떤 칸이 겹쳤는지 읽는 법

코드가 P2002인지 확인하고, 메시지나 meta에 찍힌 칸 이름을 읽습니다. 칸을 모르면 사용자에게 이메일인지 슬러그인지 말할 수 없습니다.


프리즈마 공식 예외 처리는 PrismaClientKnownRequestError인지 본 다음 e.code === "P2002"를 비교합니다. 6 계열 참고는 meta.target 예로 { target: [ 'email' ] }을 적습니다. 7 계열 일부 어댑터에서는 target이 빠지고, 메시지 문자열 Unique constraint failed on the fields: (`email`) 또는 드라이버 원인 쪽 constraint.fields에만 남는 경우가 있습니다. 문서에 있는 칸을 먼저 보고, 없으면 메시지를 파싱하세요.


배열이 두 개면 복합 유니크입니다. ['authorId', 'slug']이면 이메일 중복이 아닙니다. 글 주소가 그 작성자에게 이미 있다는 뜻이에요. 한 칸만 보고 가입 화면을 띄우면 사용자는 이메일을 바꿉니다. 그건 고친 게 아닙니다.


P2002를 잡고 칸 이름을 읽기
import { Prisma } from '@prisma/client' try { await prisma.user.create({ data: { email, name } }) } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { const target = e.meta?.target // 6 계열: ['email'] 복합: ['authorId','slug'] // 7 어댑터에서 target이 없으면 e.message를 본다 return { ok: false, code: 'P2002', target } } throw e }

조회한 다음 넣기는 빈 칸이 남는다

findUnique가 null이어도, 그 사이에 다른 요청이 넣으면 create가 P2002를 받습니다. 빈 칸을 본 순간과 넣는 순간이 같은 잠금이 아닙니다.


가입 서버 액션에서 이메일을 먼저 찾고 없으면 넣는 패턴은 로컬에선 잘 됩니다. 프리뷰에 두 탭을 열거나 카카오 콜백이 재시도되면, 둘 다 없다고 보고 둘 다 넣습니다. 두 번째가 유니크에 걸립니다. 디비 제약이 마지막 문지기인 이유가 여기 있습니다.


고치는 쪽은 조회와 넣기를 한 문장으로 붙이는 것입니다. 있으면 갱신하고 없으면 만드는 upsert는 프리즈마 CRUD 문서가 그 자리로 안내합니다. 4.6.0부터는 가능한 곳에서 데이터베이스 네이티브 명령으로 붙습니다. 가입처럼 이미 있으면 만들면 안 되는 칸은, upsert의 update를 비워 두고 기존 행을 돌려 받은 뒤 로그인으로 보내는 편이 맞습니다. 갱신으로 이름을 덮어 쓰면 안 됩니다.


조회 후 넣기 대신 upsert로 한 번에
// 두 요청이 둘 다 null을 보면 두 번째 create가 P2002 const found = await prisma.user.findUnique({ where: { email } }) if (!found) { await prisma.user.create({ data: { email, name } }) } // 유니크 칸을 where에 두고 한 번에. 이미 있으면 행만 돌려받는다 const user = await prisma.user.upsert({ where: { email }, update: {}, create: { email, name }, })
조회와 넣기가 어긋나 유니크 제약이 거절하는 모니터 작업 장면
findUnique와 create 사이에 다른 요청이 들어가면 두 번째가 P2002를 받는다

upsert와 멱등 키가 갈리는 자리

이미 있는 행을 그대로 쓰면 upsert, 같은 요청을 두 번 보내도 한 행만 남기면 멱등 키입니다. 가입은 전자, 결제 웹훅은 후자에 가깝습니다.


방법맞는 자리주의
P2002를 잡아 409진짜 중복 가입을 사용자에게 알릴 때칸 이름을 같이 돌려 주기
upsert update 빈 객체있으면 그 행, 없으면 생성where는 유니크 칸만
createMany skipDuplicates시드·일괄 넣기가입 한 건에는 과함. 몽고·SQL Server·SQLite는 미지원
요청 아이디 유니크웹훅이 같은 본문을 다시 보낼 때이메일 유니크와 자리를 섞지 말 것

upsert의 where는 유니크 칸만 받습니다. 이름이나 가입일처럼 겹칠 수 있는 칸으로 findOrCreate를 흉내 내면 타입부터 거절됩니다. CRUD 문서도 그 한계를 적습니다.


시드 스크립트에서 같은 이메일을 여러 줄로 넣는 경우는 createMany({ skipDuplicates: true })가 맞습니다. 가입 버튼 한 번에 그걸 쓰면, 이미 있는 사용자를 성공으로 포장할 수 있습니다. 화면에는 가입 완료가 뜨고 세션은 다른 사람입니다.


카카오 이메일이 이미 표에 있으면

카카오가 준 이메일이 사용자 표에 있으면 create 대신 그 행으로 로그인합니다. 버튼을 한 번만 눌러도 P2002가 나는 자리입니다.


한국 서비스는 이메일 가입과 카카오 로그인을 같이 여는 경우가 많습니다. 같은 주소가 두 경로로 들어오면 유니크는 한 칸입니다. 콜백에서 매번 create를 호출하면, 두 번째부터 P2002입니다. 카카오 식별자를 별도 유니크로 두고 이메일은 느슨하게 두면, 이번엔 이메일 칸이 아니라 식별자 칸이 겹칩니다. 메시지 target을 읽어야 어느 화면을 띄울지 갈립니다.


조회가 되고 넣기만 403이면 유니크가 아닙니다. 슈퍼베이스 행 수준 보안이 쓰기를 막은 칸은 RLS 넣기 거절입니다. P2002는 권한이 있어서 넣으려다 값이 겹친 쪽입니다. 서비스 롤 키를 브라우저에 넣는 우회는 여기 답이 아닙니다.


카카오 콜백에서 이메일 유니크를 먼저 보기
const email = kakaoProfile.email if (!email) throw new Error('카카오 이메일이 없음') const existing = await prisma.user.findUnique({ where: { email } }) if (existing) { // 이미 있는 행으로 세션만 연다. create를 다시 치지 않는다 return existing } try { return await prisma.user.create({ data: { email, kakaoId: String(kakaoProfile.id) }, }) } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { return prisma.user.findUnique({ where: { email } }) } throw e }
카카오 콜백과 이메일 가입이 같은 유니크 칸을 쓰는 모니터 책상
같은 이메일로 카카오 콜백이 create를 한 번 더 치면 P2002가 난다

P2021, 외래 키, 소프트 삭제와 섞지 말 것

P2002는 값이 겹친 줄만 봅니다. 표가 없거나, 외래 키가 없거나, 지운 행의 이메일을 다시 쓰는 문제는 다른 코드입니다.


P2021은 현재 디비에 표가 없습니다. generate만 하고 프리뷰가 비어 있으면 그 줄이 납니다. P2003은 외래 키 칸이 가리키는 행이 없을 때입니다. P2022는 컬럼 자체가 없습니다. 셋 다 Unique constraint failed가 아닙니다.


탈퇴한 이메일을 다시 가입시키려면 살아 있는 행만 유니크로 묶는 부분 인덱스가 필요합니다. 그건 소프트 삭제와 부분 유니크입니다. 여기서 소프트 삭제를 켜면 P2002가 사라지는 게 아니라, 지운 행과 새 행이 같은 이메일을 들고 또 겹칩니다. ORM 선택 비교는 드리즐과 프리즈마로 미룹니다.


출처


기준일 2026-09-03. 프리즈마 클라이언트 6·7 계열과 포스트그레스 공식 문서.


자주 묻는 질문

가입 버튼을 한 번만 눌렀는데도 P2002가 납니다.

연타만 원인은 아닙니다. 카카오 콜백이 같은 이메일로 create를 다시 치거나, 프리뷰 시드에 이미 그 주소가 있거나, 두 탭이 조회 후 넣기를 동시에 타면 한 번 눌러도 납니다. meta.target과 표 SELECT를 먼저 보세요.


migrate deploy를 하면 고쳐지나요?

고치지 않습니다. 표가 없는 P2021 자리입니다. P2002는 표와 제약이 있는 상태에서 값이 겹친 줄이라, 마이그레이션을 다시 돌려도 같은 이메일은 또 거절됩니다.


meta.target이 없습니다. 버그인가요?

7 계열 일부 드라이버 어댑터에서 target이 빠지는 사례가 있습니다. 메시지 Unique constraint failed on the fields 또는 constraint.fields를 읽으세요. 코드 e.code === "P2002"는 그대로입니다.


upsert로 바꾸면 가입이 항상 성공하나요?

이미 있는 이메일은 그 행을 돌려줄 뿐입니다. 새 계정으로 포장하면 안 됩니다. 기존 행이면 로그인으로 보내고, 진짜 새 값만 create 경로를 타게 하세요. where는 유니크 칸만 됩니다.


소프트 삭제한 이메일이 다시 가입이 안 됩니다.

지운 행이 같은 유니크를 아직 들고 있어서입니다. 살아 있는 행만 묶는 부분 유니크가 필요하고, 그건 소프트 삭제 글의 자리입니다. P2002 핸들러만 바꿔서는 재가입이 열리지 않습니다.


조회는 되고 넣기만 막힙니다. P2002인가요?

권한 정책이면 403이고 코드도 P2002가 아닙니다. 값이 겹치면 프리즈마가 P2002를 던집니다. 응답 본문의 코드와 슈퍼베이스 정책을 섞지 마세요.


P2002는 유니크 칸이 이미 차 있다는 뜻입니다. 코드와 칸 이름을 읽고, 조회 후 넣기를 끊고, 카카오 콜백은 있는 행으로 로그인하세요. 관련 글: P2021 테이블 없음, 소프트 삭제 부분 유니크, 이중 제출 버튼, RLS 넣기 거절, 드리즐과 프리즈마.


PrismaP2002unique중복 키가입upsert슈퍼베이스카카오PostgreSQL넥스트백엔드유니크개발자

함께 보면 좋은 문제 해결

EXPLORE / Backend

이어서 읽어보기

전체 토픽 둘러보기