Seed 데이터 FK 삭제 순서 삽질 — Prisma deleteMany가 터지는 이유
Prisma seed 스크립트에서 deleteMany 순서를 잘못 잡으면 FK 제약 조건 위반 에러가 발생합니다. 테이블 의존 관계를 분석하고 자식→부모 순으로 삭제하는 실전 패턴을 정리합니다. 바로 적용할 수 있는 방법을 알려드릴게요.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- Prisma seed 스크립트에서
deleteMany순서를 잘못 잡으면 FK 제약 조건 위반 에러가 터진다- 핵심 에러:
Foreign key constraint failed on the field: (not available)- 즉각 해결: 자식 테이블 → 부모 테이블 순서로
deleteMany호출- 장기 전략: 삭제 순서를 의존 그래프 역순으로 코드에 명시하고, 주석으로 FK 관계 표기
- Prisma는 MySQL/PostgreSQL과 달리
CASCADE삭제가 자동 적용되지 않으므로, 직접 순서를 관리해야 한다
🔍 증상: seed 스크립트가 FK 제약 조건 위반으로 실패
npx prisma db seed를 실행했더니 바로 터졌다.
로컬 개발 중에 “데이터 갈아엎고 다시 넣자”는 단순한 의도였다. 근데 그게 함정이었다 😅
❌ 발생한 에러 메시지
$ npx prisma db seed
Environment variables loaded from .env
Running seed command `ts-node --compiler-options {"module":"CommonJS"} prisma/seed.ts` ...
Invalid `prisma.course.deleteMany()` invocation:
Foreign key constraint failed on the field: (not available)
at RequestHandler.handleRequestError (node_modules/@prisma/client/runtime/library.js:128:7)
at RequestHandler.request (node_modules/@prisma/client/runtime/library.js:123:13)
---
...
An error occurred while running the seed command:
Error: Command failed with exit code 1: ts-node ...
처음엔 DB 연결 문제인 줄 알았다.
.env 확인하고, npx prisma generate도 다시 돌려봤다.
당연히 아무것도 안 바뀌었다.
⚠️ 주의:
Foreign key constraint failed on the field: (not available)에서(not available)은 MySQL + Prisma 조합에서 자주 보이는 형태다. 어떤 필드가 문제인지 명시되지 않아서 처음엔 방향 잡기가 어렵다.
🔁 재현 조건
이 에러가 발생하는 전형적인 패턴이 있다.
- 테이블 A가 테이블 B를 외래 키로 참조한다
- seed 스크립트에서 B를 먼저
deleteMany호출한다 - A에 B를 참조하는 레코드가 남아있어서 DB가 삭제를 거부한다
Course (부모)
└── Lesson (자식, course_id FK)
└── UserLesson (손자, lesson_id FK)
이 구조에서 Course를 먼저 삭제하려 하면 Lesson이 course_id로 참조 중이라 실패한다.
Lesson을 먼저 삭제하려 해도 UserLesson이 lesson_id로 잡고 있어서 또 실패한다.
🔎 원인: deleteMany는 FK 참조를 무시하지 않는다

