SQLAlchemy 2.0 #7 Alembic과 실전 구성: 마이그레이션, 비동기, 팀 규약
시리즈를 여기까지 따라오셨다면 모델을 선언하고, 세션으로 조작하고, 관계와 쿼리를 다룰 수 있습니다. 남은 것은 운영입니다. 스키마는 반드시 바뀌고, 트래픽이 늘면 비동기가 필요해지고, 팀이 커지면 규약이 필요해집니다. 마지막 편은 이 세 가지입니다.
create_all의 한계와 Alembic #
Base.metadata.create_all(engine)은 없는 테이블을 만들 뿐입니다. 이미 있는 테이블에 컬럼을 추가하거나, 타입을 바꾸거나, 인덱스를 만들지 않습니다. 모델과 실제 DB가 어긋나기 시작하는 순간부터는 스키마 변경 이력을 관리하는 도구가 필요하고, SQLAlchemy 진영의 표준이 같은 개발자가 만든 Alembic입니다.
uv add alembic
alembic init migrationsalembic init이 만드는 것 중 손댈 곳은 두 군데입니다.
alembic.ini:sqlalchemy.url에 DB 연결 URL을 설정합니다(실무에서는 환경 변수에서 읽도록env.py에서 처리하는 편이 좋습니다).migrations/env.py:target_metadata = Base.metadata로 모델의 메타데이터를 연결합니다. autogenerate가 비교할 “목표 상태"가 이것입니다.
이후의 사이클은 세 명령의 반복입니다.
# 1. 모델을 수정한 뒤, 현재 DB와의 차이로 마이그레이션 파일 생성
alembic revision --autogenerate -m "user에 nickname 컬럼 추가"
# 2. 생성된 파일을 열어 리뷰 (필수!)
# 3. 적용
alembic upgrade headautogenerate는 초안일 뿐입니다 #
autogenerate는 Base.metadata(목표)와 실제 DB(현재)를 비교해 upgrade와 downgrade 함수를 채운 파이썬 파일을 생성합니다. 잘 감지하는 것과 못 하는 것을 알고 있어야 합니다.
| 잘 감지 | 감지 못 하거나 불완전 |
|---|---|
| 테이블·컬럼 추가와 삭제 | 컬럼 이름 변경 (삭제 + 추가로 생성됨 → 데이터 유실!) |
| nullable 변화 | 서버 기본값(server_default) 변화 (일부만) |
| 명시적 인덱스·유니크 제약 | 데이터 이전(백필)이 필요한 변경 |
| 외래 키 추가 | CHECK 제약, 일부 타입 세부 변화 |
가장 위험한 것이 이름 변경입니다. nickname을 alias로 바꾸면 autogenerate는 “nickname 삭제, alias 추가"를 생성하고, 그대로 적용하면 데이터가 사라집니다. 파일을 열어 op.drop_column + op.add_column을 op.alter_column(..., new_column_name=...)으로 고쳐야 합니다. “autogenerate 결과는 초안이고, 리뷰 없이 적용하지 않는다"를 팀 규칙으로 박아 두는 이유입니다.
운영 적용에서는 두 가지를 더 지킵니다. 첫째, 마이그레이션 파일은 코드와 같은 PR에서 리뷰합니다. 둘째, 큰 테이블의 컬럼 추가나 인덱스 생성은 잠금 시간을 확인합니다. PostgreSQL의 CREATE INDEX CONCURRENTLY처럼 DB별 무중단 옵션이 필요한 경우가 있고, 이런 부분은 autogenerate가 알아서 해 주지 않습니다.
3편에서 설정한 네이밍 컨벤션이 여기서 효과를 냅니다. 제약 이름이 예측 가능하므로, autogenerate가 만드는 op.drop_constraint("uq_user_account_email", ...)가 어느 DB에서든 그대로 동작합니다.
비동기: create_async_engine과 AsyncSession #
FastAPI 같은 비동기 프레임워크에서 동기 SQLAlchemy를 그대로 쓰면 DB 대기 시간 동안 이벤트 루프가 막힙니다. 2.0은 asyncio를 정식 지원하며, 지금까지 배운 API와 거의 같은 모양입니다.
uv add "sqlalchemy[asyncio]" asyncpg # PostgreSQL 비동기 드라이버from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
engine = create_async_engine("postgresql+asyncpg://user:pw@localhost/mydb")
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def list_users() -> list[User]:
async with AsyncSessionLocal() as session:
result = await session.scalars(select(User))
return list(result.all())바뀌는 것은 규칙적입니다. 엔진은 create_async_engine, 드라이버는 비동기용(asyncpg 등), 세션은 AsyncSession, 그리고 I/O가 일어나는 호출(execute, scalars, commit, flush)에 await가 붙습니다. 모델 선언과 select() 조립은 동기와 완전히 동일합니다.
다른 점 하나는 조심해야 합니다. 비동기 세션에서는 지연 로딩이 기본적으로 동작하지 않습니다. user.addresses 접근이 암묵적으로 I/O를 일으키는 구조가 await 없이는 성립하지 않기 때문입니다(MissingGreenlet 에러로 나타납니다). 비동기에서는 5편의 eager loading(selectinload)이 선택이 아니라 사실상 필수가 됩니다. 위 예제의 expire_on_commit=False도 같은 맥락으로, 커밋 후 속성 접근이 재조회를 일으키지 않게 하는 비동기 관례입니다.
프로젝트 구성과 팀 규약 #
시리즈를 마치며, 실전 프로젝트에서 검증된 구성을 정리합니다.
myapp/
├── app/
│ ├── db.py # 엔진, sessionmaker (프로세스당 1회 생성)
│ ├── models/ # 도메인별 모델 모듈, Base는 한 곳에서
│ ├── repositories/ # 쿼리를 모으는 층 (선택)
│ └── ...
├── migrations/ # alembic init 결과물
├── alembic.ini
└── pyproject.toml- 엔진은 프로세스당 하나입니다. 요청마다
create_engine을 부르는 것은 풀을 매번 새로 만드는 것과 같습니다.db.py같은 모듈에서 한 번 만들고 임포트해서 씁니다. - 모델은 도메인별 모듈로 나누되
Base는 하나입니다. Alembic의target_metadata가 모든 모델을 봐야 하므로,env.py에서 모델 모듈이 전부 임포트되도록 합니다. “새 모델을 만들었는데 autogenerate가 못 본다"의 원인은 대부분 임포트 누락입니다. - 쿼리를 모으는 층(repository든 service든 이름은 자유)을 두면
select()조립이 뷰 코드에 흩어지지 않고, N+1 대응(어떤 조회에 어떤 eager loading을 쓰는지)을 한곳에서 관리할 수 있습니다. - 팀 규약 세 가지: 스키마 변경은 반드시 Alembic을 거친다(운영 DB에 수동 DDL 금지), autogenerate 결과는 리뷰 후 병합한다, 세션 스코프는 작업 단위당 하나를 지킨다. 이 셋만 지켜져도 SQLAlchemy 관련 사고의 대부분이 예방됩니다.
시리즈를 마치며 #
7편을 요약하면 이렇습니다. SQLAlchemy는 Core 위에 ORM이 얹힌 2층 구조이고(1편), 엔진과 명시적 트랜잭션이 토대이며(2편), 모델은 Mapped로 선언하고(3편), 세션이 변경을 모아 SQL로 내보내고(4편), 관계는 편리하지만 N+1을 경계해야 하고(5편), 쿼리는 select() 하나로 조립되며(6편), 운영은 Alembic과 규약이 지탱합니다(7편). FastAPI와의 연동 형태는 모던 파이썬 실전 #3에서, 패키지·의존성 관리는 파이썬 패키징 시리즈에서 이어서 보실 수 있습니다.
정리 #
create_all은 새 테이블만 만듭니다. 스키마 변경 이력 관리는 Alembic의 일이고, 사이클은 revision –autogenerate, 리뷰, upgrade head의 반복입니다.- autogenerate는 초안입니다. 특히 이름 변경은 삭제 + 추가로 생성되어 데이터를 유실시키므로, 리뷰에서
alter_column으로 바로잡아야 합니다. - 비동기는
create_async_engine+AsyncSession으로 규칙적으로 전환되지만, 지연 로딩이 동작하지 않으므로 eager loading이 필수가 됩니다. - 엔진은 프로세스당 하나, Base는 프로젝트당 하나, 스키마 변경은 항상 Alembic 경유. 이 규약이 운영 사고를 막습니다.