프로그래밍/Python

🔀 Django 팀 개발의 악몽: "Multiple leaf nodes in the migration graph"

Tiboong 2025. 10. 17. 11:09
728x90
반응형

"브랜치 머지했더니 마이그레이션이 터졌어요!"

월요일 아침, 주말 동안 열심히 개발한 기능을 main 브랜치에 머지했습니다. 자신감 넘치게 서버를 업데이트하려는데...

python manage.py migrate

CommandError: Conflicting migrations detected; 
multiple leaf nodes in the migration graph: 
(0023_add_user_profile, 0023_add_product_category in products).
To fix them run 'python manage.py makemigrations --merge'

 

뭐지? 0023이 두 개라고??

 

알고 보니 같은 시간에 다른 팀원도 모델을 수정해서 0023 마이그레이션을 만들었고, 두 개의 평행 우주가 생겨버린 것입니다.

이것이 바로 Django 팀 개발에서 거의 모든 팀이 한 번은 겪는 문제, 데이터베이스 마이그레이션 충돌입니다.

 

27년간 수많은 팀 프로젝트를 경험하면서 확신하는 것은, 마이그레이션 충돌이 **"단순한 기술 문제가 아니라 팀 협업 프로세스의 문제"**라는 것입니다.

 

오늘은 Django 마이그레이션이 어떻게 작동하는지, 왜 충돌이 발생하는지, 그리고 팀 차원에서 어떻게 예방하고 해결하는지 알아보겠습니다.

🤔 Django 마이그레이션은 어떻게 작동할까요?

마이그레이션의 기본 개념: 데이터베이스의 버전 관리

Django 마이그레이션은 데이터베이스의 Git과 같습니다. 코드를 버전 관리하듯이, 데이터베이스 스키마도 버전 관리를 합니다.

 

작동 방식:

1. 모델 변경:
   models.py에서 필드 추가/수정/삭제

2. makemigrations:
   python manage.py makemigrations
   → 변경사항을 Python 파일로 생성
   → migrations/0001_initial.py 같은 파일 생성

3. migrate:
   python manage.py migrate
   → 마이그레이션 파일을 실행
   → 실제 데이터베이스 스키마 변경

 

마이그레이션 파일의 구조:

# products/migrations/0002_add_price.py
class Migration(migrations.Migration):
    dependencies = [
        ('products', '0001_initial'),  # 이전 마이그레이션에 의존
    ]

    operations = [
        migrations.AddField(
            model_name='product',
            name='price',
            field=models.DecimalField(max_digits=10, decimal_places=2),
        ),
    ]

 

핵심 포인트:

  • 순차적 실행: 마이그레이션은 반드시 순서대로 실행됨
  • 의존성 관리: 각 마이그레이션은 이전 마이그레이션에 의존
  • 일방향: 한 번 실행하면 되돌리기 어려움 (특히 데이터가 있을 때)

마이그레이션 그래프: 선형 vs 분기

정상적인 선형 구조:

0001_initial → 0002_add_field → 0003_remove_field → 0004_update_field

이것은 단일 선으로 이어진 깔끔한 구조입니다.

 

충돌이 발생한 분기 구조:

                    ↗ 0003_add_profile (개발자 A)
0002_base_model 
                    ↘ 0003_add_category (개발자 B)

이것은 평행 우주가 생긴 것입니다. Django는 어느 것을 먼저 실행해야 할지 모릅니다.

Git 충돌과의 비교

Git 충돌과 비슷하지만 다름:

Git 충돌:

  • 같은 파일의 같은 줄을 동시에 수정
  • 충돌 마커로 표시
  • 개발자가 직접 선택해서 해결

마이그레이션 충돌:

  • 같은 "번호"의 마이그레이션을 동시에 생성
  • 자동으로 감지되지만 자동 해결 안 됨
  • 특별한 병합 마이그레이션 필요

💥 마이그레이션 충돌이 발생하는 상황들

상황 1: 평행 개발에서의 충돌

타임라인:

월요일 오전:
개발자 A: feature-a 브랜치 생성
개발자 B: feature-b 브랜치 생성
(둘 다 main의 0022_last_migration에서 시작)

