장고: 개발 도중 커스텀 유저 모델로 변경하기

이 글은 어떻게 현재 사용되는 장고 (Django) 프로젝트에서 데이터를 잃지 않고 커스텀 유저 모델로 변경할 수 있는지를 다룹니다.

저도 커스텀 유저 모델이 필요가 없어도 꼭 써야 된다는 것을 모르고 프로젝트를 개발하다가 이 절차를 따라야 했습니다. 사실 장고의 사용 설명 문서에서도 이를 권장하는데, 이 경고는 빠른 시작 가이드나 튜토리얼에선 찾을 수가 없어 처음부터 따라야 하는지도 몰랐습니다. 아마도 설명 문서가 이를 조금 더 강조했다면 좋지 않았을까요?

어쨌든, 이와 같은 상황이라면 이 글에 적힌 방법으로 문제를 해결할 수 있습니다.

경고

언제나 데이터베이스를 백업해두어, 문제가 생기면 바로 원상복구할 수 있도록 준비하세요!

1. 최초 마이그레이션이 있는 장고 앱 찾기

이미 유저 모델을 만들 수 있는 장고 앱이 있다면 축하합니다! 바로 6번으로 넘어가세요.

중요: 위의 장고 앱은 꼭 최초 마이그레이션(예를 들어 migrations/ 디렉터리에 있는 0001_initial.py)이 있어야 합니다. 장고 앱에 더 이상 모델이 없어도 중요하진 않습니다.

만약 없으시다면 장고 앱을 새로 만들어보겠습니다.

2. 새로운 장고 앱 만들기

다음 명령으로 프로젝트에 새로운 장고 앱을 생성합니다:

python3 manage.py startapp accounts

앱 이름은 users처럼 아무렇게나 하셔도 좋습니다. 전 이미 accounts의 이름을 가진 앱이 프로젝트에 있어, 이 튜토리얼에선 accounts 앱을 사용하겠습니다.

3. 최초 마이그레이션을 위해 가짜 모델 생성

새로운 앱의 models.py를 편집하면서 다음 가짜 모델을 생성합니다:

from django.db import models


class FakeModel(models.Model):
    pass

나중에 마이그레이션이 모두 끝난 후 이 모델을 제거합니다.

4. 최초 마이그레이션 생성

다음을 실행합니다:

python3 manage.py makemigrations

그럼 앱에 최초 마이그레이션이 생깁니다. 예를 들어, 제 마이그레이션은 accounts/migrations/0001_initial.py에 다음과 같이 생성되어 있었습니다:

# Generated by Django 3.2.5 on 2021-07-12 16:28

from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
    ]

    operations = [
        migrations.CreateModel(
            name='FakeModel',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
            ],
        ),
    ]

5. 중간 단계 릴리즈 배포하기

이제 새로운 릴리즈를 만든 다음, 모든 서버 관리자가 이 버전으로 업그레이드하게 한 후, 모든 서버에서 마이그레이션을 적어도 한 번 실행합니다.

이렇게 하는 이유는 장고에서 무슨 마이그레이션을 적용했는지 기록해두기 때문입니다. 만약 새로운 마이그레이션에서 곧바로 커스텀 유저 모델을 위한 새로운 데이터베이스 테이블을 생성할 경우, 장고는 마이그레이션을 적용하는 도중 이미 테이블이 존재하는 것을 확인하고 오류를 발생시킵니다.

이를 방지하기 위해서, 이전에 있던 모든 서버 인스턴스를 이 중간 단계 릴리즈로 업그레이드/마이그레이션한 다음, 유저 모델 마이그레이션 정보를 최초 마이그레이션에 추가로 패치해버리면, 이전 서버 인스턴스는 최초 마이그레이션을 두 번 적용하지 않을 것이고, 새로운 서버 인스턴스들은 유저 테이블이 없기에 최초 마이그레이션을 적용하게 됩니다.

6. 커스텀 유저 모델 생성

만약 1번에서 바로 오셨다면 사용할 앱 이름을 대체하는 것을 꼭 잊지 마세요. 전 accounts라는 앱 이름을 사용했습니다.

이제 커스텀 유저 모델을 생성할 차례입니다. models.py를 편집한 다음, 다음 코드를 추가합니다:

from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
    class Meta:
        db_table = 'auth_user'

