Seed 데이터의 함정 — FK 삭제 순서 삽질기
📚 교육용 풀스택 SaaS 개발기 시리즈 (7편)
Prisma seed 스크립트에서 deleteMany 순서를 잘못 잡으면 FK 제약 조건 위반으로 터진다. 30개 테이블의 의존 그래프를 분석하고 자식→부모 역순 삭제 패턴을 정립한 실전 삽질기.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- Prisma의
deleteMany는 FK 참조를 자동으로 해석하지 않는다 — 삭제 순서는 개발자가 직접 관리Foreign key constraint failed on the field: (not available)— 이 에러의 원인은 부모 테이블을 자식보다 먼저 삭제했기 때문- 해결: FK 의존 그래프를 그리고, 자식 → 부모 역순으로
deleteMany호출$transaction배열로 묶어도 내부 순서는 직접 지켜야 한다- 30개 테이블의 삭제 순서를 한 번 정리해두면, 이후 seed 확장이 훨씬 편해진다
- 테이블 추가 시 FK 의존 관계 주석을 함께 업데이트하는 것이 재발 방지의 핵심
🌱 왜 seed를 다시 만져야 했나
이전 편에서 전체 테이블의 PK를 CUID → Mixed ID Strategy로 전환했다. User 계열은 CUID 유지, 마스터 데이터는 Int, 트랜잭션 데이터는 BigInt. 303건의 seed 데이터(Level 31개, ContentItem 19개, 매핑 158개, MetricTag 95개)를 넣고, verify-seed.ts까지 만들어서 검증까지 완료했다.
그때까지는 순조로웠다. 문제는 seed를 두 번째로 실행할 때 터졌다.
“데이터를 갈아엎고 다시 넣자”는 단순한 의도였다. 기존 데이터를 deleteMany로 전부 지우고, 새로 넣는 흔한 패턴. CUID 시절엔 이게 문제없이 동작했다. 테이블 간 참조가 ID 문자열로 느슨하게 연결되어 있었기 때문이다.
그런데 Int autoincrement PK로 바꾼 뒤로는 사정이 달라졌다. FK 제약 조건이 정확하게 동작하기 시작한 것이다.
📌 핵심: CUID 시절에 문제가 없었던 이유는 단순하다. PK가 랜덤 문자열이라 FK 관계가 느슨했고,
deleteMany순서에 관계없이 DB가 관대하게 처리했기 때문이다. Int autoincrement로 바꾸면서 FK가 “진짜” 외래 키 역할을 하기 시작했다.
🔥 증상 — Foreign key constraint failed
seed 스크립트를 실행했더니 바로 터졌다.
$ npx prisma db seed
Environment variables loaded from .env
Running seed command `ts-node --compiler-options {"module":"CommonJS"} prisma/seed.ts` ...
Invalid `prisma.contentItem.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)
(not available). 어떤 필드가 문제인지조차 안 알려준다.
처음엔 .env 연결 문제인 줄 알았다. npx prisma generate도 다시 돌려봤다. 아무것도 안 바뀌었다. DB에 직접 들어가서 테이블을 확인해봐도 정상. 레코드도 정상적으로 들어가 있었다.
⚠️ 주의: PostgreSQL + Prisma 조합에서
(not available)은 FK 제약 조건 위반의 전형적인 에러 형태다. 필드명이 안 나오는 건 Prisma가 DB 에러를 그대로 전달하기 때문인데, 덕분에 처음 보면 방향 잡기가 어렵다.
재현 조건
에러가 나는 패턴은 이렇다.
ContentItem (부모)
└── ContentPlayableLevel (자식, contentItemId FK)
└── ContentMetricTag (자식, contentItemId FK)
seed 스크립트에서 ContentItem을 먼저 deleteMany 하려고 했다. 하지만 ContentPlayableLevel과 ContentMetricTag가 아직 ContentItem을 참조 중이다. DB가 삭제를 거부한다.
이전 편에서 Level 31개, ContentItem 19개를 넣었고, 그 사이의 매핑(ContentPlayableLevel 158건, ContentMetricTag 95건)이 FK로 단단히 연결되어 있었다. 이 관계를 무시하고 부모부터 지우려 하니 당연히 터지는 거였다.
🔍 탐색 — 잘못된 가설과 진짜 원인

가설 1: Prisma 버전 문제?
npm ls @prisma/client
# └── @prisma/[email protected]
같은 버전으로 다른 프로젝트에서도 재현됐다. 버전 문제는 아니다.
가설 2: PostgreSQL 설정 문제?
MySQL에서는 SET FOREIGN_KEY_CHECKS = 0으로 FK 검사를 끌 수 있다. PostgreSQL에서는?
-- PostgreSQL에서 FK를 우회하는 방법
SET session_replication_role = 'replica';
-- 이러면 FK 트리거가 비활성화된다
이건 실제로 작동한다. 하지만 개발 환경에서도 이걸 쓰면 안 된다. FK 제약 조건을 끄면 orphan 레코드(고아 레코드)가 쌓인다. ContentItem이 없는데 ContentPlayableLevel이 남아있는 상태. 다음 마이그레이션 때 터진다.
⚠️ 주의: FK 검사를 끄는 건 임시방편이다. seed에 이걸 넣으면 데이터 정합성이 깨지고, 다음 seed 실행에서 예측 불가능한 에러가 난다. 순서를 고치는 게 유일한 정답이다.
가설 3: onDelete: Cascade 설정하면?
Prisma 스키마에서 cascade를 설정하면 부모 삭제 시 자식도 따라가니까 해결되는 거 아닌가?
model ContentPlayableLevel {
id Int @id @default(autoincrement())
contentItemId Int
contentItem ContentItem @relation(fields: [contentItemId],
references: [id], onDelete: Cascade)
// ...
}
이건 기술적으로 맞다. 하지만 seed 편의를 위해 프로덕션 스키마를 수정하는 건 트레이드오프가 크다. 실수로 부모 레코드를 지우면 자식 데이터가 전부 사라진다. 마스터 데이터인 ContentItem을 Cascade로 설정하면, 실서비스에서 콘텐츠를 삭제할 때 관련 매핑이 전부 날아가는 위험을 안게 된다.
📌 핵심:
onDelete: Cascade는 “삭제 시 연쇄 삭제가 필요한” 비즈니스 요구가 있을 때만 쓰는 거다. seed 스크립트의 편의를 위해 스키마를 바꾸면, 그 Cascade가 프로덕션에서도 동작한다. 삭제 순서를 명시적으로 관리하는 쪽이 의도가 명확하다.
진짜 원인: Prisma는 deleteMany의 순서를 관리하지 않는다
Prisma 공식 문서를 뒤져봤다.
deleteanddeleteManydo not cascade by default. You need to either setonDelete: Cascadein your schema or handle deletion order manually.
결국 개발자가 직접 순서를 잡아야 한다. Prisma의 deleteMany는 내부적으로 그냥 DELETE FROM ...을 실행할 뿐이다. FK 관계를 분석해서 순서를 결정해주는 마법은 없다.
🛠️ 해결 — 의존 그래프 역순 삭제
테이블 의존 관계 매핑
먼저 30개 테이블의 FK 의존 관계를 전부 정리했다. 이때 정리한 의존 그래프가 이후 4개월간 seed를 확장할 때마다 참조하는 기준이 됐다.
핵심 구조만 추리면:
// 레벨 3: 손자 (먼저 삭제)
ContentPlayableLevel ──(contentItemId FK)──→ ContentItem
ContentPlayableLevel ──(levelId FK)──→ Level
ContentMetricTag ──(contentItemId FK)──→ ContentItem
// 레벨 2: 자식
ContentItem (독립)
Level (독립)
// 참고: 이후 추가된 테이블들
Assignment ──(studentId FK)──→ Student
Block ──(assignmentId FK)──→ Assignment
ContentAttempt ──(blockId FK)──→ Block
ProblemAttempt ──(contentAttemptId FK)──→ ContentAttempt
의존 방향의 반대로 삭제해야 한다. 가장 깊은 손자부터 지우고, 마지막에 부모를 지운다.
❌ Before: 부모부터 삭제
// prisma/seed.ts — ❌ 이렇게 하면 터진다
async function clearData() {
// ContentItem을 먼저 지우려 하지만,
// ContentPlayableLevel이 contentItemId로 참조 중
await prisma.contentItem.deleteMany();
await prisma.level.deleteMany();
await prisma.contentPlayableLevel.deleteMany();
await prisma.contentMetricTag.deleteMany();
}
prisma.contentItem.deleteMany() 호출 시점에 바로 FK 위반. ContentPlayableLevel 158건이 contentItemId를 잡고 있기 때문이다.
✅ After: 자식 → 부모 역순 삭제 + 트랜잭션
// prisma/seed.ts — ✅ FK 의존 그래프 역순
async function clearData(prisma: PrismaClient) {
await prisma.$transaction([
// === 레벨 3: 손자 (가장 먼저 삭제) ===
prisma.contentPlayableLevel.deleteMany(),
prisma.contentMetricTag.deleteMany(),
// === 레벨 2: 자식 ===
prisma.contentItem.deleteMany(),
prisma.level.deleteMany(),
// === 레벨 1: 부모 (마지막에 삭제) ===
// 이 시점에서는 아직 User/Academy 등 독립 테이블만 남음
]);
console.log('[seed] 기존 데이터 전체 삭제 완료');
}
$transaction 배열로 묶으면 하나가 실패해도 전체가 롤백된다. ContentPlayableLevel을 지우다가 에러가 나면 아무것도 안 지워진 상태로 돌아간다. 중간 상태로 남아서 다음 실행 때 예측 불가능한 에러가 나는 것을 방지한다.
📌 핵심:
$transaction배열 방식은 내부적으로 순서대로 실행된다. 하지만 배열 안에서의 FK 역순은 트랜잭션이 자동으로 잡아주지 않는다. 순서는 여전히 개발자 책임이다.
삭제 후 로그 패턴
async function clearData(prisma: PrismaClient) {
const results = await prisma.$transaction([
prisma.contentPlayableLevel.deleteMany(),
prisma.contentMetricTag.deleteMany(),
prisma.contentItem.deleteMany(),
prisma.level.deleteMany(),
]);
const labels = ['ContentPlayableLevel', 'ContentMetricTag', 'ContentItem', 'Level'];
results.forEach((r, i) => {
console.log(`[seed] ${labels[i]} 삭제: ${r.count}건`);
});
}
deleteMany()는 { count: number }를 반환한다. 이걸 로그로 찍어두면 “왜 데이터가 안 들어갔지?” 류의 삽질을 막을 수 있다. 특히 seed 실행 결과를 CI에서 확인할 때 유용하다.
[seed] ContentPlayableLevel 삭제: 158건
[seed] ContentMetricTag 삭제: 95건
[seed] ContentItem 삭제: 19건
[seed] Level 삭제: 31건
[seed] 기존 데이터 전체 삭제 완료
이 로그를 보면 데이터가 제대로 들어갔었는지, 삭제가 정상적으로 됐는지 한눈에 알 수 있다.
🧩 실전에서 마주친 추가 함정들

함정 1: 30개 테이블의 삭제 순서
초기 seed는 4개 테이블만 다뤘다. 하지만 프로젝트가 진행되면서 테이블이 30개로 늘어났다. Assignment, Block, ContentAttempt, ProblemAttempt, DiagnosticSession… 학습 활동 기록 테이블들이 추가될 때마다 seed의 clearData 함수도 업데이트해야 했다.
테이블이 늘어날수록 의존 관계가 복잡해진다. ContentAttempt가 Block을 참조하고, Block이 Assignment를 참조하고, Assignment가 Student를 참조하는 체인이 4단계가 된다.
ProblemAttempt → ContentAttempt → Block → Assignment → Student
이 체인에서 하나라도 순서가 어긋나면 FK 위반이다. 결국 의존 그래프를 코드 주석으로 유지하는 게 유일한 방법이었다.
/**
* DB 초기화 순서 (FK 의존 역순)
*
* ProblemAttempt
* └─(contentAttemptId FK)→ ContentAttempt
* └─(blockId FK)→ Block
* └─(assignmentId FK)→ Assignment
* ContentPlayableLevel
* └─(contentItemId FK)→ ContentItem
* └─(levelId FK)→ Level
* ...
*
* 삭제: ProblemAttempt → ContentAttempt → Block → Assignment → ...
* 생성: ... → Assignment → Block → ContentAttempt → ProblemAttempt
*
* ※ 새 테이블 추가 시 이 주석과 $transaction 배열을 함께 업데이트
*/
이 주석 하나가 이후 30개 세션 동안 seed 관련 삽질을 막아줬다. “왜 이 순서야?”라는 질문이 코드에서 바로 답이 된다.
함정 2: seed의 멱등성
seed 스크립트는 몇 번을 실행해도 동일한 결과가 나와야 한다. 이걸 멱등성(idempotency)이라고 한다.
deleteMany 순서가 잘못되면 멱등성이 깨진다. 첫 실행은 데이터가 없어서 성공하지만, 두 번째 실행부터 FK 위반으로 실패한다.
검증 방법은 간단하다.
# 이게 되어야 한다
npx prisma db seed && npx prisma db seed
두 번 연속 성공하면 멱등성이 보장된 거다. 한 번만 돌려서 “됐다” 하고 넘어가면, 다음날 팀원이 clone 받아서 seed를 돌릴 때 터진다.
🔍 단서: seed를
upsert로 작성하는 방법도 있다.deleteMany대신upsert를 쓰면 이미 있으면 업데이트, 없으면 생성. FK 삭제 순서 문제를 우회할 수 있다. 다만 대량의 seed 데이터를 넣을 때는deleteMany+ 재생성이 깔끔한 경우가 많다.
함정 3: autoincrement 갭
이전 편에서 PK를 Int autoincrement로 바꿨다. 그런데 deleteMany 후 다시 create하면 ID가 1부터 시작하지 않는다.
// 첫 번째 seed 실행
await prisma.level.create({ data: { name: 'L1', ... } }); // id: 1
await prisma.level.create({ data: { name: 'L2', ... } }); // id: 2
// deleteMany 후 두 번째 seed 실행
await prisma.level.create({ data: { name: 'L1', ... } }); // id: 32 ← ???
PostgreSQL의 sequence는 삭제해도 리셋되지 않는다. TRUNCATE ... RESTART IDENTITY를 쓰면 리셋할 수 있지만, Prisma의 deleteMany는 DELETE FROM이지 TRUNCATE가 아니다.
seed에서 ID 값에 의존하는 로직이 있으면 깨진다. 항상 name이나 unique 필드로 참조해야 한다.
// ❌ Before: ID 하드코딩
const level = await prisma.level.findUnique({ where: { id: 1 } });
// ✅ After: unique 필드로 참조
const level = await prisma.level.findFirst({ where: { name: 'L1' } });
⚠️ 주의: autoincrement PK에서
id: 1을 하드코딩하는 건 seed 실행 횟수에 따라 결과가 달라지는 불안정한 코드다. 항상 비즈니스 키(name, code, slug 등)로 조회하자.
🔬 Prisma vs Raw SQL — 삭제 전략 비교
seed 삭제 전략을 세 가지 비교해봤다.
전략 1: Prisma deleteMany (채택)
await prisma.$transaction([
prisma.contentPlayableLevel.deleteMany(),
prisma.contentMetricTag.deleteMany(),
prisma.contentItem.deleteMany(),
prisma.level.deleteMany(),
]);
장점: 타입 안전, Prisma 생태계 통합, 로그 가능 단점: FK 순서를 수동 관리, 대량 데이터 시 느림
전략 2: Raw SQL TRUNCATE
await prisma.$executeRaw`TRUNCATE TABLE "ContentPlayableLevel", "ContentMetricTag", "ContentItem", "Level" RESTART IDENTITY CASCADE`;
장점: 한 줄로 끝남, CASCADE로 순서 무시, RESTART IDENTITY로 시퀀스 리셋
단점: 타입 안전성 없음, 테이블명 오타 시 런타임 에러, CASCADE가 위험
전략 3: FK 체크 비활성화 (비추)
await prisma.$executeRaw`SET session_replication_role = 'replica'`;
await prisma.contentItem.deleteMany();
await prisma.$executeRaw`SET session_replication_role = 'DEFAULT'`;
장점: 순서 무관하게 삭제 가능 단점: orphan 레코드 위험, 데이터 정합성 붕괴 가능
결론적으로 **전략 1(Prisma deleteMany + 수동 순서)**을 선택했다. FK 순서를 주석으로 관리하는 추가 비용이 있지만, 타입 안전성과 데이터 정합성을 포기하는 것보다 낫다.

🛡️ 예방 — 재발 방지 체크리스트
이 경험 이후로 seed 스크립트를 수정할 때마다 아래 체크리스트를 돌린다.
- 새 테이블의 FK 참조 방향을 schema에서 확인했는가?
-
clearData함수의 삭제 순서에 새 테이블을 올바른 위치에 추가했는가? - FK 의존 관계 주석이 업데이트됐는가?
-
$transaction으로 묶여있는가? - 삭제 결과
count를 로그로 출력하는가? -
npx prisma db seed가 두 번 연속 성공하는가?
마지막 항목이 포인트다. 한 번만 돌려서 성공했다고 넘어가면 안 된다. “클린 → seed → 클린 → seed”가 모두 성공해야 진짜로 고친 거다.
특히 이 프로젝트에서는 테이블이 초기 4개에서 30개로 늘어났기 때문에, seed를 건드릴 때마다 이 체크리스트가 필수였다. 한 번 정리해두면 이후 4개월간 seed 관련 삽질이 사라진다.
📋 정리 — 핵심 요약
| 상황 | 안티패턴 | 권장 패턴 |
|---|---|---|
| seed 초기화 | ❌ 부모 테이블부터 deleteMany | ✅ 자식 → 부모 역순 |
| 삭제 순서 관리 | ❌ 순서 없이 나열 | ✅ FK 의존 그래프 역순 + 주석 |
| 안전한 삭제 | ❌ 개별 deleteMany 호출 | ✅ $transaction([...]) 배열 |
| FK 우회 시도 | ❌ SET session_replication_role = 'replica' | ✅ 수동 순서 관리 |
| ID 참조 | ❌ findUnique({ where: { id: 1 } }) | ✅ unique 필드로 참조 |
| 멱등성 검증 | ❌ 한 번만 실행 | ✅ 두 번 연속 실행 테스트 |
숫자로 보는 삽질
- 최초 에러까지 소요 시간: 5분 (seed 두 번째 실행 시점)
- 원인 파악까지: 약 30분 (가설 3개 기각 후)
- FK 의존 그래프 정리: 약 1시간 (30개 테이블 전체)
- 이후 seed 관련 에러: 0건 (4개월간)
FK 삭제 순서는 알면 당연한데, 모르면 엉뚱한 곳만 파다가 시간이 간다. 특히 Prisma처럼 ORM이 많은 것을 추상화해주는 환경에서는 “DB가 직접 거부한다”는 사실을 잊기 쉽다. deleteMany는 마법이 아니라 그냥 DELETE FROM이다. 그걸 알게 된 하루.
다음 편에서는 DDD를 도입하기로 한 이야기를 한다. Repository/Domain/Application 3계층 분리를 결심한 순간.