월요일 오후:
개발자 A: User 모델에 profile 필드 추가
         makemigrations → 0023_add_user_profile.py 생성

개발자 B: Product 모델에 category 필드 추가
         makemigrations → 0023_add_product_category.py 생성

화요일:
개발자 A: feature-a를 main에 머지 ✅
개발자 B: feature-b를 main에 머지 시도 ❌

Git: "충돌 없음, 머지 성공!"
Django: "0023이 두 개? 충돌 발생!"

 

왜 Git은 통과시켰을까?

  • 서로 다른 파일이므로 Git 입장에서는 충돌 아님
  • 하지만 Django 입장에서는 같은 번호라서 문제

상황 2: 장기 브랜치의 악몽

상황 설명:

3주 전: feature-long 브랜치 생성 (0020에서 시작)

그동안 main 브랜치:
- 0021, 0022, 0023, 0024, 0025까지 진행

3주 후: feature-long에서 모델 수정
- makemigrations → 0021_feature_long.py 생성 ❌

문제:
main에는 이미 0021~0025가 존재
feature-long의 0021은 "과거로의 여행"

 

결과:

  • 마이그레이션 그래프가 완전히 꼬임
  • 단순 병합으로는 해결 불가
  • 수동 번호 조정 필요

상황 3: 롤백 후 재생성

위험한 시나리오:

1. 개발자가 0025 마이그레이션 실행
2. 문제 발견, 롤백 시도
   python manage.py migrate products 0024

3. 마이그레이션 파일 삭제
   rm products/migrations/0025_*.py

4. 모델 다시 수정
5. makemigrations → 새로운 0025 생성

문제:
- 다른 개발자나 서버는 "옛날 0025"를 실행함
- 같은 번호, 다른 내용 → 데이터베이스 불일치

상황 4: Squash 후 충돌

마이그레이션 압축 시나리오:

main 브랜치:
0001~0050 존재, 0030~0050을 압축(squash)
→ 0030_squashed_0050.py 생성

feature 브랜치:
여전히 0001~0050 개별 파일 참조

결과:
병합 시 마이그레이션 경로 불일치

🚨 충돌의 증상과 진단

증상 1: 명백한 충돌 메시지

가장 흔한 에러:

CommandError: Conflicting migrations detected; 
multiple leaf nodes in the migration graph: 
(0023_add_profile, 0023_add_category in products).

 

의미:

  • "leaf nodes" = 그래프의 끝점이 여러 개
  • 두 개의 0023이 평행하게 존재
  • 어느 것을 다음으로 해야 할지 모름

증상 2: 조용한 불일치

더 위험한 상황:

서버 A: 0025_version_a 실행됨
서버 B: 0025_version_b 실행됨

두 서버의 데이터베이스 스키마가 다름!
하지만 에러 메시지 없음

 

증상:

  • 특정 서버에서만 이상한 에러 발생
  • "필드가 없습니다" 또는 "테이블이 없습니다"
  • 로컬에서는 되는데 서버에서만 안 됨

진단 도구: showmigrations

현재 마이그레이션 상태 확인:

python manage.py showmigrations

products
 [X] 0001_initial
 [X] 0002_add_name
 [ ] 0003_add_profile
 [ ] 0003_add_category  ← 충돌!

 

그래프 시각화:

python manage.py showmigrations --plan

[ ] products.0003_add_profile
[ ] products.0003_add_category
    Conflict! Multiple leaf nodes detected.

🛠️ 충돌 해결 방법

방법 1: 자동 병합 (권장)

Django의 병합 기능 사용:

# 1. 충돌 감지
python manage.py migrate
# → 에러 발생

# 2. 병합 마이그레이션 생성
python manage.py makemigrations --merge

# Django가 물어봄:
Merging will only work if the operations printed above do not conflict
with each other (working on different fields or models)
Do you want to merge these migration branches? [y/N] y

# 3. 병합 마이그레이션 파일 생성
Created new merge migration products/0026_merge_0023_add_profile_0023_add_category.py

 

생성된 병합 마이그레이션:

