# 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로 대체)