Prisma P2003, 외래키, foreign key | 부모 아이디를 넣었는데 자식만 막히면?
프리즈마 P2003은 표와 칸이 있어도 자식이 넣은 부모 아이디가 그 디비에 없을 때 납니다. generate 초록과 프리뷰 User 빈 표는 다른 칸이고, meta.field_name과 카카오 회원번호, 트랜잭션 upsert를 먼저 봅니다. Prisma, 외래키, 슈퍼베이스, 버셀, Next.js, 카카오 로그인, 1인 개발자 기준. 2026년 9월 프리즈마 오류·포스트그레스 외래키 공식 문서.
부모 표에 그 아이디 행이 있는지부터 봅니다. 프리즈마 오류 참고의 P2003 문구는 그 칸에서 외래키가 실패했다는 뜻이고, 포스트그레스는 같은 거절을 23503으로 남깁니다. 표가 없거나 칸이 없거나 연결이 끊긴 줄이 아닙니다.
자식 표에 값을 넣을 때 그 값이 부모 표의 기본키(또는 유니크)에 있어야 합니다. 세션의 사용자 아이디를 넣는데 사용자 표에 그 값이 없으면 디비가 거절합니다. 로컬에선 예전에 가입해 둔 사용자가 있고, 프리뷰 디비는 비어 있으면 같은 코드가 한쪽에선 통과하고 한쪽에선 P2003이 납니다.
제가 쓰는 카카오 콜백도 닉네임 화면까지는 되고, 세션 행을 넣는 줄에서만 500이 난 적이 있습니다. 로그에 P2003이 찍히면 콘솔 리다이렉트 주소가 아니라 부모 행을 봐야 합니다. 화면이 조용해져도 부모 표가 비어 있으면 다음 넣기에서 같은 줄이 반복됩니다.
먼저 기억할 것 | 클라이언트 생성 초록은 타입만 맞춘 상태입니다. 프리뷰 사용자 표에 그 아이디가 있는지는 다른 확인입니다.
자식이 넣은 아이디가 부모 표에 없으면 디비가 그 넣기를 거절한다
P2025, P2002, P2014는 다른 코드다
P2003은 외래키, P2002는 유니크, P2025는 그 행 없음입니다. 세 코드가 카카오 콜백 500 뒤에 같이 숨어 보여도, 고치는 줄이 다릅니다.
코드
디비가 거절한 이유
먼저 볼 칸
P2003
자식이 가리킨 부모 행이 없음, 또는 지우기가 자식을 남김
메타의 칸 이름, 부모 표 조회
P2002
유니크 칸이 이미 참
메타의 대상, 이메일과 카카오 아이디
P2025
고치기나 지우기가 찾는 그 행이 없음
조건 칸, 프리뷰 빈 표
P2014
필수 관계를 깨는 변경 (클라이언트 쪽 메시지)
관계 모드, 필수 관계 이름
P2021, P2022
표 없음, 칸 없음
마이그레이션 배포, 스키마 이름
P2014는 예전 클라이언트 검사나 관계 모드를 프리즈마로 둔 설정에서 필수 관계를 깨면 납니다. 디비 외래키가 살아있는 기본 모드에선 같은 장면이 P2003으로 옵니다. 프리즈마 참조 동작 문서도 제한(Restrict)이나 동작 없음(NoAction) 지우기가 디비에서 막히면 P2003이라고 적습니다.
유니크 중복은 P2002, 고칠 행이 없는 갱신은 P2025입니다. 여기서는 부모-자식 아이디만 봅니다. 코드를 보고 스키마를 고치기 전에, 지금 붙은 디비에 부모 행이 있는지만 먼저 확인해도 시간이 줄어듭니다.
클라이언트 생성 초록과 프리뷰 부모 행
클라이언트 생성은 타입만 만들고, 부모 행은 그 디비에 있어야 합니다. 로컬 슈퍼베이스에 사용자 세 명이 있어도 버셀 프리뷰가 붙는 프로젝트는 다른 디비입니다.
스키마에 세션의 사용자 아이디가 사용자 표 아이디를 가리키면, 마이그레이션은 제약만 만듭니다. 제약이 있어도 사용자 표가 비어 있으면 자식 넣기는 매번 거절됩니다. 표 자체가 없으면 P2021, 칸이 없으면 P2022입니다. P2003은 표와 칸과 제약이 다 있는 다음 단계입니다.
프리뷰 환경 변수의 디비 주소가 로컬과 같은 프로젝트인지, 스테이징 빈 프로젝트를 가리키는지 한 줄로 확인하세요. 넥스트 환경 변수와 버셀 환경 변수 문서 기준으로, 프리뷰 칸에 넣은 값이 빌드가 아니라 런타임 함수에 들어가야 콜백이 그 디비를 봅니다. 주소만 같고 표가 비어 있으면 연결은 되고 자식만 막힙니다.
콘솔 주소는 디비 행 밖 | 카카오 개발자 콘솔의 리다이렉트 주소가 맞아도, 프리뷰 사용자 표가 비어 있으면 세션 넣기는 P2003입니다. 콘솔을 고치기 전에 부모 표를 엽니다.
메타 칸 이름으로 부모를 찾기
메타의 칸 이름이 가리키는 자식 칸의 부모 표를 엽니다. 메시지에 세션 사용자 아이디 제약 같은 이름이 오면 세션이 사용자 표 아이디를 못 찾은 줄입니다. 모델 이름이 아니라 제약 이름이 찍히는 경우가 많아서, 스키마의 관계 칸과 맞춰 읽습니다.
흔한 실수는 카카오 회원번호(토큰의 회원 식별값, 또는 사용자 정보 아이디)를 사용자 표 기본키에 넣는 줄입니다. 기본키가 자동 아이디이고 카카오 아이디 칸이 따로 있으면, 세션의 사용자 아이디에는 그 자동 아이디가 들어가야 합니다. 회원번호를 그대로 넣으면 로컬에 우연히 같은 문자열이 없는 한 프리뷰와 로컬 모두 P2003입니다.
타입도 갈립니다. 부모 아이디가 문자열인데 자식에 숫자를 넣거나, 큰정수 칸에 문자 자동 아이디를 넣으면 디비가 그 값을 부모에서 못 찾습니다. 스튜디오에 행이 보여도 비교하는 값이 다르면 같은 거절입니다. 로컬에서 한 번 가입해 둔 행을 보고 프리뷰도 같다고 믿으면, 그 착각이 500으로 남습니다.
넣기, 지우기, 연결하기 중 어느 줄이 거절됐는지로 갈립니다. 같은 P2003이라도 고치는 명령이 다릅니다.
갈래
증상
먼저 할 일
넣기
자식만 넣다가 거절
부모를 찾고, 없으면 부모부터 생성
고치기
사용자 아이디를 다른 값으로 바꿈
새 값이 부모에 있는지 확인
지우기
부모를 지우려다 거절
제한이면 자식부터, 연쇄면 스키마 확인
연결하기
중첩 연결 아이디가 없음
연결 대신 생성, 또는 부모 아이디를 다시 조회
잘못된 칸
카카오 아이디를 사용자 아이디에 넣음
사용자 기본키와 카카오 아이디를 분리
중첩 쓰기는 관계 쿼리 문서의 연결하기가 이미 있는 행만 붙입니다. 없는 아이디를 연결하면 이 코드가 납니다. 부모와 자식을 한 번에 만들 때는 중첩 생성이 맞고, 이미 로그인된 사용자만 붙일 때는 그 사용자 아이디를 조회한 뒤 연결합니다. 카카오 콜백처럼 첫 로그인과 재로그인이 섞이면, 연결하기만 쓰면 첫 방문에서 바로 거절됩니다.
kakaoId를 userId에 넣으면 거절되는 줄
// User.id 가 cuid 인데 카카오 회원번호를 넣으면 부모를 못 찾는다
await prisma.session.create({
data: { userId: kakaoSub }, // P2003
})
// User.id 를 조회한 뒤 그 값을 넣는다
const user = await prisma.user.findUnique({ where: { kakaoId: kakaoSub } })
if (!user) throw new Error('부모 User 없음')
await prisma.session.create({
data: { userId: user.id },
})
카카오 콜백 500 뒤에 숨은 외래키
콜백 500은 화면이고, 로그의 P2003이 외래키입니다. 카카오 로그인 문서의 토큰 발급과 사용자 정보는 통과해도, 그 다음에 세션이나 계정 행을 넣는 서버 액션이 거절되면 사용자는 빨간 화면만 봅니다.
순서는 보통 토큰 교환, 사용자 정보, 사용자 있으면 갱신, 세션 넣기입니다. 네 번째 줄만 실패하면 콘솔 리다이렉트 주소를 만지작거리게 됩니다. 버셀 함수 로그에 P2003과 칸 이름이 있으면, 카카오 콘솔이 아니라 프리뷰 사용자 표입니다.
로컬에선 예전에 제가 한 번 로그인해 사용자가 남아 있고, 프리뷰는 마이그레이션만 돌려 표만 있는 경우가 많습니다. 콜백을 프리뷰 주소로 열면 그 사용자가 없으니 세션이 막힙니다. 토큰 응답의 아이디는 카카오 쪽 식별자고, 우리 사용자 기본키와 같은 칸이 아닙니다. 콘솔을 고치기 전에 프리뷰 스튜디오에서 사용자 표를 한 번 열어 보세요.
카카오 화면이 돌아와도 세션 넣기의 부모 User는 그 프리뷰 디비에 있어야 한다
onDelete와 트랜잭션으로 맞추기
부모를 같은 트랜잭션에서 만든 뒤 자식을 넣습니다. 콜백처럼 사용자와 세션을 연달아 쓰면, 중간에 끊기면 자식만 남은 상태가 아니라 둘 다 취소됩니다. 지우기 갈래는 스키마의 지우기 동작이 제한인지 연쇄인지부터 맞춥니다.
제한(또는 기본 동작 없음)이면 사용자를 지우기 전에 세션을 먼저 지워야 합니다. 연쇄면 사용자 지우기가 자식을 같이 지웁니다. 프리뷰에서만 거절되면 로컬 스키마에 연쇄를 적어 두고 프리뷰에는 마이그레이션이 안 들어간 경우도 있습니다. 스키마 파일과 그 디비 제약이 같아야 합니다.
혼자 돌리는 넥스트라면 있으면 갱신과 넣기를 한 트랜잭션으로 묶는 편이 짧습니다. 부모를 만든 다음 그 사용자 아이디만 자식에 넣으면, 카카오 회원번호를 외래키 칸에 넣는 실수도 줄어듭니다. 제약 이름만 보고 칸을 추측하지 말고, 스튜디오에서 부모 조회를 한 번 실행하세요.
User upsert와 Session create를 한 트랜잭션으로
const session = await prisma.$transaction(async (tx) => {
const user = await tx.user.upsert({
where: { kakaoId },
create: { kakaoId, nickname },
update: { nickname },
})
return tx.session.create({
data: { userId: user.id, kakaoAccessToken },
})
})
// 스키마 예: 부모를 지우면 세션도 같이 지울 때
// user User @relation(fields: [userId], references: [id], onDelete: Cascade)
프리뷰 디비에서 부모 행과 외래키 제약 확인
SELECT id, "kakaoId" FROM "User" WHERE "kakaoId" = '카카오회원번호';
SELECT conname, conrelid::regclass AS child, confrelid::regclass AS parent
FROM pg_constraint
WHERE contype = 'f';
-- 자식에 넣으려던 값이 첫 쿼리 결과에 없으면 P2003이 맞다