# products/migrations/0026_merge.py
class Migration(migrations.Migration):
    dependencies = [
        ('products', '0023_add_profile'),
        ('products', '0023_add_category'),
    ]

    operations = []  # 보통 비어있음

 

작동 원리:

              → 0023_add_profile ↘
0022_base                         0026_merge → 0027_next
              → 0023_add_category ↗

병합 마이그레이션이 두 개의 분기를 하나로 합칩니다.

방법 2: 수동 번호 조정

언제 사용하나?

  • 아직 배포하지 않은 로컬 개발 상태
  • 다른 개발자와 공유하지 않은 상태
  • 간단하게 해결하고 싶을 때

과정:

# 1. 충돌하는 마이그레이션 파일 확인
ls products/migrations/
# 0023_add_profile.py
# 0023_add_category.py

# 2. 하나의 번호를 변경 (더 최근 것)
mv products/migrations/0023_add_category.py \
   products/migrations/0024_add_category.py

# 3. 파일 내부의 dependencies도 수정
# 0024_add_category.py
dependencies = [
    ('products', '0023_add_profile'),  # 0022에서 0023으로 변경
]

# 4. 마이그레이션 실행
python manage.py migrate

 

주의사항:

  • 절대 이미 배포된 마이그레이션은 수정하지 말 것
  • 다른 개발자가 이미 실행했을 수 있음
  • 데이터베이스 불일치 발생 가능

방법 3: 리베이스와 재생성

장기 브랜치의 경우:

# 1. main 브랜치의 최신 상태로 리베이스
git checkout feature-long
git rebase main

# 2. 충돌하는 마이그레이션 삭제 (로컬만!)
rm products/migrations/0021_feature_long.py

# 3. 마이그레이션 재생성
python manage.py makemigrations
# → 0026_feature_long.py 생성 (최신 번호로)

# 4. 테스트
python manage.py migrate

# 5. 커밋 및 푸시
git add products/migrations/
git commit -m "Regenerate migration after rebase"

방법 4: 페이크 마이그레이션 (위급 시)

운영 환경에서 긴급 상황:

# 서버 A는 0025_version_a 실행
# 서버 B는 0025_version_b 실행 
# 둘 다 이미 운영 중

# 해결책: 
# 1. 하나를 "가짜"로 실행한 것으로 표시
python manage.py migrate products 0025_version_a --fake

# 2. 그 다음 실제 마이그레이션 실행
python manage.py migrate

# 주의: 스키마 불일치 수동 확인 필요!

 

매우 위험한 방법:

  • 데이터베이스 상태와 Django 기록이 불일치할 수 있음
  • 절대 첫 번째 선택지로 사용하지 말 것
  • 정말 어쩔 수 없는 긴급 상황에만

🛡️ 마이그레이션 충돌 예방하기

예방책 1: 커뮤니케이션과 조율

팀 차원의 규칙:

"모델 수정하기 전에 팀에 알리기"

Slack 메시지:
개발자 A: "지금부터 User 모델 수정합니다. 
          30분 정도 다른 분들은 모델 수정 자제해주세요!"
개발자 B: "알겠습니다. 제 작업은 오후에 할게요."

 

모델 수정 예약제:

  • 하루의 특정 시간에만 모델 수정 허용
  • 예: 오전 10시~11시는 "모델 수정 시간"
  • 나머지 시간은 로직만 수정

예방책 2: 짧은 브랜치 수명

장기 브랜치의 위험:

❌ 나쁜 패턴:
feature 브랜치 생성 → 3주 개발 → 머지 시도
(그동안 main에 많은 변경사항 누적)

✅ 좋은 패턴:
feature 브랜치 생성 → 2-3일 개발 → 머지
정기적으로 main에서 rebase

 

주기적 동기화:

# 매일 아침 main 브랜치 변경사항 가져오기
git checkout feature-branch
git fetch origin
git rebase origin/main

# 충돌 발생 시 바로 해결 (작을 때 해결이 쉬움)

예방책 3: Pre-commit Hook

자동 충돌 감지:

#!/bin/bash
# .git/hooks/pre-commit

