업보 청산 타임(Feat. Alembic)

프로필

2025년 05월 11일

96 0

시간이 없다는 핑계 아닌 핑계로 계속 미뤄왔던 블로그 리팩토링 작업을 요 며칠간 진행하고 있는 중이다.

우선 게시글 리스트는 /posts로 이관했고, 기존 /에는 새로운 랜딩 페이지를 붙였다.
엔드포인트는 post/{post_id} -> posts/{post_id}로 바꾸며 RESTful하게 정리했고, HTML 템플릿들의 디자인들을 조금씩 개선하는 작업을 진행했다.

이 과정에서 체감이 가장 컸던 건 Build Kit 캐시 최적화Discord 웹훅 알림 도입이었다.

배포 속도는 3~4분 -> 40초로 줄었고, Actions 창을 쳐다보지 않아도 알림으로 로그가 날아오니 집중력도 유지됐다.

그런데...
원래 오늘 하려고 했던건 CRUD 리팩토링이었다. 하지만 천 줄에 가까운 게시글 코드를 마주한 순간, 자연스럽게 눈이 아래로 내려갔다.

일단 모델이나 좀 정리해볼까...

이 안일한 선택이 지옥의 문을 열 줄은 그땐 차마 몰랐다.

모델을 정리하는데, 언젠가 쓸 거라 생각했지만 아예 쓰이지 않은 컬럼, 무슨 의도를 갖고 만들었는지 기억도 안나는 컬럼도 존재했고, 현 상태에서 사용하면서 부족함을 느낀 기능을 위해서 추가가 필요한 테이블/컬럼 역시 존재했다.

근데 문제가 있었다. 마지막 리비전을 살펴보니 6달 전이었고, 그 이후로 난 마이그레이션을 진행한 적이 한번도 없다는 점이었다. 툴은 Alembic을 사용했었다 정도의 기억이라 기억을 더듬고, 검색을 통해 사용법을 찾아봤다.

Alembic이란?

Alembic은 SQLAlchemy 기반 프로젝트에서 데이터베이스 마이그레이션을 자동으로 관리해주는 도구다.

쉽게 말하면 모델을 수정했을 때, 그 변경사항을 데이터베이스에도 반영해주는 버전 관리 시스템이다.

예를 들어 users 테이블에 새로운 컬럼을 하나 추가했다고 치자.
이걸 매번 ALTER TABLE로 수동 작성하고 배포에 반영하려면 귀찮고 실수도 많다.
Alembic은 이런 과정을 버전 파일(migrations/versions/.py)로 기록해두고, alembic upgrade head 같은 명령어로 DB를 자동으로 최신 상태로 맞춰준다.

더 간단하게 말하자면 DB를 Git처럼 버전 관리해주는 친군데, 그 모태가 되는 Git과 마찬가지로 한번 꼬이면 지옥을 맛볼 수 있다.

억까의 시작

그냥 DB 인스턴스에 따로 접속해서 손 SQL문으로 수정을 할까도 고민을 해봤는데, 지워야 할 컬럼도 여러개고, 새로 생성해야 할 테이블도 있는 상황인데다 API 인스턴스와 DB 인스턴스가 분리되어 있는 환경이라 그걸 또 models.py에 반영해줘야 하는 문제점이 있었다.

근데 그렇다고 Alembic을 쓰기에는 내 환경이 너무 복잡했다. 일단 Alembic은 API 인스턴스에서 도커 컨테이너 상에서 돌고 있고, DB는 다른 인스턴스의 도커 위에서 돌아가고 있었다. 반년 동안 많은 변화가 있었어서 일단 Alembic과 DB 사이의 연결부터 다음 명령어로 확인해봤다.

docker exec -it main bash
echo $DATABASE_URL

하지만 돌아오는 결과값은 mysql+pymysql://:@:/였다.
.env에서 값은 읽었지만, 환경 변수가

DATABASE_URL = mysql+pymysql://${DB_USERNAME}:${DB_PASSWORD}@${DB_ENDPOINT}:${DB_PORT}/${DB_NAME}

DB_USERNAME = john
DB_PASSWORD = doe
DB_ENDPOINT = 0.0.0.0
DB_PORT = 0000
DB_NAME = DB

이 형태로 되어 있었기 때문인데, .env 파일에서는 변수 치환이 기본적으로 지원되지 않기 때문이었다. 사실 DB 연결 자체에 문제가 있는 건 아니지만, 생각을 해보니까 환경변수를 저렇게 쓸 필요가 없다는 걸 깨닫고 .env를 수정했다.

이렇게 환경을 정리해놓고 호스트 환경에서 Alembic으로 마이그레이션을 시도했다.

docker exec -it main bash
cd /example/app
alembic revision --autogenerate -m "Description"
alembic upgrade head
INFO  [alembic.autogenerate.compare] Detected added column 'posts.series'
INFO  [alembic.migration] 마이그레이션 완료

  Generating /example/app/migrations/versions/00000000_add_series_column.py

  ...  done

성공적이었고, Workbench로 확인해봐도 새로 추가한 컬럼이 정상적으로 표기되는 상황이었다.

확인 후, 로컬의 models.py를 수정사항에 맞춰서 저장하고 커밋을 하자 502 Bad Gateway가 떴고, 로그를 확인해보니 리비전 파일을 찾지 못해 API 컨테이너가 터져있었다.

main             | FAILED: Can't locate revision identified by '000000000'
main             | ERROR [alembic.util.messaging] Can't locate revision identified by '000000000'

main exited with code 255

