[SQLAlchemy + Alembic] 데이터 마이그레이션 관리

mrcocoball·2024년 10월 13일

Python

목록 보기
3/3

개요

ORM 사용 시 엔티티 모델 - 테이블 간 싱크 문제

Spring Data, SQLAlchemy, MongoEngine과 같은 ORM을 사용할 때 가장 쉽게 볼 수 있는 문제 중 하나가 엔티티 모델과 실제 테이블 간의 싱크 문제이다.

Spring Data, 정확히는 Hibernate 기반으로 ORM을 사용했을 때엔 auto-ddl 기능을 활용하여 엔티티 모델과 테이블 간의 싱크를 맞췄고 MongoEngine은 자동으로 관리를 해줬으며 SQLAlchemy 역시 비슷한 기능이 있는 것으로 확인된다.

이렇게 ORM 마다 자동으로 엔티티 모델, 테이블 간 싱크를 지원해주는 기능이 있어서 편리한 부분이 있는데 그러나 이것만으로 해결이 되지 않는 문제들도 있는 것 같다. (개발 환경이면 모를까 운영 환경에서는 자동보다는 수동 마이그레이션을 하는 편이기도 하고)

개인적으로 많이 경험했던 문제는 다음과 같다.

1. 엔티티 모델의 변경이 일어난 환경과 DB 테이블의 싱크 문제
가장 빈번하게 겪었던 문제로, 작업자의 실수로 엔티티 모델의 변경이 일어난 환경과 DB의 싱크가 맞지 않는 경우가 있었다.

가령, 특정 피쳐가 추가되면서 엔티티 모델의 변경이 이뤄졌는데 dev 환경에 먼저 적용을 했다가 prod 환경으로도 넘어가는 경우, 엔티티 모델은 코드에서 수정하기 때문에 dev -> prod로 적용하는 것이 쉽지만 테이블 변경의 경우 수동으로 하기 때문에 작업자가 까먹고 dev에만 적용하고 prod에는 적용하는 것을 깜빡하는 경우를 예시로 들 수 있다.

실제로 서비스 마이그레이션을 하면서 개발, 운영 환경 배포를 짧은 시간에 자주 하면서 종종 이런 문제를 겪었는데 엔티티 모델 및 테이블 변경에 대한 이력을 Slack 등으로 팀원들에게 공유하면서 문제가 발생할 상황을 최소화하긴 했지만 사람이 직접 해야 하는 것이기 때문에 실수가 발생할 가능성은 여전히 존재했다.

2. 엔티티 모델의 변경으로 인해 필요 없어진 컬럼에 대한 문제
엔티티 모델의 변경 (컬럼명이 바뀌거나, 아예 필요가 없어진 컬럼이 생기는 경우) 으로 테이블에는 존재하나 모델과는 매핑이 되지 않는 필요 없어진 컬럼이 생겼을 때, 기존에 이미 배포된 서비스와 충돌하지 않기 위해 컬럼을 남겨두는 경우도 있긴 하지만 테이블만 봤을 때는 주석을 남기지 않는 이상 그 컬럼이 남게 된 이력을 확인하기가 어렵다.

이력을 아예 모르는 사람 입장에서는 테이블을 봤을 때 이 컬럼이 필요한 것인지 필요하지 않은 것인지를 확인하려면 컬럼 자체에 남겨진 주석을 보거나, 엔티티 모델의 주석을 확인해야 한다. 이런 이력 관련 작업을 틈틈이 해두었다면 그나마 상관이 없겠지만 이력들을 남기지 않았다면, 추후 다른 DB로 마이그레이션 작업을 해야 할 때 필요 / 불필요 컬럼들을 어떻게 식별할지 혼동이 있을 수 있을 것 같다.

이런 문제들은 내가 프로젝트를 진행하던 2개의 팀 모두 겪었었던 문제였는데 공통적인 원인으로 많이 지적되던 것이 엔티티 모델의 변경 이력이 관리되지 않고 있었던 점, 그리고 이 변경 이력에 따른 DB / 테이블의 변경 이력 역시 관리되지 않고 있었던 점이었다.

이런 원인들에 대해 위에서 이야기했던 것처럼 변경이 생길 때마다 Slack 등으로 팀원들에게 공유하는 식으로 대응을 하긴 했으나, 사람이 직접 변경 내용을 정리해야 하는 점, 그리고 변경 이력이 파일 등으로 관리되지 않아 휘발성을 띈다는 점 등 미흡한 부분이 있었다.

