From 34e0cada48c87b0202c5a0f03f2cf79ce5029291 Mon Sep 17 00:00:00 2001 From: Dohyun Lim Date: Tue, 10 Feb 2026 14:58:45 +0900 Subject: [PATCH] update get_videos --- app/archive/api/routers/v1/archive.py | 107 ++-------- get_videos_업데이트_설계서.md | 291 ++++++++++++++++++++++++++ 2 files changed, 314 insertions(+), 84 deletions(-) create mode 100644 get_videos_업데이트_설계서.md diff --git a/app/archive/api/routers/v1/archive.py b/app/archive/api/routers/v1/archive.py index 48eaefb..3885712 100644 --- a/app/archive/api/routers/v1/archive.py +++ b/app/archive/api/routers/v1/archive.py @@ -53,7 +53,7 @@ GET /archive/videos/?page=1&page_size=10 ## 참고 - **본인이 소유한 프로젝트의 영상만 반환됩니다.** - status가 'completed'인 영상만 반환됩니다. -- 재생성된 영상 포함 모든 영상이 반환됩니다. +- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다. - created_at 기준 내림차순 정렬됩니다. """, response_model=PaginatedResponse[VideoListItem], @@ -70,112 +70,50 @@ async def get_videos( ) -> PaginatedResponse[VideoListItem]: """완료된 영상 목록을 페이지네이션하여 반환합니다.""" logger.info( - f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}" + f"[get_videos] START - user: {current_user.user_uuid}, " + f"page: {pagination.page}, page_size: {pagination.page_size}" ) - logger.debug(f"[get_videos] current_user.user_uuid: {current_user.user_uuid}") try: offset = (pagination.page - 1) * pagination.page_size - # DEBUG: 각 조건별 데이터 수 확인 - # 1) 전체 Video 수 - all_videos_result = await session.execute(select(func.count(Video.id))) - all_videos_count = all_videos_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - 전체 Video 수: {all_videos_count}") - - # 2) completed 상태 Video 수 - completed_videos_result = await session.execute( - select(func.count(Video.id)).where(Video.status == "completed") - ) - completed_videos_count = completed_videos_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - completed 상태 Video 수: {completed_videos_count}") - - # 3) is_deleted=False인 Video 수 - not_deleted_videos_result = await session.execute( - select(func.count(Video.id)).where(Video.is_deleted == False) - ) - not_deleted_videos_count = not_deleted_videos_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - is_deleted=False Video 수: {not_deleted_videos_count}") - - # 4) 전체 Project 수 및 user_uuid 값 확인 - all_projects_result = await session.execute( - select(Project.id, Project.user_uuid, Project.is_deleted) - ) - all_projects = all_projects_result.all() - logger.debug(f"[get_videos] DEBUG - 전체 Project 수: {len(all_projects)}") - for p in all_projects: - logger.debug( - f"[get_videos] DEBUG - Project: id={p.id}, user_uuid={p.user_uuid}, " - f"user_uuid_type={type(p.user_uuid)}, is_deleted={p.is_deleted}" - ) - - # 4-1) 현재 사용자 UUID 타입 확인 - logger.debug( - f"[get_videos] DEBUG - current_user.user_uuid={current_user.user_uuid}, " - f"type={type(current_user.user_uuid)}" - ) - - # 4-2) 현재 사용자 소유 Project 수 - user_projects_result = await session.execute( - select(func.count(Project.id)).where( - Project.user_uuid == current_user.user_uuid, - Project.is_deleted == False, - ) - ) - user_projects_count = user_projects_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - 현재 사용자 소유 Project 수: {user_projects_count}") - - # 5) 현재 사용자 소유 + completed + 미삭제 Video 수 - user_completed_videos_result = await session.execute( - select(func.count(Video.id)) + # 서브쿼리: task_id별 최신 Video ID 추출 + # id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치 + latest_video_ids = ( + select(func.max(Video.id).label("latest_id")) .join(Project, Video.project_id == Project.id) .where( Project.user_uuid == current_user.user_uuid, Video.status == "completed", - Video.is_deleted == False, - Project.is_deleted == False, + Video.is_deleted == False, # noqa: E712 + Project.is_deleted == False, # noqa: E712 ) - ) - user_completed_videos_count = user_completed_videos_result.scalar() or 0 - logger.debug( - f"[get_videos] DEBUG - 현재 사용자 소유 + completed + 미삭제 Video 수: {user_completed_videos_count}" + .group_by(Video.task_id) + .subquery() ) - # 기본 조건: 현재 사용자 소유, completed 상태, 미삭제 - base_conditions = [ - Project.user_uuid == current_user.user_uuid, - Video.status == "completed", - Video.is_deleted == False, - Project.is_deleted == False, - ] - - # 쿼리 1: 전체 개수 조회 (모든 영상) - count_query = ( - select(func.count(Video.id)) - .join(Project, Video.project_id == Project.id) - .where(*base_conditions) + # 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만) + count_query = select(func.count(Video.id)).where( + Video.id.in_(select(latest_video_ids.c.latest_id)) ) total_result = await session.execute(count_query) total = total_result.scalar() or 0 - logger.debug(f"[get_videos] DEBUG - 전체 영상 개수 (total): {total}") - # 쿼리 2: Video + Project 데이터를 JOIN으로 한 번에 조회 (모든 영상) - query = ( + # 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만) + data_query = ( select(Video, Project) .join(Project, Video.project_id == Project.id) - .where(*base_conditions) + .where(Video.id.in_(select(latest_video_ids.c.latest_id))) .order_by(Video.created_at.desc()) .offset(offset) .limit(pagination.page_size) ) - result = await session.execute(query) + result = await session.execute(data_query) rows = result.all() - logger.debug(f"[get_videos] DEBUG - 최종 조회 결과 수: {len(rows)}") - # VideoListItem으로 변환 (JOIN 결과에서 바로 추출) - items = [] - for video, project in rows: - item = VideoListItem( + # VideoListItem으로 변환 + items = [ + VideoListItem( video_id=video.id, store_name=project.store_name, region=project.region, @@ -183,7 +121,8 @@ async def get_videos( result_movie_url=video.result_movie_url, created_at=video.created_at, ) - items.append(item) + for video, project in rows + ] response = PaginatedResponse.create( items=items, diff --git a/get_videos_업데이트_설계서.md b/get_videos_업데이트_설계서.md new file mode 100644 index 0000000..f8773a4 --- /dev/null +++ b/get_videos_업데이트_설계서.md @@ -0,0 +1,291 @@ +# 📋 설계 문서: get_videos 엔드포인트 업데이트 + +## 1. 요구사항 요약 + +### 기능적 요구사항 +| # | 요구사항 | 현재 상태 | 변경 | +|---|---------|----------|------| +| 1 | current_user 소유 프로젝트의 영상만 반환 | 구현됨 | 유지 | +| 2 | status='completed', is_deleted=False 필터 | 구현됨 | 유지 | +| 3 | 동일 task_id 중 created_at 최신 영상 1개만 반환 | 미구현 (전체 반환) | **신규** | +| 4 | created_at DESC 정렬 | 구현됨 | 유지 | +| 5 | DEBUG 쿼리 제거 | 6개 DEBUG 쿼리 존재 | **삭제** | + +### 비기능적 요구사항 +- 기존 페이지네이션 인터페이스(PaginatedResponse, PaginationParams) 유지 +- 기존 응답 스키마(VideoListItem) 유지 +- SQLAlchemy 비동기 + PostgreSQL 호환 + +--- + +## 2. 설계 개요 + +### 현재 문제점 +1. **DEBUG 쿼리 6개** (lines 80~142): 전체 Video 수, completed 수, is_deleted 수, 전체 Project 수, 사용자 Project 수, 사용자 completed Video 수를 매 요청마다 조회 → 불필요한 DB 부하 +2. **task_id 중복 반환**: 동일 task_id에 재생성된 영상이 여러 개 존재할 때 모두 반환 + +### 설계 방향 +- DEBUG 쿼리 6개 전면 삭제 +- **서브쿼리 방식**으로 task_id별 최신 영상 필터링 +- 쿼리를 count 쿼리 + 데이터 쿼리 2개로 정리 (기존 구조 유지) + +--- + +## 3. API 설계 + +### 엔드포인트 +변경 없음 — 기존 인터페이스 유지 + +``` +GET /archive/videos/?page=1&page_size=10 +``` + +- **Method**: GET +- **Auth**: Bearer Token (get_current_user) +- **Query Params**: page (int, default=1), page_size (int, default=10, max=100) +- **Response**: PaginatedResponse[VideoListItem] + +### description 업데이트 내용 +``` +- 본인이 소유한 프로젝트의 영상만 반환됩니다. +- status가 'completed'인 영상만 반환됩니다. +- 동일 task_id의 영상이 여러 개인 경우, 가장 최근에 생성된 영상만 반환됩니다. +- created_at 기준 내림차순 정렬됩니다. +``` + +--- + +## 4. 데이터 모델 + +### 기존 모델 (변경 없음) + +**Video** (app/video/models.py) +| 컬럼 | 타입 | 용도 | +|------|------|------| +| id | Integer (PK, autoincrement) | 고유 식별자 | +| project_id | Integer (FK → project.id) | 프로젝트 연결 | +| task_id | String(36) | 작업 식별자 (중복 가능) | +| status | String(50) | 처리 상태 | +| result_movie_url | String(2048) | 영상 URL | +| is_deleted | Boolean | 소프트 삭제 | +| created_at | DateTime | 생성 일시 | + +**Project** (app/home/models.py) +| 컬럼 | 타입 | 용도 | +|------|------|------| +| id | Integer (PK) | 고유 식별자 | +| user_uuid | String(36, FK → user.user_uuid) | 소유자 | +| store_name | String | 업체명 | +| region | String | 지역명 | +| is_deleted | Boolean | 소프트 삭제 | + +### 인덱스 활용 +- `idx_video_task_id`: task_id GROUP BY에 활용 +- `idx_video_project_id`: JOIN 조건에 활용 +- `idx_video_is_deleted`: WHERE 필터에 활용 +- `idx_project_user_uuid`: 사용자 소유 필터에 활용 + +--- + +## 5. 서비스 레이어 + +### 쿼리 설계 (핵심) + +현재 아키텍처에서 get_videos는 라우터에서 직접 쿼리를 실행하고 있음 (별도 서비스 레이어 없음). 이 패턴을 유지하되, 쿼리 로직만 수정한다. + +#### 5.1 서브쿼리: task_id별 최신 Video ID 추출 + +```python +from sqlalchemy import func, select + +# 서브쿼리: 조건을 만족하는 영상 중, task_id별 MAX(id)를 추출 +# (id는 autoincrement이므로 created_at 최신과 동일) +latest_video_ids = ( + select(func.max(Video.id).label("latest_id")) + .join(Project, Video.project_id == Project.id) + .where( + Project.user_uuid == current_user.user_uuid, + Video.status == "completed", + Video.is_deleted == False, + Project.is_deleted == False, + ) + .group_by(Video.task_id) + .subquery() +) +``` + +**설계 근거**: `Video.id`는 autoincrement이므로 나중에 생성된 레코드가 항상 더 큰 id를 가진다. 따라서 `MAX(id)`는 `created_at`이 가장 최신인 레코드와 일치한다. Window Function(ROW_NUMBER) 대비 쿼리가 단순하고 성능이 우수하다. + +#### 5.2 COUNT 쿼리 (페이지네이션용) + +```python +count_query = ( + select(func.count(Video.id)) + .where(Video.id.in_(select(latest_video_ids.c.latest_id))) +) +``` + +#### 5.3 데이터 쿼리 + +```python +data_query = ( + select(Video, Project) + .join(Project, Video.project_id == Project.id) + .where(Video.id.in_(select(latest_video_ids.c.latest_id))) + .order_by(Video.created_at.desc()) + .offset(offset) + .limit(pagination.page_size) +) +``` + +#### 5.4 전체 쿼리 흐름 + +``` +1. latest_video_ids (서브쿼리) + → Video JOIN Project + → WHERE: user_uuid, status, is_deleted 필터 + → GROUP BY task_id → MAX(id) + +2. count_query + → WHERE Video.id IN (latest_video_ids) + → scalar count + +3. data_query + → Video JOIN Project + → WHERE Video.id IN (latest_video_ids) + → ORDER BY created_at DESC + → OFFSET/LIMIT +``` + +### 생성되는 SQL (참고) + +```sql +-- 서브쿼리 +SELECT MAX(v.id) AS latest_id +FROM video v +JOIN project p ON v.project_id = p.id +WHERE p.user_uuid = :user_uuid + AND v.status = 'completed' + AND v.is_deleted = FALSE + AND p.is_deleted = FALSE +GROUP BY v.task_id; + +-- 데이터 쿼리 +SELECT v.*, p.* +FROM video v +JOIN project p ON v.project_id = p.id +WHERE v.id IN (위 서브쿼리) +ORDER BY v.created_at DESC +OFFSET :offset LIMIT :limit; +``` + +--- + +## 6. 스키마 + +### 변경 없음 — 기존 스키마 유지 + +**VideoListItem** (app/video/schemas/video_schema.py) +```python +class VideoListItem(BaseModel): + video_id: int + store_name: Optional[str] + region: Optional[str] + task_id: str + result_movie_url: Optional[str] + created_at: Optional[datetime] +``` + +**PaginatedResponse[VideoListItem]** (app/utils/pagination.py) +```python +{ + "items": [VideoListItem, ...], + "total": int, + "page": int, + "page_size": int, + "total_pages": int, + "has_next": bool, + "has_prev": bool +} +``` + +--- + +## 7. 파일 구조 + +| 파일 | 작업 | 설명 | +|------|------|------| +| app/archive/api/routers/v1/archive.py | **수정** | get_videos 함수 리팩토링 | + +**수정하지 않는 파일:** +- app/video/models.py (변경 없음) +- app/video/schemas/video_schema.py (변경 없음) +- app/utils/pagination.py (변경 없음) +- app/dependencies/pagination.py (변경 없음) +- app/home/models.py (변경 없음) + +--- + +## 8. 구현 순서 + +개발 에이전트(`/develop`)가 따라야 할 순서: + +### Step 1: get_videos 함수 수정 + +1. **DEBUG 쿼리 삭제** (lines 80~142) + - 전체 Video 수 조회 삭제 + - completed 상태 Video 수 조회 삭제 + - is_deleted=False Video 수 조회 삭제 + - 전체 Project 수/상세 조회 삭제 + - 현재 사용자 소유 Project 수 조회 삭제 + - 현재 사용자 completed Video 수 조회 삭제 + +2. **서브쿼리 추가**: task_id별 MAX(id) 추출 + - base_conditions를 서브쿼리의 WHERE절에 적용 + +3. **COUNT 쿼리 수정**: Video.id IN (서브쿼리) 조건 적용 + +4. **데이터 쿼리 수정**: Video.id IN (서브쿼리) + ORDER BY + OFFSET/LIMIT + +5. **엔드포인트 description 업데이트**: "동일 task_id의 가장 최근 영상만 반환" 문구 추가, 기존 "재생성된 영상 포함 모든 영상이 반환됩니다" 문구 삭제 + +### Step 2: 로깅 정리 + +- 기존 DEBUG 로그 삭제 +- 핵심 로그만 유지: START, SUCCESS, EXCEPTION + +--- + +## 9. 설계 검수 결과 + +### 검수 체크리스트 + +- [x] **기존 프로젝트 패턴과 일관성**: 기존 라우터 직접 쿼리 패턴 유지, PaginatedResponse.create() 활용 +- [x] **비동기 처리 설계**: async/await + AsyncSession 유지 +- [x] **N+1 쿼리 문제**: JOIN으로 한 번에 조회, 서브쿼리는 IN절로 단일 쿼리 실행 +- [x] **트랜잭션 경계**: 읽기 전용 쿼리이므로 트랜잭션 불필요 (기존과 동일) +- [x] **예외 처리 전략**: 기존 try/except + HTTPException 500 패턴 유지 +- [x] **확장성**: 서브쿼리 방식은 추가 필터 조건 확장 용이 +- [x] **직관적 구조**: 서브쿼리(최신 ID 추출) → COUNT → DATA 3단계로 명확 +- [x] **SOLID 준수**: 단일 책임(영상 목록 조회), 기존 인터페이스 유지(OCP) + +### 성능 고려사항 +- 서브쿼리 `GROUP BY task_id`는 `idx_video_task_id` 인덱스 활용 +- `Video.id IN (서브쿼리)`는 PK 인덱스로 빠른 조회 +- 기존 대비 DEBUG 쿼리 6개 삭제로 DB 요청 횟수: 8회 → 2회 + +### 대안 검토 + +| 방식 | 장점 | 단점 | 채택 | +|------|------|------|------| +| **MAX(id) 서브쿼리** | 단순, 빠름, DB 무관 | id 순서 = 시간 순서 전제 | **채택** | +| ROW_NUMBER() 윈도우 함수 | created_at 직접 기준 | 쿼리 복잡, 서브쿼리 래핑 필요 | 미채택 | +| DISTINCT ON (PostgreSQL) | PostgreSQL 최적화 | DB 종속, 정렬 제약 | 미채택 | + +MAX(id) 서브쿼리 채택 근거: Video.id는 autoincrement이므로 `MAX(id)`가 `created_at` 최신 레코드와 일치. 쿼리가 가장 단순하고 모든 RDBMS에서 동작. + +--- + +## 다음 단계 + +설계 검토 완료 후 `/develop` 명령으로 구현을 진행합니다.