Prisma가 실제로 보내는 SQL
Prisma의 deleteMany는 마법이 없다.
내부적으로는 그냥 DELETE FROM ...을 실행한다.
MySQL은 FK 제약 조건 검사를 기본으로 켜두기 때문에, 참조 중인 레코드를 삭제하려 하면 DB 엔진이 직접 거부한다.
📌 핵심: Prisma는
deleteMany호출 시 FK 관계를 자동 분석해서 삭제 순서를 결정해주지 않는다. 개발자가 직접 순서를 지정해야 한다.
처음엔 이게 Prisma 버그인 줄 알았다. Prisma 공식 문서를 뒤져보니 의도된 동작이었다.
deleteanddeleteManydo not cascade by default. You need to either setonDelete: Cascadein your schema or handle deletion order manually.
아, 그러니까 순서를 내가 직접 잡아야 한다는 거였다.
잘못된 가설과 기각 과정
가설 1: Prisma 버전 문제 아닐까?
npm ls @prisma/client
# └── @prisma/[email protected]
버전 문제가 아니었다. 같은 버전으로 다른 프로젝트에서도 재현됐다.
가설 2: MySQL 설정 문제 아닐까?
-- FK 체크를 임시로 끄는 방법 (이건 쓰면 안 된다)
SET FOREIGN_KEY_CHECKS = 0;
이건 실제로 작동하긴 한다. 근데 데이터 정합성을 깨뜨릴 위험이 크다. 실수로 고아 레코드(orphan record)가 생기면 나중에 더 큰 삽질이 기다린다 💀
⚠️ 주의:
SET FOREIGN_KEY_CHECKS = 0은 임시방편이다. seed 스크립트에 이걸 넣으면 orphan 레코드가 쌓여서 다음 마이그레이션 때 터진다. 올바른 삭제 순서가 훨씬 안전하다.
테이블 의존 관계 파악하기
해결 전에 테이블 간 참조 관계를 먼저 정리했다.
UserLesson ──(lesson_id FK)──→ Lesson ──(course_id FK)──→ Course
의존 방향이 이렇다면, 삭제는 반드시 반대 방향으로 해야 한다.
삭제 순서: UserLesson → Lesson → Course
생성 순서: Course → Lesson → UserLesson
FK 참조 그래프를 그리고, 그 역순으로 삭제하면 된다. 이게 이 문제의 핵심 전부다.
✅ 해결: 자식 → 부모 순서로 삭제
❌ Before: 부모 테이블부터 삭제하려다 실패
// prisma/seed.ts
async function clearData() {
// ❌ Course를 먼저 삭제하려 하지만,
// Lesson이 course_id로 Course를 참조 중이라 FK 위반 발생
---
await prisma.course.deleteMany();
await prisma.lesson.deleteMany();
await prisma.userLesson.deleteMany();
---
}
prisma.course.deleteMany() 호출 시점에 바로 에러가 터진다.
Lesson 테이블에 course_id가 살아있기 때문이다.
✅ After: 자식 테이블부터 역순으로 삭제
// prisma/seed.ts
async function clearData() {
// ✅ FK 의존 관계: UserLesson → Lesson → Course
// 삭제는 반드시 역순(자식 → 부모)으로 수행
const deletedUserLessons = await prisma.userLesson.deleteMany();
console.log(`[seed] userLesson 삭제: ${deletedUserLessons.count}건`);
// ↑ UserLesson이 Lesson을 참조하므로 가장 먼저 삭제
const deletedLessons = await prisma.lesson.deleteMany();
console.log(`[seed] lesson 삭제: ${deletedLessons.count}건`);
// ↑ Lesson이 Course를 참조하므로 그 다음 삭제
const deletedCourses = await prisma.course.deleteMany();
console.log(`[seed] course 삭제: ${deletedCourses.count}건`);
// ↑ 참조하는 자식이 없으므로 마지막에 삭제 가능
---
}
deleteMany()는 삭제된 레코드 수(count)를 반환한다.
이걸 로그로 찍어두는 걸 습관처럼 하고 있는데, “왜 데이터가 안 들어갔지?” 류의 삽질을 꽤 막아준다.
⚠️ 트랜잭션으로 묶기
삭제 도중 에러가 나면 중간 상태로 남을 수 있다.
UserLesson은 지워졌는데 Lesson을 지우다 실패했다면?
다음 seed 실행 때 예상치 못한 충돌이 생긴다.
// prisma/seed.ts
async function clearData(prisma: PrismaClient) {
await prisma.$transaction([
// ✅ 트랜잭션 안에서도 순서는 동일하게 유지
---
// FK 의존: UserLesson → Lesson → Course
prisma.userLesson.deleteMany(), // 손자 먼저
prisma.lesson.deleteMany(), // 자식 다음
---
prisma.course.deleteMany(), // 부모 마지막
]);
console.log('[seed] 기존 데이터 전체 삭제 완료');
}
$transaction으로 감싸면 중간에 하나가 실패해도 전체가 롤백된다.
개발 환경 seed라도 이 패턴을 기본으로 깔고 가는 게 좋다.
📌 핵심:
$transaction배열 방식은 내부적으로 순서대로 실행된다. 단, 배열 안에서도 FK 역순은 직접 지켜야 한다. 트랜잭션이 순서 문제까지 해결해주진 않는다.
직접 써보면 clearData 함수가 단순해 보여도, 테이블이 10개를 넘어가면 순서 잘못 잡기 딱 좋다. 처음부터 주석으로 FK 관계를 명시해두는 게 미래의 나를 위한 최소한의 배려다.
🛡️ 예방: FK 삭제 순서를 코드에 새기는 패턴

