[FastAPI] 4. SQLAlchemy + Alembic 조합을 이용한 Database Migration 가이드

반응형

웹 서비스를 개발하고 배포한 뒤에 반드시 한 번 쯤 따르는 업데이트 사항이 바로 데이터베이스 마이그레이션입니다. 데이터베이스를 마이그레이션하는 경우는 보통 아래의 3가지가 대표적입니다.

 

  • 인스턴스의 이전

    클라우드를 사용한다면 다른 인스턴스, 혹은 다른 리전으로의 이전

  • 특정 Entity에 대한 변경 요청

    우리 부서 혹은 우리 서비스에는 이 기능을 넣기 위해 이 칼럼이 더 추가로 필요할 것 같아요.

  • 일부 영속된 데이터를 특정 데이터로 변경

    이제는 이 명칭을 이 명칭으로 바꿀거에요. 모든 사항에 적용되었으면 좋겠어요.

 

간단하게 생각한다면 이러한 작업을 수동으로 처리할 수도 있습니다. 다른 데이터베이스 인스턴스를 하나 테스트용으로 만들고, SQL 쿼리를 짜본 다음 쿼리 테스트하고, 확실한 쿼리가 작성되었다면 이를 적용하는 방법을 쓰는 것이죠.

 

그런데, 이러한 마이그레이션이 하나 둘 쌓이다 보면 처음에는 어떻게 사용했고, 어떠한 점을 변형했는지에 대한 추적이 어려워지게 됩니다. 우리 서비스는 처음에 이렇게 사용했다가 이러한 점이 필요했어서, 혹은 요청이 되서 라는 것들을 기록해야 할 수 있습니다.

 

또 한 가지, 개발자가 Dev, Stage, Prod 이렇게 같은 서비스를 여러 개의 인스턴스 환경으로 나누어서 사용하는 경우 어떤 환경에 데이터베이스 마이그레이션이 이루어졌고, 이루어지지 않았는지 확인이 어렵습니다. 혹여라도 이미 마이그레이션이 진행된 인스턴스에 다시 한 번 마이그레이션이 진행된다면 중복이 발생하고, 이로 인한 데이터 변화에 대해 일부 데이터 소실에 대한 원인이 되기도 하며 오히려 생산성을 떨어뜨리게 되거나 심각하게는 서비스의 장시간 장애로도 이어지게 됩니다.

 

 

 

 

Alembic

Python 개발 스택에서 alembic은 이러한 DB 마이그레이션 버전 관리를 제공합니다. Spring 개발 스택에서 Flyway와 동일한 역할을 하는 녀석입니다. 

 

모든 마이그레이션 alembic 스크립트에 의해 이루어집니다. 버전 관리를 위해 각 인스턴스에서는 migration 테이블이 존재하고, 이 테이블을 통해 어디까지 마이그레이션이 되었는지를 확인할 수 있으며 이를 통해 중복 마이그레이션 및 버전 관리를 할 수 있습니다.

 

Flyway와 다른 점이 있다면 Flyway는 이러한 변경 정보를 각 테이블에 모두 기록하는 반면, alembic은 마지막에 마이그레이션 된 버전의 해시값만을 기록합니다.

 

 

 

 

Initialization alembic

alembic을 사용하기 위해 디펜던시를 설치한 다음 아래의 명령어를 이용하여 프로젝트 최상위에 원하는 디렉터리 이름으로 마이그레이션 폴더를 만들어줍니다.

$ alembic init migrations

alembic init 명령어를 이용해서 마이그레이션 스크립트를 초기화하고, 초기화한 스크립트와 파일은 migrations 라는 새로운 폴더를 생성하여 만들어줍니다.

 