이는 뭐 찾아볼 필요도 없이 이유가 직감이 됐는데,
1. 방금 진행한 마이그레이션은 API 컨테이너 내부에서 진행되어, 리비전도 컨테이너 내부에 생성되었지만, 그 리비전 파일은 Git이나 Docker 이미지에 포함되지 않은 상태다.
2. 하지만 방금 커밋으로 새롭게 덮어씌워진 이미지 안에는 그 리비전 파일이 존재하지 않는다.
3. DB에는 alembic_version = '000000000'이라고 기록되어 있지만 파일은 존재하지 않는다.
4. 파일이 존재하지 않으므로 마이그레이션이 깨지고, 내 Docker-compose는 기본적으로 실행 시에 마이그레이션을 진행 한 뒤에 켜지므로 컨테이너 자체가 죽어버림.

일단 502만 띄우고 있는 서버를 살리려고, 어차피 컬럼 자체는 생성되어 있으니, 리비전 파일과 이름만 맞춘 더미를 생성해서 커밋했다.

"""recreate series column"""
revision = '000000000'
down_revision = '111111111'

def upgrade():
    pass

def downgrade():
    pass

하지만 이는 근본적인 해결책이 아니었고, 결국에 이를 해결하려면 호스트 환경이 아닌 로컬 환경에서 리비전 생성 후, 호스트에서는 alembic upgrade head만 진행하는 방식이 제일 깔끔했다.

그렇게 호스트에서 almebic을 실행했더니 Command not found: alembic를 내뿜었고, 이번엔 Poetry가 문제였다.

poetry show로 확인을 해봤는데, Alembic은 정상적으로 설치가 되어 있었고, grep alembic도 정상적으로 동작했지만 bad interpreter: /MAIN_BLOG/.venv/bin/python: no such file or directory가 뜨며, 가상환경 안의 실행파일이 깨져있었다.

현기증이 나기 시작했지만, 꾹 참고

rm -rf .venv
poetry install --no-root

를 통해 가상환경을 싹 다 날리고 의존성을 다시 설치해줬다.

그렇게 다시 정상작동하는 Alembic으로 마이그레이션을 진행하려 했으나...

line 7, in <module>
    from app.database import Base, SQLALCHEMY_DATABASE_URL
ModuleNotFoundError: No module named 'app'

Alembic이 프로젝트의 Python 모듈을 찾지 못했다.

example/
├── app/
│   ├── alembic.ini
│   ├── migrations/
│   ├── models.py
│   └── ...
├── pyproject.toml
└── ...

지금은 이런 구조이기 때문에 경로지정이 필요했다. 또한 찾아보니 일반적으로는 alembic.ini를 프로젝트 루트에 두는 게 권장된다고 하여 alembic.ini와 migrations를 루트로 옮겨줬다.

그러자 이번엔 다른 오류를 뱉었는데

sqlalchemy.exc.OperationalError: (pymysql.err.OperationalError) (2003, "Can't connect to MySQL server on '0.0.0.0' (timed out)")

바로 DB 타임아웃이었다. 근데 저 IP를 잘 살펴보니 저건 Public IP가 아닌 Private IP였고, 접근이 안되는 게 당연했다. 이거 하자고 퍼블릭을 열어주는 건 보안 상 문제가 될 수 있을 것 같아, 다른 방법을 찾아보던 나는 포트포워딩을 떠올렸고 로컬의 3306포트를 DB의 3306 포트로 터널링했다.

ssh -i ~/.ssh/key.pem -L 3306:0.0.0.0:3306 ubuntu@0.0.0.0

근데 그럼 이제 Alembic이 읽는 .env 파일의 DB URL을 localhost:3306으로 바꿔줘야 할 필요가 있었는데, 그렇게 수정하면 api가 읽는 DB URL이 자기 자신을 가르키게 될 거라, env파일을 두 개로 나누는 작업도 동시에 진행했다.

또한 Alembic의 구성 파일인 alembic.ini와 /migrations의 경로가 루트로 옮겨졌기 때문에 docker-compose.yml의 수정도 이루어졌다.

모든 수정이 끝난 후에, 로컬에서 다시 리비전 생성을 시도해봤고, 정상적으로 생성이 되는 걸 확인했다.
그 이후에 커밋을 진행하면 설정대로 순서에 따라 alembic upgrade head를 통해 마이그레이션이 정상적으로 완료되는 것도 확인한 나는 간신히 지옥에서 벗어날 수 있었다.

결론

하나하나를 따져보면 별다른 고민 없이 했던 결정들이 쌓이고 얽히면서 결국 컨테이너, Git, 환경변수, Dockerfile, Poetry, Alembic, DB 연결, 마이그레이션 정책까지 전면 수술이 필요해지는 상황이 되어버렸다.

언제나 그렇듯, 그땐 아무렇지도 않았던 선택들이 결국엔 내 등에 칼을 꽂는다.
이번에도 그랬고, 반년 전의 나는 무슨 생각으로 블로그를 이렇게 복잡하게 구성했는지 스스로에게 되물었다.

그런데 그게 꼭 나쁜 것만도 아닌게, 이 복잡한 구조 때문에 실무에 가기 전 실무급 트러블슈팅을 경험할 수 있었고, 그걸 정리하고 돌파하는 과정에서 확실히 빠르게 늘고 있다는 생각도 든다.

그리고 하나 확실하게 느낀 게 있다. 개발을 하면 할수록, 결국엔 ‘설계’가 전부라는 사실이다. 초기에 10분 고민을 덜 해서, 나중에 10시간을 쓰게 되는 일이 진짜 비일비재하다.

마지막으로, 기술 블로그를 운영하려는 사람들에게 조언을 하나 하자면, 작은 프로젝트라면 Alembic 같은 마이그레이션 툴은 안 쓰는 게 속 편하다. 그냥 SQL 손으로 써라. 그게 정신건강에 이롭다.

#DB #Database #Alembic #Migration #마이그레이션

댓글 0개

댓글을 작성하려면 로그인이 필요합니다