Alembic 자동생성이 놓치는 변경들 — head가 둘로 갈렸을 때 합치는 법

Alembic 자동생성이 놓치는

alembic revision --autogenerate를 돌렸는데 마이그레이션 파일이 텅 비어 있던 경험, 한 번쯤 있으실 거예요. 결론부터 말씀드리면 Alembic 자동생성이 놓치는 변경들은 따로 정해져 있고, 이 범위를 모르고 쓰면 스키마와 모델이 조용히 어긋나 버립니다. 여기에 더해 여러 명이 동시에 마이그레이션을 만들면 head가 두 개로 갈라지는 문제까지 겹치는데, 이 글에서는 Alembic 1.13 기준으로 두 문제를 실제 명령어와 함께 정리해 드릴게요.

autogenerate는 왜 이 변경들을 못 보나요?

Alembic의 autogenerate는 env.py의 target_metadata에 등록된 SQLAlchemy MetaData와, 실제 DB를 리플렉션한 결과를 Comparator로 비교하는 방식으로 동작해요. 즉 “모델 코드의 의도”가 아니라 “현재 DB 구조 vs 메타데이터 구조”라는 두 스냅샷만 비교하는 구조입니다.

그래서 의도를 추론해야 하는 변경, 이를테면 컬럼 이름을 바꾼 건지 아니면 기존 컬럼을 지우고 새 컬럼을 추가한 건지는 구분하지 못해요. DB 입장에서는 둘 다 “컬럼 하나 사라지고 컬럼 하나 생김”으로 똑같이 보이기 때문입니다. 공식 문서에서도 이 비교 범위를 명확히 규정해 두고 있는데, 자동생성이 어디까지 보는지는 자동생성 항목에 구체적으로 나와 있어요.

자동생성이 놓치는 변경들, 실무에서 자주 걸리는 다섯 가지

실제 프로젝트에서 반복적으로 문제가 되는 패턴을 정리하면 아래와 같아요. env.py의 context.configure()에 어떤 옵션을 켜느냐에 따라 감지 여부가 달라지는 항목도 포함했습니다.

변경 유형 기본 설정으로 감지됨? 해결 방법
테이블/컬럼 이름 변경 아니오 (drop+add로 오인) op.rename_table, op.alter_column(new_column_name=...) 직접 작성
컬럼 타입 변경 부분적 (백엔드마다 다름) compare_type=True 옵션 켜기
server_default 값 변경 아니오 compare_server_default=True 옵션 켜기
이름 없는 제약조건(unnamed constraint) 아니오 naming_convention으로 제약조건에 명시적 이름 부여
CHECK 제약, 트리거, 뷰 아니오 수동으로 op.create_check_constraint 등 작성

갈라진 두 브랜치가 하나로 합쳐지는 데이터베이스 마이그레이션 흐름 다이어그램

이 중에서도 특히 자주 당하는 건 server_default 변경이에요. 모델 코드의 default=는 Python 레벨 기본값이라 autogenerate 비교 대상이 아니고, DB 레벨 server_default만 비교 대상인데 이 옵션이 꺼져 있으면 아예 비교 자체를 건너뜁니다.

head가 두 개로 갈라지는 원리

Alembic의 마이그레이션 파일은 각자 down_revision으로 이전 리비전을 가리키는 연결 리스트 구조예요. 두 명의 개발자가 같은 리비전에서 각자 alembic revision --autogenerate를 실행하면, 두 마이그레이션 모두 같은 down_revision을 갖게 되고 결과적으로 head가 두 개 생깁니다.

이 상태에서 alembic upgrade head를 실행하면 “Multiple head revisions are present” 에러가 발생해요. 먼저 상황을 확인해 보겠습니다.

$ alembic heads
1a2b3c4d5e6f (head)
7f8e9d0c1b2a (head)

$ alembic history
1a2b3c4d5e6f -> (head), add email column
7f8e9d0c1b2a -> (head), add order status
9z0y8x7w6v5u -> 1a2b3c4d5e6f, 7f8e9d0c1b2a (base revision shared)

두 리비전이 같은 9z0y8x7w6v5u를 공통 부모로 두고 갈라진 게 보이죠. 이건 흔히 말하는 스키마 마이그레이션 도구 대부분이 버전 관리 시스템의 브랜치 충돌과 똑같은 방식으로 겪는 문제입니다.

alembic merge로 갈라진 head 합치는 절차

해결 방법은 두 head를 부모로 하는 빈 병합 리비전을 하나 만드는 거예요. alembic merge 명령이 바로 이 역할을 합니다.

$ alembic merge -m "merge heads" 1a2b3c4d5e6f 7f8e9d0c1b2a
  Generating .../versions/3c2b1a_merge_heads.py ... done

$ alembic heads
3c2b1a (head)

$ alembic upgrade head
INFO  [alembic.runtime.migration] Running upgrade 1a2b3c4d5e6f, 7f8e9d0c1b2a -> 3c2b1a, merge heads

생성된 병합 파일을 열어 보면 down_revision이 튜플 형태로 ('1a2b3c4d5e6f', '7f8e9d0c1b2a') 이렇게 두 값을 갖고 있어요. upgrade()/downgrade() 본문은 비어 있는 게 정상이고, 이 파일 자체는 “두 흐름이 여기서 합쳐진다”는 지점 표시 역할만 합니다. 스키마를 실제로 바꾸는 코드를 여기 추가할 필요는 없어요.

merge 뒤에도 충돌이 남는 경우가 있나요?

네, 남을 수 있어요. alembic merge는 리비전 그래프상의 순서만 정리해 줄 뿐, 두 브랜치가 같은 테이블의 같은 컬럼을 서로 다르게 바꾼 “의미적 충돌”까지 해결해 주지는 않습니다.

예를 들어 A 브랜치에서 status 컬럼을 String(20)으로, B 브랜치에서 같은 컬럼을 Enum으로 바꿨다면, merge 리비전을 만들어도 두 변경이 순서대로 실행되면서 최종 타입이 둘 중 나중에 적용된 쪽으로 남아요. 이런 경우엔 병합 리비전의 upgrade()에 수동으로 정리 코드를 추가하거나, 두 브랜치 중 하나를 되돌리고 다시 작성하는 편이 안전합니다. 또한 운영 DB에 이미 한쪽 브랜치가 배포된 상태라면, merge 순서에 따라 다운타임 중 적용되는 변경 순서도 달라질 수 있으니 스테이징 환경에서 alembic upgrade head를 먼저 검증해 보시길 권해 드려요.

지금 프로젝트에서 바로 확인해볼 체크리스트

env.py를 열어서 context.configure()에 compare_type=True와 compare_server_default=True가 켜져 있는지부터 확인해 보세요. 꺼져 있다면 지금 당장 DB와 모델이 몰래 어긋나 있을 가능성이 있습니다. 그다음 alembic heads 명령을 실행해서 head가 하나인지 확인하고, 둘 이상이면 위에서 소개한 alembic merge로 정리하시면 됩니다.

팀 단위로 작업한다면 CI 파이프라인에 alembic heads 결과가 한 줄인지 검사하는 스텝을 추가해 두는 것도 효과적이에요. 그러면 머지 직전에 head 분기를 미리 잡아낼 수 있어서, 배포 시점에 “Multiple head revisions” 에러를 처음 마주치는 상황을 피할 수 있습니다.

새벽 첫 요청만 DB 오류가 나는 이유, pool_recycle·pre_ping으로 잡기

Leave a Comment