16 Commits

Author SHA1 Message Date
cb6e2d992a perf(travel-proxy): 배치 DB 연결 + nginx sync timeout 600s
- db.py: batch_sync_album, batch_mark_thumbs_done 추가
- indexer.py: 앨범 단위 배치 동기화로 전환
- nginx: /api/travel/ proxy_read_timeout 600s 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:15:21 +09:00
7011d3ef3a docs: CLAUDE.md travel-proxy 섹션 — DB·API·파일 구조 업데이트
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:10:46 +09:00
eb322b7450 chore(travel-proxy): __init__.py 추가 + docker-compose TRAVEL_DB_PATH 환경변수 반영
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:10:08 +09:00
4fde9e6f58 fix(travel-proxy): 온디맨드 썸네일 폴백 시 has_thumb DB 동기화 + 미사용 import 정리
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:09:34 +09:00
7d78fae77f refactor(travel-proxy): main.py DB 기반 전환 — 메모리 캐시 제거 + 신규 API 2026-04-24 09:06:27 +09:00
e82ff83a5f fix(travel-proxy): indexer.py stat() 에러 핸들링 + updated 카운터 + 로깅 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:05:12 +09:00
fac2e65ed8 feat(travel-proxy): indexer.py — 폴더 동기화 + 썸네일 일괄 생성 2026-04-24 09:02:42 +09:00
42242f86eb fix(travel-proxy): db.py 중복 쿼리 제거 + 타입 힌트 개선
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 09:01:49 +09:00
c5682e07a7 feat(travel-proxy): db.py — SQLite 스키마 + 쿼리 헬퍼 2026-04-24 08:59:19 +09:00
8f0b1fbbfa docs: travel-proxy 성능 개선 구현 계획 — 5 Tasks
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 08:57:10 +09:00
e88989d3c1 docs: travel-proxy 성능 개선 설계 — SQLite 인덱스 DB + 앨범 커버 + 썸네일 사전 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 08:49:08 +09:00
f38631cdae docs: Travel 갤러리 리디자인 구현 계획 (10 tasks)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:10:09 +09:00
b2accba65a docs: Travel 갤러리 리디자인 설계 스펙
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 01:03:23 +09:00
8d92e50009 docs: 반응형 웹 UI/UX 구현 계획 23개 태스크
Phase 1a: breakpoint 통일 (Task 1-4)
Phase 1b: 공통 컴포넌트 + 앱 셸 (Task 5-12)
Phase 2: 주요 4페이지 (Task 13-16)
Phase 3: 나머지 페이지 (Task 17-22)
Phase 4: 검증 (Task 23)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:24:22 +09:00
bd7875b36a docs: 반응형 설계 리뷰 피드백 반영
- 라우트 경로 수정 (/lab/music→/music, /blog-marketing→/blog-lab 등)
- /realestate/property 미등록 라우트 제외, 실제 14개 뷰로 정정
- breakpoint 예외 목록 명시 (420/520/700px)
- 사이드바→바텀네비 마이그레이션 상세 계획 추가
- react-swipeable 경량 라이브러리 활용 명시
- 미니플레이어+바텀네비 스태킹 사양 추가
- viewport-fit=cover, prefers-reduced-motion, 테스트 뷰포트 명시
- Phase 1을 1a(breakpoint 정리) + 1b(컴포넌트)로 세분화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:17:07 +09:00
5ac5cce0fe docs: 반응형 웹 UI/UX 전면 개선 설계 문서
13개 페이지 모바일 대응 + 공통 모바일 인프라 설계.
바텀 네비, 풀다운 리프레시, 스와이프, FAB, 바텀시트 포함.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:10:28 +09:00
13 changed files with 7077 additions and 137 deletions

View File

@@ -349,9 +349,18 @@ docker compose up -d
### travel-proxy (travel-proxy/)
- 원본 사진: `/data/travel/` (RO)
- 썸네일 캐시: `/data/thumbs/` (RW)
- DB: `/data/thumbs/travel.db` (photos, album_covers 테이블)
- 메타: `/data/travel/_meta/region_map.json`, `regions.geojson`
- 썸네일: 480×480 리사이징 (Pillow), 온디맨드 생성 후 영구 캐시
- 메모리 캐시: TTL 300초 (앨범 스캔 결과)
- 파일 구조: `main.py`, `db.py`, `indexer.py`
- 썸네일: 480×480 리사이징 (Pillow), 동기화 시 사전 생성 + 온디맨드 폴백
- 데이터 흐름: 수동 sync → 폴더 스캔 → SQLite 인덱싱 + 썸네일 일괄 생성
**travel.db 테이블**
| 테이블 | 설명 |
|--------|------|
| `photos` | 사진 인덱스 (album, filename, mtime, has_thumb) |
| `album_covers` | 앨범별 커버 사진 지정 |
**travel-proxy API 목록**
@@ -359,7 +368,9 @@ docker compose up -d
|--------|------|------|
| GET | `/api/travel/regions` | 지역 GeoJSON |
| GET | `/api/travel/photos` | 사진 목록 (region, page=1, size=20) |
| POST | `/api/travel/reload` | 메모리 캐시 초기화 |
| POST | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| GET | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
| PUT | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
### blog-lab (blog-lab/)
- 블로그 마케팅 수익화 서비스 (키워드 분석 → AI 글 생성 → 마케팅 강화 → 품질 리뷰 → 포스팅 → 수익 추적)

View File

