# Django 데이터 마이그레이션에서 모델을 import 하면 안 되는 이유

Django의 데이터 마이그레이션은 RunPython으로 작성합니다. 이때 마이그레이션 파일 상단에서 from myapp.models import Person처럼 모델을 직접 import 하면 작성 시점에는 정상 동작하고 CI도 통과합니다. 문제는 몇 달 뒤 새 환경에서 마이그레이션을 처음부터 실행할 때 드러납니다.

이 글에서는 왜 이런 시차를 두고 깨지는지, 히스토리컬 모델(historical model)이 무엇이고 무엇이 빠져 있는지, 그리고 이 실수를 사람이 아닌 테스트가 잡게 만드는 방법을 정리합니다.

# 1. 언제 터지는가

마이그레이션 파일에 다음과 같이 썼다고 하겠습니다.

# ❌ 나중에 깨진다
from django.db import migrations
from myapp.models import Person   # 현재 코드의 모델

def combine_names(apps, schema_editor):
    for person in Person.objects.all():
        person.name = f"{person.first_name} {person.last_name}"
        person.save()

지금은 잘 동작합니다. Person 모델의 현재 정의와 이 마이그레이션이 기대하는 정의가 아직 같기 때문입니다. 이후 누군가 first_name을 삭제하거나 필드를 추가하면, 이 마이그레이션은 자기가 만들어진 시점이 아니라 현재 시점의 모델을 보게 됩니다. 공식 문서의 경고가 정확히 이 상황을 가리킵니다.

If you import models directly rather than using the historical models, your migrations may work initially but will fail in the future when you try to rerun old migrations (commonly, when you set up a new installation and run through all the migrations to set up the database).

즉 이 결함은 기존 환경에서는 절대 드러나지 않습니다. 이미 마이그레이션을 적용해 둔 개발자·스테이징·운영 DB는 해당 마이그레이션을 다시 실행하지 않기 때문입니다. 깨지는 것은 신규 개발자의 로컬 환경, 새 환경 구축, 그리고 테스트 DB를 처음부터 만드는 CI입니다.

# 2. 히스토리컬 모델이란

Django는 마이그레이션을 실행할 때 마이그레이션 파일들에 기록된 정보로 그 시점의 모델을 재구성합니다. 이것이 히스토리컬 모델입니다.

myapp/migrations/
├── 0001_initial.py          ← Person(first_name, last_name)
├── 0002_add_name.py         ← Person(first_name, last_name, name)
├── 0003_combine_names.py    ← 이 시점의 Person 을 봐야 한다
└── 0004_remove_split.py     ← Person(name)  ← 현재 코드의 모습

0003을 실행할 때는 first_name이 아직 살아 있는 0003 시점의 Person이 필요합니다. 현재 코드의 Person에는 그 필드가 없으므로, 직접 import 하면 AttributeErrorFieldError로 실패합니다.

When you run migrations, Django is working from historical versions of your models stored in the migration files. If you write Python code using the RunPython operation ... you need to use these historical model versions rather than importing them directly.

# 3. apps.get_model - 올바른 작성법

RunPython이 호출하는 함수는 appsschema_editor 두 인자를 받습니다. 여기서 apps히스토리컬 모델이 등록된 앱 레지스트리입니다.

from django.db import migrations

def combine_names(apps, schema_editor):
    # 현재 코드의 모델보다 새로울 수 있으므로 직접 import 하지 않는다.
    # 이 마이그레이션 시점의 버전을 사용한다.
    Person = apps.get_model("myapp", "Person")
    for person in Person.objects.all():
        person.name = f"{person.first_name} {person.last_name}"
        person.save()

class Migration(migrations.Migration):
    dependencies = [
        ("myapp", "0002_add_name"),
    ]

    operations = [
        migrations.RunPython(combine_names),
    ]

핵심은 apps.get_model("<app_label>", "<ModelName>") 한 줄이고, 함수 안에서 호출해야 한다는 점입니다. 모듈 최상단에서 꺼내 두면 의미가 없습니다.

# 4. 히스토리컬 모델에 없는 것

여기서 두 번째 함정이 나옵니다. apps.get_model로 얻은 모델은 현재 코드의 모델과 동일하지 않습니다.

Because it's impossible to serialize arbitrary Python code, these historical models will not have any custom methods that you have defined. They will, however, have the same fields, relationships, managers (limited to those with use_in_migrations = True) and Meta options.

문서의 경고는 더 직접적입니다.

This means that you will NOT have custom save() methods called on objects when you access them in migrations, and you will NOT have any custom constructors or instance methods. Plan appropriately!

항목 히스토리컬 모델
필드, 관계, Meta 있음
커스텀 매니저 use_in_migrations = True인 것만
커스텀 save() 호출되지 않음
커스텀 인스턴스 메서드·생성자 없음
signals (post_save 등) 모델 코드에 연결된 것은 기대대로 동작하지 않음

실무에서 이것이 사고로 이어지는 전형적인 경로는 save() 오버라이드입니다. 모델의 save()에서 슬러그 생성이나 정규화를 하고 있었다면, 마이그레이션에서 obj.save()를 불러도 그 로직은 실행되지 않습니다. 데이터가 조용히 비정규 상태로 들어갑니다. 필요한 로직은 마이그레이션 함수 안에 명시적으로 다시 써야 합니다.