# 마이그레이션 충돌 감지
python manage.py makemigrations --check --dry-run

if [ $? -ne 0 ]; then
    echo "❌ 마이그레이션 충돌이 감지되었습니다!"
    echo "다음 명령어를 실행하세요:"
    echo "  python manage.py makemigrations --merge"
    exit 1
fi

echo "✅ 마이그레이션 충돌 없음"

예방책 4: CI/CD 파이프라인 검증

GitHub Actions 예시:

# .github/workflows/migration-check.yml
name: Migration Check

on: [pull_request]

jobs:
  check-migrations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      
      - name: Check for migration conflicts
        run: |
          python manage.py makemigrations --check --dry-run
          if [ $? -ne 0 ]; then
            echo "::error::Migration conflicts detected"
            exit 1
          fi
      
      - name: Run migrations
        run: |
          python manage.py migrate
          
      - name: Check migration order
        run: |
          python manage.py showmigrations --plan

 

효과:

  • PR 생성 시 자동으로 충돌 검사
  • 머지 전에 문제 발견
  • 운영 환경 배포 전 안전 확인

예방책 5: 마이그레이션 네이밍 컨벤션

의미있는 이름 사용:

# ❌ 자동 생성된 이름
0023_auto_20240115_1430.py

# ✅ 명확한 이름
0023_add_user_profile_fields.py

# 생성 시 이름 지정
python manage.py makemigrations --name add_user_profile_fields

 

장점:

  • 충돌 발생 시 어떤 변경사항인지 즉시 파악
  • Git 히스토리에서 추적 용이
  • 팀원들이 이해하기 쉬움

🎯 실제 해결 사례: 성장하는 스타트업

초기 혼란과 반복되는 충돌

팀 상황:

  • 개발자 8명으로 확대
  • 빠른 기능 추가로 하루 5-10개 PR
  • 매주 2-3번 마이그레이션 충돌 발생
  • 배포마다 "누가 마이그레이션 확인했어?" 질문

발생했던 사고들:

 

사고 1: 금요일 밤의 배포 실패

오후 6시: 5개 PR 동시 머지
오후 7시: 프로덕션 배포 시작
오후 7시 5분: 마이그레이션 충돌 발견
오후 11시: 긴급 수정 후 재배포
주말: CTO가 코드 리뷰

 

사고 2: 데이터베이스 불일치

서버 A: 0045_add_field_x 실행
서버 B: 0045_add_field_y 실행 (다른 내용!)

결과:
- 특정 API가 서버마다 다르게 작동
- 사용자 불만 증가
- 3일간 원인 파악에 소요

 

사고 3: 롤백의 악몽

월요일: 0050 배포
화요일: 문제 발견, 롤백 시도
그런데: 0050이 데이터 마이그레이션 포함
롤백하면: 데이터 손실 발생
결과: 롤백 불가능, 긴급 패치로만 해결

체계적인 프로세스 구축

1단계: 규칙 수립 (1주)

마이그레이션 관리 규칙:

1. 모델 수정 전 Slack 채널에 공지
2. 하루 1회 "마이그레이션 타임" 지정 (오전 10시)
3. PR에 [MIGRATION] 태그 필수
4. 마이그레이션 포함 PR은 빠른 리뷰
5. 배포 전 마이그레이션 충돌 검사 필수

 

2단계: 자동화 도구 도입 (2주)

# 1. Pre-commit hook 설치
# 2. CI/CD 파이프라인에 검증 추가
# 3. 배포 스크립트에 안전장치 추가

# deploy.sh
#!/bin/bash
echo "마이그레이션 충돌 검사 중..."
python manage.py makemigrations --check --dry-run

if [ $? -ne 0 ]; then
    echo "❌ 마이그레이션 충돌 발견! 배포 중단"
    exit 1
fi

echo "✅ 마이그레이션 안전"

 

3단계: 모니터링 시스템 (1개월)

# 마이그레이션 히스토리 추적
class MigrationLog(models.Model):
    migration_name = models.CharField(max_length=255)
    applied_at = models.DateTimeField(auto_now_add=True)
    applied_by = models.CharField(max_length=100)
    server = models.CharField(max_length=100)
    
    class Meta:
        indexes = [
            models.Index(fields=['migration_name', 'server']),
        ]

