발행일

Django RunPython으로 수동 마이그레이션 다루기

Django RunPython으로 수동 마이그레이션 다루기

Django 마이그레이션은 대부분 makemigrationsmigrate로 자동 처리되지만, 기존 데이터를 변환하거나 파생 필드를 채워야 할 때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'])

수동 마이그레이션 시 팁

실무에서 마이그레이션 문제를 만났을 때 도움이 된 접근 방식입니다:

  1. 테이블에 문제가 있을 때 무조건 삭제하기 전에 — 수동으로 상태를 확인한다
  2. 연관 관계를 먼저 파악 — 외래 키가 걸린 테이블은 순서가 중요하다
  3. 이미 존재하는 마이그레이션 파일과 충돌 시 — 파일 이름(번호)을 바꿔서 의존성을 재정렬한다
  4. --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단계 패턴은 이렇게 조건이 붙습니다.

상황순서
마이그레이션 파일을 나눌 때필드 추가 → 데이터 채우기 / (다음 파일) 제약 추가
한 파일에 넣을 때필드 추가 → 제약 추가 → 데이터 채우기

backwardnoop으로 두면 되돌릴 자리가 사라진다

예제는 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)에 있다면 변환 대신 재적재를 먼저 검토하세요