o2o-site-AEO/solution/backend/scripts/migrate.py
Mina Choi 64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00

112 lines
5.1 KiB
Python

"""스키마 마이그레이션 — 이미 만들어진 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)))