매니저를 마이그레이션에서 쓰고 싶다면 선언이 필요합니다.

class ActiveManager(models.Manager):
    use_in_migrations = True   # 이 선언이 있어야 히스토리컬 모델에 포함된다

class Person(models.Model):
    objects = ActiveManager()

# 5. 그 밖에 함께 챙길 것

# 5-1. 되돌릴 수 있게 하기

RunPython은 역방향 함수를 주지 않으면 되돌릴 수 없는 마이그레이션이 됩니다. 되돌릴 필요가 없다면 명시적으로 그렇게 선언하는 편이 낫습니다.

operations = [
    migrations.RunPython(combine_names, migrations.RunPython.noop),
]

# 5-2. 대량 데이터

Person.objects.all()을 그대로 순회하면 전체를 메모리에 올립니다. 행 수가 많으면 배치 처리로 나눠야 합니다.

def combine_names(apps, schema_editor):
    Person = apps.get_model("myapp", "Person")
    batch = []
    for person in Person.objects.all().iterator(chunk_size=2000):
        person.name = f"{person.first_name} {person.last_name}"
        batch.append(person)
        if len(batch) >= 2000:
            Person.objects.bulk_update(batch, ["name"])
            batch.clear()
    if batch:
        Person.objects.bulk_update(batch, ["name"])

# 5-3. 다중 DB

라우터를 쓰는 환경이라면 schema_editor.connection.alias를 확인해 대상 DB에서만 실행되게 해야 합니다. 그렇지 않으면 모든 연결에서 같은 데이터 조작이 반복됩니다.

# 6. 사람이 아니라 테스트가 잡게 하기

이 결함은 리뷰에서 놓치기 쉽고, 놓치면 몇 달 뒤 남의 환경에서 터집니다. 그래서 마이그레이션 파일을 정적으로 검사하는 테스트를 두는 편이 확실합니다. AST로 파일 전체(함수 안 포함)의 모델 import를 찾는 방식이면 실행 없이 검사할 수 있습니다.

import ast
import pathlib

import pytest

MIGRATIONS = sorted(pathlib.Path("app").glob("*/migrations/0*.py"))

@pytest.mark.parametrize("path", MIGRATIONS, ids=lambda p: str(p))
def test_migration_does_not_import_models(path):
    """데이터 마이그레이션은 apps.get_model 로 히스토리컬 모델을 써야 한다."""
    tree = ast.parse(path.read_text(encoding="utf-8"))

    offenders = [
        node.module
        for node in ast.walk(tree)
        if isinstance(node, ast.ImportFrom)
        and node.module
        and node.module.endswith(".models")
    ]

    assert not offenders, (
        f"{path}: 모델을 직접 import 했습니다 ({offenders}). "
        f"RunPython 안에서 apps.get_model('<app>', '<Model>') 을 사용하세요. "
        f"의도된 예외라면 이 목록에 사유와 함께 등록하세요."
    )

가드를 만들 때 두 가지를 같이 고려해야 합니다.

  • 사각지대: from myapp import modelsmodels.Person으로 접근하는 형태, import myapp.models, 상대 import(from ..models import Person)는 위 검사에 걸리지 않습니다. ast.walk가 트리 전체를 돌기 때문에 함수 안의 from myapp.models import ...는 잡힙니다. 잡을 범위를 정하고 그 범위를 주석으로 남기는 편이 낫습니다.
  • 오탐 탈출구: 정당한 예외(모델이 아닌 상수·Enum import 등)가 반드시 생깁니다. 실패 메시지에 어떻게 예외 처리하는지를 적어 두지 않으면, 막힌 사람이 테스트 자체를 지우게 됩니다.

# 7. 트러블슈팅

증상 원인 해결
로컬은 되는데 새 환경에서 FieldError/AttributeError 마이그레이션이 현재 모델을 참조 apps.get_model로 전환
CI에서만 실패 테스트 DB를 처음부터 생성 → 옛 마이그레이션 재실행 위와 동일
마이그레이션 후 데이터가 비정규 상태 커스텀 save()가 호출되지 않음 로직을 마이그레이션 함수에 명시적으로 작성
Manager isn't available use_in_migrations 미선언 매니저에 use_in_migrations = True
롤백 불가 역방향 함수 없음 RunPython.noop 또는 역함수 제공

이미 배포된 마이그레이션이라도 고칠 수 있습니다. 문서도 그렇게 안내합니다.

If you run into this kind of failure, it's OK to edit the migration to use the historical models rather than direct imports and commit those changes.

이미 적용된 환경에서는 해당 마이그레이션이 다시 실행되지 않으므로, 파일을 고쳐도 기존 DB에는 영향이 없습니다.

# 8. 마무리

  • 데이터 마이그레이션에서 모델을 직접 import 하면 당장은 통과하고 나중에, 남의 환경에서 깨집니다.
  • RunPython 함수 안에서 apps.get_model()로 히스토리컬 모델을 얻는 것이 유일한 정답입니다.
  • 히스토리컬 모델에는 커스텀 메서드와 save() 오버라이드가 없습니다. 필요한 로직은 마이그레이션 안에 다시 써야 합니다.
  • 이런 종류의 결함은 리뷰로 막기 어렵습니다. 정적 검사 테스트로 강제하되, 오탐 탈출구를 실패 메시지에 함께 적어 두어야 가드가 살아남습니다.

# 참고