"사용자를 삭제했더니 모든 주문 내역이 날아갔어요!"
금요일 오후, 테스트 계정을 정리하려고 관리자 페이지에서 사용자 한 명을 삭제했습니다.
몇 분 후, 고객센터에서 다급한 전화가 걸려왔습니다.
"고객들이 주문 내역이 사라졌다고 난리예요! 결제는 됐는데 주문 기록이 없대요!"
알고 보니 삭제한 테스트 계정이 실수로 실제 주문들과 연결되어 있었고, 사용자 삭제와 함께 수백 건의 주문이 연쇄 삭제되어 버린 것입니다. 원인은 단 한 줄의 코드였습니다.
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) # 이것이 문제!
이것이 바로 Django를 배우는 개발자들이 무심코 넘어가는 가장 위험한 설정, ForeignKey의 on_delete 옵션입니다.
27년간 수많은 데이터 손실 사고를 목격하고 복구해오면서 확신하는 것은, on_delete 설정이 **"단순한 기술 선택이 아니라 비즈니스 로직과 직결된 중요한 의사결정"**이라는 것입니다.
오늘은 Django의 ForeignKey 관계에서 on_delete가 무엇인지, 각 옵션이 어떤 의미인지, 그리고 실제 상황에서 어떻게 올바르게 선택하는지 알아보겠습니다.
🤔 on_delete가 도대체 뭘까요?
관계형 데이터베이스의 기본 문제
상황 설명:
블로그 시스템:
- 작가(Author) 테이블
- 글(Post) 테이블
관계:
한 명의 작가가 여러 글을 쓸 수 있음
각 글은 한 명의 작가에게 속함
문제 발생:
작가 A: 100개의 글 작성
어느 날: 작가 A가 회원 탈퇴 요청
질문: 작가 A를 삭제하면 그가 쓴 100개의 글은 어떻게 되어야 할까?
가능한 선택지들:
- 함께 삭제: 작가가 사라지면 글도 모두 삭제 (CASCADE)
- 삭제 방지: 글이 있으면 작가를 삭제할 수 없음 (PROTECT)
- 연결 끊기: 글은 남기고 작가 정보만 NULL로 (SET_NULL)
- 기본값 설정: 글은 "탈퇴한 사용자"에게 소속 (SET_DEFAULT)
- 직접 처리: 개발자가 원하는 대로 처리 (DO_NOTHING 또는 SET)
실생활 비유: 회사와 직원의 관계
CASCADE - 연쇄 삭제:
회사가 부서를 없애면:
→ 그 부서의 모든 직원도 해고
→ 부서와 직원이 운명 공동체
PROTECT - 보호:
부서를 없애려고 시도:
→ "직원이 있어서 부서를 없앨 수 없습니다"
→ 먼저 직원들을 다른 부서로 이동시켜야 함
SET_NULL - 연결 끊기:
부서를 없애면:
→ 직원들은 회사에 남음
→ 소속 부서가 "없음"으로 표시
→ 프리랜서 같은 상태
SET_DEFAULT - 기본값 할당:
부서를 없애면:
→ 모든 직원이 "일반 부서"로 자동 배치
→ 아무도 갈 곳 없는 사람이 없음
💥 잘못된 on_delete로 벌어지는 참사들
참사 1: CASCADE로 인한 데이터 대량 손실
시나리오: 전자상거래 사이트
# 잘못된 설계
class Order(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
created_at = models.DateTimeField(auto_now_add=True)
class OrderItem(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.IntegerField()
price = models.DecimalField(max_digits=10, decimal_places=2)
사고 발생:
1. 스팸 사용자 계정 삭제 시도
2. CASCADE 옵션으로 인해:
- 사용자 삭제
→ 해당 사용자의 모든 주문 삭제
→ 주문에 속한 모든 주문 항목 삭제
3. 결과:
- 결제는 완료됨
- 하지만 주문 기록이 없음
- 고객이 뭘 주문했는지 알 수 없음
- 배송 불가능
- 환불 처리도 불가능
실제 피해:
- 5,000건의 주문 기록 영구 손실
- 환불 처리 혼란: 어떤 주문이 삭제됐는지 파악 어려움
- 고객 신뢰 추락: "내 주문이 사라졌어요!"
- 법적 문제: 상거래 기록 보관 의무 위반
참사 2: PROTECT로 인한 시스템 마비
시나리오: 콘텐츠 관리 시스템
# 과도한 보호
class Category(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
title = models.CharField(max_length=200)
category = models.ForeignKey(Category, on_delete=models.PROTECT)
author = models.ForeignKey(User, on_delete=models.PROTECT)
문제 상황:
카테고리 구조 변경 필요:
1. "IT 뉴스" 카테고리를 "기술 뉴스"로 통합하려고 시도
2. 삭제 시도 → 에러 발생
"Cannot delete 'IT 뉴스': Protected by 500 posts"
3. 해결하려면:
- 500개 글을 하나씩 다른 카테고리로 이동
- 또는 대량 업데이트 스크립트 작성
- 시간 소요: 수 시간
4. 그 사이 콘텐츠 관리자는:
- 카테고리 수정 불가
- 업무 마비
운영상의 문제:
- 유연성 부족: 간단한 구조 변경도 어려움
- 관리자 불만: "왜 이렇게 불편해요?"
- 긴급 상황 대응 불가: 빠른 변경이 필요할 때 막힘
참사 3: SET_NULL의 잘못된 사용
시나리오: 의료 기록 시스템
# 위험한 설계
class Doctor(models.Model):
name = models.CharField(max_length=100)
license_number = models.CharField(max_length=50)
class MedicalRecord(models.Model):
patient = models.ForeignKey(Patient, on_delete=models.PROTECT)
doctor = models.ForeignKey(
Doctor,
on_delete=models.SET_NULL, # 위험!
null=True
)
diagnosis = models.TextField()
prescription = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
문제 발생:
의사 퇴사 → 계정 삭제
결과:
- 해당 의사가 작성한 모든 진료 기록에서 의사 정보가 NULL
- "누가 이 진단을 내렸나?" → 알 수 없음
- "이 처방은 누가 했나?" → 알 수 없음
법적/의료적 문제:
- 의료 기록은 담당 의사 정보 필수
- 책임 추적 불가능
- 의료 사고 발생 시 증거 부족
- 법적 소송에서 불리
참사 4: DO_NOTHING으로 인한 데이터 무결성 파괴
시나리오: 재고 관리 시스템
# 매우 위험한 설계
class Warehouse(models.Model):
name = models.CharField(max_length=100)
location = models.CharField(max_length=200)
class Inventory(models.Model):
product = models.ForeignKey(Product, on_delete=models.CASCADE)
warehouse = models.ForeignKey(
Warehouse,
on_delete=models.DO_NOTHING # 위험!
)
quantity = models.IntegerField()
데이터 손상 시나리오:
1. 창고 폐쇄 → Warehouse 삭제
2. DO_NOTHING이므로 Django는 아무것도 안 함
3. 하지만 데이터베이스에는:
- Inventory 레코드가 존재하지 않는 warehouse_id 참조
- "외래키 제약 조건 위반" 또는 "고아 레코드" 발생
4. 시스템 오작동:
- 재고 조회 시 에러
- 존재하지 않는 창고의 재고 표시
- 통계 계산 오류
- 주문 처리 불가
🛠️ 각 on_delete 옵션 완벽 이해하기
CASCADE: 연쇄 삭제
작동 방식:
class Author(models.Model):
name = models.CharField(max_length=100)
class BlogPost(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE)
title = models.CharField(max_length=200)
삭제 시나리오:
author = Author.objects.get(id=1)
author.delete()
# 실행되는 일:
# 1. author의 모든 BlogPost 찾기
# 2. 모든 BlogPost 삭제
# 3. author 삭제
적합한 경우:
- 강한 종속 관계: 부모 없이는 의미 없는 자식
- 게시글과 댓글 (글이 없으면 댓글도 의미 없음)
- 주문과 주문 항목 (주문이 없으면 항목도 의미 없음)
- 앨범과 사진 (앨범이 없으면 사진 그룹화 의미 없음)
부적합한 경우:
- 독립적 가치: 부모가 사라져도 자식은 의미 있음
- 사용자와 주문 (사용자 탈퇴해도 주문 기록 필요)
- 작가와 책 (작가 퇴사해도 책은 남아야 함)
PROTECT: 삭제 방지
작동 방식:
class Category(models.Model):
name = models.CharField(max_length=100)
class Product(models.Model):
category = models.ForeignKey(Category, on_delete=models.PROTECT)
name = models.CharField(max_length=200)
삭제 시도:
category = Category.objects.get(id=1)
category.delete()
# 실행되는 일:
# 1. 이 category를 참조하는 Product가 있는지 확인
# 2. 있으면 ProtectedError 발생
# 3. 삭제 중단
# 예외 발생:
# ProtectedError: Cannot delete some instances of model 'Category'
# because they are referenced through protected foreign keys
적합한 경우:
- 필수 참조: 자식이 부모를 반드시 필요로 함
- 주문과 결제 정보 (결제 정보 없는 주문은 안 됨)
- 직원과 부서 (부서 없는 직원은 문제)
- 상품과 카테고리 (분류 없는 상품은 혼란)
부적합한 경우:
- 유연한 구조 변경 필요: 자주 재구성되는 관계
- 임시 태그나 레이블
- 자주 변경되는 분류 체계
SET_NULL: NULL로 설정
작동 방식:
class Department(models.Model):
name = models.CharField(max_length=100)
class Employee(models.Model):
name = models.CharField(max_length=100)
department = models.ForeignKey(
Department,
on_delete=models.SET_NULL,
null=True, # 필수!
blank=True
)
삭제 시나리오:
dept = Department.objects.get(id=1)
dept.delete()
# 실행되는 일:
# 1. 이 department를 참조하는 모든 Employee 찾기
# 2. 각 Employee의 department를 NULL로 설정
# 3. Department 삭제
# 결과:
# Employee.objects.filter(department__isnull=True)
# → 부서 없는 직원들
적합한 경우:
- 선택적 관계: 부모 없어도 자식이 존재 가능
- 글과 작성자 (탈퇴 회원의 글도 보존)
- 직원과 멘토 (멘토 퇴사해도 직원 유지)
- 상품과 할인 이벤트 (이벤트 종료 후에도 상품 판매)
부적합한 경우:
- 필수 정보: NULL이 되면 안 되는 경우
- 결제와 주문 (주문 정보 없는 결제는 문제)
- 진료 기록과 담당 의사 (책임 추적 필요)
SET_DEFAULT: 기본값 설정
작동 방식:
class Tag(models.Model):
name = models.CharField(max_length=50)
# 먼저 기본 태그 생성
default_tag = Tag.objects.create(name='미분류')
class Article(models.Model):
title = models.CharField(max_length=200)
tag = models.ForeignKey(
Tag,
on_delete=models.SET_DEFAULT,
default=default_tag.id # 또는 default=1
)
삭제 시나리오:
tag = Tag.objects.get(name='Python')
tag.delete()
# 실행되는 일:
# 1. 'Python' 태그를 가진 모든 Article 찾기
# 2. 각 Article의 tag를 기본값(미분류)으로 변경
# 3. 'Python' 태그 삭제
적합한 경우:
- 분류 시스템: 항상 어떤 카테고리에 속해야 함
- 상품과 카테고리 ("기타" 카테고리로)
- 글과 상태 ("임시저장" 상태로)
- 직원과 부서 ("일반부서"로)
주의사항:
- 기본값이 실제로 존재해야 함: DB에 없으면 에러
- 마이그레이션 순서: 기본값 생성을 먼저 해야 함
SET(): 함수로 동적 설정
작동 방식:
def get_deleted_user():
"""탈퇴한 사용자를 나타내는 특별 계정 반환"""
deleted_user, _ = User.objects.get_or_create(
username='[탈퇴한 사용자]',
defaults={'is_active': False}
)
return deleted_user
class Comment(models.Model):
content = models.TextField()
author = models.ForeignKey(
User,
on_delete=models.SET(get_deleted_user)
)
삭제 시나리오:
user = User.objects.get(id=5)
user.delete()
# 실행되는 일:
# 1. 이 user가 쓴 모든 Comment 찾기
# 2. get_deleted_user() 함수 호출
# 3. 반환된 User를 각 Comment의 author로 설정
# 4. 원래 user 삭제
적합한 경우:
- 복잡한 로직: 단순 기본값이 아닌 계산이 필요
- 탈퇴 회원 → "탈퇴한 사용자" 계정으로
- 삭제된 부서 → 상위 부서로 자동 이동
- 삭제된 카테고리 → 유사 카테고리로 자동 배정
DO_NOTHING: 아무것도 안 함 (위험!)
작동 방식:
class Parent(models.Model):
name = models.CharField(max_length=100)
class Child(models.Model):
parent = models.ForeignKey(Parent, on_delete=models.DO_NOTHING)
name = models.CharField(max_length=100)
삭제 시나리오:
parent = Parent.objects.get(id=1)
parent.delete()
# Django는 아무것도 안 함!
# Child 레코드는 존재하지 않는 parent_id를 계속 참조
결과:
# 데이터베이스 무결성 파괴
Child.objects.filter(parent_id=1).first()
# → parent_id=1인데 Parent.id=1은 존재 안 함 → 고아 레코드
# 시스템 오작동
child.parent # DoesNotExist 에러 발생
사용해야 하는 경우 (거의 없음):
- 수동 제어: 개발자가 직접 모든 것을 처리
- 레거시 시스템: 이미 복잡한 로직이 있음
- 성능 최적화: 대량 삭제 시 FK 체크 비용 제거 (매우 위험)
대부분의 경우 사용 금지!
🎯 실제 상황별 올바른 선택
전자상거래 시스템
사용자와 주문:
class Order(models.Model):
user = models.ForeignKey(
User,
on_delete=models.SET_NULL, # ✅ 사용자 탈퇴해도 주문 기록 보존
null=True,
related_name='orders'
)
created_at = models.DateTimeField(auto_now_add=True)
total_amount = models.DecimalField(max_digits=10, decimal_places=2)
주문과 주문 항목:
class OrderItem(models.Model):
order = models.ForeignKey(
Order,
on_delete=models.CASCADE, # ✅ 주문 삭제 시 항목도 함께 삭제
related_name='items'
)
product = models.ForeignKey(Product, on_delete=models.PROTECT) # ✅ 상품 삭제 방지
quantity = models.IntegerField()
이유:
- 주문 기록은 법적으로 보존 의무
- 주문 항목은 주문의 일부이므로 함께 삭제
- 판매된 상품은 삭제 불가 (데이터 무결성)
블로그 시스템
작가와 글:
def get_deleted_author():
return Author.objects.get_or_create(
username='deleted_user',
defaults={'is_active': False}
)[0]
class Post(models.Model):
author = models.ForeignKey(
Author,
on_delete=models.SET(get_deleted_author) # ✅ 탈퇴 회원 처리
)
title = models.CharField(max_length=200)
content = models.TextField()
글과 댓글:
class Comment(models.Model):
post = models.ForeignKey(
Post,
on_delete=models.CASCADE # ✅ 글 삭제 시 댓글도 삭제
)
author = models.ForeignKey(
Author,
on_delete=models.SET(get_deleted_author) # ✅ 댓글 작성자 탈퇴 처리
)
content = models.TextField()
인사 관리 시스템
부서와 직원:
class Department(models.Model):
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
class Employee(models.Model):
name = models.CharField(max_length=100)
department = models.ForeignKey(
Department,
on_delete=models.PROTECT, # ✅ 직원 있으면 부서 삭제 불가
limit_choices_to={'is_active': True} # 활성 부서만 선택 가능
)
대안: Soft Delete 패턴:
class Department(models.Model):
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
deleted_at = models.DateTimeField(null=True, blank=True)
def delete(self, *args, **kwargs):
# 실제 삭제 대신 비활성화
self.is_active = False
self.deleted_at = timezone.now()
self.save()
🎓 정리하며: on_delete 선택 가이드
의사결정 체크리스트
질문 1: 부모가 삭제되면 자식도 무의미한가?
- YES → CASCADE
- NO → 다음 질문으로
질문 2: 자식이 부모를 필수로 필요로 하는가?
- YES → PROTECT
- NO → 다음 질문으로
질문 3: 자식이 부모 없이 존재 가능한가?
- YES, NULL 허용 → SET_NULL
- YES, 기본값 필요 → SET_DEFAULT 또는 SET()
- NO → PROTECT
비즈니스 관점에서 생각하기
법적 요구사항:
- 상거래 기록: 절대 삭제 불가 → SET_NULL 또는 Soft Delete
- 의료 기록: 추적 가능성 필수 → PROTECT
- 개인정보: 삭제 권리 보장 → CASCADE (단, 필수 기록 제외)
사용자 경험:
- 콘텐츠 보존: 탈퇴 회원의 글도 유지 → SET(get_deleted_user)
- 일관성: 모든 상품에 카테고리 → SET_DEFAULT
- 안전성: 실수로 중요 데이터 삭제 방지 → PROTECT
절대 규칙들
- 중요한 비즈니스 데이터에 CASCADE 사용 금지
- NULL을 허용하지 않는 필드에 SET_NULL 사용 금지
- DO_NOTHING은 99.9% 사용하지 말 것
- 배포 전 삭제 시나리오 반드시 테스트
- 팀 전체가 on_delete 의미를 이해해야 함
💬 ForeignKey 설정이 헷갈리시나요?
"우리 시스템의 관계 설정이 올바른지 확신이 서지 않아요", "데이터 손실 사고를 예방하고 싶어요"
27년 경력의 시니어 개발자가 여러분의 Django 프로젝트 데이터 모델을 전면적으로 점검하고 개선해드립니다.
- 🔍 전체 모델 관계 분석 및 on_delete 설정 검토
- 🛡️ 비즈니스 로직에 맞는 안전한 관계 설계
- 📊 데이터 손실 방지 시스템 구축
- 🎓 팀 전체 데이터 모델링 교육
"잘못된 on_delete 설정은 시한폭탄입니다. 전문가와 함께 안전한 데이터 구조를 만드세요!"