들어가며
지난해 논문으로 출판되었던 CellCraft는 오픈소스 프로젝트로서 제가 석사과정을 졸업한 이후에도 꾸준히 개발하고 있는 웹 서비스입니다. 무엇보다도 프로덕션으로 배포되어 운영되고 있기에 유지 보수 또한 지속적으로 필요합니다. 오랫동안 개발한 프로젝트인 만큼 개선해야 할 레거시도 많기 때문에 시간 날 때마다 하나씩 개선하면서 기록해보려고 합니다. 오늘은 개발 단계에서 편의성을 위해 많이 사용하는 SQLAlchemy의 메서드인 create_all()을 Alembic 기반의 버전 관리로 전환한 과정을 공유하고자 합니다.
Base.metadata.create_all(bind=engine)
create_all()은 무엇인가
Base.metadata.create_all(bind=engine)은 SQLAlchemy에서 Python으로 정의한 ORM 모델을 기반으로 DB에 테이블을 생성하는 메서드입니다. 내부적으로는 모델 클래스에 정의된 테이블, 컬럼, 제약조건 등의 메타데이터를 읽어서 CREATE TABLE DDL을 생성하고 실행합니다. 이때 주의해야 할 특징이 있는데 이미 존재하는 테이블은 스킵한다는 점입니다. 즉, 테이블이 없으면 생성하고 있으면 그냥 넘어갑니다.
이러한 특성 덕분에 개발 초기에 매우 편리하게 사용됩니다. 서버를 시작할 때 create_all()을 호출해두면 DB가 비어있을 때 자동으로 테이블이 만들어지고, 이미 테이블이 있으면 그냥 넘어가기 때문에 개발자가 별도의 DB 셋업 과정 없이 바로 개발에 집중할 수 있습니다. CellCraft에서도 동일한 의도로 main.py에서 서버 초기 실행 시 create_all()을 호출하고 있었습니다.
models.Base.metadata.create_all(bind=engine)
global_engine = engine
그러나 create_all()에는 근본적인 한계가 있습니다. 테이블의 생성만 할 수 있고 변경은 할 수 없습니다. 모델에 새로운 컬럼을 추가하거나 기존 컬럼의 타입을 변경해도, 이미 테이블이 존재하면 스킵하기 때문에 변경 사항이 DB에 반영되지 않습니다. 또한, 스키마의 변경 이력을 추적하지 않기 때문에 특정 시점의 스키마로 롤백하는 것도 불가능합니다.
개발 과정에서는 DB를 통째로 삭제하고 다시 만들면 그만이기 때문에 이러한 한계가 문제가 되지 않습니다. 하지만 CellCraft는 현재 프로덕션 서버로 운영 중이며 오픈소스로 이미 배포 중입니다. 현재 상황에서 create_all()에 의존하는 구조는 장애를 일으킬 수 있는 레거시라고 판단했습니다.
예상 가능한 장애 시나리오
개선하기 전 CellCraft는 create_all()로 초기 DB 테이블을 생성하고, 이후의 부분적인 스키마 변경만 Alembic으로 수동 관리하는 이중 구조를 가지고 있습니다. 이러한 구성에서는 아래 3가지 장애 시나리오를 가정할 수 있습니다.
- 시나리오 A: 기존 사용자의 버전 업그레이드
v1.0 설치 → create_all() 메서드로 모든 DB 테이블이 생성됨.
v2.0 업그레이드 → create_all() 메서드는 이미 기존 DB 테이블이 존재하기 때문에 스킵됨. 별도의 Alembic 명령어를 통해 변경된 DB 스키마에 따른 revision을 생성하고 업그레이드해주지 않으면 변경된 컬럼에 접근 시 오류가 발생합니다.
- 시나리오 B: 사용자가 수동으로 Alembic을 실행
시나리오 A 상황을 해결하기 위해 사용자가 alembic upgrade head를 수동으로 실행하면, create_all() 메서드를 통해서 생성한 DB 테이블에 대해서는 Alembic 버전이 존재하지 않습니다. Alembic이 현재 DB 테이블의 상태를 모르기 때문에 루트 revision부터 순차적으로 이미 적용된 변경을 중복 적용하려 해서 에러가 발생합니다.
- 시나리오 C: 프로덕션에서의 롤백
프로덕션 서버에서 최신 버전 배포 후에 만약 오류가 발생했을 경우에, 이전 버전으로 롤백하려고 합니다. 하지만 create_all() 메서드로 테이블 생성만 했기에 이전 DB 스키마에 대한 정보가 남아있지 않아서 실패합니다.
위 시나리오들의 근본 원인은 모두 동일합니다. create_all()은 DB 스키마의 현재 상태만 만들 뿐, 변경 이력을 남기지 않는다는 점입니다. Alembic이 별도로 존재하지만, 초기 테이블 생성이 create_all()의 몫이기 때문에 Alembic 입장에서는 DB가 어떤 상태인지 알 수 없는 문제가 존재합니다.
이에 따라서 기존에 create_all() 메서드로 초기 DB 테이블을 생성하고 부분적인 마이그레이션만 Alembic으로 수동으로 하던 방식을Alembic만으로 초기 DB 테이블 생성부터 버전 추적 및 변경이 가능하도록 개선하고자 목표를 설정했습니다.
구체적으로는 서버 시작 시 create_all() 메서드 대신 alembic upgrade head를 호출하여 아래 세 가지 상황을 모두 처리할 수 있도록 합니다.
- 첫 설치: 최신 버전의 모델 구성에 따라 DB 테이블을 생성
- 버전 업그레이드: 미적용 revision을 추적하여 최신 버전의 revision까지 업그레이드를 진행
- 롤백: 기록된 버전들로 복원 가능
해결해야할 선행 과제
개선에 앞서 현재 Alembic 구성에도 해결해야 할 2가지 문제가 존재했습니다.
- 1. 초기 테이블 생성 마이그레이션의 부재
현재는 모든 revision이 테이블이 존재함을 가정하고 add_column 등의 오퍼레이션만 수행합니다. create_all()을 대체하려면 테이블을 처음부터 생성하는 create_table() 연산을 수행하는 초기 마이그레이션 revision이 필요합니다.
- 2. Multiple Heads
현재 alembic/versions/ 폴더 내의 revision 구성에서 head가 3개 존재하여 여러 분기로 나뉘어 있습니다. alembic upgrade head는 단일 head를 전제로 동작하기 때문에, 이를 먼저 일렬로 정리해야 합니다.
해당 두 가지 선행 과제를 포함하여 문제 해결을 위한 순서를 아래와 같이 먼저 정리하고 작업 계획을 수립했습니다.
| Step | 작업 |
|---|---|
| Step 1 | Multiple Heads 병합 → 체인을 일렬로 정리 |
| Step 2 | Initial Revision 추가 → 체인 앞에 테이블 생성 revision 삽입 |
| Step 3 | main.py 전환 → create_all()을 alembic upgrade head로 교체 |
| Step 4 | 기존 사용자 호환 → 이미 create_all()로 만들어진 DB 처리 |
1. Multiple Heads 병합 → 체인을 일렬로 정리
alembic upgrade head는 단일 head를 전제로 동작합니다. 하지만 이전 alembic/versions/ 폴더 내의 revision 구성을 확인해보면 head가 3개 존재하여 여러 분기로 나뉜 상태였습니다.
ff8810e9f544
├── a8f3c2b1d9e4 (down_revision = ff8810e9f544)
├── a1b2c3d4e5f6 (down_revision = ff8810e9f544)
│ └── add_plugin_id_fk (down_revision = a1b2c3d4e5f6)
└── 696551e16a9a (down_revision = ff8810e9f544)
이를 해결하는 방법은 2가지가 존재합니다.
- alembic merge 명령어로 병합 revision 생성: Alembic이 제공하는 공식 방법으로 3개의 head를 하나로 합치는 merge revision을 자동 생성합니다. 장점은 기존 revision 파일을 전혀 수정하지 않아서 안전하지만, 단점은 체인이 여전히 분기 형태라 읽기가 복잡합니다.
- down_revision을 수정해서 일렬로 재정렬: 3개의 분기를 직접 일렬 체인으로 재배선하는 방법으로 각 파일에서 down_revision만 수정합니다. 장점은 체인이 깔끔한 일렬 구조가 되어 이해하기 쉽지만, 단점은 기존 revision 파일을 직접 수정해야 합니다.
방법 선택은 기존 프로덕션 시스템에서 DB 스키마 버전 관리에 적극적으로 Alembic을 사용하는지 여부에 따라서 결정했습니다. 기존 Alembic은 개발 과정에서 부분적으로만 적용했기에 변경해도 문제없다고 판단하여, 근본적으로 체인을 깔끔하게 재구성하는 2번 방법을 채택했습니다.
재정렬을 진행하기 위해 각 revision별로 변경 사항을 확인하여 충돌이 존재하지 않는지 체크했습니다.
ff8810e9f544 → 696551e16a9a → a8f3c2b1d9e4 → a1b2c3d4e5f6 → add_plugin_id_fk
배치 순서의 논리적 근거는 다음과 같습니다. plugins 테이블 관련 작업을 먼저 완료(696551e16a9a → plugin_type 컬럼 추가, a8f3c2b1d9e4 → (name, source) unique constraint 추가)하고, 그 다음 tasks 테이블 관련 작업(a1b2c3d4e5f6 → plugin_image_uri 컬럼 추가, add_plugin_id_fk → plugin_id FK 추가)을 수행합니다. add_plugin_id_fk는 plugins 테이블을 FK로 참조하므로, plugins 관련 변경이 반드시 먼저 완료되어야 했습니다.
근데 해당 과정에서 중요한 사실을 하나 확인했습니다. 각 revision의 변경 사항을 분석하면서, 기존 revision들이 모두 현재 model.py에 정의된 모델의 이전 버전에 대한 이력들임을 파악했습니다.
즉, add_column이나 constraint 추가 같은 오퍼레이션들은 과거 개발 과정에서의 점진적 변경을 기록한 것이지, 현재 프로덕션 DB 스키마를 처음부터 구성하는 데는 필요하지 않은 버전들이었습니다. 해당 확인이 이후 Initial Revision을 어떤 전략으로 생성할지 결정하는 근거가 됩니다.
2. Initial Revision 추가 → 체인 앞에 테이블 생성 revision 삽입
현재 모든 revision은 테이블이 이미 존재함을 가정하고 add_column 등의 오퍼레이션만 수행합니다. create_all()을 대체하려면 테이블을 처음부터 생성하는 Initial Revision이 필요합니다.
Initial Revision을 생성하는 방법은 2가지입니다.
- autogenerate로 자동 생성
- 수동 생성
기존 revision이 현재 model.py에 정의된 모델의 이전 버전의 이력들이 포함되어 있기에, autogenerate로 생성하면 현재 모델과 현재 DB의 차이만 반영되어 맨 앞 루트 노드로 들어가기에 적절치 않습니다. 따라서 수동 생성이 적절한데, 이에 대해서 전략을 2가지로 세울 수 있습니다.
- 기존 revision들을 유지하면서 해당 변경 사항을 제외한 기본 테이블들만 생성하는 Initial Revision 작성. 장점은 기존 revision이 유지되지만, 단점은 기존 revision들을 고려한 불완전한 이전 DB 스키마를 추정해서 Initial Revision을 만들어야 합니다.
- 기존 revision들을 모두 삭제하고 현재 model.py를 그대로 반영한 Initial Revision 생성. 장점은 revision이 깔끔해지며 불필요한 이전 내역들을 고려할 필요가 없지만, 단점은 기존 revision들이 사라집니다.
이전 과정에서 각 revision의 변경 사항을 분석하면서 확인했듯이, 기존 revision들은 과거 개발 과정에서의 점진적 변경 이력이며 프로덕션에 미치는 영향이 거의 없습니다. 그리고 1번 전략을 선택할 경우, revision들을 통해 과거의 불완전한 DB 스키마를 추측하여 Initial Revision을 구현해야 하는 리스크가 존재합니다.
이를 고려하여 2번 전략을 선택하고, 기존 revision 파일들을 삭제한 뒤 현재 모델을 그대로 반영한 Initial Revision을 생성했습니다.
0001_initial (root, down_revision=None) ★ 단일 HEAD
│ CREATE TABLE users
│ CREATE TABLE plugins (with enum, unique constraint)
│ CREATE TABLE files
│ CREATE TABLE workflows
│ CREATE TABLE tasks (with plugin_id FK, index)
│ CREATE TABLE user_plugin_association
Initial Revision 설계
Initial Revision을 수동으로 작성할 때 아래 3가지 요소를 고려했습니다.
1. 테이블 생성 순서 — FK 의존성 기반
# 1단계: FK 의존성이 없는 독립 테이블
users, plugins
# 2단계: users에 의존하는 테이블
files, workflows
# 3단계: users + workflows + plugins 모두에 의존하는 테이블
tasks
# 4단계: users + plugins에 의존하는 연관 테이블
user_plugin_association
FK가 참조하는 테이블이 먼저 존재해야 하기 때문에 해당 순서로 생성합니다. downgrade()에서도 마찬가지로 역순으로 삭제합니다.
2. Enum 타입 선처리
plugintype_enum = sa.Enum('ANALYSIS', 'VISUALIZATION', name='plugintype')
plugintype_enum.create(op.get_bind(), checkfirst=True)
PostgreSQL에서 Enum은 컬럼 타입이 아니라 독립적인 DB 객체로 처리됩니다. 테이블보다 먼저 생성해야 plugins 테이블에서 참조할 수 있습니다. checkfirst=True는 이미 존재하면 스킵하는 안전장치로 작동합니다.
3. Plugin 모델의 created_at/updated_at 특수 처리
Base 클래스의 공통 created_at/updated_at은 sa.DateTime()이지만, Plugin 모델은 자체적으로 sa.DateTime(timezone=True) + server_default=func.now()로 재정의(override)하고 있으므로, Initial Revision에서 해당 차이를 반영했습니다.
# users, files, workflows, tasks → Base의 기본 DateTime
sa.Column('created_at', sa.DateTime(), nullable=True)
# plugins → 자체 정의된 DateTime(timezone=True) + server_default
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)
3. main.py 전환 → create_all()을 alembic upgrade head로 교체
Initial Revision이 준비되었으므로 이제 main.py에서 create_all() 호출을 alembic upgrade head로 교체합니다.
기존 코드는 아래와 같이 구성되어 있었습니다.
models.Base.metadata.create_all(bind=engine)
global_engine = engine
교체 방법에 대해 2가지 방법을 고려했습니다.
- 모듈 최상위에서 직접 호출 (기존 create_all() 위치)
- @app.on_event("startup") 안에서 호출
이에 대해서는 첫번째 방법을 채택했습니다. 이유는 uvicorn이 앱을 import하는 시점에 DB가 준비되어 있어야 정상 동작하는데, startup 이벤트로 옮기면 앱은 떠 있는데 DB가 없는 타이밍 갭이 생길 수 있는 가능성이 있는 문제가 있기 때문입니다.
4. 기존 사용자 호환 → 이미 create_all()로 만들어진 DB 처리
1~3까지의 작업으로 신규 설치 환경에서는 Alembic 단일 체계가 정상 동작합니다. 하지만 이미 create_all()로 만들어진 기존 DB를 사용하고 있는 사용자에 대한 호환성 처리가 필요했습니다.
기존 사용자가 CellCraft를 업그레이드하면 서버 시작 시 alembic upgrade head가 호출되는데, create_all()로 만든 DB에는 alembic_version 테이블이 없기 때문에 Alembic은 빈 DB로 간주하고 Initial Revision부터 실행하려 해서 이미 존재하는 테이블을 다시 생성하려는 에러가 발생합니다.
이에 대해 2가지 방법을 고려했습니다.
- Initial Revision에 존재 확인 로직 추가: 장점은 마이그레이션 파일 하나에서 자체적으로 해결되지만, 단점은 모든 create_table에 조건문이 붙어서 코드가 복잡해지고 향후 추가되는 revision에는 적용되지 않습니다.
- run_migrations()에서 DB 상태를 감지하여 분기: 장점은 Initial Revision이 깔끔하게 유지되고 감지 로직이 한 곳에 집중되지만, 단점은 main.py에 DB 상태 감지 로직이 추가됩니다.
최종적으로는 2번째 방법을 선택했습니다. 이유는 관심사 분리 측면에서 Initial Revision은 테이블을 만드는 것에만 집중하고, 기존 DB 감지는 run_migrations()가 담당하도록 분리하는 것이 적절하다고 판단했습니다.
이로 인해 Initial Revision의 재사용성이 보장되어 테스트 DB 초기화, CI 환경에서 그대로 사용 가능합니다. 또한, 기존 사용자 호환은 이번 전환에서만 발생하는 일회성 상황이기에 revision 안에 기록하는 것은 적절하지 않다고 판단했습니다.
run_migrations()의 DB 상태 감지 로직은 아래와 같이 3가지 상태로 분기합니다.
run_migrations() 실행
│
├── alembic_version 테이블이 존재하는가?
│ │
│ ├── YES → 상태 C (이미 Alembic 관리 중)
│ │ → alembic upgrade head (미적용 revision만 적용)
│ │
│ └── NO → 앱 테이블(users 등)이 존재하는가?
│ │
│ ├── YES → 상태 B (create_all()로 만든 기존 DB)
│ │ → alembic stamp head (현재 상태를 최신으로 표시만)
│ │
│ └── NO → 상태 A (완전히 새로운 설치)
│ → alembic upgrade head (initial부터 전체 실행)
해당 로직에서의 핵심은 핵심은 상태 B에서 사용하는 alembic stamp head입니다. 해당 명령어는 DB에 어떤 SQL도 실행하지 않고, alembic_version 테이블만 생성하고 최신 revision ID를 기록합니다. 기존 DB의 스키마는 현재 모델과 동일한 상태이므로 변경할 것이 없고, 버전 기록만 남기면 됩니다. 이후 새 revision이 추가되면, alembic upgrade head가 stamp 이후의 revision만 실행하게 됩니다.
동작 흐름 검증(결과)
A: 완전히 새로운 설치 (빈 DB) → alembic upgrade head 실행 → 모든 테이블 생성 + alembic_version에 0001_initial 기록
B: create_all()로 만들어진 기존 DB → alembic stamp head 실행 → DB에 어떤 SQL도 실행하지 않음 (테이블 변경 없음) → alembic_version 테이블만 생성하고 0001_initial 기록 → 이후부터 Alembic 관리 체제로 전환됨
C: 이미 Alembic으로 관리 중인 DB → alembic upgrade head 실행 → alembic_version의 현재 revision과 head를 비교 → 미적용 revision이 있으면 실행, 없으면 스킵
마치며
create_all()은 개발 초기에 DB 셋업 없이 바로 개발할 수 있게 해주는 편리한 메서드였고, 당시에는 FastAPI 예제들을 보고 따라 했기에 합리적인 선택이라고 생각했었습니다. 하지만 시간이 지나 CellCraft가 프로덕션 환경에서 운영되고 오픈소스로 배포되면서, 초기의 편리한 코드가 레거시로 변하여 리스크로 돌아온다는 것을 이번 작업을 통해 배울 수 있었습니다. 지금은 편한 코드를 어느 시점에서 개선해야할 레거시 코드로 정의해야 하는지 적절하게 판단하고 전환하는 것이 중요한 것 같네요.
사실 전환 작업 자체는 create_all() 메서드 한 줄을 alembic upgrade head로 교체한 것에 불과합니다. 하지만 그 한 줄의 교체를 통해 DB 스키마의 전체 라이프사이클이 확보되었습니다. 이제 새로운 스키마 변경이 필요하면 revision을 추가하면 되고, 사용자는 docker compose만 재실행하면 미적용 revision이 자동으로 적용됩니다. 문제가 발생하면 이전 버전으로 롤백하는 것도 가능해졌습니다.
코드 한 줄의 변경이지만, 문제 정의부터 여러 단계의 판단과 검토를 거쳐 해결 방법을 선택했고 과정 하나하나가 오픈소스 배포와 프로덕션 환경에서의 안정적인 운영을 고려해서 진행하다 보니 생각보다 의미 있는 작업이었던 것 같습니다.
'백엔드' 카테고리의 다른 글
| Docker 기반 플러그인 아키텍처 설계 (0) | 2026.03.29 |
|---|---|
| Celery 비동기 시스템 개선기: 리소스 기반 동시성 제어 (0) | 2026.03.28 |
| Celery 비동기 시스템 개선기: Timeout 오류 해결 (0) | 2026.03.28 |
| Docker로 GPU 환경 구성하기 (0) | 2025.03.30 |
| Celery를 활용한 비동기 작업 처리 (0) | 2025.03.02 |