docs: travel-proxy 성능 개선 설계 — SQLite 인덱스 DB + 앨범 커버 + 썸네일 사전 생성
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
203
docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
Normal file
203
docs/superpowers/specs/2026-04-24-travel-proxy-perf-design.md
Normal 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로 대체)
|
||||
Reference in New Issue
Block a user