@@ -161,7 +161,7 @@ services:
- TRAVEL_ROOT=${TRAVEL_ROOT:-/data/travel}
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
- TRAVEL_DB_PATH=${TRAVEL_DB_PATH:-/data/thumbs/travel.db}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes:
- ${PHOTO_PATH}:/data/travel:ro

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 업데이트"
```

View File

@@ -0,0 +1,360 @@
# 반응형 웹 UI/UX 전면 개선 설계
> 모바일에서 UI 짤림 현상 해결 + 풀 모바일 경험 적용
> 작성일: 2026-04-23
> 리뷰 반영: 2026-04-23 (라우트 경로 수정, breakpoint 예외 명시, 구현 복잡도 보완)
---
## 1. 목표
- 전체 15개 뷰(12개 라우트 + 3개 서브라우트)에서 모바일 UI 짤림 현상 해결
- 현재 다크 네온 사이버펑크 디자인 톤 유지
- 모바일 전용 UX 패턴 추가 (바텀 네비게이션, 스와이프, 풀다운 리프레시, FAB, 바텀시트)
- 기능적 손실 없이 반응형 적용
**대상 뷰 목록 (routes.jsx 기준):**
| # | 라우트 | 컴포넌트 | 비고 |
|---|--------|---------|------|
| 1 | `/` | Home | |
| 2 | `/lotto` | Lotto | 3탭 (Briefing/Analysis/Purchase) |
| 3 | `/stock` | Stock | |
| 4 | `/stock/trade` | StockTrade | 서브라우트 |
| 5 | `/travel` | Travel | |
| 6 | `/blog` | Blog | |
| 7 | `/blog-lab` | BlogMarketing | |
| 8 | `/realestate` | Subscription | |
| 9 | `/music` | MusicStudio | |
| 10 | `/todo` | Todo | |
| 11 | `/agent-office` | AgentOffice | |
| 12 | `/lab` | EffectLab | |
| 13 | `/lab/sword-stream` | SwordStream | 서브라우트 |
| 14 | `/lab/day-calc` | DayCalc | 서브라우트 |
> Note: `RealEstate.jsx` (`/realestate/property`)는 routes.jsx에 미등록 상태. 반응형 스코프에서 제외.
---
## 2. 접근 방식
**글로벌 모바일 시스템 구축 → 주요 페이지 적용 → 전체 페이지 확장**
1. 공통 모바일 인프라(컴포넌트, breakpoint, 앱 셸) 구축
2. 주요 4개 페이지 (홈, 로또, 주식, 여행) 우선 적용
3. 나머지 페이지 확장 적용
---
## 3. 글로벌 모바일 인프라
### 3-1. Breakpoint 시스템 통일
현재 53개 미디어 쿼리에서 다양한 값이 혼재. 4단계로 통일:
| 이름 | 값 | 용도 |
|------|-----|------|
| sm | 480px | 소형 폰 |
| md | 768px | 태블릿/대형 폰 (주요 분기점) |
| lg | 1024px | 소형 데스크톱 |
| xl | 1280px | 대형 데스크톱 |
기존 미디어 쿼리의 비표준 값(640px, 900px, 960px, 1100px 등)은 기능 손실 없이 가장 가까운 표준 breakpoint로 정리한다.
**허용 예외 (이동 시 시각적 회귀 발생):**
| 기존 값 | 파일 | 사유 |
|---------|------|------|
| 420px | Stock.css (4곳) | 소형 폰 전용 패딩/라벨 축소, 480px로 이동 시 중간 기기에서 불필요한 축소 |
| 520px | Stock.css (1곳) | 지표 카드 특수 레이아웃 |
| 700px | Stock.css (1곳) | AI 코치 설정 그리드, 768px로 이동 시 태블릿에서 조기 축소 |
위 값들은 해당 페이지 CSS에서 기존 값을 유지한다.
### 3-2. 바텀 네비게이션 바 (`BottomNav`)
- 768px 이하에서 사이드바 대신 표시
- 주요 5개 메뉴 아이콘 + "더보기" 메뉴 (나머지 페이지)
- 현재 페이지 활성 표시 — 네온 시안 글로우 유지
- 사이드바는 모바일에서 완전히 숨김 (기존 햄버거→슬라이드 방식 제거)
- 높이: 56~64px
- `env(safe-area-inset-bottom)` 대응 (노치/홈 인디케이터 기기)
- `index.html``viewport-fit=cover` 추가 필요: `<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">`
- 더보기 메뉴: 탭 시 위로 펼쳐지는 오버레이 패널
**사이드바→바텀네비 마이그레이션 상세:**
- `Navbar.jsx`: 768px 이하에서 사이드바 렌더링 제거, `sidebar-toggle` 버튼 제거
- `Navbar.css`: `.sidebar` transform/transition 미디어 쿼리 제거, `.sidebar__overlay` 제거
- `Navbar.jsx` useEffect: `body.overflow = 'hidden'` 토글 로직 정리
- `App.jsx`에서 `BottomNav` 컴포넌트 조건부 렌더링 (`useIsMobile()` 기반)
**더보기 메뉴 내용 (나머지 네비게이션 항목):**
| 순서 | 아이콘 | 라벨 | 경로 |
|------|--------|------|------|
| 1 | 음악 | 뮤직 | `/music` |
| 2 | 로봇 | 에이전트 | `/agent-office` |
| 3 | 블로그 | 블로그 | `/blog` |
| 4 | 마케팅 | 블로그랩 | `/blog-lab` |
| 5 | 건물 | 청약 | `/realestate` |
| 6 | 체크 | TODO | `/todo` |
| 7 | 실험 | 이펙트랩 | `/lab` |
**기본 5개 메뉴 구성:**
| 순서 | 아이콘 | 라벨 | 경로 |
|------|--------|------|------|
| 1 | 홈 | 홈 | `/` |
| 2 | 클로버 | 로또 | `/lotto` |
| 3 | 차트 | 주식 | `/stock` |
| 4 | 카메라 | 여행 | `/travel` |
| 5 | 더보기 | 메뉴 | 오버레이 |
### 3-3. 공통 모바일 컴포넌트
| 컴포넌트 | 파일 | 역할 |
|---------|------|------|
| `BottomNav` | `src/components/BottomNav.jsx` | 하단 고정 네비게이션 |
| `PullToRefresh` | `src/components/PullToRefresh.jsx` | 터치 풀다운 새로고침 래퍼 |
| `SwipeableView` | `src/components/SwipeableView.jsx` | 좌우 스와이프 탭/뷰 전환 |
| `FAB` | `src/components/FAB.jsx` | 플로팅 액션 버튼 (바텀 네비 위 배치) |
| `MobileSheet` | `src/components/MobileSheet.jsx` | 바텀시트 모달 (드래그 핸들, 스냅 포인트) |
**공통 훅 (신규 `src/hooks/` 디렉토리 생성):**
> 기존 훅은 페이지별 디렉토리에 colocate (`src/pages/lotto/hooks/` 등).
> 모바일 인프라 훅은 여러 페이지에서 공유하므로 `src/hooks/`에 배치한다.
| 훅 | 파일 | 역할 |
|----|------|------|
| `useIsMobile` | `src/hooks/useIsMobile.js` | 768px 이하 감지 (matchMedia) |
| `useSwipe` | `src/hooks/useSwipe.js` | 터치 스와이프 방향·거리 감지 |
**경량 라이브러리 활용:**
- `react-swipeable` (~3KB gzipped): SwipeableView/useSwipe 기반으로 활용 — 터치 velocity, threshold snap, 방향 판별을 직접 구현하지 않음
- PullToRefresh: 터치 이벤트 직접 구현하되, iOS Safari rubber-banding 및 `overscroll-behavior: contain` 대응 필수
- MobileSheet: CSS `transform` + `touch-action: none`으로 구현, 스냅 포인트 2단계 (50%, 90%)
### 3-4. 앱 셸 레이아웃 변경
```
데스크톱: [사이드바 240px] [콘텐츠]
모바일: [탑바 56px]
[콘텐츠 (padding-bottom: 바텀네비 높이)]
[바텀 네비 56-64px]
```
- 콘텐츠 영역에 `padding-bottom` 추가 (바텀 네비 겹침 방지)
- 탑바: 현재 구조 유지, 페이지 타이틀 + 액션 버튼 영역
- `body` overflow: 모바일에서 auto (현재와 동일)
---
## 4. 주요 페이지별 모바일 설계
### 4-1. 홈 (Home) — `/`
| 영역 | 데스크톱 | 모바일 (≤768px) |
|------|---------|-----------------|
| 히어로 | 2컬럼 그리드 | 1컬럼 스택, 타이틀 축소 |
| 네비 카드 그리드 | auto-fill minmax(180px) | 2컬럼 고정, 카드 높이 축소 |
| TODO 보드 | 3컬럼 칸반 | 스와이프 탭 (Todo/진행중/완료) |
| 블로그 포스트 | 카드 그리드 | 1컬럼 리스트 |
| 프로필 섹션 | 사이드 카드 | 하단 접이식 패널 |
- 풀다운 리프레시: 블로그 포스트 갱신
- FAB: 없음 (네비게이션 허브)
### 4-2. 로또 (Lotto) — `/lotto`
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 3탭 구조 | 상단 탭바 | 스와이프 탭 전환 |
| 브리핑 탭 | 카드 레이아웃 | 1컬럼, 볼 크기 36→32px |
| 분석 탭 | 그리드 카드 | 1컬럼 스택 |
| 구매 이력 테이블 | 6컬럼 그리드 | 가로 스크롤 테이블 + 행 터치 바텀시트 |
| 번호 추천 카드 | 다중 그리드 | 1컬럼, 볼 간격 조정 |
| 전략 차트 | 넓은 차트 | 가로스크롤 또는 축소 |
- FAB: "추천받기" (빠른 번호 추천)
- 풀다운 리프레시: 브리핑/분석 데이터 갱신
### 4-3. 주식 (Stock / StockTrade) — `/stock`, `/stock/trade`
**Stock (뉴스/지표)**
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 헤더 | 2컬럼 | 1컬럼 스택 |
| 뉴스 그리드 | auto-fit minmax(260px) | 1컬럼 카드 리스트 |
| 필터 | 가로 나열 | 가로 스크롤 칩 바 |
| 지표 카드 | 그리드 | 가로 스크롤 카드 캐러셀 |
**StockTrade (매매)**
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 포트폴리오 테이블 | 넓은 테이블 | 카드형 리스트 (종목별 카드) |
| 매도 이력 | 테이블 | 가로 스크롤 + 행 터치 바텀시트 |
| 자산 차트 | 넓은 recharts | 풀 너비, 축 라벨 축소 |
| 예수금 섹션 | 인라인 | 접이식 카드 |
- FAB: "종목 추가" (Stock), "매도 기록" (StockTrade)
- 풀다운 리프레시: 뉴스/포트폴리오 갱신
### 4-4. 여행 (Travel) — `/travel`
| 영역 | 데스크톱 | 모바일 |
|------|---------|--------|
| 지역 선택 | Leaflet 지도 | 높이 50vh→35vh, 핀치 줌 |
| 사진 그리드 | 다중 컬럼 | 2컬럼 → 1컬럼 (≤480px) |
| 사진 상세 | 모달 | 풀스크린 뷰어 + 스와이프 넘기기 |
| 지역 필터 | 드롭다운 | 바텀시트 지역 선택 |
- 풀다운 리프레시: 사진 목록 갱신
- FAB: 없음
---
## 5. 나머지 페이지 모바일 설계
### 5-1. 블로그 (Blog) — `/blog`
| 영역 | 모바일 변경 |
|------|------------|
| 글 목록 | 1컬럼 리스트형 |
| 글 상세 | 풀 너비, 폰트 크기 조정 |
| 태그 필터 | 가로 스크롤 칩 바 |
| 작성/수정 폼 | 풀 너비, 툴바 축소 |
- FAB: "글 쓰기"
- 풀다운 리프레시: 글 목록 갱신
### 5-2. 블로그 마케팅 (BlogMarketing) — `/blog-lab`
| 영역 | 모바일 변경 |
|------|------------|
| 대시보드 지표 | 2컬럼 → 1컬럼 (≤480px) |
| 파이프라인 테이블 | 카드형 리스트 (상태 배지) |
| 키워드 분석 | 접이식 아코디언 |
| 수익 내역 | 가로 스크롤 테이블 |
- FAB: "키워드 분석"
- 풀다운 리프레시: 대시보드 갱신
### 5-3. 부동산 청약 (Subscription) — `/realestate`
| 영역 | 모바일 변경 |
|------|------------|
| 공고 목록 | 1컬럼 카드 리스트 |
| 필터 | 바텀시트 필터 패널 |
| 공고 상세 | 바텀시트 상세보기 |
| 매칭 결과 | 1컬럼, 점수 강조 |
| 대시보드 | 2컬럼 그리드 |
- FAB: "공고 등록"
- 풀다운 리프레시: 공고/매칭 갱신
### 5-4. 뮤직 스튜디오 (MusicStudio) — `/music`
| 영역 | 모바일 변경 |
|------|------------|
| 헤더 | 1컬럼, 타이틀 클램프 축소 |
| 생성 폼 | 풀 너비 스택 |
| 라이브러리 | 1컬럼 리스트 (앨범아트 + 제목) |
| 플레이어 | 미니 플레이어 바텀 고정 (높이 56px, 바텀 네비 위 = bottom: 64px) |
| 가사 에디터 | 풀 너비 |
| 레이더 위젯 | 중앙 정렬 |
- FAB: "음악 생성"
- 풀다운 리프레시: 라이브러리 갱신
- 미니 플레이어 표시 시 콘텐츠 padding-bottom: 바텀네비(64px) + 미니플레이어(56px) = 120px
### 5-5. TODO — `/todo`
| 영역 | 모바일 변경 |
|------|------------|
| 칸반 보드 | 스와이프 탭 (Todo/진행중/완료) |
| 할일 카드 | 스와이프로 상태 변경 |
| 입력 폼 | FAB → 바텀시트 입력 폼 |
- FAB: "할일 추가"
### 5-6. 에이전트 오피스 (AgentOffice) — `/agent-office`
| 영역 | 모바일 변경 |
|------|------------|
| 캔버스 오피스 | 풀스크린 캔버스, 핀치 줌/패닝 |
| 에이전트 패널 | 바텀시트 에이전트 상세 |
| 작업 로그 | 바텀시트 로그 뷰 |
| 명령 입력 | 하단 입력 바 (채팅 UX) |
| WebSocket 상태 | 탑바에 연결 상태 아이콘 |
### 5-7. 이펙트 랩 — `/lab`, `/lab/day-calc`, `/lab/sword-stream`
| 페이지 | 모바일 변경 |
|--------|------------|
| EffectLab 허브 | 카드 그리드 → 1컬럼 리스트 |
| DayCalc | 풀 너비 스택, 네이티브 날짜 피커 |
| SwordStream | 풀스크린 캔버스, 터치 인터랙션 유지, 오버레이 축소 |
---
## 6. 터치 타겟 가이드라인
- 모든 터치 타겟: 최소 44×44px (Apple HIG 기준)
- 버튼 간 간격: 최소 8px
- FAB 크기: 56×56px
- 바텀 네비 아이템: 최소 48×48px 터치 영역
---
## 7. 성능 고려사항
- 모바일에서 글로우/그라디언트 효과: box-shadow 개수 줄이기 (3중→1중)
- `background-attachment: fixed` → 모바일에서 `scroll` (현재 적용됨, 유지)
- 이미지: `loading="lazy"` 속성 확인
- 스와이프/터치 이벤트: passive listener 사용
- 바텀시트 애니메이션: `transform` + `will-change` 사용 (layout thrashing 방지)
- 신규 애니메이션(스와이프, 바텀시트, 풀다운)은 `prefers-reduced-motion: reduce` 쿼리 존중 — Travel.css, MusicStudio.css 기존 패턴과 통일
### 주의: Stock.css / StockTrade.jsx 커플링
`StockTrade.jsx``Stock.css`의 스타일을 공유한다. Stock.css의 반응형 수정은 StockTrade에도 영향을 미치므로, 반드시 두 페이지를 함께 검증해야 한다.
---
## 8. 구현 순서
### Phase 1: 글로벌 인프라
**Phase 1a: Breakpoint 정리 (기존 CSS만 수정, 신규 코드 없음)**
1. Breakpoint 시스템 통일 — 각 CSS 파일의 비표준 미디어 쿼리를 표준 값으로 정리
2. `index.html``viewport-fit=cover` 추가
3. 회귀 테스트: 정리 후 각 페이지 데스크톱/모바일 확인
**Phase 1b: 공통 컴포넌트 & 앱 셸**
4. `react-swipeable` 패키지 설치
5. `src/hooks/` 디렉토리 생성 + `useIsMobile`, `useSwipe` 훅 구현
6. `BottomNav` 컴포넌트 구현 + 사이드바 모바일 제거 마이그레이션 (Navbar.jsx/css 수정)
7. `PullToRefresh`, `SwipeableView`, `FAB`, `MobileSheet` 컴포넌트 구현
8. 앱 셸 레이아웃 수정 (App.jsx, App.css)
### Phase 2: 주요 페이지 적용
9. 홈 페이지 반응형 개선
10. 로또 페이지 반응형 개선
11. 주식 페이지 (Stock + StockTrade 함께 검증) 반응형 개선
12. 여행 페이지 반응형 개선
### Phase 3: 나머지 페이지 확장
13. 블로그 (`/blog`) + 블로그 마케팅 (`/blog-lab`)
14. 부동산 청약 (`/realestate`)
15. 뮤직 스튜디오 (`/music`)
16. TODO (`/todo`)
17. 에이전트 오피스 (`/agent-office`)
18. 이펙트 랩 (`/lab` + `/lab/day-calc` + `/lab/sword-stream`)
### Phase 4: 검증
19. 전체 뷰 모바일 UI 검증 — 대상 뷰포트: 360px (Galaxy S), 390px (iPhone 14), 768px (iPad), 1024px (데스크톱)
20. `prefers-reduced-motion` 동작 확인
21. 터치 타겟 크기 검증 (44×44px 최소)

