도메인별 스키마(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>
112 lines
5.1 KiB
Python
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)))
|