# 불일치 감지
def check_migration_consistency():
    servers = ['server-1', 'server-2', 'server-3']
    migrations_by_server = {}
    
    for server in servers:
        migrations_by_server[server] = get_applied_migrations(server)
    
    # 서버 간 차이 확인
    if not all_equal(migrations_by_server.values()):
        alert_team("서버 간 마이그레이션 불일치 감지!")

 

4단계: 교육과 문서화

# 마이그레이션 가이드

## 모델 수정 시 체크리스트
- [ ] Slack #migrations 채널에 공지
- [ ] 다른 개발자의 작업 확인
- [ ] 의미있는 마이그레이션 이름 지정
- [ ] 로컬에서 migrate 테스트
- [ ] 롤백 가능성 검토

## 충돌 발생 시 대응
1. 당황하지 말 것
2. makemigrations --merge 실행
3. 생성된 파일 검토
4. 테스트 후 커밋
5. 팀에 공유

## 절대 하지 말아야 할 것
- 이미 배포된 마이그레이션 수정 ❌
- 마이그레이션 파일 직접 삭제 ❌
- --fake 무분별하게 사용 ❌

개선 후 결과

정량적 개선:

  • 마이그레이션 충돌: 주 2-3회 → 월 0-1회 (95% 감소)
  • 배포 실패율: 15% → 2% (마이그레이션 원인)
  • 충돌 해결 시간: 평균 2시간 → 10분
  • 데이터베이스 불일치 사고: 0건 (6개월간)

정성적 개선:

  • 개발자 스트레스 감소: "금요일 배포 공포" 사라짐
  • 배포 신뢰도 향상: 안심하고 자주 배포
  • 온보딩 개선: 신입도 마이그레이션 안전하게 다룸
  • 팀 협업 강화: 모델 변경 시 자연스러운 커뮤니케이션

부수 효과:

  • 전체 배포 프로세스 개선으로 이어짐
  • 데이터베이스 스키마 문서 자동 생성
  • 마이그레이션 리뷰 문화 정착
  • 전체 코드 품질 향상

🎓 정리하며: 안전한 마이그레이션 관리 원칙

1. 작게 자주 머지하기

장기 브랜치는 적입니다. 작은 변경사항을 자주 main에 머지하면 충돌 확률이 크게 줄어듭니다.

2. 팀 커뮤니케이션이 핵심

마이그레이션은 혼자만의 문제가 아닙니다. 모델을 수정할 때는 항상 팀에 알리세요.

3. 자동화로 실수 방지

Pre-commit hook과 CI/CD 검증으로 사람의 실수를 기계가 잡아내게 하세요.

4. 이미 배포된 것은 절대 수정 금지

배포된 마이그레이션은 신성불가침입니다. 수정하면 데이터베이스 불일치가 발생합니다.

5. 롤백 계획도 미리 세우기

마이그레이션을 만들 때 **"문제 생기면 어떻게 되돌릴까?"**도 함께 고민하세요.

6. 문서화와 교육

모든 팀원이 마이그레이션의 중요성을 이해하고, 올바른 사용법을 알아야 합니다.


💬 마이그레이션 충돌로 고생하고 계신가요?

"매번 머지할 때마다 마이그레이션 충돌이 나요", "팀의 마이그레이션 프로세스를 개선하고 싶어요"

27년 경력의 시니어 개발자가 여러분 팀의 마이그레이션 관리 체계를 완벽하게 구축해드립니다.

  • 🔍 현재 마이그레이션 상태 전체 분석 및 정리
  • 🛡️ 팀 규모에 맞는 마이그레이션 관리 프로세스 수립
  • 🤖 자동화된 충돌 감지 및 방지 시스템 구축
  • 🎓 팀 전체 마이그레이션 교육 및 가이드 작성

➡️ Django 마이그레이션 전문가 상담받기

"마이그레이션 충돌로 시간 낭비하지 마세요. 전문가와 함께 체계적인 프로세스를 만드세요!"

728x90
반응형