View File

@@ -0,0 +1,313 @@
# Travel Gallery Redesign — Design Spec
## Goal
Travel 여행 기록 갤러리를 앨범 기반 진입 + Masonry 그리드 + HERO 확대 라이트박스로 리디자인한다. 모놀리식 1,024줄 컴포넌트를 7-8개 집중된 파일로 분리하고, 시네마틱 여행 감성을 강화한다.
## Scope
- **포함**: 프론트엔드 리디자인 (컴포넌트 분리 + 새 UX/UI)
- **포함**: 동영상 탭 UI 셸 (플레이스홀더)
- **제외**: 백엔드 동영상 API (별도 후속 스펙)
- **제외**: 핀치 줌 (복잡도 대비 효과 낮음)
## Architecture
점진적 리팩토링 — 기존 API 호출/캐싱/페이지네이션 로직을 `useTravelData` 훅으로 추출하여 재활용하고, UI 레이어를 새로 구성한다. 라우팅 변경 없이 React 상태 기반으로 앨범 진입/이탈을 관리한다.
## Tech Stack
- React 18 (기존)
- Leaflet + react-leaflet (기존, 미니맵으로 축소)
- react-swipeable (기존, 라이트박스 스와이프)
- SwipeableView 컴포넌트 (기존, 사진/영상 탭)
- CSS columns (Masonry 레이아웃)
- IntersectionObserver (무한스크롤 + 스크롤 리빌)
- Web Animations API / CSS transitions (shared element transition)
---
## 1. Component Structure & File Layout
```
src/pages/travel/
├── Travel.jsx # 메인 컨테이너 (미니맵 + 앨범 카드 리스트)
├── Travel.css # 전체 레이아웃 + CSS 변수
├── AlbumCard.jsx # 여행지 앨범 카드
├── AlbumCard.css
├── AlbumDetail.jsx # 앨범 상세 (탭 + Masonry)
├── AlbumDetail.css
├── MasonryGrid.jsx # Masonry 레이아웃 + 무한스크롤
├── MasonryGrid.css
├── HeroLightbox.jsx # HERO 확대 전환 라이트박스
├── HeroLightbox.css
├── MiniMap.jsx # Leaflet 미니맵
├── MiniMap.css
├── VideoTab.jsx # 영상 탭 UI 셸
├── VideoTab.css
└── useTravelData.js # API 호출 + 캐싱 + 페이지네이션 훅
```
### Responsibilities
| 파일 | 책임 |
|------|------|
| `Travel.jsx` | 페이지 레이아웃, 지역 필터 상태, 앨범 선택 상태 관리 |
| `useTravelData.js` | API fetch, 10분 TTL 캐시, 앨범별 그룹핑, 페이지네이션 |
| `MiniMap.jsx` | Leaflet 지도 렌더링, GeoJSON 폴리곤, 지역 클릭 이벤트 발행 |
| `AlbumCard.jsx` | 대표 사진 + 앨범명 + 사진 수 뱃지, 호버 효과 |
| `AlbumDetail.jsx` | 앨범 오버레이, 진입/이탈 애니메이션, 사진/영상 탭 전환 |
| `MasonryGrid.jsx` | CSS columns Masonry, IntersectionObserver 무한스크롤 + 스크롤 리빌 |
| `HeroLightbox.jsx` | shared element transition, 좌우 스와이프, 썸네일 스트립 |
| `VideoTab.jsx` | "영상 기능 준비 중" 플레이스홀더 |
### Page Flow
```
Travel.jsx (메인)
├── MiniMap (상단, 접기/펼치기 가능)
│ └── 지역 클릭 → selectedRegion 상태 변경 → 앨범 필터
├── AlbumCard[] (여행지 카드 리스트)
│ └── 클릭 → AlbumDetail (오버레이)
│ ├── [사진 탭] MasonryGrid
│ │ └── 사진 클릭 → HeroLightbox
│ └── [영상 탭] VideoTab
└── useTravelData (데이터 레이어)
```
---
## 2. Main View — MiniMap + Album Card List
### MiniMap
- 높이: 데스크톱 200px, 모바일 150px
- GeoJSON 지역 폴리곤 유지 (기존 MapLayer 로직 추출)
- 클릭 시 해당 지역 앨범만 필터링
- 선택된 지역: 지역별 악센트 컬러로 하이라이트
- "전체 보기" 버튼으로 필터 해제
- 접기/펼치기 토글 (기본: 펼침)
- 접힌 상태: 높이 0 + overflow hidden, 토글 버튼만 표시
### Album Card List
- **카드 구성**: 대표 사진 배경 (object-fit: cover) + 앨범 이름 + 사진 수 뱃지
- **대표 사진**: 앨범 첫 번째 사진의 썸네일 URL
- **카드 레이아웃**: `display: grid`
- 데스크톱 (>1024px): 3열
- 태블릿 (769px-1024px): 2열
- 모바일 (<=768px): 1열
- **카드 높이**: 데스크톱 240px, 모바일 200px
- **호버**: scale(1.03) + 지역 악센트 글로우
- **지역 필터 전환**: fade 애니메이션 (opacity 300ms)
### Album Data Grouping
백엔드 API 변경 없이 프론트에서 처리:
1. 각 region에 대해 `GET /api/travel/photos?region={id}&page=1&size=1` 호출
2. 응답의 `total` 필드로 사진 수 확보, `items[0]`으로 대표 사진 확보
3. region_map.json의 albums 목록에서 앨범명 추출
4. 기존 10분 TTL 캐시 로직 재활용
---
## 3. Album Detail — Masonry Grid + Tabs + Transitions
### Entry Animation (Shared Element Transition)
1. 앨범 카드 클릭 시 `getBoundingClientRect()`로 카드 시작 위치 캡처
2. 카드 clone을 `position: fixed`로 생성
3. clone을 `inset: 0` (풀스크린)으로 animate (400ms, cubic-bezier(0.4, 0, 0.2, 1))
4. 애니메이션 완료 → clone 제거, AlbumDetail 오버레이 표시
### Exit Animation
1. 뒤로가기/닫기 클릭
2. AlbumDetail을 숨기고, 원래 카드 위치로 역재생 (400ms)
3. 애니메이션 완료 → 앨범 카드 리스트로 복귀
### Photo/Video Tabs
- 앨범 상세 상단에 "사진 | 영상" 탭 바
- 기존 `SwipeableView` 컴포넌트 재활용 (모바일 스와이프 전환)
- 영상 탭: VideoTab 컴포넌트 (플레이스홀더)
### Masonry Grid (Photo Tab)
- **레이아웃**: CSS `column-count` 기반
- 데스크톱 (>1024px): 4열
- 태블릿 (769px-1024px): 3열
- 모바일 (<=768px): 2열
- **사진 비율**: 원본 유지 (`width: 100%`, `height: auto`)
- **갭**: `column-gap: 8px`, 각 사진 `margin-bottom: 8px`
- **break-inside**: `avoid` (사진이 컬럼 경계에 걸리지 않도록)
- **무한 스크롤**: IntersectionObserver 센티널, rootMargin 300px, page size 20
- **스크롤 리빌**: 뷰포트 진입 시 아래에서 20px 올라오며 fade-in, 사진마다 50ms 지연
- **lazy loading**: `loading="lazy"` 속성, 첫 8장은 `loading="eager"`
### Video Tab (Shell)
- 중앙 정렬된 비디오 아이콘 + "영상 기능 준비 중" 텍스트
- 앰버 톤 텍스트, 세리프 폰트
- 백엔드 동영상 API 완성 시 이 컴포넌트 내부만 교체
---
## 4. HERO Lightbox
### Shared Element Transition (Photo → Fullscreen)
1. Masonry에서 사진 클릭 → `getBoundingClientRect()`로 시작 위치 캡처
2. 사진 clone을 `position: fixed`로 생성
3. clone을 화면 중앙 + 최대 크기로 animate (350ms, cubic-bezier(0.4, 0, 0.2, 1))
4. 애니메이션 완료 → clone 제거, 라이트박스 UI 표시
5. 배경은 `#000` opacity 0→1 동시 전환
### Fullscreen Viewer
- **배경**: 순수 블랙 `#000`, z-index 3000
- **사진**: `max-width: 100%`, `max-height: calc(100vh - 140px)`, `object-fit: contain`
- **좌우 탐색**:
- 데스크톱: 좌우 화살표 버튼 (hover 시 표시)
- 모바일: react-swipeable로 좌우 스와이프
- 키보드: ArrowLeft/ArrowRight
- **하단 썸네일 스트립**:
- 높이 68px, 썸네일 52x52px
- 활성 썸네일: 앰버 테두리 (2px solid)
- 활성 썸네일 자동 센터링 (smooth scroll)
- 필름 퍼포레이션 장식 제거 (간소화)
- **메타 정보**: 사진 위 또는 아래에 앨범명 + 파일명 (앰버 텍스트, 14px)
- **닫기**:
- X 버튼 (우상단)
- 아래로 스와이프 (모바일, threshold 100px)
- ESC 키
- 닫기 시 역재생 transition → 원래 그리드 위치로 복귀
### Slide Animation (이전/다음)
- 좌우 전환 시 현재 사진이 나가고 새 사진이 들어오는 slide 애니메이션
- 280ms, cubic-bezier(0.25, 0.46, 0.45, 0.94)
- 방향에 따라 왼쪽/오른쪽에서 진입
---
## 5. Visual Design — Cinematic Travel Aesthetic
### Color System
- **베이스 배경**: `#0f0c09` (깊은 다크)
- **베이스 텍스트**: `#f5e6c8` (따뜻한 앰버)
- **뮤트 텍스트**: `rgba(245,230,200,0.5)`
- **라인/테두리**: `rgba(245,230,200,0.08)`
- **지역별 악센트**:
- 일본: `#c73e1d` (주홍)
- 유럽: `#2563eb` (코발트)
- 동남아: `#059669` (에메랄드)
- 국내: `#d97706` (호박)
- 기타: 기본 앰버 `#d4a574`
- 악센트 적용: 앨범 카드 호버 글로우, 미니맵 지역 하이라이트, 탭 활성 상태
### Typography
- **제목/앨범명**: `Cormorant Garamond`, serif (기존 유지)
- **메타 정보/뱃지**: `Space Mono`, monospace (기존 유지)
- **앨범 카드 제목**: 데스크톱 24px, 모바일 18px
- **사진 수 뱃지**: 11px 모노, `rgba(15,12,9,0.7)` 배경 위 앰버 텍스트
### Album Card Visual
- 대표 사진 위 하단 30% 그라디언트: `linear-gradient(transparent, rgba(15,12,9,0.85))`
- 그라디언트 위에 앨범명 + 사진 수
- `border-radius: 12px`
- `border: 1px solid rgba(245,230,200,0.08)`
- 호버: `box-shadow: 0 0 20px rgba({accent}, 0.15)` + `transform: scale(1.03)`
### Masonry Photo Style
- `border-radius: 4px`
- 호버: `filter: brightness(1.08)` + `cursor: zoom-in`
- 스크롤 리빌: translateY(20px) + opacity(0) → translateY(0) + opacity(1), 사진마다 50ms 지연
### Lightbox Visual
- 배경: `#000`
- 메타 텍스트: 앰버 `#f5e6c8`, 세리프 폰트, 14px
- 썸네일 스트립: 활성 아이템에 앰버 2px 테두리
- 카운터: "3 / 156" 형태, 우상단, 모노스페이스
---
## 6. Responsive Design
### Breakpoints
| 구간 | 앨범 카드 | Masonry 열 | 미니맵 높이 |
|------|----------|-----------|-----------|
| >1024px | 3열 | 4열 | 200px |
| 769-1024px | 2열 | 3열 | 200px |
| <=768px | 1열 | 2열 | 150px |
### Mobile Specifics
- 앨범 상세: `position: fixed; inset: 0` (풀스크린 오버레이)
- 라이트박스: 100dvh, 화살표 버튼 숨김 (스와이프로 대체)
- 미니맵: 기본 접힘 (모바일에서 공간 절약)
- 하단 네비게이션 고려: `padding-bottom: calc(var(--bottom-nav-h) + var(--safe-area-bottom))`
---
## 7. Reduced Motion
`prefers-reduced-motion: reduce` 적용 시:
- shared element transition (앨범 진입/이탈, 라이트박스 열기/닫기) → 즉시 fade (opacity 0→1, 150ms)
- 스크롤 리빌 애니메이션 → 즉시 표시 (opacity 1, transform none)
- 카드 호버 scale → 없음 (색상 변화만 유지)
- 슬라이드 전환 → 즉시 교체 (fade)
- 미니맵 접기/펼치기 → 즉시 전환
---
## 8. Data Flow
```
useTravelData hook
├── fetchRegions() → GET /api/travel/regions
├── fetchAlbums(region?) → GET /api/travel/photos?region={id}&page=1&size=1 (per region)
├── fetchPhotos(region, page) → GET /api/travel/photos?region={id}&page={n}&size=20
└── cache (Map, 10min TTL) → 기존 캐시 로직 재활용
State:
- regions: GeoJSON[]
- albums: { id, name, region, coverThumb, totalPhotos }[]
- selectedRegion: string | null
- selectedAlbum: string | null
- photos: Photo[]
- page, hasNext, loading, loadingMore
```
### API Contract (기존 유지, 변경 없음)
```
GET /api/travel/regions
→ GeoJSON FeatureCollection
GET /api/travel/photos?region=japan&page=1&size=20
→ { region, page, size, total, has_next, items: [{ album, file, url, thumb, mtime }] }
POST /api/travel/reload
→ { status: "ok" }
```
---
## 9. Performance Considerations
- **앨범 카드 대표 사진**: page=1&size=1로 최소 데이터만 요청
- **Masonry 이미지**: 썸네일(480x480) 사용, 라이트박스에서만 원본 로드
- **무한 스크롤**: 20개씩 점진적 로드, rootMargin 300px 선제 로드
- **lazy loading**: 브라우저 네이티브 `loading="lazy"`
- **캐시**: 10분 TTL, 리전 단위
- **스크롤 리빌**: IntersectionObserver 단일 인스턴스로 배치 감시
- **shared element transition**: `will-change: transform` 적용, 합성 레이어로 GPU 가속