FK 의존 관계 주석 달기
가장 단순하고 효과적인 방법이다. 코드만 봐도 “왜 이 순서인지”가 보이도록 만든다.
// prisma/seed.ts
/**
* DB 초기화 순서 (FK 의존 역순)
*
---
* UserLesson
* └─(lesson_id FK)→ Lesson
* └─(course_id FK)→ Course
---
*
* 삭제: UserLesson → Lesson → Course
* 생성: Course → Lesson → UserLesson
---
*
* ※ 새 테이블 추가 시 이 주석과 $transaction 배열을 함께 업데이트할 것
*/
---
async function clearData(prisma: PrismaClient) {
await prisma.$transaction([
prisma.userLesson.deleteMany(),
---
prisma.lesson.deleteMany(),
prisma.course.deleteMany(),
]);
---
}
이 주석 하나가 팀원 온보딩 시간을 줄여준다. “왜 이 순서야?”라는 질문이 코드 리뷰에서 안 들어온다.
Prisma Schema에서 onDelete: Cascade 설정
구조적으로 해결하고 싶다면 schema에서 직접 설정하는 방법도 있다.
// prisma/schema.prisma
model Lesson {
id Int @id @default(autoincrement())
courseId Int
---
course Course @relation(fields: [courseId],
references: [id], onDelete: Cascade)
// ✅ Course 삭제 시 연관 Lesson 자동 삭제
---
// ⚠️ 프로덕션 데이터라면 Cascade 적용 전 충분히 검토할 것
}
model UserLesson {
id Int @id @default(autoincrement())
lessonId Int
---
lesson Lesson @relation(fields: [lessonId],
references: [id], onDelete: Cascade)
// ✅ Lesson 삭제 시 연관 UserLesson 자동 삭제
---
}
이렇게 설정하면 Course만 deleteMany해도 연쇄 삭제가 일어난다.
Cascade의 장단점
| 항목 | 내용 |
|---|---|
| 장점 | 삭제 순서를 신경 쓸 필요 없음 |
| 장점 | seed 코드가 단순해짐 |
| 단점 | 실수로 부모를 지우면 자식도 전부 날아감 |
| 단점 | 프로덕션 DB에선 Cascade가 오히려 위험할 수 있음 |
| 단점 | 스키마 변경이므로 prisma migrate 실행 필요 |
⚠️ 주의:
onDelete: Cascade는 개발 편의성을 높이지만, 프로덕션 데이터에선 신중히 적용해야 한다. 실수로 부모 레코드를 지우면 자식 데이터가 전부 사라진다. seed 편의를 위해 프로덕션 스키마를 바꾸는 건 트레이드오프가 크다.
제 경우엔 seed 스크립트를 위해 schema를 수정하는 건 오버엔지니어링이라고 판단했다. 삭제 순서를 명시적으로 관리하는 쪽이 의도가 더 명확하게 드러난다.
재발 방지 체크리스트
새 테이블을 추가하거나 seed 스크립트를 수정할 때 확인할 항목이다.
- 새 테이블의 FK 참조 방향을 schema에서 확인했는가?
-
clearData함수의 삭제 순서에 새 테이블을 올바른 위치에 추가했는가? - FK 의존 관계 주석이 업데이트됐는가?
-
$transaction으로 묶여있는가? - 삭제 결과
count를 로그로 출력하는가? - 로컬에서
npx prisma db seed가 두 번 연속 성공하는가?
두 번 연속 실행 테스트가 포인트다. 한 번만 돌리면 우연히 성공하는 경우가 있다. “클린 상태 → 시드 → 클린 → 시드”가 모두 성공해야 진짜로 고친 거다.
💡 팁: seed 스크립트는 멱등성(idempotency)을 갖춰야 한다. 몇 번을 실행해도 동일한 결과가 나와야 한다는 뜻이다.
deleteMany순서가 잘못되면 멱등성이 깨진다.
📋 정리
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| seed 스크립트 초기화 | 부모 테이블부터 deleteMany | 자식 → 부모 역순으로 deleteMany |
| 삭제 순서 관리 | 순서 없이 나열 | FK 의존 그래프 역순 + 주석 명시 |
| 안전한 삭제 처리 | 개별 deleteMany 호출 | $transaction([...]) 배열로 묶기 |
| Cascade 우회 시도 | SET FOREIGN_KEY_CHECKS = 0 | onDelete: Cascade 또는 수동 순서 관리 |
FK 삭제 순서는 알면 당연한데, 모르면 엉뚱한 곳만 파다가 며칠이 간다 ✨
📚 NestJS 실전 트러블슈팅 시리즈 (12편)
- 1. NestJS + Prisma에서 N+1 쿼리 문제 해결하기
- 2. NestJS CORS 삽질 총정리 — PATCH만 안 되는 이유
- 3. Prisma 마이그레이션 실수 방지 — 컬럼 누락 해결기
- 4. NestJS DTO 클래스 필수인 이유 — interface로 만들면 터지는 두 가지
- 5. NestJS FK 제약 위반 디버깅 — Level ID 검증으로 500 에러 잡기
- 6. Prisma enum vs 도메인 타입 캐스팅 함정 — TypeScript 타입 불일치 해결기
- 7. Seed 데이터 FK 삭제 순서 삽질 — Prisma deleteMany가 터지는 이유
- 8. NestJS DI 에러 디버깅 — Nest can't resolve dependencies 3가지 원인과 서버 기동 테스트
- 9. Docker 빌드에서 pnpm 모노레포 삽질 — 데코레이터 에러 3132개의 정체
- 10. NestJS 재귀 호출 무한루프 — API 504 타임아웃의 숨겨진 원인 찾기
- 11. Soft Delete 필터가 빠진 곳 찾기 — 삭제한 데이터가 되살아나는 미스터리
- 12. prisma generate 누락 — 빌드는 되는데 런타임 에러가 나는 이유