- 발행일
Django RunPython으로 수동 마이그레이션 다루기
Django RunPython으로 수동 마이그레이션 다루기
Django 마이그레이션은 대부분 makemigrations → migrate로 자동 처리되지만, 기존 데이터를 변환하거나 파생 필드를 채워야 할 때는 RunPython을 직접 작성해야 합니다.
RunPython이란?
migrations.RunPython은 마이그레이션 파일 안에서 임의의 Python 함수를 실행할 수 있게 해주는 오퍼레이션입니다.
migrations.RunPython(forward_func, reverse_func)
forward_func:migrate시 실행reverse_func:migrate --fake또는 롤백 시 실행. 롤백이 필요 없으면migrations.RunPython.noop사용
실전 예시: 해시 필드 추가 후 기존 데이터 채우기
상황
User 모델에 name_hash 필드(SHA-256)를 추가하되, 기존 사용자의 name 값을 해시 처리해서 채워야 합니다.
1단계: 필드 추가 + 데이터 채우기 (null 허용)
from django.db import migrations, models
from utils.common import sha256
def create_name_hash(apps, schema_editor):
User = apps.get_model("user_group", "User")
for user in User.objects.all():
user.name_hash = sha256(user.name.lower())
user.save(update_fields=["name_hash"])
class Migration(migrations.Migration):
dependencies = [
("user_group", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="user",
name="name_hash",
field=models.CharField(editable=False, max_length=64, null=True),
),
migrations.RunPython(create_name_hash, migrations.RunPython.noop),
]
핵심: 먼저
null=True로 필드를 추가한 뒤,RunPython으로 데이터를 채웁니다. 처음부터null=False, unique=True로 추가하면 기존 행에 값이 없어 오류가 발생합니다.
2단계: null 허용 해제 + unique 제약 추가
class Migration(migrations.Migration):
dependencies = [
("user_group", "0005_add_name_hash"),
]
operations = [
migrations.AlterField(
model_name="user",
name="name_hash",
field=models.CharField(editable=False, max_length=64, unique=True),
),
]
RunPython 함수 작성 시 주의사항
apps.get_model() 사용
마이그레이션 함수 안에서는 실제 모델 클래스가 아닌 마이그레이션 시점의 모델 스냅샷을 사용해야 합니다.
# ✅ 올바른 방법
User = apps.get_model("user_group", "User")
# ❌ 잘못된 방법 — 현재 모델 상태와 다를 수 있음
from user_group.models import User
대용량 데이터는 배치 처리
def create_name_hash(apps, schema_editor):
User = apps.get_model("user_group", "User")
batch = []
for user in User.objects.all().iterator():
user.name_hash = sha256(user.name.lower())
batch.append(user)
if len(batch) >= 1000:
User.objects.bulk_update(batch, ['name_hash'])
batch.clear()
if batch:
User.objects.bulk_update(batch, ['name_hash'])
수동 마이그레이션 시 팁
실무에서 마이그레이션 문제를 만났을 때 도움이 된 접근 방식입니다:
- 테이블에 문제가 있을 때 무조건 삭제하기 전에 — 수동으로 상태를 확인한다
- 연관 관계를 먼저 파악 — 외래 키가 걸린 테이블은 순서가 중요하다
- 이미 존재하는 마이그레이션 파일과 충돌 시 — 파일 이름(번호)을 바꿔서 의존성을 재정렬한다
--fake와--fake-initial— 이미 DB에 반영된 마이그레이션을 건너뛸 때 사용하되, 남용하면 실제 상태와 기록이 어긋날 수 있다
나중에 알게 된 것들
이 글을 쓰고 한참 뒤에 실제로 큰 이관 마이그레이션을 하면서, 위 내용 중 고쳐야 할 것과 조건이 빠진 것을 발견했습니다.
① reverse_func는 --fake에서 실행되지 않는다
위에서 이렇게 적었습니다.
reverse_func:migrate --fake또는 롤백 시 실행
틀렸습니다. --fake는 마이그레이션을 실행하지 않고 적용된 것으로 기록만 하는 옵션이라, forward든 reverse든 아무 함수도 안 돕니다.
reverse_func가 실행되는 건 이전 상태로 되돌릴 때뿐입니다.
python manage.py migrate user_group 0004 # 0005의 reverse_func 실행
python manage.py migrate user_group 0004 --fake # 아무것도 실행 안 됨, 기록만 변경
--fake는 오히려 reverse를 건너뛰고 싶을 때 쓰는 도구예요. 정반대로 적어뒀습니다.
② 3단계 패턴에는 "파일을 나눈다"는 전제가 있다
이 글의 결론은 "필드 추가 → 데이터 채우기 → 제약 추가" 인데, 위 예제는 그걸 두 개의 마이그레이션 파일로 나눠서 합니다(0005에서 추가+채우기, 다음 파일에서 제약).
그 전제를 안 적었더니, 나중에 한 파일에 다 넣었다가 PostgreSQL에 거부당했습니다.
django.db.utils.OperationalError: cannot CREATE INDEX "..." on table "..."
because it has pending trigger events
같은 트랜잭션 안에서 RunPython의 UPDATE가 실행된 뒤에는 그 테이블에 인덱스를 만들 수 없어요. 즉 한 파일 안에서는 "데이터 채우기 → 제약 추가" 순서가 아예 불가능합니다.
해결은 순서를 뒤집는 것이었고, 결과적으로 그게 더 나은 구조였습니다.
operations = [
migrations.AddField(...),
migrations.AddConstraint(...), # 제약을 먼저 — 아직 대상 행이 없어 항상 성공
migrations.RunPython(forward, backward), # 백필이 제약을 지키며 실행됨
]
제약을 먼저 걸면 백필이 규칙 안에서만 데이터를 넣게 되고, 위반이 있으면 그 자리에서 실패합니다. "일단 넣고 나중에 제약" 보다 안전해요. 이 과정은 필수 플래그를 관계 행으로 옮긴 회고에 자세히 적었습니다.
정리하면 3단계 패턴은 이렇게 조건이 붙습니다.
| 상황 | 순서 |
|---|---|
| 마이그레이션 파일을 나눌 때 | 필드 추가 → 데이터 채우기 / (다음 파일) 제약 추가 |
| 한 파일에 넣을 때 | 필드 추가 → 제약 추가 → 데이터 채우기 |
③ backward를 noop으로 두면 되돌릴 자리가 사라진다
예제는 migrations.RunPython(create_name_hash, migrations.RunPython.noop)으로 reverse를 비워뒀습니다. 편하지만, 롤백이 "조용히 성공" 하게 돼요. 스키마는 되돌아가는데 데이터는 그대로 남습니다.
이관 마이그레이션에서는 reverse를 제대로 써두는 게 실제로 도움이 됐습니다.
def backward(apps, schema_editor):
# 이관 전 원본(is_mandatory)이 아직 남아 있으므로 새 플래그만 비우면 된다
MetaClassMetadata.objects.update(is_required=False, is_name_source=False)
포인트는 이관과 제거를 다른 마이그레이션으로 나누는 것입니다. 새 컬럼을 채우는 마이그레이션과 옛 컬럼을 지우는 마이그레이션을 분리하면, 앞엣것만 배포한 상태에서는 두 컬럼이 공존해서 되돌릴 자리가 남아요.
④ 원본이 따로 있으면 RunPython이 답이 아닐 수도 있다
이 글은 "기존 데이터를 변환해야 하면 RunPython" 을 전제로 시작하는데, 그게 늘 최선은 아니었습니다.
속성의 range를 문자열에서 FK로 바꿀 때는 데이터 마이그레이션을 아예 안 썼습니다. 필드를 지웠다 다시 만들고, 엑셀 원본에서 다시 적재했어요.
operations = [
migrations.RemoveField(model_name="metaclassproperty", name="range"),
migrations.AddField(model_name="metaclassproperty", name="range", field=models.ForeignKey(...)),
]
RunPython은 지금 DB에 있는 값을 보고 추측해야 하지만, 재적재는 원본을 다시 읽습니다. 그래서 과거의 오적재까지 같이 정정됐어요(잘못 reify된 항목 113건 → 49건). 자세한 건 range를 FK 둘로 쪼갠 회고에 있습니다.
"정본이 DB 밖에 있는가" 를 먼저 묻는 게 순서였습니다. 있다면 변환보다 재적재가 정확해요.
마무리
RunPython은 스키마 변경뿐 아니라 데이터 변환 로직까지 마이그레이션 히스토리에 남길 수 있다는 점이 유용합니다.
- 필드 추가 → 데이터 채우기 → 제약 추가 — 단, 한 파일 안에서는 제약을 백필보다 먼저
- 모델 참조는
apps.get_model()로 스냅샷을 가져옵니다 - 데이터가 많다면
bulk_update로 성능을 챙깁니다 reverse_func는 롤백 때만 실행됩니다.--fake는 아무것도 실행하지 않아요- 정본이 DB 밖(엑셀·외부 API)에 있다면 변환 대신 재적재를 먼저 검토하세요