View File

@@ -0,0 +1,203 @@
# Travel-Proxy 성능 개선 설계
## 목표
travel-proxy의 파일 스캔 기반 아키텍처를 SQLite 인덱스 DB로 전환하여 수천 장의 사진을 무난하게 처리하고, 앨범 커버 지정 + 썸네일 사전 생성을 지원한다.
## 배경
현재 travel-proxy는 `os.scandir`으로 NAS 폴더를 매번 스캔하고, 메모리 캐시(TTL 300초)로 결과를 보관한다. 사진 수백 장에서는 문제없지만, 수천 장이면:
- 캐시 만료 시 1~2초 스캔 지연
- 콜드 스타트(컨테이너 재시작) 시 첫 요청 느림
- 전체 리스트를 메모리에 상주
- 썸네일이 첫 요청 시 동기 생성되어 초기 로딩 지연
## 아키텍처
### 변경 전
```
API 요청 → os.scandir(폴더) → 메모리 캐시 → 슬라이싱 페이지네이션
썸네일 온디맨드 생성 (Pillow)
```
### 변경 후
```
수동 sync 버튼 → 폴더 스캔 → travel.db 동기화 + 썸네일 사전 생성
API 요청 → SQLite 쿼리 (인덱스) → 페이지네이션
```
### 파일 구조
| 파일 | 역할 |
|------|------|
| `main.py` | FastAPI 라우트 (기존 + 신규) |
| `db.py` (신규) | SQLite 스키마 정의, 쿼리 헬퍼 |
| `indexer.py` (신규) | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
기존 `main.py``scan_album`, `ensure_thumb`, 메모리 캐시 로직이 `indexer.py``db.py`로 이동하고, `main.py`는 라우트만 남는다.
## DB 스키마
```sql
CREATE TABLE 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,
UNIQUE(album, filename)
);
CREATE INDEX idx_photos_album ON photos(album);
CREATE TABLE album_covers (
album TEXT PRIMARY KEY,
filename TEXT NOT NULL,
updated_at TEXT NOT NULL
);
```
### 설계 포인트
- `photos` 테이블에 URL/thumb 경로를 저장하지 않음 — 런타임에 `MEDIA_BASE` + album + filename으로 조합 (환경변수 변경에 유연)
- `mtime`으로 변경 감지 — 동기화 시 파일이 삭제됐거나 mtime이 바뀌면 갱신
- `album_covers`가 비어있으면 해당 앨범의 첫 번째 사진이 자동 커버
## API 설계
### 기존 API 변경
| 엔드포인트 | 변경 내용 |
|-----------|----------|
| `GET /api/travel/photos` | 내부 로직만 변경 (os.scandir → DB 쿼리). 응답 형식 동일 |
| `GET /api/travel/regions` | 변경 없음 |
| `POST /api/travel/reload` | 제거 (sync로 대체) |
| `GET /media/travel/.thumb/{album}/{filename}` | 유지 — 동기화 시 이미 썸네일 생성되므로 Pillow 호출 빈도 대폭 감소. 미생성 분 폴백으로 온디맨드 생성 유지 |
### 신규 API
| 메서드 | 경로 | 설명 |
|--------|------|------|
| `POST` | `/api/travel/sync` | 폴더 스캔 → DB 동기화 + 썸네일 생성 |
| `GET` | `/api/travel/albums` | 앨범 목록 + 사진 수 + 커버 정보 |
| `PUT` | `/api/travel/albums/{album}/cover` | 앨범 커버 지정 |
### POST /api/travel/sync
폴더를 스캔하여 DB와 동기화하고, 미생성 썸네일을 일괄 생성한다.
**요청**: 바디 없음
**응답**:
```json
{
"added": 42,
"removed": 3,
"thumbs_generated": 42,
"duration_sec": 12.5
}
```
**동기 실행** — 수동 트리거이므로 BackgroundTask 불필요, 응답에 결과 포함.
### GET /api/travel/albums
앨범 목록과 각 앨범의 사진 수, 커버 정보를 반환한다.
**응답**:
```json
[
{
"album": "오사카",
"count": 342,
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
}
]
```
커버가 지정되지 않은 앨범은 첫 번째 사진(album + filename 정렬 기준)이 자동 커버.
### PUT /api/travel/albums/{album}/cover
특정 사진을 앨범 커버로 지정한다.
**요청**:
```json
{
"filename": "IMG_3281.jpg"
}
```
**응답**:
```json
{
"album": "오사카",
"filename": "IMG_3281.jpg",
"cover_url": "/media/travel/오사카/IMG_3281.jpg",
"cover_thumb": "/media/travel/.thumb/오사카/IMG_3281.jpg"
}
```
**검증**: 해당 album + filename 조합이 photos 테이블에 존재하는지 확인. 없으면 404.
## 동기화 로직 (indexer.py)
### sync 프로세스
1. `region_map.json`에서 전체 앨범 폴더 목록 수집
2. 각 폴더 `os.scandir``{album, filename, mtime}` 세트 수집
3. DB와 비교:
- DB에 없는 파일 → INSERT (`added`)
- DB에 있지만 폴더에 없는 파일 → DELETE (`removed`)
- mtime이 다른 파일 → UPDATE + `has_thumb=0` (변경됨)
4. `has_thumb=0`인 파일 → 썸네일 생성 → `has_thumb=1`로 갱신
5. 결과 반환: `{added, removed, thumbs_generated, duration_sec}`
### 삭제된 커버 처리
커버로 지정된 사진이 폴더에서 삭제되면 `album_covers`에서도 제거 → 자동으로 첫 번째 사진 폴백.
### 성능
- NAS Celeron J4025 기준, 2,000장 최초 동기화 + 썸네일 생성 예상: 3~5분
- 이후 동기화는 변경분만 처리 → 수초 이내
## 앨범 커버 지정 UX
프론트엔드 앨범 상세 페이지에서 사진을 길게 누르거나 우클릭 → "커버로 설정" 메뉴. `PUT /api/travel/albums/{album}/cover` 호출.
프론트엔드 변경은 이 스펙 범위 밖 — 백엔드 API만 제공하고, 프론트 연동은 별도 작업.
## 기존 API 호환성
- `GET /api/travel/photos` 응답 형식 (`items`, `total`, `has_next`, `matched_albums`) 완전히 유지
- 프론트엔드 `useTravelData` 훅은 수정 없이 동작
- `GET /api/travel/albums`는 선택적 개선용 — 프론트가 앨범 카드 커버를 표시할 때 활용
## Docker 변경
- `travel.db` 저장 위치: 썸네일 볼륨 내 `/data/thumbs/travel.db` (추가 볼륨 불필요)
- `requirements.txt``aiosqlite` 추가 불필요 — 동기 sqlite3 표준 라이브러리 사용
- Dockerfile 변경 없음
### docker-compose.yml 변경
기존 볼륨에 DB를 함께 저장하므로 추가 볼륨 불필요:
```yaml
volumes:
- ${PHOTO_PATH}:/data/travel:ro
- ${RUNTIME_PATH}\travel-thumbs:/data/thumbs:rw # travel.db도 여기에 저장
```
## 제거되는 코드
- `main.py``CACHE`, `CACHE_TTL`, `META_MTIME_CACHE` 딕셔너리 및 관련 로직
- `main.py``scan_album()` 함수 (indexer.py로 이동)
- `main.py``ensure_thumb()` 함수 (indexer.py로 이동, 온디맨드 폴백은 유지)
- `POST /api/travel/reload` 엔드포인트 (sync로 대체)