각 파일에 대해 간략히 설명을 드리자면..

 

  • versions

    이 폴더에 마이그레이션 할 스크립트 코드가 들어갑니다.

  • env.py

    데이터베이스 마이그레이션시 실행되는 서버 연결 및 마이그레이션 실행 코드입니다.

  • script.py.mako

    마이그레이션 스크립트 템플릿 파일입니다.

  • alembic.ini

    env.py 파일에서 Configuration 파일로 사용되는 alembic 설정 파일입니다.

 

여기서 우리가 사용해야 하는 파일은 env.py와 versions 두 가지가 되며 env.py 파일로 한 번 환경을 셋팅하고 난 뒤에는 그 다음부터는 versions를 이용하여 관리하게 됩니다.

 

 

 

 

 

Set database configuration

alembic.ini 파일을 열고, sqlalchemy.url 변수에 연결하고자 하는 데이터베이스 인스턴스 주소를 입력해줍니다.

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8

sqlalchemy.url = postgressql://postgres:postgres@127.0.0.1/nkmemo

ini 파일이기 때문에 Python에서 제공하는 string 관련 함수를 사용할 수 없으며 오직 정적 문자열만을 이용해야 합니다. 그런데, 이렇게 설정하게 되면 환경별로 데이터베이스를 마이그레이션 할 때마다 인스턴스 주소를 바꿔줘야 하는 불편함이 생깁니다.

 

이런 경우에는 alembic.ini 파일에 있는 sqlalchemy.url 변수를 제거한 후 env.py 파일에서 sqlalchemy.url을 수동으로 설정할 수 있는 방법이 있습니다.

config = context.config

if not config.get_main_option('sqlalchemy.url'):
    config.set_main_option('sqlalchemy.url', 'postgresql://{username}:{password}@{host}:{port}/{db_name}'.format(
    username='postgres', password='postgres', host='127.0.0.1', port='5432', db_name='nkmemo'
))

env.py 파일에는 alembic에서 제공하는 context가 있고, 이 context를 통해서 DB 인스턴스 주소를 결정할 수 있습니다. env.py 파일은 Python 코드이기 때문에 Python에서 제공하는 함수 등을 이용하여 환경별로 DB 마이그레이션을 쉽게 할 수 있습니다.

 

 

 

 

Generate Migration script

그럼 이제 마이그레이션 스크립트를 하나 작성해보겠습니다. alembic에서 마이그레이션 스크립트 템플릿을 자동으로 만들어주는 명령어를 사용하여 초기 파일을 만들어보도록 하겠습니다.

$ alembic revision -m "Initialize nkmemo entity.."

alembic revision 명령어에 m 옵션을 사용하여 해당 마이그레이션에 대한 코멘트를 남기어 이 마이그레이션이 어떤 변경 사항인지를 쉽게 파악할 수 있습니다.

"""Initialize nkmemo entity..

Revision ID: 906254a28c71
Revises: 
Create Date: 2021-01-16 13:10:10.770502

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '906254a28c71'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    pass


def downgrade():
    pass

 

코드를 확인하면 revision, down_revision이라는 변수가 보입니다. 이들 변수를 이용하여 alembic은 이 마이그레이션 스크립트가 동작되었는지를 확인하게 됩니다. 

 

upgrade 함수는 이 마이그레이션을 적용했을 때 사용할 스크립트가 들어가며, downgrade는 마이그레이션 이후 이를 다시 롤백할 경우 발생하는데, 구체적으로는 아래와 같이 동작합니다.

$ alembic upgrade [revision_hash]
$ alembic downgrade [revision_hash or current_revision +- 1]

alembic upgrade 명령어와 함께 revision 해쉬를 입력하면 해당 hash에 맞는 마이그레이션 스크립트의 upgrade 함수를 순차적으로 호출하여 마이그레이션이 진행됩니다.

 

반대로 downgrade는 현재의 revision에서 +- 숫자를 조정 입력하여 롤백과 업그레이드를 반복할 수 있습니다. 

 

그럼 직접 마이그레이션 스크립트를 짜보도록 하죠. 

"""Initialize nkmemo entity..

