6.5 KiB
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 스키마
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와 동기화하고, 미생성 썸네일을 일괄 생성한다.
요청: 바디 없음
응답:
{
"added": 42,
"removed": 3,
"thumbs_generated": 42,
"duration_sec": 12.5
}
동기 실행 — 수동 트리거이므로 BackgroundTask 불필요, 응답에 결과 포함.
GET /api/travel/albums
앨범 목록과 각 앨범의 사진 수, 커버 정보를 반환한다.
응답:
[
{
"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
특정 사진을 앨범 커버로 지정한다.
요청:
{
"filename": "IMG_3281.jpg"
}
응답:
{
"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 프로세스
region_map.json에서 전체 앨범 폴더 목록 수집- 각 폴더
os.scandir→{album, filename, mtime}세트 수집 - DB와 비교:
- DB에 없는 파일 → INSERT (
added) - DB에 있지만 폴더에 없는 파일 → DELETE (
removed) - mtime이 다른 파일 → UPDATE +
has_thumb=0(변경됨)
- DB에 없는 파일 → INSERT (
has_thumb=0인 파일 → 썸네일 생성 →has_thumb=1로 갱신- 결과 반환:
{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를 함께 저장하므로 추가 볼륨 불필요:
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로 대체)