View File

@@ -91,6 +91,7 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 600s;
proxy_pass http://travel-proxy:8000/api/travel/;
}

View File

233
travel-proxy/app/db.py Normal file
View File

@@ -0,0 +1,233 @@
import os
import sqlite3
from typing import Any, Dict, List, Optional, Set
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 = sum(r["cnt"] for r in rows)
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[str]) -> 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),
)
def batch_sync_album(album: str, items: List[Dict[str, Any]], existing_filenames: Set[str]) -> Dict[str, int]:
"""앨범 단위 배치 동기화. 단일 커넥션으로 upsert + 삭제 처리."""
added = updated = 0
with _conn() as conn:
for item in items:
existing = conn.execute(
"SELECT mtime FROM photos WHERE album = ? AND filename = ?",
(album, item["filename"]),
).fetchone()
if not existing:
conn.execute(
"INSERT INTO photos (album, filename, mtime, has_thumb) VALUES (?, ?, ?, 0)",
(album, item["filename"], item["mtime"]),
)
added += 1
elif existing["mtime"] != item["mtime"]:
conn.execute(
"UPDATE photos SET mtime = ?, has_thumb = 0 WHERE album = ? AND filename = ?",
(item["mtime"], album, item["filename"]),
)
updated += 1
# 삭제 처리
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
removed = len(to_remove)
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 {"added": added, "updated": updated, "removed": removed}
def batch_mark_thumbs_done(items: List[Dict[str, str]]) -> None:
"""썸네일 생성 완료 배치 표시."""
if not items:
return
with _conn() as conn:
for item in items:
conn.execute(
"UPDATE photos SET has_thumb = 1 WHERE album = ? AND filename = ?",
(item["album"], item["filename"]),
)

