merged get_videos
parent
34e0cada48
commit
bc2342163f
|
|
@ -1,291 +0,0 @@
|
||||||
# 📋 설계 문서: 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` 명령으로 구현을 진행합니다.
|
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
# 타임존 검수 보고서
|
||||||
|
|
||||||
|
**검수일**: 2026-02-10
|
||||||
|
**대상**: o2o-castad-backend 전체 프로젝트
|
||||||
|
**기준**: 서울 타임존(Asia/Seoul, KST +09:00)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 타임존 설정 현황
|
||||||
|
|
||||||
|
### 1.1 FastAPI 전역 타임존 (`config.py:15`)
|
||||||
|
```python
|
||||||
|
TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul"))
|
||||||
|
```
|
||||||
|
- `ZoneInfo`를 사용한 aware datetime 기반
|
||||||
|
- `.env`로 오버라이드 가능 (기본값: `Asia/Seoul`)
|
||||||
|
|
||||||
|
### 1.2 타임존 유틸리티 (`app/utils/timezone.py`)
|
||||||
|
```python
|
||||||
|
def now() -> datetime:
|
||||||
|
return datetime.now(TIMEZONE) # aware datetime (tzinfo=Asia/Seoul)
|
||||||
|
|
||||||
|
def today_str(fmt="%Y-%m-%d") -> str:
|
||||||
|
return datetime.now(TIMEZONE).strftime(fmt)
|
||||||
|
```
|
||||||
|
- 모든 모듈에서 `from app.utils.timezone import now` 사용 권장
|
||||||
|
- 반환값은 **aware datetime** (tzinfo 포함)
|
||||||
|
|
||||||
|
### 1.3 데이터베이스 타임존
|
||||||
|
- MySQL `server_default=func.now()` → DB 서버의 시스템 타임존 사용
|
||||||
|
- DB 생성 시 서울 타임존으로 설정됨 → `func.now()`는 KST 반환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 검수 결과 요약
|
||||||
|
|
||||||
|
| 구분 | 상태 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| `datetime.now()` 직접 호출 | ✅ 정상 | 앱 코드에서 bare `datetime.now()` 사용 없음 |
|
||||||
|
| `datetime.utcnow()` 사용 | ✅ 정상 | 프로젝트 전체에서 사용하지 않음 |
|
||||||
|
| `app.utils.timezone.now()` 사용 | ✅ 정상 | 필요한 모든 곳에서 사용 중 |
|
||||||
|
| 모델 `server_default=func.now()` | ✅ 정상 | DB 서버 타임존(서울) 기준 |
|
||||||
|
| naive/aware datetime 혼합 비교 | ⚠️ 주의 | `now().replace(tzinfo=None)` 패턴으로 처리됨 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 모듈별 상세 검수
|
||||||
|
|
||||||
|
### 3.1 `app/user/services/jwt.py` — ✅ 정상
|
||||||
|
|
||||||
|
| 위치 | 코드 | 판정 |
|
||||||
|
|------|------|------|
|
||||||
|
| L27 | `now() + timedelta(minutes=...)` | ✅ 토큰 만료시간 계산에 서울 타임존 사용 |
|
||||||
|
| L52 | `now() + timedelta(days=...)` | ✅ 리프레시 토큰 만료시간 |
|
||||||
|
| L110 | `now().replace(tzinfo=None) + timedelta(days=...)` | ✅ DB 저장용 naive datetime 변환 |
|
||||||
|
|
||||||
|
### 3.2 `app/user/services/auth.py` — ✅ 정상
|
||||||
|
|
||||||
|
| 위치 | 코드 | 판정 |
|
||||||
|
|------|------|------|
|
||||||
|
| L171 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 |
|
||||||
|
| L226 | `db_token.expires_at < now().replace(tzinfo=None)` | ✅ naive datetime끼리 비교 |
|
||||||
|
| L486 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 |
|
||||||
|
| L511 | `revoked_at=now().replace(tzinfo=None)` | ✅ DB 저장용 naive 변환 |
|
||||||
|
|
||||||
|
### 3.3 `app/user/api/routers/v1/auth.py` — ✅ 정상
|
||||||
|
|
||||||
|
| 위치 | 코드 | 판정 |
|
||||||
|
|------|------|------|
|
||||||
|
| L464 | `user.last_login_at = now().replace(tzinfo=None)` | ✅ 테스트 엔드포인트, DB 저장용 |
|
||||||
|
|
||||||
|
### 3.4 `app/social/services.py` — ✅ 정상
|
||||||
|
|
||||||
|
| 위치 | 코드 | 판정 |
|
||||||
|
|------|------|------|
|
||||||
|
| L308 | `current_time = now().replace(tzinfo=None)` | ✅ DB datetime과 비교용 |
|
||||||
|
| L506 | `current_time = now().replace(tzinfo=None)` | ✅ 토큰 만료 확인 |
|
||||||
|
| L577 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 만료시간 DB 저장 |
|
||||||
|
| L639 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 신규 계정 토큰 만료시간 |
|
||||||
|
| L693 | `now().replace(tzinfo=None) + timedelta(seconds=...)` | ✅ 토큰 업데이트 시 만료시간 |
|
||||||
|
| L709 | `account.connected_at = now().replace(tzinfo=None)` | ✅ 재연결 시간 DB 저장 |
|
||||||
|
|
||||||
|
### 3.5 `app/social/worker/upload_task.py` — ✅ 정상
|
||||||
|
|
||||||
|
| 위치 | 코드 | 판정 |
|
||||||
|
|------|------|------|
|
||||||
|
| L74 | `upload.uploaded_at = now().replace(tzinfo=None)` | ✅ 업로드 완료시간 DB 저장 |
|
||||||
|
|
||||||
|
### 3.6 `app/utils/logger.py` — ✅ 정상
|
||||||
|
|
||||||
|
| 위치 | 코드 | 판정 |
|
||||||
|
|------|------|------|
|
||||||
|
| L27 | `from app.utils.timezone import today_str` | ✅ 로그 파일명에 서울 기준 날짜 사용 |
|
||||||
|
| L89 | `today = today_str()` | ✅ `{날짜}_app.log` 파일명 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 모델 `created_at` / `updated_at` 패턴 검수
|
||||||
|
|
||||||
|
모든 모델의 `created_at`, `updated_at` 컬럼은 `server_default=func.now()`를 사용합니다.
|
||||||
|
|
||||||
|
| 모델 | 파일 | `created_at` | `updated_at` | 판정 |
|
||||||
|
|------|------|:---:|:---:|:---:|
|
||||||
|
| User | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ |
|
||||||
|
| RefreshToken | `app/user/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| SocialAccount | `app/user/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ |
|
||||||
|
| Project | `app/home/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| Image | `app/home/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| Lyric | `app/lyric/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| Song | `app/song/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| SongTimestamp | `app/song/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| Video | `app/video/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| SNSUploadTask | `app/sns/models.py` | `func.now()` | — | ✅ |
|
||||||
|
| SocialUpload | `app/social/models.py` | `func.now()` | `func.now()` + `onupdate` | ✅ |
|
||||||
|
|
||||||
|
> `func.now()`는 MySQL 서버의 `NOW()` 함수를 호출하므로 DB 서버 타임존(서울)이 적용됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `now().replace(tzinfo=None)` 패턴 분석
|
||||||
|
|
||||||
|
이 프로젝트에서는 **aware datetime → naive datetime** 변환 패턴이 일관되게 사용됩니다:
|
||||||
|
|
||||||
|
```python
|
||||||
|
now().replace(tzinfo=None) # Asia/Seoul aware → naive (값은 KST 유지)
|
||||||
|
```
|
||||||
|
|
||||||
|
**이유**: MySQL의 `DateTime` 타입은 타임존 정보를 저장하지 않으므로(naive datetime), DB에 저장하거나 DB 값과 비교할 때 `tzinfo`를 제거해야 합니다.
|
||||||
|
|
||||||
|
**검증**: 이 패턴은 `now()`가 이미 서울 타임존 기준이므로, `.replace(tzinfo=None)` 후에도 **값 자체는 KST 시간**을 유지합니다. DB의 `func.now()`도 KST이므로 비교 시 일관성이 보장됩니다.
|
||||||
|
|
||||||
|
| 사용처 | 목적 | 일관성 |
|
||||||
|
|--------|------|:------:|
|
||||||
|
| `jwt.py:110` | refresh token 만료시간 DB 저장 | ✅ |
|
||||||
|
| `auth.py:171` | 마지막 로그인 시간 DB 저장 | ✅ |
|
||||||
|
| `auth.py:226` | refresh token 만료 여부 비교 | ✅ |
|
||||||
|
| `auth.py:486,511` | token 폐기 시간 DB 저장 | ✅ |
|
||||||
|
| `auth.py(router):464` | 테스트 엔드포인트 로그인 시간 | ✅ |
|
||||||
|
| `social/services.py:308,506` | 토큰 만료 비교 | ✅ |
|
||||||
|
| `social/services.py:577,639,693` | 토큰 만료시간 DB 저장 | ✅ |
|
||||||
|
| `social/services.py:709` | 재연결 시간 DB 저장 | ✅ |
|
||||||
|
| `social/worker/upload_task.py:74` | 업로드 완료시간 DB 저장 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 최종 결론
|
||||||
|
|
||||||
|
### ✅ 전체 판정: 정상 (PASS)
|
||||||
|
|
||||||
|
프로젝트 전반에 걸쳐 타임존 처리가 **일관되게** 구현되어 있습니다:
|
||||||
|
|
||||||
|
1. **bare `datetime.now()` 미사용** — 앱 코드에서 타임존 없는 `datetime.now()` 직접 호출이 없음
|
||||||
|
2. **`datetime.utcnow()` 미사용** — UTC 기반 시간 생성 없음
|
||||||
|
3. **`app.utils.timezone.now()` 일관 사용** — 모든 서비스/라우터에서 유틸리티 함수 사용
|
||||||
|
4. **DB 저장 시 naive 변환 일관** — `now().replace(tzinfo=None)` 패턴 통일
|
||||||
|
5. **모델 기본값 `func.now()` 통일** — DB 서버 타임존(서울) 기준으로 자동 설정
|
||||||
|
6. **비교 연산 안전** — DB의 naive datetime과 비교 시 항상 naive로 변환 후 비교
|
||||||
|
|
||||||
|
### 주의사항 (현재 문제 아님, 향후 참고용)
|
||||||
|
|
||||||
|
1. **DB 서버 타임존 변경 주의**: `func.now()`는 DB 서버 타임존에 의존하므로, DB 서버의 타임존이 변경되면 `created_at`/`updated_at` 등의 자동 생성 시간이 영향을 받습니다.
|
||||||
|
2. **다중 타임존 확장 시**: 현재는 단일 타임존(서울)만 사용하므로 문제없지만, 다국적 서비스 확장 시 UTC 기반 저장 + 표시 시 변환 패턴으로 전환을 고려할 수 있습니다.
|
||||||
|
3. **`replace(tzinfo=None)` 패턴**: 값은 유지하면서 타임존 정보만 제거하므로 안전하지만, 코드 리뷰 시 의도를 명확히 하기 위해 주석을 유지하는 것이 좋습니다(현재 `social/services.py:307`에 주석 존재).
|
||||||
Loading…
Reference in New Issue