여기에서 db_table 필드가 중요한데, 이전에 있던 유저 모델 테이블을 원할하게 그대로 끌어다 사용할 것이기 때문입니다. 또, 커스텀 유저 모델을 User로 이름짓어 데이터베이스 내부의 관계도를 보존시키는 것도 중요합니다. 물론, 나중에 모델 이름을 변경하는 것도 가능하기에, 일단은 이렇게 추가해두세요.

1-5번에 나와 있는 과정을 따라 하셨다면, models.py 내용은 다음과 같을 겁니다:

from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    class Meta:
        db_table = 'auth_user'


class FakeModel(models.Model):
    pass

만약 1번에서 바로 오셨다면 FakeModel 모델이 없어도 걱정하지 마세요.

7. 최초 마이그레이션을 수동으로 패치하기

이제 앱의 최초 마이그레이션을 직접 수정해줍니다. 이 마이그레이션 파일에 예전 모델들을 위한 마이그레이션들도 포함되어 있을 겁니다.

경고: 이 패치는 2021년 7월 13일 장고 버전 3.2.5에서 작동하는 것을 확인했습니다. 이후 버전은 필드나 마이그레이션에 차이가 있을 수 있습니다. 만약 차이가 있는지 확인하려면 빈 장고 프로젝트를 만든 후, 커스텀 유저 모델을 생성한 다음, 마이그레이션을 생성해서 이 섹션에 나와 있는 패치와 비교해주세요. 만약 업데이트된 마이그레이션이 있다면, 그 빈 프로젝트에서 값을 복사하시면 됩니다!

일단 필요한 의존성을 불러옵니다:

import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone

dependencies 섹션에 다음을 추가해 줍니다:

        ('auth', '0012_alter_user_first_name_max_length'),

업데이트 된 dependencies 섹션:

    dependencies = [
        ('auth', '0012_alter_user_first_name_max_length'),
    ]

operations 섹션 안에 다음을 추가합니다:

        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('is_superuser', models.BooleanField(default=False,
                                                     help_text='Designates that this user has all permissions without explicitly assigning them.',
                                                     verbose_name='superuser status')),
                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'},
                                              help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
                                              max_length=150, unique=True,
                                              validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
                                              verbose_name='username')),
                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
                ('is_staff', models.BooleanField(default=False,
                                                 help_text='Designates whether the user can log into this admin site.',
                                                 verbose_name='staff status')),
                ('is_active', models.BooleanField(default=True,
                                                  help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
                                                  verbose_name='active')),
                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
                ('groups', models.ManyToManyField(blank=True,
                                                  help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
                                                  related_name='user_set', related_query_name='user', to='auth.Group',
                                                  verbose_name='groups')),
                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.',
                                                            related_name='user_set', related_query_name='user',
                                                            to='auth.Permission', verbose_name='user permissions')),
            ],
            options={
                'db_table': 'auth_user',
            },
            managers=[
                ('objects', django.contrib.auth.models.UserManager()),
            ],
        ),

만약 1-5번을 똑같이 하셨다면 마이그레이션 파일이 다음과 같을 겁니다:

# Generated by Django 3.2.5 on 2021-07-12 16:28

import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone

from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
        ('auth', '0012_alter_user_first_name_max_length'),
    ]

    operations = [
        migrations.CreateModel(
            name='FakeModel',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
            ],
        ),
        migrations.CreateModel(
            name='User',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('password', models.CharField(max_length=128, verbose_name='password')),
                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
                ('is_superuser', models.BooleanField(default=False,
                                                     help_text='Designates that this user has all permissions without explicitly assigning them.',
                                                     verbose_name='superuser status')),
                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'},
                                              help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.',
                                              max_length=150, unique=True,
                                              validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
                                              verbose_name='username')),
                ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
                ('is_staff', models.BooleanField(default=False,
                                                 help_text='Designates whether the user can log into this admin site.',
                                                 verbose_name='staff status')),
                ('is_active', models.BooleanField(default=True,
                                                  help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.',
                                                  verbose_name='active')),
                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
                ('groups', models.ManyToManyField(blank=True,
                                                  help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
                                                  related_name='user_set', related_query_name='user', to='auth.Group',
                                                  verbose_name='groups')),
                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.',
                                                            related_name='user_set', related_query_name='user',
                                                            to='auth.Permission', verbose_name='user permissions')),
            ],
            options={
                'db_table': 'auth_user',
            },
            managers=[
                ('objects', django.contrib.auth.models.UserManager()),
            ],
        ),
    ]

