From 8f0b1fbbfa6df0c4002586608752139dfd2a37b5 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 24 Apr 2026 08:57:10 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20travel-proxy=20=EC=84=B1=EB=8A=A5=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=20?= =?UTF-8?q?=E2=80=94=205=20Tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-04-24-travel-proxy-perf.md | 681 ++++++++++++++++++ 1 file changed, 681 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-travel-proxy-perf.md diff --git a/docs/superpowers/plans/2026-04-24-travel-proxy-perf.md b/docs/superpowers/plans/2026-04-24-travel-proxy-perf.md new file mode 100644 index 0000000..d1206d1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-travel-proxy-perf.md @@ -0,0 +1,681 @@ +# Travel-Proxy 성능 개선 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** travel-proxy의 os.scandir 기반 아키텍처를 SQLite 인덱스 DB로 전환하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다. + +**Architecture:** 기존 main.py의 스캔/캐시/썸네일 로직을 db.py(스키마+쿼리)와 indexer.py(동기화+썸네일)로 분리. main.py는 라우트만 담당. DB 경로는 `/data/thumbs/travel.db`. + +**Tech Stack:** Python 3.12, FastAPI, SQLite (표준 라이브러리 sqlite3), Pillow + +--- + +### 파일 구조 + +| 파일 | 역할 | 상태 | +|------|------|------| +| `travel-proxy/app/db.py` | SQLite 스키마 정의, 커넥션 헬퍼, 쿼리 함수 | 신규 | +| `travel-proxy/app/indexer.py` | 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성 | 신규 | +| `travel-proxy/app/main.py` | FastAPI 라우트 (기존 수정 + 신규 추가) | 수정 | + +--- + +### Task 1: db.py — SQLite 스키마 및 쿼리 헬퍼 + +**Files:** +- Create: `travel-proxy/app/db.py` + +- [ ] **Step 1: db.py 파일 생성** + +```python +import os +import sqlite3 +from typing import Any, Dict, List, Optional + +DB_PATH = os.getenv("TRAVEL_DB_PATH", "/data/thumbs/travel.db") + + +def _conn() -> sqlite3.Connection: + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db() -> None: + with _conn() as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + album TEXT NOT NULL, + filename TEXT NOT NULL, + mtime REAL NOT NULL, + has_thumb INTEGER DEFAULT 0, + indexed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')), + UNIQUE(album, filename) + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_photos_album ON photos(album)") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS album_covers ( + album TEXT PRIMARY KEY, + filename TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ) + """) + + +def get_photos_by_region(albums: List[str], page: int, size: int) -> Dict[str, Any]: + """region에 속한 앨범들의 사진을 페이지네이션하여 반환.""" + if not albums: + return {"items": [], "total": 0, "has_next": False, "matched_albums": []} + + placeholders = ",".join("?" for _ in albums) + + with _conn() as conn: + # 앨범별 사진 수 + rows = conn.execute( + f"SELECT album, COUNT(*) as cnt FROM photos WHERE album IN ({placeholders}) GROUP BY album", + albums, + ).fetchall() + matched_albums = [{"album": r["album"], "count": r["cnt"]} for r in rows] + + # 전체 수 + total_row = conn.execute( + f"SELECT COUNT(*) as cnt FROM photos WHERE album IN ({placeholders})", + albums, + ).fetchone() + total = total_row["cnt"] + + # 페이지네이션 + offset = (page - 1) * size + items = conn.execute( + f"""SELECT album, filename, mtime FROM photos + WHERE album IN ({placeholders}) + ORDER BY album, filename + LIMIT ? OFFSET ?""", + [*albums, size, offset], + ).fetchall() + + return { + "items": [dict(r) for r in items], + "total": total, + "has_next": (offset + size) < total, + "matched_albums": matched_albums, + } + + +def get_all_albums() -> List[Dict[str, Any]]: + """전체 앨범 목록 + 사진 수 + 커버 정보.""" + with _conn() as conn: + rows = conn.execute(""" + SELECT p.album, COUNT(*) as count, + COALESCE(c.filename, MIN(p.filename)) as cover_filename + FROM photos p + LEFT JOIN album_covers c ON p.album = c.album + GROUP BY p.album + ORDER BY p.album + """).fetchall() + return [dict(r) for r in rows] + + +def set_album_cover(album: str, filename: str) -> bool: + """앨범 커버 지정. 해당 photo가 존재하면 True, 없으면 False.""" + with _conn() as conn: + exists = conn.execute( + "SELECT 1 FROM photos WHERE album = ? AND filename = ?", + (album, filename), + ).fetchone() + if not exists: + return False + + conn.execute( + """INSERT INTO album_covers (album, filename, updated_at) + VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ON CONFLICT(album) DO UPDATE SET + filename = excluded.filename, + updated_at = excluded.updated_at""", + (album, filename), + ) + return True + + +def get_album_cover(album: str) -> Optional[str]: + """앨범 커버 파일명 반환. 미지정 시 None.""" + with _conn() as conn: + row = conn.execute( + "SELECT filename FROM album_covers WHERE album = ?", + (album,), + ).fetchone() + return row["filename"] if row else None + + +def upsert_photo(album: str, filename: str, mtime: float) -> str: + """사진 upsert. 반환: 'added' | 'updated' | 'unchanged'.""" + with _conn() as conn: + existing = conn.execute( + "SELECT mtime, has_thumb FROM photos WHERE album = ? AND filename = ?", + (album, filename), + ).fetchone() + + if not existing: + conn.execute( + "INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)", + (album, filename, mtime), + ) + return "added" + elif existing["mtime"] != mtime: + conn.execute( + "UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?", + (mtime, album, filename), + ) + return "updated" + return "unchanged" + + +def remove_missing_photos(album: str, existing_filenames: set) -> int: + """폴더에 없는 사진을 DB에서 제거. 제거 수 반환.""" + with _conn() as conn: + db_rows = conn.execute( + "SELECT filename FROM photos WHERE album = ?", (album,) + ).fetchall() + db_filenames = {r["filename"] for r in db_rows} + to_remove = db_filenames - existing_filenames + + if to_remove: + placeholders = ",".join("?" for _ in to_remove) + conn.execute( + f"DELETE FROM photos WHERE album = ? AND filename IN ({placeholders})", + [album, *to_remove], + ) + # 삭제된 파일이 커버였으면 커버도 제거 + conn.execute( + f"DELETE FROM album_covers WHERE album = ? AND filename IN ({placeholders})", + [album, *to_remove], + ) + return len(to_remove) + + +def get_photos_without_thumb() -> List[Dict[str, str]]: + """썸네일 미생성 사진 목록.""" + with _conn() as conn: + rows = conn.execute( + "SELECT album, filename FROM photos WHERE has_thumb = 0" + ).fetchall() + return [dict(r) for r in rows] + + +def mark_thumb_done(album: str, filename: str) -> None: + """썸네일 생성 완료 표시.""" + with _conn() as conn: + conn.execute( + "UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?", + (album, filename), + ) +``` + +- [ ] **Step 2: 커밋** + +```bash +git add travel-proxy/app/db.py +git commit -m "feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼" +``` + +--- + +### Task 2: indexer.py — 폴더 동기화 + 썸네일 일괄 생성 + +**Files:** +- Create: `travel-proxy/app/indexer.py` + +- [ ] **Step 1: indexer.py 파일 생성** + +기존 main.py의 `ensure_thumb` 로직(라인 105-144)과 `scan_album` 로직(라인 146-166)을 기반으로 작성. `IMAGE_EXT`, `THUMB_SIZE`, 경로 상수는 main.py에서 import. + +```python +import os +import time +import json +import logging +from pathlib import Path +from typing import Any, Dict, List, Set + +from PIL import Image + +from . import db + +logger = logging.getLogger(__name__) + +IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"} +THUMB_SIZE = (480, 480) + + +def _scan_folder(folder: Path) -> List[Dict[str, Any]]: + """폴더 내 이미지 파일 목록 수집 (os.scandir).""" + if not folder.exists(): + return [] + items = [] + with os.scandir(folder) as entries: + for entry in entries: + if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT: + items.append({ + "filename": entry.name, + "mtime": entry.stat().st_mtime, + }) + return items + + +def _generate_thumb(src: Path, dest: Path) -> bool: + """원본에서 480x480 썸네일 생성. 성공 시 True.""" + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_name(dest.stem + ".tmp" + dest.suffix) + try: + with Image.open(src) as im: + im.thumbnail(THUMB_SIZE) + ext = dest.suffix.lower() + if ext in (".jpg", ".jpeg"): + fmt = "JPEG" + elif ext == ".png": + fmt = "PNG" + elif ext == ".webp": + fmt = "WEBP" + else: + fmt = (im.format or "").upper() or "JPEG" + im.save(tmp, format=fmt, quality=85, optimize=True) + tmp.replace(dest) + return True + except Exception as e: + logger.warning("Thumb generation failed: %s → %s", src, e) + try: + if tmp.exists(): + tmp.unlink() + except Exception: + pass + return False + + +def sync( + travel_root: Path, + thumb_root: Path, + region_map_path: Path, +) -> Dict[str, Any]: + """ + 폴더 스캔 → DB 동기화 + 썸네일 일괄 생성. + + Returns: + {"added": int, "removed": int, "thumbs_generated": int, "duration_sec": float} + """ + start = time.time() + + # 1. region_map.json에서 전체 앨범 폴더 수집 + with open(region_map_path, "r", encoding="utf-8") as f: + region_map = json.load(f) + + all_albums: Set[str] = set() + for v in region_map.values(): + if isinstance(v, list): + all_albums.update(v) + elif isinstance(v, dict) and isinstance(v.get("albums"), list): + all_albums.update(v["albums"]) + + # 2. 각 앨범 폴더 스캔 → DB 동기화 + added = 0 + removed = 0 + + for album in sorted(all_albums): + folder = travel_root / album + items = _scan_folder(folder) + existing_filenames = set() + + for item in items: + existing_filenames.add(item["filename"]) + result = db.upsert_photo(album, item["filename"], item["mtime"]) + if result == "added": + added += 1 + + removed += db.remove_missing_photos(album, existing_filenames) + + # 3. 썸네일 미생성 분 일괄 생성 + no_thumb = db.get_photos_without_thumb() + thumbs_generated = 0 + + for photo in no_thumb: + src = travel_root / photo["album"] / photo["filename"] + dest = thumb_root / photo["album"] / photo["filename"] + if _generate_thumb(src, dest): + db.mark_thumb_done(photo["album"], photo["filename"]) + thumbs_generated += 1 + + duration = round(time.time() - start, 2) + logger.info( + "Sync complete: added=%d removed=%d thumbs=%d duration=%.2fs", + added, removed, thumbs_generated, duration, + ) + + return { + "added": added, + "removed": removed, + "thumbs_generated": thumbs_generated, + "duration_sec": duration, + } +``` + +- [ ] **Step 2: 커밋** + +```bash +git add travel-proxy/app/indexer.py +git commit -m "feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성" +``` + +--- + +### Task 3: main.py 리팩토링 — DB 기반 photos API + 캐시 제거 + +**Files:** +- Modify: `travel-proxy/app/main.py` + +이 Task에서 main.py의 메모리 캐시, `scan_album()`, 기존 `photos()` 라우트를 DB 기반으로 교체한다. + +- [ ] **Step 1: main.py를 DB 기반으로 재작성** + +main.py 전체를 아래로 교체: + +```python +import os +import json +import logging +from pathlib import Path +from typing import Any, List + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse +from pydantic import BaseModel +from PIL import Image + +from .db import init_db, get_photos_by_region, get_all_albums, set_album_cover, get_album_cover +from .indexer import sync + +logger = logging.getLogger(__name__) + +app = FastAPI() + +# ----------------------------- +# Env / Paths +# ----------------------------- +ROOT = Path(os.getenv("TRAVEL_ROOT", "/data/travel")).resolve() +MEDIA_BASE = os.getenv("TRAVEL_MEDIA_BASE", "/media/travel") + +META_DIR = ROOT / "_meta" +REGION_MAP_PATH = META_DIR / "region_map.json" +REGIONS_GEOJSON_PATH = META_DIR / "regions.geojson" + +THUMB_ROOT = Path(os.getenv("TRAVEL_THUMB_ROOT", "/data/thumbs")).resolve() +THUMB_SIZE = (480, 480) + +THUMB_ROOT.mkdir(parents=True, exist_ok=True) + +IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"} + +# ----------------------------- +# DB init +# ----------------------------- +init_db() + +# ----------------------------- +# Helpers +# ----------------------------- +def _read_json(path: Path) -> Any: + if not path.exists(): + raise HTTPException(500, f"Missing required file: {path}") + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def load_region_map() -> dict: + return _read_json(REGION_MAP_PATH) + + +def load_regions_geojson() -> dict: + return _read_json(REGIONS_GEOJSON_PATH) + + +def _get_albums_for_region(region: str, region_map: dict) -> List[str]: + if region not in region_map: + raise HTTPException(400, "Unknown region") + v = region_map[region] + if isinstance(v, list): + return v + if isinstance(v, dict) and isinstance(v.get("albums"), list): + return v["albums"] + raise HTTPException(500, "Invalid region_map format") + + +def _ensure_thumb_fallback(src: Path, album: str) -> Path: + """온디맨드 썸네일 폴백 (sync 누락 분 대응).""" + out = THUMB_ROOT / album / src.name + if out.exists(): + return out + out.parent.mkdir(parents=True, exist_ok=True) + tmp = out.with_name(out.stem + ".tmp" + out.suffix) + try: + with Image.open(src) as im: + im.thumbnail(THUMB_SIZE) + ext = out.suffix.lower() + if ext in (".jpg", ".jpeg"): + fmt = "JPEG" + elif ext == ".png": + fmt = "PNG" + elif ext == ".webp": + fmt = "WEBP" + else: + fmt = (im.format or "").upper() or "JPEG" + im.save(tmp, format=fmt, quality=85, optimize=True) + tmp.replace(out) + return out + finally: + try: + if tmp.exists(): + tmp.unlink() + except Exception: + pass + + +# ----------------------------- +# Models +# ----------------------------- +class CoverRequest(BaseModel): + filename: str + + +# ----------------------------- +# Routes +# ----------------------------- +@app.get("/health") +def health(): + return {"status": "healthy", "service": "travel-proxy"} + + +@app.get("/api/travel/regions") +def regions(): + return load_regions_geojson() + + +@app.get("/api/travel/photos") +def photos( + region: str = Query(...), + page: int = Query(1, ge=1), + size: int = Query(20, ge=1, le=100), +): + region_map = load_region_map() + albums = _get_albums_for_region(region, region_map) + result = get_photos_by_region(albums, page, size) + + # URL 조합 (DB에는 경로를 저장하지 않음) + items = [] + for row in result["items"]: + items.append({ + "album": row["album"], + "file": row["filename"], + "url": f"{MEDIA_BASE}/{row['album']}/{row['filename']}", + "thumb": f"{MEDIA_BASE}/.thumb/{row['album']}/{row['filename']}", + "mtime": row["mtime"], + }) + + return { + "region": region, + "page": page, + "size": size, + "total": result["total"], + "has_next": result["has_next"], + "items": items, + "matched_albums": result["matched_albums"], + } + + +@app.post("/api/travel/sync") +def sync_endpoint(): + result = sync( + travel_root=ROOT, + thumb_root=THUMB_ROOT, + region_map_path=REGION_MAP_PATH, + ) + return result + + +@app.get("/api/travel/albums") +def albums_list(): + rows = get_all_albums() + result = [] + for r in rows: + cover = r["cover_filename"] + result.append({ + "album": r["album"], + "count": r["count"], + "cover_url": f"{MEDIA_BASE}/{r['album']}/{cover}", + "cover_thumb": f"{MEDIA_BASE}/.thumb/{r['album']}/{cover}", + }) + return result + + +@app.put("/api/travel/albums/{album}/cover") +def set_cover(album: str, body: CoverRequest): + ok = set_album_cover(album, body.filename) + if not ok: + raise HTTPException(404, f"Photo not found: {album}/{body.filename}") + return { + "album": album, + "filename": body.filename, + "cover_url": f"{MEDIA_BASE}/{album}/{body.filename}", + "cover_thumb": f"{MEDIA_BASE}/.thumb/{album}/{body.filename}", + } + + +@app.get("/media/travel/.thumb/{album}/{filename}") +def get_thumb(album: str, filename: str): + if ".." in album or ".." in filename: + raise HTTPException(400, "Invalid path") + src = (ROOT / album / filename).resolve() + if not str(src).startswith(str(ROOT)): + raise HTTPException(403, "Access denied") + if not src.exists() or not src.is_file(): + raise HTTPException(404, "Source not found") + p = _ensure_thumb_fallback(src, album) + if not p.exists() or not p.is_file(): + raise HTTPException(404, "Thumbnail not found") + return FileResponse(str(p)) + + +@app.get("/api/version") +def version(): + return {"version": os.getenv("APP_VERSION", "dev")} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add travel-proxy/app/main.py +git commit -m "refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API" +``` + +--- + +### Task 4: 통합 검증 + +**Files:** +- 없음 (기존 파일 검증만) + +- [ ] **Step 1: import 구조 확인** + +travel-proxy/app/ 디렉토리에 `__init__.py`가 필요한지 확인. FastAPI uvicorn 실행 명령이 `app.main:app`이므로 패키지 import가 동작하려면 `__init__.py`가 필요. + +```bash +ls travel-proxy/app/ +``` + +`__init__.py`가 없으면 생성: + +```python +# travel-proxy/app/__init__.py +``` + +- [ ] **Step 2: Dockerfile 확인** + +현재 Dockerfile의 `COPY app /app/app` 라인이 db.py, indexer.py를 포함하는지 확인. 디렉토리 단위 복사이므로 추가 파일은 자동 포함됨. 변경 불필요. + +- [ ] **Step 3: docker-compose.yml 환경변수 확인** + +`TRAVEL_DB_PATH` 환경변수를 docker-compose.yml에 추가: + +```yaml +# docker-compose.yml의 travel-proxy 서비스 environment에 추가 +- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db} +``` + +- [ ] **Step 4: photos 응답 호환성 검증** + +기존 응답 필드와 비교: +- `region` ✓ +- `page`, `size` ✓ +- `total`, `has_next` ✓ +- `items[].album`, `items[].file`, `items[].url`, `items[].thumb`, `items[].mtime` ✓ +- `matched_albums` — 기존에는 `photos()` 응답에 없었으나 캐시 데이터에 포함. DB 버전은 항상 포함. + +- [ ] **Step 5: 커밋 (변경 있을 시)** + +```bash +git add travel-proxy/app/__init__.py docker-compose.yml +git commit -m "chore(travel-proxy): __init__.py + TRAVEL_DB_PATH 환경변수 추가" +``` + +--- + +### Task 5: CLAUDE.md 업데이트 + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: travel-proxy 섹션에 DB 정보 추가** + +CLAUDE.md의 travel-proxy 섹션에 아래 내용 추가: + +- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블) +- 파일 구조에 `db.py`, `indexer.py` 추가 + +API 목록 테이블에 신규 API 3개 추가: + +| 메서드 | 경로 | 설명 | +|--------|------|------| +| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 | +| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 | +| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 | + +`POST /api/travel/reload` 제거 표기. + +- [ ] **Step 2: 커밋** + +```bash +git add CLAUDE.md +git commit -m "docs: CLAUDE.md travel-proxy DB·API 업데이트" +```