그래서 지금 당장은 현재 방식대로 대응을 하겠지만 추후 서비스 확장 등으로 변경 이력이 많이 생길 경우를 대비해 장기적으로 엔티티 모델 / DB 변경 이력에 대해 어떻게 개선해야 할 지를 찾아봤는데, Python 진영에서 많이 알려진 엔티티 모델 + DB 마이그레이션 관리 방식이 SQLAlchemy + Alembic이라고 하여 Alembic 활용에 대한 레퍼런스를 찾아보게 되었다.

Alembic이란

Alembic is a lightweight database migration tool for usage with the SQLAlchemy Database Toolkit for Python.

공식 문서의 소개에 따르면 Alembic은 SQLAlchemy 툴킷과 같이 사용할 수 있는 경량형 DB 마이그레이션 도구이다.

Alembic의 autogenerate 기능을 사용하면 SQLAlchemy의 Base의 metadata를 확인하여 이 metadata와 연결되어 있는 엔티티 모델들의 칼럼을 읽은 뒤 실제로 연결된 DB와 비교하여 마이그레이션 스크립트를 생성하고 이러한 과정 등을 이력으로 남길 수 있다.

그리고 이러한 이력에는 revision이 있으며 이를 이용해 업그레이드, 다운그레이드, 병합 등을 할 수 있다.

이를 통해 앞서 이야기했던 문제 발생의 원인이었던 '엔티티 모델의 변경 이력이 관리되지 않고 있던 점', '변경 이력에 따른 DB / 테이블의 변경 이력이 관리되지 않고 있던 점' 을 해결할 수 있다.

세팅

프로젝트 구조 및 Alembic 설치

예시에서 사용할 프로젝트 구조는 다음과 같다.

project
ㄴ migrations
ㄴ models
  ㄴ models_base.py
  ㄴ models_a.py
  ㄴ models_b.py
ㄴ main.py
  • migrations : alembic 관련 디렉터리
  • models : model 관련 패키지
    • models_base : 모든 모델이 공통적으로 사용할 Base, BaseMixin 등 설정
    • models_a, models_b.. : 엔티티 모델 설정

예시에서 사용할 models_base.py

from sqlalchemy import Column
from sqlalchemy.types import DateTime
from sqlalchemy.sql.expression import func
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class BaseMixin:
    created_at = Column(DateTime(timezone=True), server_default=func.current_timestamp())
    updated_at = Column(DateTime(timezone=True), server_default=func.current_timestamp(), onupdate=func.current_timestamp())

예시에서 사용할 일반적인 모델의 구조

# 여기서 BaseMixin, Base는 models_base에서 import한다
class CocoballUser(BaseMixin, Base):
    __tablename__ = "cocoball_user"
    __table_args__ = (
        UniqueConstraint('nickname'),
        {'extend_existing': True} 
    )
    
    id = Column(BigInteger, primary_key=True, autoincrement=True)
    email = Column(String(length=100), nullable=False)
    nickname = Column(String(length=100), nullable=False)

프로젝트에 Alembic을 설치

pip install alembic

마이그레이션 환경 세팅

alembic을 설치하였다면 migrations 디렉터리로 이동한 후 다음 명령어를 실행한다.

alembic init {환경명}

# local
alembic init local

# dev
alembic init dev

# prod
alembic init prod_kr

이렇게 하면 migrations 디렉터리 내에 alembic.ini 파일이 생성되고 그 외에 환경별로 디렉터리가 추가로 생성된다. 구조는 다음과 같다.

project
ㄴ migrations
  ㄴ dev						# dev 환경
    ㄴ versions				  # 스크립트 모음 디렉터리
      ㄴ 스크립트1...py            # 마이그레이션 스크립트
      ...
    ㄴ env.py                  # 마이그레이션 env
  ㄴ local					# local 환경
    ...
  ㄴ prod					# prod 환경
    ...
  alembic.ini    			# alembic 실행 설정 파일
...

위의 구조에서는 local, dev, prod 디렉터리가 마이그레이션 스크립트가 생성될 환경이 되며 환경별 디렉터리 내의 env.py 등으로 스크립트를 다르게 실행시킬 수 있다.

alembic.ini 설정