8. 설정에서 커스텀 유저 모델 활성화하기

settings.py 파일에 다음 설정값을 추가합니다;

AUTH_USER_MODEL = "accounts.User"

이때 accounts를 사용하는 앱 이름으로 변경하는 것을 잊지 마세요!

9. 빈 마이그레이션 생성하기

이제 커스텀 유저 모델로 전환하기 전에 마지막 마이그레이션 하나를 만들어야 합니다. 다음을 실행합니다:

python3 manage.py makemigrations --empty accounts --name change_user_type

이때 accounts를 사용하는 앱 이름으로 변경하는 것을 잊지 마세요. 장고는 새로운 빈 마이그레이션 파일을 생성하는데, 제 프로젝트에선 accounts/migrations/0002_change_user_type.py에 파일이 생겼습니다

10. 빈 마이그레이션 수정하기

다음 함수를 마이그레이션 파일 최상단에 추가합니다 (의존성 불러오기 아래에):

def change_user_type(apps, schema_editor):
    ContentType = apps.get_model('contenttypes', 'ContentType')
    ct = ContentType.objects.filter(
        app_label='auth',
        model='user'
    ).first()
    if ct:
        ct.app_label = 'user'
        ct.save()

그 다음, operations 섹션에 다음을 추가합니다:

      migrations.RunPython(change_user_type),

참고로 앱에 이전 마이그레이션이 있었을 경우 dependencies 섹션이 다를 수 있습니다. 완료되면 마이그레이션 파일이 다음과 같아야 합니다:

# Generated by Django 3.2.5 on 2021-07-12 16:38

from django.db import migrations


def change_user_type(apps, schema_editor):
    ContentType = apps.get_model('contenttypes', 'ContentType')
    ct = ContentType.objects.filter(
        app_label='auth',
        model='user'
    ).first()
    if ct:
        ct.app_label = 'user'
        ct.save()


class Migration(migrations.Migration):

    dependencies = [
        ('accounts', '0001_initial'),
    ]

    operations = [
        migrations.RunPython(change_user_type),
    ]

11. 새로운 db_table 필드로 변경

이제 마이그레이션을 추가해서 기본 테이블로 이동해도 됩니다. models.py를 수정한 다음, Meta 클래스를 제거합니다. 이 변경으로 유저 모델이 비어 있다면, pass를 추가하는 것도 잊지 마세요. 그럼 유저 모델이 다음과 같아야 합니다:

class User(AbstractUser):
    pass

이제 마이그레이션을 새로 생성합니다.

만약 1번에서 6번으로 바로 넘어오셨다면 여기에서 마치셔도 됩니다. 새로운 릴리즈를 생성한 다음, 관리자들이 업그레이드하도록 하시면 됩니다. 추후의 마이그레이션은 이전처럼 생성하고 적용하면 됩니다.

12. 임시 가짜 모델 삭제하기

만약 1-5번을 따르셨다면, 최초 마이그레이션을 생성하기 위해 만들었던 가짜 모델을 제거해야 됩니다. models.py를 수정하면서 FakeModel 모델을 제거해주세요.

새로운 마이그레이션을 생성한 다음 배포하시면 됩니다. 서버 관리자들이 중간 단계 릴리즈로 먼저 업그레이드 후 데이터베이스 마이그레이션을 진행하는 것을 잊지 않게 안내하시는 것을 추천합니다.

결론

이 과정을 백엔드에서 PostgreSQL과 배포 관리용 도커 (Docker)를 사용하는 제 장고 프로젝트에서 시험했는데, 마이그레이션은 별 문제 없이 진행되었고 이상한 변경사항 없이 기존의 데이터베이스 테이블을 보존할 수 있었습니다.

위의 튜토리얼이 잘 작동했는지 안 했는지 꼭 밑에 댓글로 남겨주세요!

참조

이 블로그 글은 Caktus Group이 작성한 블로그 글장고의 버그트래커에 올라와 있는 버그 리포트 (특히 댓글 24번)을 참조하여 작성되었습니다.

댓글