부모 행이 삭제됐는데 자식 행이 남아 있는 것 — 이게 고아 레코드(orphaned row)입니다. 애플리케이션 레벨에서만 관계를 관리하고 외래키(FK) 제약을 안 걸었거나, FK를 NO ACTION으로 두고 부모를 강제 삭제했을 때 쌓입니다. 집계가 안 맞거나, 상세 조회에서 부모가 NULL로 나오면 의심해야 합니다.
왜 생기는가 — 두 가지 경로
| 원인 | 상황 | 결과 |
|---|---|---|
| FK 미설정 | ORM·앱 코드만 믿고 제약 생략 | 부모 삭제 시 자식 잔존 |
| 잘못된 삭제 | 배치/수동 DELETE가 부모만 지움 | 자식이 끊긴 참조 보유 |
핵심은 DB가 강제하지 않은 관계는 언젠가 반드시 깨진다는 점입니다. 코드는 버그가 나고 배치는 순서가 꼬입니다.
고아 레코드 찾기
자식 테이블에서 부모에 매칭되지 않는 행을 LEFT JOIN 후 NULL 필터로 찾습니다.
SQL
-- order_items 중 부모 orders가 사라진 행
SELECT oi.id, oi.order_id
FROM order_items oi
LEFT JOIN orders o ON o.id = oi.order_id
WHERE o.id IS NULL;
-- 개수만 빠르게 파악
SELECT COUNT(*) FROM order_items oi
LEFT JOIN orders o ON o.id = oi.order_id
WHERE o.id IS NULL;
order_id가 NULL 허용이라면 AND oi.order_id IS NOT NULL을 더해 의도된 빈 참조와 진짜 고아를 구분합니다.
안전하게 정리하는 순서
- 백업 먼저 — 삭제 전 대상 행을 별도 테이블로 보존한다. 잘못 지웠을 때 복구 근거가 된다.
SQL
CREATE TABLE order_items_orphan_bak AS
SELECT oi.* FROM order_items oi
LEFT JOIN orders o ON o.id = oi.order_id
WHERE o.id IS NULL;
- 소량씩 삭제 — 대량 DELETE는 잠금·복제 지연을 유발하니 배치로 나눈다.
SQL
DELETE oi FROM order_items oi
LEFT JOIN orders o ON o.id = oi.order_id
WHERE o.id IS NULL
LIMIT 1000; -- 반복 실행
- FK 제약 추가 — 정리 후 같은 일이 재발하지 않게 외래키를 건다. 고아가 남아 있으면 추가가 실패하므로, 이 단계가 정리 완료의 검증 역할도 한다.
SQL
ALTER TABLE order_items
ADD CONSTRAINT fk_oi_order
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE;
ON DELETE CASCADE는 부모 삭제 시 자식을 함께 지웁니다. 자식을 남기되 참조만 끊으려면 ON DELETE SET NULL을 씁니다.
요점 정리
- 고아 레코드 = 부모 없는 자식 행. 원인은 FK 미설정 또는 부모만 삭제.
LEFT JOIN ... WHERE 부모.id IS NULL로 탐지한다.- 정리는 백업
→배치 삭제→FK 추가 순서로, FK 추가 성공이 곧 검증이다. - 재발 방지는 코드가 아니라 DB의 FK 제약으로 강제한다.
고아 레코드를 직접 만들어 탐지·정리하고 FK 제약으로 막는 실습은 데이터베이스 트랙에서 회원가입 없이 무료로 할 수 있습니다.