Files
web-page-backend/docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md

204 lines
6.5 KiB
Markdown

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