136
travel-proxy/app/indexer.py Normal file
View File

@@ -0,0 +1,136 @@
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:
try:
mtime = entry.stat().st_mtime
except OSError as e:
logger.warning("Cannot stat %s: %s", entry.path, e)
continue
items.append({
"filename": entry.name,
"mtime": 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에서 전체 앨범 폴더 수집
try:
with open(region_map_path, "r", encoding="utf-8") as f:
region_map = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error("Failed to load region_map: %s", e)
raise
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
updated = 0
removed = 0
for album in sorted(all_albums):
folder = travel_root / album
items = _scan_folder(folder)
existing_filenames = {item["filename"] for item in items}
result = db.batch_sync_album(album, items, existing_filenames)
added += result["added"]
updated += result["updated"]
removed += result["removed"]
# 3. 썸네일 미생성 분 일괄 생성
no_thumb = db.get_photos_without_thumb()
thumbs_generated = 0
thumb_done_batch = []
for photo in no_thumb:
src = travel_root / photo["album"] / photo["filename"]
dest = thumb_root / photo["album"] / photo["filename"]
if _generate_thumb(src, dest):
thumb_done_batch.append(photo)
thumbs_generated += 1
db.batch_mark_thumbs_done(thumb_done_batch)
duration = round(time.time() - start, 2)
logger.info(
"Sync complete: added=%d updated=%d removed=%d thumbs=%d duration=%.2fs",
added, updated, removed, thumbs_generated, duration,
)
return {
"added": added,
"updated": updated,
"removed": removed,
"thumbs_generated": thumbs_generated,
"duration_sec": duration,
}

View File

@@ -1,14 +1,19 @@
import os
import json
import time
import logging
from pathlib import Path
from typing import Dict, Any, List
from typing import Any, List
from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
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, mark_thumb_done
from .indexer import sync
logger = logging.getLogger(__name__)
app = FastAPI()
# -----------------------------
@@ -26,32 +31,16 @@ THUMB_SIZE = (480, 480)
THUMB_ROOT.mkdir(parents=True, exist_ok=True)
# 썸네일 정적 서빙
app.mount(
f"{MEDIA_BASE}/.thumb",
StaticFiles(directory=THUMB_ROOT),
name="travel-thumbs",
)
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".webp"}
# -----------------------------
# Cache
# DB init
# -----------------------------
CACHE: Dict[str, Dict[str, Any]] = {}
CACHE_TTL = int(os.getenv("TRAVEL_CACHE_TTL", "300"))
META_MTIME_CACHE: Dict[str, float] = {}
init_db()
# -----------------------------
# Helpers
# -----------------------------
def _file_mtime(p: Path) -> float:
try:
return p.stat().st_mtime
except FileNotFoundError:
return 0.0
def _read_json(path: Path) -> Any:
if not path.exists():
raise HTTPException(500, f"Missing required file: {path}")
@@ -67,57 +56,27 @@ def load_regions_geojson() -> dict:
return _read_json(REGIONS_GEOJSON_PATH)
def _meta_changed_invalidate_cache():
cur = _file_mtime(REGION_MAP_PATH) + _file_mtime(REGIONS_GEOJSON_PATH)
if META_MTIME_CACHE.get("meta") != cur:
CACHE.clear()
META_MTIME_CACHE["meta"] = cur
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 _thumb_path_for(src: Path, album: str) -> Path:
"""
썸네일 저장 위치 결정.
- TRAVEL_THUMB_ROOT가 있으면: THUMB_ROOT/album/<filename>
- 없으면: 원본 폴더 album/.thumb/<filename>
"""
if THUMB_ROOT:
base = THUMB_ROOT / album
base.mkdir(parents=True, exist_ok=True)
return base / src.name
thumb_dir = src.parent / ".thumb"
thumb_dir.mkdir(exist_ok=True)
return thumb_dir / src.name
def ensure_thumb(src: Path, album: str) -> Path:
out = _thumb_path_for(src, album) # THUMB_ROOT/album/<filename> or album/.thumb/<filename>
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)
# ✅ 확장자 유지: IMG_3281.tmp.JPG (끝이 .JPG로 끝나게)
tmp = out.with_name(out.stem + ".tmp" + out.suffix)
try:
with Image.open(src) as im:
im.thumbnail(THUMB_SIZE)
# ✅ 확장자 기준으로 포맷 명시 (대문자 .JPG도 대응)
ext = out.suffix.lower()
if ext in (".jpg", ".jpeg"):
fmt = "JPEG"
@@ -126,44 +85,25 @@ def ensure_thumb(src: Path, album: str) -> Path:
elif ext == ".webp":
fmt = "WEBP"
else:
# 혹시 모를 경우: Pillow가 읽은 포맷을 사용
fmt = (im.format or "").upper() or "JPEG"
im.save(tmp, format=fmt, quality=85, optimize=True)
# ✅ 생성 완료 후 교체
tmp.replace(out)
mark_thumb_done(album, src.name)
return out
finally:
# 실패 시 tmp 찌꺼기 정리
try:
if tmp.exists():
tmp.unlink()
except Exception:
pass
def scan_album(album: str) -> List[Dict[str, Any]]:
album_dir = ROOT / album
if not album_dir.exists():
return []
items = []
# os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴)
with os.scandir(album_dir) as entries:
for entry in entries:
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
# ⚡ 성능 핵심: 여기서 썸네일 생성(ensure_thumb) 절대 하지 않음!
# 파일 존재 여부만 확인하고 바로 리턴
items.append({
"album": album,
"file": entry.name,
"url": f"{MEDIA_BASE}/{album}/{entry.name}",
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{entry.name}",
# 정렬을 위해 수정시간/이름 필요하면 여기서 저장
"mtime": entry.stat().st_mtime
})
return items
# -----------------------------
# Models
# -----------------------------
class CoverRequest(BaseModel):
filename: str
# -----------------------------
# Routes
@@ -172,18 +112,11 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
def health():
return {"status": "healthy", "service": "travel-proxy"}
@app.get("/api/travel/regions")
def regions():
_meta_changed_invalidate_cache()
return load_regions_geojson()
@app.post("/api/travel/reload")
def reload_cache():
"""강제로 캐시를 비워서 새로고침"""
CACHE.clear()
META_MTIME_CACHE.clear()
return {"ok": True, "message": "Cache cleared"}
@app.get("/api/travel/photos")
def photos(
@@ -191,53 +124,67 @@ def photos(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
):
_meta_changed_invalidate_cache()
region_map = load_region_map()
albums = _get_albums_for_region(region, region_map)
result = get_photos_by_region(albums, page, size)
# 1. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략)
# 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문.
now = time.time()
cached = CACHE.get(region)
if not cached or now - cached["ts"] >= CACHE_TTL:
region_map = load_region_map()
albums = _get_albums_for_region(region, region_map)
all_items = []
matched = []
for album in albums:
items = scan_album(album)
matched.append({"album": album, "count": len(items)})
all_items.extend(items)
# 정렬: 앨범명 > 파일명 (또는 찍은 날짜)
all_items.sort(key=lambda x: (x["album"], x["file"]))
cached_data = {
"region": region,
"matched_albums": matched,
"items": all_items,
"total": len(all_items),
}
CACHE[region] = {"ts": now, "data": cached_data}
else:
cached_data = cached["data"]
# 2. 페이지네이션 슬라이싱
all_items = cached_data["items"]
total = len(all_items)
start = (page - 1) * size
end = start + size
paged_items = all_items[start:end]
# 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": total,
"has_next": end < total,
"items": paged_items,
"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}",
}
@@ -245,19 +192,17 @@ def photos(
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(src, album)
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():
import os
return {"version": os.getenv("APP_VERSION", "dev")}