alembic.ini 파일에서 alembic 실행과 관련될 설정을 할 수 있으며, 환경별로 설정을 다르게 가져갈 수 있다.

  • script_location : 스크립트 생성에 사용될 환경 디렉터리명을 지정 (참조할 env.py, 스크립트가 생성될 version 등을 포함하고 있는 환경 디렉터리)
  • prepend_sys_path : 프로젝트 루트 설정으로, alembic 스크립트를 실행할 때 다른 경로에 있는 모듈, 패키지를 참조해야 하므로 이 설정을 하지 않으면 스크립트가 실행되지 않을 수 있으므로 주의 (기본값 .)
  • sqlalchemy.url : alembic가 바라볼 DB의 주소
# alembic.ini

# 기본 설정
[alembic]
script_location = local   # 스크립트 생성에 사용될 환경 디렉터리명
prepend_sys_path = ../   # 프로젝트 루트 설정
sqlalchemy.url = {실제 DB주소}

# local 환경 설정
[local]
script_location = local   # 스크립트 생성에 사용될 환경 디렉터리명
prepend_sys_path = ../   # 프로젝트 루트 설정
sqlalchemy.url = 실제 DB주소 (local 환경)

# dev 환경 설정
[dev]
script_location = dev   # 스크립트 생성에 사용될 환경 디렉터리명
prepend_sys_path = ../   # 프로젝트 루트 설정
sqlalchemy.url = 실제 DB주소 (dev 환경)

# prod 환경 설정
[prod]
script_location = prod   # 스크립트 생성에 사용될 환경 디렉터리명
prepend_sys_path = ../   # 프로젝트 루트 설정
sqlalchemy.url = 실제 DB주소 (prod 환경)

alembic 실행 시 환경을 지정하려면 명령어 실행 시 --name {환경명} 를 추가하면 된다.

# 기본값 기준 autogenerate 실행
alembic revision --autogenerate -m "init"

# prod 기준 autogenerate 실행
alembic --name prod revision --autogenerate -m "init"

그밖의 다양한 옵션에 대해서는 아래의 링크를 참조하면 된다.
https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file

env.py 설정

alembic.ini가 alembic 실행 시 사용될 설정값을 관리한다면, env.py는 스크립트 생성 시 사용되는 부가 설정을 관리한다.

부가 설정의 예시는 다음과 같다.

  • 마이그레이션 대상 SQLAlchemy 모델 인식 설정 (Base.metadata)
  • sqlalchemy.url 동적 할당
  • 테이블 포함 / 미포함 관련 함수 정의 (include_object())
  • 등등…

env.py는 환경 디렉터리별로 설정이 가능하기 때문에, 환경 별로 다른 스크립트를 생성해야할 경우 유용하게 사용할 수 있다.

env.py에서 가장 중요한 것은 alembic이 마이그레이션 대상 SQLAlchemy 모델을 인식하게 만드는 것인데, 위에서 설명하였듯이 metadata를 통해 모델들을 감지하기 때문에, 개발자가 작성한 엔티티 모델이 공통적으로 사용하는 Base를 지정해주어야 한다.

예시에서는 models_base.py Base를 선언하였고 이를 다른 모델들이 import를 하고 있기 때문에 env.py에서 이 Base를 target metadata로 지정해줘야 한다.

또한, metadata 뿐만 아니라 모델들 역시 인식을 해야 하기 때문에 models_a, models_b 처럼 엔티티 모델 클래스가 모여 있는 파이썬 스크립트를 import해야 한다.

# env.py

from models.models_base import Base
# 아래 import는 모델 인식을 위한 import
from models import model_a
from models import model_b

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata

target_metadata = Base.metadata

실행

마이그레이션 스크립트 생성 전 변경 사항 체크

마이그레이션 스크립트 생성을 하기 전, 엔티티 모델과 데이터베이스 테이블 사이의 변경점이 있는지 체크를 할 수 있다.

# alembic.ini 파일이 있는 디렉터리에서 실행
alembic --name {환경명} check

만약 변경된 사항이 있다면 다음과 같이 노출된다.

FAILED: New upgrade operations detected: [ ... ]

마이그레이션 스크립트 생성 (autogenerate)

다음 명령어로 마이그레이션 스크립트를 생성한다.

# alembic.ini 파일이 있는 디렉터리에서 실행
alembic --name {환경명} revision --autogenerate --m "{메시지}"

생성된 마이그레이션 스크립트 확인

