- 발행일
Django management command로 시드 데이터 다루기 — data migration과의 분기점
Django management command로 시드 데이터 다루기 — data migration과의 분기점
Django 프로젝트에 "기본으로 들어가 있어야 하는 데이터" 가 생기는 순간, 다들 한 번쯤 고민합니다. data migration에 RunPython으로 박을까, 아니면 management command로 뺄까? 이번 카페 프로젝트에서는 둘 다 써 봤고, 상황별로 선택 기준이 꽤 명확해졌습니다.
1. 우리 프로젝트의 시드 데이터
이스케이프룸 카페 데이터는 두 종류였습니다.
Cafe: 상호, 주소, 전화번호 등 50여 개Theme: 각 카페에 속한 테마 정보 300여 개
추가로 커뮤니티 체감을 위한 더미 게시글도 있지만, 그건 시드가 아니라 개발/데모용이라 분리했습니다(이건 다른 포스트에서).
처음에는 모두 data migration에 넣었는데, 카페 목록을 수정할 때마다 새 마이그레이션이 쌓이는 게 영 불편했습니다. 그래서 시드 성격별로 분리했습니다.
| 데이터 종류 | 처리 방법 | 이유 |
|---|---|---|
| 공지사항 카테고리 고정값 | data migration | 한 번 정하면 안 바뀜 |
| 카페/테마 목록 | management command | 자주 갱신됨 |
| 개발용 더미 게시글 | management command | 운영에는 실행 안 함 |
2. management command — populate_cafe_data
관리 명령어는 app/management/commands/ 아래에 파일 하나로 둡니다. 파일명이 곧 명령어 이름이 됩니다.
cafe/
management/
__init__.py
commands/
__init__.py
populate_cafe_data.py
sync_cafe_theme_seed.py
__init__.py를 두 군데 다 만들어야 한다는 걸 처음엔 까먹었습니다. 한 쪽만 있으면 Unknown command 에러가 납니다.
# cafe/management/commands/populate_cafe_data.py
from django.core.management.base import BaseCommand
from django.db import transaction
from cafe.models import Cafe
from cafe.seed_data import CAFE_SEED
class Command(BaseCommand):
help = '카페 시드 데이터를 적재합니다. 기존 데이터가 있으면 갱신합니다.'
def add_arguments(self, parser):
parser.add_argument(
'--reset',
action='store_true',
help='기존 카페 데이터를 모두 지우고 새로 넣습니다.',
)
@transaction.atomic
def handle(self, *args, **options):
if options['reset']:
deleted, _ = Cafe.objects.all().delete()
self.stdout.write(self.style.WARNING(f'기존 카페 {deleted}건 삭제'))
created = updated = 0
for row in CAFE_SEED:
obj, is_created = Cafe.objects.update_or_create(
name=row['name'],
defaults=row,
)
if is_created:
created += 1
else:
updated += 1
self.stdout.write(self.style.SUCCESS(
f'완료: 생성 {created}건, 갱신 {updated}건'
))
몇 가지 포인트:
@transaction.atomic— 중간에 깨지면 전부 롤백. 시드 데이터는 "절반만 들어간 상태" 가 가장 안 좋습니다.update_or_create— 멱등성 확보. 같은 명령어를 두 번 실행해도 결과가 같아야 합니다.--reset옵션 — 초기 세팅용. 운영에서는 절대 안 쓰지만 개발할 때 필요합니다.self.stdout.write+self.style—print는 테스트에서 잡기 어렵습니다. 로그 스타일도 색상으로 구분돼서 실행 결과가 눈에 잘 들어옵니다.
실행은 이렇게.
python manage.py populate_cafe_data
python manage.py populate_cafe_data --reset
3. seed_data.py — 데이터는 코드 옆에 둔다
시드 데이터 본문을 JSON으로 둘지, 파이썬 리터럴로 둘지는 취향인데, 저는 파이썬을 택했습니다.
# cafe/seed_data.py
CAFE_SEED = [
{
'name': '키이스케이프 강남점',
'address': '서울특별시 강남구 ...',
'phone': '02-...',
'lat': 37.5012,
'lng': 127.0396,
},
# ...
]
JSON이 더 "데이터답긴" 한데:
- IDE 자동완성과 타입 힌트가 편함
- 주석을 달 수 있음 (JSON은 못 달죠)
- 파이썬 상수(
Cafe.CATEGORY_CHOICES[0]같은)를 바로 참조할 수 있음
데이터가 수천 건 단위로 커지면 CSV/JSON이 맞지만, 수십~수백 건이면 파이썬 리터럴이 제일 편합니다.
4. sync_cafe_theme_seed — 참조 무결성을 지키는 동기화
테마는 카페에 속하므로 카페가 먼저 들어가 있어야 합니다. 이걸 두 번째 command로 분리했습니다.
# cafe/management/commands/sync_cafe_theme_seed.py
class Command(BaseCommand):
help = '카페별 테마 데이터를 동기화합니다.'
@transaction.atomic
def handle(self, *args, **options):
missing = []
for row in THEME_SEED:
try:
cafe = Cafe.objects.get(name=row['cafe_name'])
except Cafe.DoesNotExist:
missing.append(row['cafe_name'])
continue
Theme.objects.update_or_create(
cafe=cafe,
title=row['title'],
defaults={
'difficulty': row['difficulty'],
'running_time': row['running_time'],
},
)
if missing:
self.stdout.write(self.style.WARNING(
f'다음 카페를 찾지 못해 건너뜀: {set(missing)}'
))
"카페가 없으면 건너뛰고 경고" 로 처리한 이유는 부분 실행을 허용하기 위해서입니다. 카페 목록이 갱신된 후 테마만 다시 넣고 싶을 때 편합니다. 강하게 막고 싶으면 여기서 CommandError 를 던지면 됩니다.
5. data migration을 써야 할 때
그럼 언제 RunPython으로 박는 게 낫냐. 저는 이런 경우만 data migration에 남겼습니다.
- 선택지(Choices)처럼 고정된 참조값 —
Role,Category같은 테이블. 스키마와 한 몸이라 같이 올라가야 합니다. - 스키마 변경과 동반되는 데이터 이동 — 필드 분리, 기본값 일괄 채우기. 이건 마이그레이션 순서가 중요해서 command로 빼면 실행 순서가 불안해집니다.
- 운영 환경에서 자동 적용돼야 하는 값 — CI/CD가
migrate만 돌리고 끝난다면, 수동 command 실행을 전제로 하는 건 위험합니다.
반대로 자주 갱신되는 목록성 데이터는 data migration에 절대 넣지 않습니다. 카페 하나 추가할 때마다 마이그레이션이 쌓이면 장기적으로 000N_cafe_add_xxx.py 수십 개가 생기고, 스쿼시할 때마다 지옥을 봅니다.
6. 운영 배포에서 실행 방법
management command는 명시적으로 누가 실행해 줘야 합니다. 저는 Makefile 스크립트로 묶었습니다.
seed-cafe:
python manage.py populate_cafe_data
python manage.py sync_cafe_theme_seed
seed-dev: seed-cafe
python manage.py populate_dummy_posts
운영에서는 seed-cafe만, 개발에서는 seed-dev까지. 더미 게시글은 절대 운영에 나가면 안 됩니다.
7. 다시 보면 — "절대 안 됩니다"를 코드가 안 막고 있다
이 글에는 강한 금지 문장이 세 개 있습니다.
--reset옵션 — 초기 세팅용. 운영에서는 절대 안 쓰지만 개발할 때 필요합니다. 더미 게시글은 절대 운영에 나가면 안 됩니다.
그런데 그걸 막는 코드가 하나도 없습니다. 금지를 문서와 Makefile 타깃 이름으로만 표현했어요.
① --reset은 카페만 지우지 않는다
if options['reset']:
deleted, _ = Cafe.objects.all().delete()
Theme가 Cafe를 FK로 참조하니, 이 한 줄이 테마 300여 건까지 CASCADE로 같이 지웁니다. 출력은 기존 카페 N건 삭제라고만 말하고요. 실제 삭제 범위와 로그가 다릅니다.
되돌릴 수 없는 명령이라면 최소한 확인을 받아야 했습니다.
if options['reset']:
if not settings.DEBUG and not options['force']:
raise CommandError("운영 환경에서 --reset 은 --force 와 함께만 실행할 수 있습니다.")
cafe_count = Cafe.objects.count()
theme_count = Theme.objects.count()
self.stdout.write(self.style.WARNING(
f"카페 {cafe_count}건 + 연결된 테마 {theme_count}건이 삭제됩니다."
))
"이 명령은 무엇을 지우는가"를 실행 전에 보여주는 것만으로도 사고를 크게 줄일 수 있어요. 토큰 승인 오클릭 가드에서 배운 것과 같은 이야기입니다 — 위험한 액션에는 그 순간에 위험을 알려주는 장치가 필요합니다.
② 더미 데이터를 막는 건 Makefile 타깃 이름뿐
seed-dev: seed-cafe
python manage.py populate_dummy_posts
운영에서 seed-dev를 안 치면 되는 구조인데, 누군가 python manage.py populate_dummy_posts를 직접 치면 그냥 실행됩니다. 명령 자체에는 아무 방어도 없어요.
def handle(self, *args, **options):
if not settings.DEBUG:
raise CommandError("개발 환경 전용 명령입니다.")
이 두 줄이면 됩니다. "규칙으로 지킨다"와 "코드가 막는다"의 차이인데, 규칙 쪽은 새로 합류한 사람이나 급한 상황에서 먼저 무너져요.
③ 순서 의존을 경고로만 처리하면 CI가 못 잡는다
sync_cafe_theme_seed는 카페를 못 찾으면 건너뛰고 경고를 남깁니다. 부분 실행을 허용하려는 의도는 맞는데, 종료 코드가 0이라 CI/배포 스크립트는 성공으로 봅니다.
카페 50개가 전부 없어서 테마가 하나도 안 들어가도 "완료"로 끝나요. 최소한 "전부 건너뛰었다" 는 실패로 다뤄야 합니다.
if missing and len(missing) == len(THEME_SEED):
raise CommandError("카페 데이터가 없습니다. populate_cafe_data 를 먼저 실행하세요.")
8. update_or_create에 숨은 전제
Cafe.objects.update_or_create(name=row['name'], defaults=row)
멱등성을 위해 쓴 건 맞는데, 이 코드는 name이 유일하다는 전제 위에 서 있습니다. 같은 이름의 카페가 둘 있으면 MultipleObjectsReturned로 터져요. 지점명까지 포함한 상호라 실제로는 거의 안 겹치겠지만, "거의 안 겹친다"와 "겹칠 수 없다"는 다릅니다.
모델에 unique=True(또는 테마의 경우 unique_together = ("cafe", "title"))가 없다면, 멱등성은 운이 좋아서 유지되는 상태예요. 같은 종류의 문제를 워크플로우 상태 생성에서 한 번 겪었는데, 그때도 원인이 "DB 제약이 없어서 중복 행이 생길 수 있다" 였습니다.
그리고 defaults=row에 name이 그대로 포함되는 것도 정리 대상입니다. 조회 키와 갱신 값이 겹쳐 있어서, 나중에 name을 바꾸는 시드가 들어오면 새 행이 생기고 옛 행이 남습니다.
정리
- 고정 참조값은 data migration, 자주 바뀌는 목록성 데이터는 management command
- command는
@transaction.atomic+update_or_create로 멱등성 확보 — 단 조회 키에 DB 유일 제약이 있어야 진짜 멱등입니다 --reset같은 파괴적 플래그는 환경 검사와 삭제 범위 출력을 붙이기. "운영에서는 안 쓴다"는 규칙만으로는 안 막힙니다- 개발 전용 명령은
settings.DEBUG가드를 명령 안에 두기. Makefile 타깃 이름은 방어가 아닙니다 - 참조 관계가 있는 시드는 command를 분리해서 부분 실행을 허용하되, 전부 실패한 경우는 종료 코드로 알리기
- 배포 스크립트에서 명시적으로 어떤 command를 돌릴지 고정