"""스키마 마이그레이션 — 이미 만들어진 DB 를 init.sql 최신으로 끌어올린다. cd solution/backend && .venv/bin/python scripts/migrate.py cd solution/backend && .venv/bin/python scripts/migrate.py --dry-run ★ 왜 필요한가 (2026-09-09) `init-data/init.sql` 은 **DB 를 처음 만들 때만** 돈다(postgres 이미지의 초기화 훅). 그래서 파일에 컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다. 실제로 로컬 DB 에 `local.place_contents` 테이블과 `place.places.external_category` 컬럼이 없었고, TourAPI 가 주변 정보를 받아 와도 저장할 곳이 없어 축제·맛집이 0건이었다. 화면에는 "그냥 안 나오는 것"으로만 보여서 원인을 짚는 데 한참 걸렸다. DECISIONS.md 가 예고한 그대로다 — "운영 DB 가 생기는 순간 다시 필요해진다". ★ Alembic 을 쓰지 않는다. 이 레포는 ORM 과 init.sql 두 곳에 스키마를 두고 `tests/test_schema_ddl.py` 로 대조하는 구조다. 거기에 세 번째 정의(Alembic 리비전)를 더하면 어긋날 자리가 하나 더 생긴다. 필요한 건 "안 돌린 SQL 을 순서대로 돌린다" 뿐이다. ★ 적용 기록은 `public.schema_migrations` 에 남는다. 이미 있는 번호는 건너뛴다. 파일은 재실행 안전하게 쓰므로(IF NOT EXISTS), 기록이 날아가도 다시 돌리면 그만이다. """ import argparse import asyncio import os import sys from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) os.environ.setdefault("APP_ENV", "local") from sqlalchemy import text # noqa: E402 from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402 from common.enums import DBWRType # noqa: E402 from common.database.model.models import places # noqa: E402 # parents[3] = 레포 루트 (scripts → backend → solution → 루트). # ★ test_schema_ddl.py 와 같은 계산이다 — 폴더를 옮기면 둘 다 고친다. MIGRATIONS_DIR = Path(__file__).resolve().parents[3] / "postgres-init" / "migrations" _LEDGER_DDL = """ CREATE TABLE IF NOT EXISTS public.schema_migrations ( version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ def pending(applied: set[str]) -> list[Path]: """아직 안 돌린 파일. 파일명 순서가 곧 적용 순서다.""" files = sorted(p for p in MIGRATIONS_DIR.glob("*.sql")) return [p for p in files if p.stem not in applied] async def main(dry_run: bool) -> int: if not MIGRATIONS_DIR.is_dir(): print(f"마이그레이션 폴더가 없습니다: {MIGRATIONS_DIR}") return 1 db = await DB_SESSION_MNG.start_session(places.DBType(), DBWRType.DB_WRITE.value) try: await db.execute(text(_LEDGER_DDL)) await db.commit() rows = await db.execute(text("SELECT version FROM public.schema_migrations")) applied = {r[0] for r in rows} todo = pending(applied) if not todo: print(f"적용할 마이그레이션이 없습니다 (적용됨 {len(applied)}건)") return 0 print(f"적용 대상 {len(todo)}건:") for path in todo: print(f" {path.stem}") if dry_run: print("\n--dry-run — 아무것도 적용하지 않았습니다.") return 0 for path in todo: sql = path.read_text(encoding="utf-8") print(f"\n▶ {path.stem}") try: # ★ 파일 하나를 한 트랜잭션으로 돌린다 — 중간에 실패하면 그 파일은 통째로 되돌아간다. # 반쯤 적용된 파일이 기록에 남으면 다음 실행이 그것을 건너뛴다. # ★ asyncpg 드라이버 커넥션으로 직접 보낸다. SQLAlchemy 의 text() 는 prepared # statement 가 되는데, asyncpg 는 거기에 문장을 여러 개 못 넣는다 # ("cannot insert multiple commands into a prepared statement"). # 마이그레이션 파일은 본래 여러 문장이라 이 경로가 맞다. raw = await (await db.connection()).get_raw_connection() await raw.driver_connection.execute(sql) await db.execute( text("INSERT INTO public.schema_migrations (version) VALUES (:v)"), {"v": path.stem}, ) await db.commit() print(" 적용됨") except Exception as ex: await db.rollback() print(f" 실패 — {ex}") print(" ★ 여기서 멈춥니다. 뒤 파일은 돌리지 않습니다(순서가 뜻을 갖는다).") return 1 print(f"\n완료 — {len(todo)}건 적용") return 0 finally: await DB_SESSION_MNG.end_session(places.DBType(), DBWRType.DB_WRITE.value) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--dry-run", action="store_true", help="적용하지 않고 대상만 출력") args = parser.parse_args() raise SystemExit(asyncio.run(main(args.dry_run)))