마이그레이션 스크립트는 versions 디렉터리 내 revision 이름.py 형식으로 생성이 된다.

스크립트 내용은 위에서 설명하였듯이 alembic이 인식한 모델 정의와 현재 연결된 데이터베이스 테이블 상태를 비교하여 upgrade, downgrade 시 수행해야 할 작업들이다.

"""init

Revision ID: 361c8b323feb
Revises: 
Create Date: 2024-10-13 11:58:43.576905

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '361c8b323feb'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create ....
    # ### end Alembic commands ###


def downgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop ...
    # ### end Alembic commands ###

자동 생성된 스크립트를 그대로 사용할 수도 있겠지만, 개발자가 필요한 내용을 직접 추가하거나 삭제할 수도 있다.

여기서 주의할 점은, DB를 새로 만듬과 동시에 마이그레이션을 시작하는 것이라면 괜찮겠으나 처음부터 Alembic을 도입하지 않았던 상태에서 마이그레이션 스크립트를 생성한다면 create, drop table 등의 내용이 있을 수 있으니 꼭 확인해야 한다.

마이그레이션 적용 / 롤백

마이그레이션을 적용하는 방법에는 DB에 적용하지 않고 현재 상태만 최신화하는 방법과, DB에도 적용하는 방식이 있다.

DB에 반영하지 않고 현재 상태만 최신화하여 적용하기 (stamp)

alembic --name {환경명} stamp head

DB에 반영하기 (upgrade)

alembic --name {환경명} upgrade head

롤백하기 (downgrade)
만약 DB에 적용한 내용을 롤백해야 한다면 downgrade를 통해 롤백할 수 있다.

이 때 이전 버전으로 롤백하거나, 특정 revision을 지정하여 롤백할 수 있다.

# 이전 버전으로 롤백
alembic --name {환경명} downgrade -1

# 또는 revision id를 입력
alembic --name {환경명} downgrade {revision id}

마이그레이션 이력 확인하기

마이그레이션 이력을 확인할 수도 있다.

alembic --name {환경명} history

Branch 활용

Alembic으로 마이그레이션 스크립트를 생성할 시, 환경 및 revision을 구분할 수 있기 때문에 이는 자연스럽게 Git과 유사하게 Branch를 통해 스크립트를 관리할 수 있게 된다.

자세한 내용은 아래의 페이지를 참고하면 된다.
https://alembic.sqlalchemy.org/en/latest/branches.html

고려해야할 점

확실히 엔티티 모델과 DB 테이블을 비교하여 자동으로 스크립트를 생성해주고, 이에 대한 이력까지 관리할 수 있다는 점은 분명 메리트가 있다.

하지만 실제 개발에 바로 쓰일지는 아직 내부 논의 중인데 이유는 다음과 같다.

여러 개의 피쳐가 동시에 개발될 경우 스크립트 형상 관리는 어떻게?

여러 개의 피쳐가 동시 개발되면서 스크립트 역시 피쳐별로 생성이 된다면 형상 관리를 어떻게 해야 할 지 논의가 필요하다.

일단 Alembic에서 제공하는 Branch 기능을 사용할 수 있겠지만 Alembic 사용 방법에 대한 숙지가 필요하며 Git과 마찬가지로 병합 과정에서의 실수가 일어나거나 할 수 있어서 여러 개발자가 피쳐를 개발할 경우 사용 초반에는 이슈가 생길 가능성이 높아 보인다.

운영 환경에서의 마이그레이션 자동화가 가능할지

CI / CD에 Alembic 스크립트 실행을 포함시키게 될 경우 개발 환경에서의 마이그레이션이라면 상관 없겠지만 실 서비스되고 있는 운영 환경에서의 마이그레이션은 쉽게 자동화하기엔 위험 부담이 따른다.

그래도 결국은 자동화를 하는 방향으로 가야 할 것 같은데... 실제로 적용을 해본 적이 없다보니 이에 대한 고민이 이어지고 있다.

현재로서는 매력이 있는 도구임에는 분명하지만 어떻게 쓰고 어떻게 관리해야 할 지?에 대한 뚜렷한 방안이 나오지 않아서 실제 개발에 도입은 아직 하지 않고 있는데 추후 방안이 결정되고 사용하게 된다면 이 도구를 사용했을 때 생겼던 이슈들을 정리해보고 싶다.

profile
Backend Developer

0개의 댓글