diff --git a/docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md b/docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md new file mode 100644 index 0000000..16f9d7e --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md @@ -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로 대체)