Revision ID: 906254a28c71
Revises: 
Create Date: 2021-01-16 13:10:10.770502

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '906254a28c71'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    op.create_table('memos',
                    sa.Column('id', sa.String(length=120), nullable=False),
                    sa.Column('title', sa.String(length=80), nullable=False, index=True),
                    sa.Column('content', sa.Text, nullable=True),
                    sa.Column('is_favorite', sa.Boolean, nullable=False),
                    sa.PrimaryKeyConstraint('id'))


def downgrade():
    op.drop_table('memos')

alembic의 op에서 제공하는 create_table, drop_table 등의 함수를 사용하여 시작 쿼리를 정의하고, sqlalchemy의 Column, String 등을 사용하여 컬럼의 정보를 정의할 수 있습니다.

$ alembic upgrade head

alembic upgrade의 head를 이용하면 최신의 마이그레이션 스크립트까지를 전부 적용하며 적용이 끝났으면 memos라는 테이블이 만들어져 있음을 확인할 수 있습니다.

 

 

 

 

Using auto generate

Alembic에서 제공하는 autogenerate 옵션은 SQLAlchemy의 ORM을 이용하여 정의한 Model의 메타데이터를 읽어 변경된 부분을 자동으로 확인하여 마이그레이션 스크립트를 작성해주는 좋은 옵션입니다.

 

그러나 단점이 있다면 애플리케이션의 작성이 시작된 시점부터 autogenerate를 꾸준히 사용해야 하며 애플리케이션 개발 중간에 사용하는 경우, 변경점을 alembic을 파악하지 못하여 오히려 배보다 배꼽이 더 큰 결과를 초래하기도 합니다. 

from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

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

autogenerate 옵션을 사용하기 위해서는 먼저 target_metadata 변수를 설정해줍니다. 초기값이 None으로 설정이 되어 있는데, SQLAlchemy를 사용하고 있다면 SQLAlchemy에서 제공하는 declarative_base 함수에서 반환하는 metadata를 사용하실 수 있습니다. 

class Memo(Base):
    __tablename__ = 'memos'

    id = Column(String(120), primary_key=True, default=lambda: str(uuid.uuid4()))
    title = Column(String(80), default='No title', nullable=False, index=True)
    content = Column(Text, nullable=True)
    is_favorite = Column(Boolean, nullable=False, default=False)

지난번에 작성한 Memo 모델을 다시 한 번 적용해보도록 하겠습니다.

$ alembic revision --autogenerate -m "Initialize nkmemo entity.."
"""Initialize nkmemo entity..

Revision ID: 62040f1ba614
Revises: 
Create Date: 2021-01-16 13:37:22.969248

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = '62040f1ba614'
down_revision = None
branch_labels = None
depends_on = None


def upgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('memos',
    sa.Column('id', sa.String(length=120), nullable=False),
    sa.Column('title', sa.String(length=80), nullable=False),
    sa.Column('content', sa.Text(), nullable=True),
    sa.Column('is_favorite', sa.Boolean(), nullable=False),
    sa.PrimaryKeyConstraint('id')
    )
    op.create_index(op.f('ix_memos_title'), 'memos', ['title'], unique=False)
    # ### end Alembic commands ###


def downgrade():
    # ### commands auto generated by Alembic - please adjust! ###
    op.drop_index(op.f('ix_memos_title'), table_name='memos')
    op.drop_table('memos')
    # ### end Alembic commands ###

그러면 이렇게 자동으로 생성된 memos 테이블의 마이그레이션 코드를 확인할 수 있습니다.

 

 

 

 

 

Alembic version management

alembic이 어떤식으로 버전을 관리하는지 잠깐 둘러보도록 할까요?

 

마이그레이션 스크립트를 최초로 돌리면 이렇게 테이블이 만들어지고, 그와 함께 alembic_version이라는 이름의 새로운 테이블이 하나 더 생성이 되는데요.

 

이 번호는 아까 우리가 위에서 마이그레이션 스크립트를 생성한 revision 해시 번호입니다. 실제로 alembic은 이 번호를 보고 이 데이터베이스가 어디까지 마이그레이션 되었는지를 확인합니다. 

 

그런데, 서비스를 운영하는 스키마과 같은 곳에 버전 관리 테이블을 놓게 되니 이를 구분하기가 어렵습니다. alembic의 버전 관리 테이블을 다른 스키마에서 운영하려면 어떻게 해야 할까요?

 

해당 상태에서 스키마를 변경하고자 한다면, alembic downgrade를 진행한 뒤, env.py 파일에서 스키마를 지정하여 alembic의 테이블을 별도로 관리할 수 있습니다.

def run_migrations_online():
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection, target_metadata=target_metadata,
            include_schema=True,
            version_table_schema='alembic'
        )

        connection.execute('CREATE SCHEMA IF NOT EXISTS {schema}'.format(schema='alembic'))

        with context.begin_transaction():
            context.run_migrations()

저는 스키마를 alembic이라는 이름으로 사용해보도록 하겠습니다. 만약을 대비하여 alembic 스키마가 없을 경우, alembic 스키마를 자동으로 만들어 줄 수 있도록 CREATE SCHEMA IF 쿼리를 삽입해주도록 합시다.

 

그러면 이렇게 깔끔하게 서비스 스키마와 마이그레이션 관리 스키마가 분리되어, 좀 더 모델이 보기 쉬워진 것을 볼 수 있습니다.

 

 

 

 

마치며...

여기까지 alembic과 SQLAlchemy를 이용하여 데이터베이스 마이그레이션을 좀 더 효율적으로 편리하게 관리하는 방법에 대해 알아봤습니다. 

 

이 글은 카테고리 범주가 FastAPI로 속해져 있긴 하지만 다른 프레임워크에서도 충분히 이 방법을 응용할 수 있습니다. 다만 Django나 Flask와 같이 전용 ORM이 존재하는 경우에 대해서는 다른 방법을 사용해야 하고, 순수 SQLAlchemy 라이브러리를 사용해야 하는 경우에만 이 글을 따르시는 것을 추천합니다.

 

개인적으로 Flyway를 사용했을 때보다 직관적인 면이 조금 떨어지는 느낌을 받았습니다. Flyway는 마이그레이션한 스크립트를 데이터베이스 테이블에 전부 보여줌으로써 어떤 마이그레이션이 실행되었고, 실행되지 않았는지가 한 눈에 보이는 편리함이 있었지만 alembic은 단순히 마지막에 마이그레이션된 버전만을 보여주기 때문에 일일이 과거에 어떤 마이그레이션이 있었는지를 직접 추적해야하는 불편함을 초래했습니다.

 

반면에 Flyway는 마이그레이션시 Java 코드와 병행없이 오직 SQL 코드만을 작성해야 하는 불편함이 있고, 여러 개의 Dialect를 사용한다면 해당 Dialect마다 마이그레이션 코드를 별도로 작성해야 하는 번거로움을 감수해야 합니다. 그러나 alembic은 어느 정도 난이도 없는 쿼리에 대해서는 통합적으로 관리할 수 있도록 Python script를 사용하게 해준다는 점에서 편리함을 얻었다는 것이 저의 개인적인 의견이었습니다.

 

덧붙여서, autogenerate 기능이 완벽하지는 않지만 기본적인 컬럼 변화나 index의 추가 유무에 정확하게 트래킹함으로써 마이그레이션에 들어가는 비용을 많이 낮춰주는 장점을 볼 수 있었고, 간단한 커맨드 사용으로 템플릿 코드를 자동으로 만들어준다는 점은 감명깊었네요.

 

 

반응형
comments powered by Disqus

Tistory Comments 0