From 325fb9af69ed48422e213993d5d21fbe3eea0331 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 9 Feb 2026 10:59:44 +0900 Subject: [PATCH 1/7] =?UTF-8?q?social=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20refresh=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/social/exceptions.py | 1 + app/social/services.py | 140 +++++++++++++++++++++++++++++-- app/social/worker/upload_task.py | 13 ++- app/user/api/routers/v1/auth.py | 32 ++++++- 4 files changed, 175 insertions(+), 11 deletions(-) diff --git a/app/social/exceptions.py b/app/social/exceptions.py index f172711..712b6d6 100644 --- a/app/social/exceptions.py +++ b/app/social/exceptions.py @@ -123,6 +123,7 @@ class TokenExpiredError(OAuthException): status_code=status.HTTP_401_UNAUTHORIZED, code="TOKEN_EXPIRED", ) + self.platform = platform # ============================================================================= diff --git a/app/social/services.py b/app/social/services.py index 931e1a9..2ee4fcd 100644 --- a/app/social/services.py +++ b/app/social/services.py @@ -27,8 +27,10 @@ redis_client = Redis( from app.social.exceptions import ( InvalidStateError, OAuthStateExpiredError, + OAuthTokenRefreshError, SocialAccountAlreadyConnectedError, SocialAccountNotFoundError, + TokenExpiredError, ) from app.social.oauth import get_oauth_client from app.social.schemas import ( @@ -209,13 +211,15 @@ class SocialAccountService: self, user_uuid: str, session: AsyncSession, + auto_refresh: bool = True, ) -> list[SocialAccountResponse]: """ - 연동된 소셜 계정 목록 조회 + 연동된 소셜 계정 목록 조회 (토큰 자동 갱신 포함) Args: user_uuid: 사용자 UUID session: DB 세션 + auto_refresh: 토큰 자동 갱신 여부 (기본 True) Returns: list[SocialAccountResponse]: 연동된 계정 목록 @@ -233,8 +237,95 @@ class SocialAccountService: logger.debug(f"[SOCIAL] 연동 계정 {len(accounts)}개 조회됨") + # 토큰 자동 갱신 + if auto_refresh: + for account in accounts: + await self._try_refresh_token(account, session) + return [self._to_response(account) for account in accounts] + async def refresh_all_tokens( + self, + user_uuid: str, + session: AsyncSession, + ) -> dict[str, bool]: + """ + 사용자의 모든 연동 계정 토큰 갱신 (로그인 시 호출) + + Args: + user_uuid: 사용자 UUID + session: DB 세션 + + Returns: + dict[str, bool]: 플랫폼별 갱신 성공 여부 + """ + logger.info(f"[SOCIAL] 모든 연동 계정 토큰 갱신 시작 - user_uuid: {user_uuid}") + + result = await session.execute( + select(SocialAccount).where( + SocialAccount.user_uuid == user_uuid, + SocialAccount.is_active == True, # noqa: E712 + SocialAccount.is_deleted == False, # noqa: E712 + ) + ) + accounts = result.scalars().all() + + refresh_results = {} + for account in accounts: + success = await self._try_refresh_token(account, session) + refresh_results[f"{account.platform}_{account.id}"] = success + + logger.info(f"[SOCIAL] 토큰 갱신 완료 - results: {refresh_results}") + return refresh_results + + async def _try_refresh_token( + self, + account: SocialAccount, + session: AsyncSession, + ) -> bool: + """ + 토큰 갱신 시도 (실패해도 예외 발생하지 않음) + + Args: + account: 소셜 계정 + session: DB 세션 + + Returns: + bool: 갱신 성공 여부 + """ + # refresh_token이 없으면 갱신 불가 + if not account.refresh_token: + logger.debug( + f"[SOCIAL] refresh_token 없음, 갱신 스킵 - account_id: {account.id}" + ) + return False + + # 만료 시간 확인 (만료 1시간 전이면 갱신) + should_refresh = False + if account.token_expires_at is None: + should_refresh = True + else: + buffer_time = datetime.now() + timedelta(hours=1) + if account.token_expires_at <= buffer_time: + should_refresh = True + + if not should_refresh: + logger.debug( + f"[SOCIAL] 토큰 아직 유효, 갱신 스킵 - account_id: {account.id}" + ) + return True + + # 갱신 시도 + try: + await self._refresh_account_token(account, session) + return True + except Exception as e: + logger.warning( + f"[SOCIAL] 토큰 갱신 실패 (재연동 필요) - " + f"account_id: {account.id}, error: {e}" + ) + return False + async def get_account_by_platform( self, user_uuid: str, @@ -402,15 +493,34 @@ class SocialAccountService: Returns: str: 유효한 access_token + + Raises: + TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요) """ - # 만료 시간 확인 (만료 10분 전이면 갱신) - if account.token_expires_at: + # refresh_token이 없으면 갱신 불가 → 재연동 필요 + if not account.refresh_token: + logger.warning( + f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}" + ) + raise TokenExpiredError(platform=account.platform) + + # 만료 시간 확인 (만료 10분 전이면 갱신, 만료 시간 없어도 갱신 시도) + should_refresh = False + if account.token_expires_at is None: + logger.info( + f"[SOCIAL] token_expires_at 없음, 갱신 시도 - account_id: {account.id}" + ) + should_refresh = True + else: buffer_time = datetime.now() + timedelta(minutes=10) if account.token_expires_at <= buffer_time: logger.info( f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" ) - return await self._refresh_account_token(account, session) + should_refresh = True + + if should_refresh: + return await self._refresh_account_token(account, session) return account.access_token @@ -428,17 +538,33 @@ class SocialAccountService: Returns: str: 새 access_token + + Raises: + TokenExpiredError: 갱신 실패 시 (재연동 필요) """ if not account.refresh_token: logger.warning( - f"[SOCIAL] refresh_token 없음, 갱신 불가 - account_id: {account.id}" + f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}" ) - return account.access_token + raise TokenExpiredError(platform=account.platform) platform = SocialPlatform(account.platform) oauth_client = get_oauth_client(platform) - token_response = await oauth_client.refresh_token(account.refresh_token) + try: + token_response = await oauth_client.refresh_token(account.refresh_token) + except OAuthTokenRefreshError as e: + logger.error( + f"[SOCIAL] 토큰 갱신 실패, 재연동 필요 - " + f"account_id: {account.id}, error: {e}" + ) + raise TokenExpiredError(platform=account.platform) + except Exception as e: + logger.error( + f"[SOCIAL] 토큰 갱신 중 예외 발생, 재연동 필요 - " + f"account_id: {account.id}, error: {e}" + ) + raise TokenExpiredError(platform=account.platform) # 토큰 업데이트 account.access_token = token_response.access_token diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py index 4326687..7e00a90 100644 --- a/app/social/worker/upload_task.py +++ b/app/social/worker/upload_task.py @@ -19,7 +19,7 @@ from sqlalchemy.exc import SQLAlchemyError from config import social_upload_settings from app.database.session import BackgroundSessionLocal from app.social.constants import SocialPlatform, UploadStatus -from app.social.exceptions import UploadError, UploadQuotaExceededError +from app.social.exceptions import TokenExpiredError, UploadError, UploadQuotaExceededError from app.social.models import SocialUpload from app.social.services import social_account_service from app.social.uploader import get_uploader @@ -352,6 +352,17 @@ async def process_social_upload(upload_id: int) -> None: error_message="플랫폼 API 일일 할당량이 초과되었습니다. 내일 다시 시도해주세요.", ) + except TokenExpiredError as e: + logger.error( + f"[SOCIAL_UPLOAD] 토큰 만료, 재연동 필요 - " + f"upload_id: {upload_id}, platform: {e.platform}" + ) + await _update_upload_status( + upload_id=upload_id, + status=UploadStatus.FAILED, + error_message=f"{e.platform} 계정 인증이 만료되었습니다. 계정을 다시 연동해주세요.", + ) + except Exception as e: logger.error( f"[SOCIAL_UPLOAD] 예상치 못한 에러 - " diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index 2c730f9..79796a2 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -33,10 +33,12 @@ from app.user.services import auth_service, kakao_client from app.user.services.jwt import ( create_access_token, create_refresh_token, + decode_token, get_access_token_expire_seconds, get_refresh_token_expires_at, get_token_hash, ) +from app.social.services import social_account_service from app.utils.common import generate_uuid @@ -140,6 +142,19 @@ async def kakao_callback( ip_address=ip_address, ) + # 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신 + try: + payload = decode_token(result.access_token) + if payload and payload.get("sub"): + user_uuid = payload.get("sub") + await social_account_service.refresh_all_tokens( + user_uuid=user_uuid, + session=session, + ) + except Exception as e: + # 토큰 갱신 실패해도 로그인은 성공 처리 + logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}") + # 프론트엔드로 토큰과 함께 리다이렉트 redirect_url = ( f"{prj_settings.PROJECT_DOMAIN}" @@ -205,9 +220,20 @@ async def kakao_verify( ip_address=ip_address, ) - logger.info( - f"[ROUTER] 카카오 인가 코드 검증 완료 - user_id: {result.user.id}, is_new_user: {result.user.is_new_user}" - ) + # 로그인 성공 후 연동된 소셜 계정 토큰 자동 갱신 + try: + payload = decode_token(result.access_token) + if payload and payload.get("sub"): + user_uuid = payload.get("sub") + await social_account_service.refresh_all_tokens( + user_uuid=user_uuid, + session=session, + ) + except Exception as e: + # 토큰 갱신 실패해도 로그인은 성공 처리 + logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}") + + logger.info(f"[ROUTER] 카카오 인가 코드 검증 완료 - is_new_user: {result.is_new_user}") return result From e29e10eb2996d973b2cc52f63c1dcdc0aa1ba0db Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 9 Feb 2026 13:15:20 +0900 Subject: [PATCH 2/7] =?UTF-8?q?youtube=20bug=20fix,=20timezone=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20lazyloading=20=EC=88=98=EC=A0=95=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/social/services.py | 34 ++++++++++++++++++-------------- app/social/worker/upload_task.py | 2 +- app/user/models.py | 2 ++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/app/social/services.py b/app/social/services.py index 6e7936b..c48d014 100644 --- a/app/social/services.py +++ b/app/social/services.py @@ -4,6 +4,7 @@ Social Account Service 소셜 계정 연동 관련 비즈니스 로직을 처리합니다. """ +import json import logging import secrets from datetime import timedelta @@ -27,10 +28,8 @@ redis_client = Redis( decode_responses=True, ) from app.social.exceptions import ( - InvalidStateError, OAuthStateExpiredError, OAuthTokenRefreshError, - SocialAccountAlreadyConnectedError, SocialAccountNotFoundError, TokenExpiredError, ) @@ -90,7 +89,7 @@ class SocialAccountService: await redis_client.setex( state_key, social_oauth_settings.OAUTH_STATE_TTL_SECONDS, - str(state_data), + json.dumps(state_data), # JSON으로 직렬화 ) logger.debug(f"[SOCIAL] OAuth state 저장 - key: {state_key}") @@ -126,9 +125,7 @@ class SocialAccountService: SocialAccountResponse: 연동된 소셜 계정 정보 Raises: - InvalidStateError: state 토큰이 유효하지 않은 경우 - OAuthStateExpiredError: state 토큰이 만료된 경우 - SocialAccountAlreadyConnectedError: 이미 연동된 계정인 경우 + OAuthStateExpiredError: state 토큰이 만료되거나 유효하지 않은 경우 """ logger.info(f"[SOCIAL] OAuth 콜백 처리 시작 - state: {state[:20]}...") @@ -140,8 +137,8 @@ class SocialAccountService: logger.warning(f"[SOCIAL] state 토큰 없음 또는 만료 - state: {state[:20]}...") raise OAuthStateExpiredError() - # state 데이터 파싱 - state_data = eval(state_data_str) # {"user_uuid": "...", "platform": "..."} + # state 데이터 파싱 (JSON 역직렬화) + state_data = json.loads(state_data_str) user_uuid = state_data["user_uuid"] platform = SocialPlatform(state_data["platform"]) @@ -307,7 +304,9 @@ class SocialAccountService: if account.token_expires_at is None: should_refresh = True else: - buffer_time = now() + timedelta(hours=1) + # DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교 + current_time = now().replace(tzinfo=None) + buffer_time = current_time + timedelta(hours=1) if account.token_expires_at <= buffer_time: should_refresh = True @@ -514,7 +513,9 @@ class SocialAccountService: ) should_refresh = True else: - buffer_time = now() + timedelta(minutes=10) + # DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교 + current_time = now().replace(tzinfo=None) + buffer_time = current_time + timedelta(minutes=10) if account.token_expires_at <= buffer_time: logger.info( f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" @@ -573,11 +574,13 @@ class SocialAccountService: if token_response.refresh_token: account.refresh_token = token_response.refresh_token if token_response.expires_in: - account.token_expires_at = now() + timedelta( + # DB에 naive datetime으로 저장 (MySQL DateTime은 timezone 미지원) + account.token_expires_at = now().replace(tzinfo=None) + timedelta( seconds=token_response.expires_in ) await session.commit() + await session.refresh(account) logger.info(f"[SOCIAL] 토큰 갱신 완료 - account_id: {account.id}") return account.access_token @@ -631,10 +634,10 @@ class SocialAccountService: Returns: SocialAccount: 생성된 소셜 계정 """ - # 토큰 만료 시간 계산 + # 토큰 만료 시간 계산 (DB에 naive datetime으로 저장) token_expires_at = None if token_response.expires_in: - token_expires_at = now() + timedelta( + token_expires_at = now().replace(tzinfo=None) + timedelta( seconds=token_response.expires_in ) @@ -687,7 +690,8 @@ class SocialAccountService: if token_response.refresh_token: account.refresh_token = token_response.refresh_token if token_response.expires_in: - account.token_expires_at = now() + timedelta( + # DB에 naive datetime으로 저장 + account.token_expires_at = now().replace(tzinfo=None) + timedelta( seconds=token_response.expires_in ) if token_response.scope: @@ -703,7 +707,7 @@ class SocialAccountService: # 재연결 시 연결 시간 업데이트 if update_connected_at: - account.connected_at = now() + account.connected_at = now().replace(tzinfo=None) await session.commit() await session.refresh(account) diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py index 5dd727a..6e48f89 100644 --- a/app/social/worker/upload_task.py +++ b/app/social/worker/upload_task.py @@ -71,7 +71,7 @@ async def _update_upload_status( if error_message: upload.error_message = error_message if status == UploadStatus.COMPLETED: - upload.uploaded_at = now() + upload.uploaded_at = now().replace(tzinfo=None) await session.commit() logger.info( diff --git a/app/user/models.py b/app/user/models.py index 2c734dc..fea0cc0 100644 --- a/app/user/models.py +++ b/app/user/models.py @@ -391,6 +391,7 @@ class RefreshToken(Base): user: Mapped["User"] = relationship( "User", back_populates="refresh_tokens", + lazy="selectin", # lazy loading 방지 ) def __repr__(self) -> str: @@ -591,6 +592,7 @@ class SocialAccount(Base): user: Mapped["User"] = relationship( "User", back_populates="social_accounts", + lazy="selectin", # lazy loading 방지 ) def __repr__(self) -> str: From bc777ba66cb0cd65df6725599c2511835fa38bb7 Mon Sep 17 00:00:00 2001 From: hbyang Date: Mon, 9 Feb 2026 16:53:15 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refresh=20token=20=EC=88=98=EC=A0=95=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/exceptions.py | 15 ++++++++++---- app/social/oauth/youtube.py | 2 +- app/social/services.py | 41 ++++++++++++++++++------------------- 3 files changed, 32 insertions(+), 26 deletions(-) diff --git a/app/core/exceptions.py b/app/core/exceptions.py index abf8b26..4a4e277 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -291,15 +291,22 @@ def add_exception_handlers(app: FastAPI): # SocialException 핸들러 추가 from app.social.exceptions import SocialException + from app.social.exceptions import TokenExpiredError + @app.exception_handler(SocialException) def social_exception_handler(request: Request, exc: SocialException) -> Response: logger.debug(f"Handled SocialException: {exc.__class__.__name__} - {exc.message}") + content = { + "detail": exc.message, + "code": exc.code, + } + # TokenExpiredError인 경우 재연동 정보 추가 + if isinstance(exc, TokenExpiredError): + content["platform"] = exc.platform + content["reconnect_url"] = f"/social/oauth/{exc.platform}/connect" return JSONResponse( status_code=exc.status_code, - content={ - "detail": exc.message, - "code": exc.code, - }, + content=content, ) @app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/app/social/oauth/youtube.py b/app/social/oauth/youtube.py index 9d8a53a..e2b89e2 100644 --- a/app/social/oauth/youtube.py +++ b/app/social/oauth/youtube.py @@ -59,7 +59,7 @@ class YouTubeOAuthClient(BaseOAuthClient): "response_type": "code", "scope": " ".join(YOUTUBE_SCOPES), "access_type": "offline", # refresh_token 받기 위해 필요 - "prompt": "select_account", # 계정 선택만 표시 (이전 동의 유지) + "prompt": "select_account", # 계정 선택만 표시 (동의 화면은 최초 1회만) "state": state, } url = f"{self.AUTHORIZATION_URL}?{urlencode(params)}" diff --git a/app/social/services.py b/app/social/services.py index c48d014..498eab7 100644 --- a/app/social/services.py +++ b/app/social/services.py @@ -498,34 +498,33 @@ class SocialAccountService: Raises: TokenExpiredError: 토큰 갱신 실패 시 (재연동 필요) """ - # refresh_token이 없으면 갱신 불가 → 재연동 필요 - if not account.refresh_token: - logger.warning( - f"[SOCIAL] refresh_token 없음, 재연동 필요 - account_id: {account.id}" - ) - raise TokenExpiredError(platform=account.platform) - - # 만료 시간 확인 (만료 10분 전이면 갱신, 만료 시간 없어도 갱신 시도) - should_refresh = False + # 만료 시간 확인 + is_expired = False if account.token_expires_at is None: - logger.info( - f"[SOCIAL] token_expires_at 없음, 갱신 시도 - account_id: {account.id}" - ) - should_refresh = True + is_expired = True else: - # DB datetime은 naive, now()는 aware이므로 naive로 통일하여 비교 current_time = now().replace(tzinfo=None) buffer_time = current_time + timedelta(minutes=10) if account.token_expires_at <= buffer_time: - logger.info( - f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" - ) - should_refresh = True + is_expired = True - if should_refresh: - return await self._refresh_account_token(account, session) + # 아직 유효하면 그대로 사용 + if not is_expired: + return account.access_token - return account.access_token + # 만료됐는데 refresh_token이 없으면 재연동 필요 + if not account.refresh_token: + logger.warning( + f"[SOCIAL] access_token 만료 + refresh_token 없음, 재연동 필요 - " + f"account_id: {account.id}" + ) + raise TokenExpiredError(platform=account.platform) + + # refresh_token으로 갱신 + logger.info( + f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}" + ) + return await self._refresh_account_token(account, session) async def _refresh_account_token( self, From 4e87c76b3561039c5cc94b0ae1985b808c79d453 Mon Sep 17 00:00:00 2001 From: hbyang Date: Tue, 10 Feb 2026 13:54:22 +0900 Subject: [PATCH 4/7] =?UTF-8?q?timezone=20auth=20=EB=B9=84=EA=B5=90=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/user/api/routers/v1/auth.py | 2 +- app/user/services/auth.py | 8 ++++---- app/user/services/jwt.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index 9a6e22c..3981621 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -461,7 +461,7 @@ async def generate_test_token( session.add(db_refresh_token) # 마지막 로그인 시간 업데이트 - user.last_login_at = now() + user.last_login_at = now().replace(tzinfo=None) await session.commit() logger.info( diff --git a/app/user/services/auth.py b/app/user/services/auth.py index aa648c1..35269e7 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -168,7 +168,7 @@ class AuthService: logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}") # 7. 마지막 로그인 시간 업데이트 - user.last_login_at = now() + user.last_login_at = now().replace(tzinfo=None) await session.commit() redirect_url = f"{prj_settings.PROJECT_DOMAIN}" @@ -223,7 +223,7 @@ class AuthService: if db_token.is_revoked: raise TokenRevokedError() - if db_token.expires_at < now(): + if db_token.expires_at < now().replace(tzinfo=None): raise TokenExpiredError() # 4. 사용자 확인 @@ -483,7 +483,7 @@ class AuthService: .where(RefreshToken.token_hash == token_hash) .values( is_revoked=True, - revoked_at=now(), + revoked_at=now().replace(tzinfo=None), ) ) await session.commit() @@ -508,7 +508,7 @@ class AuthService: ) .values( is_revoked=True, - revoked_at=now(), + revoked_at=now().replace(tzinfo=None), ) ) await session.commit() diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py index 4f38658..ebcf2d6 100644 --- a/app/user/services/jwt.py +++ b/app/user/services/jwt.py @@ -107,7 +107,7 @@ def get_refresh_token_expires_at() -> datetime: Returns: 리프레시 토큰 만료 datetime (로컬 시간) """ - return now() + timedelta( + return now().replace(tzinfo=None) + timedelta( days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS ) From 34e0cada48c87b0202c5a0f03f2cf79ce5029291 Mon Sep 17 00:00:00 2001 From: Dohyun Lim Date: Tue, 10 Feb 2026 14:58:45 +0900 Subject: [PATCH 5/7] 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` 명령으로 구현을 진행합니다. From bc2342163fa5e527fcb256ddd267de166edad6cd Mon Sep 17 00:00:00 2001 From: Dohyun Lim Date: Wed, 11 Feb 2026 11:17:59 +0900 Subject: [PATCH 6/7] merged get_videos --- get_videos_업데이트_설계서.md | 291 ---------------------------------- timezone-check.md | 163 +++++++++++++++++++ 2 files changed, 163 insertions(+), 291 deletions(-) delete mode 100644 get_videos_업데이트_설계서.md create mode 100644 timezone-check.md diff --git a/get_videos_업데이트_설계서.md b/get_videos_업데이트_설계서.md deleted file mode 100644 index f8773a4..0000000 --- a/get_videos_업데이트_설계서.md +++ /dev/null @@ -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` 명령으로 구현을 진행합니다. diff --git a/timezone-check.md b/timezone-check.md new file mode 100644 index 0000000..c9a2baa --- /dev/null +++ b/timezone-check.md @@ -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`에 주석 존재). From 18635d7995eb6fe1363a5d55641ac4eb3088eddb Mon Sep 17 00:00:00 2001 From: jaehwang Date: Wed, 11 Feb 2026 07:09:34 +0000 Subject: [PATCH 7/7] update crawler retry and timeout setting --- app/utils/nvMapPwScraper.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/utils/nvMapPwScraper.py b/app/utils/nvMapPwScraper.py index 2885242..3fc6468 100644 --- a/app/utils/nvMapPwScraper.py +++ b/app/utils/nvMapPwScraper.py @@ -15,7 +15,8 @@ class NvMapPwScraper(): _context = None _win_width = 1280 _win_height = 720 - _max_retry = 60 # place id timeout threshold seconds + _max_retry = 3 + _timeout = 60 # place id timeout threshold seconds # instance var page = None @@ -97,7 +98,7 @@ patchedGetter.toString();''') async def get_place_id_url(self, selected): count = 0 get_place_id_url_start = time.perf_counter() - while (count <= 1): + while (count <= self._max_retry): title = selected['title'].replace("", "").replace("", "") address = selected.get('roadAddress', selected['address']).replace("", "").replace("", "") encoded_query = parse.quote(f"{address} {title}") @@ -106,9 +107,12 @@ patchedGetter.toString();''') wait_first_start = time.perf_counter() try: - await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000) except: - await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*1000) + if "/place/" in self.page.url: + return self.page.url + logger.error(f"[ERROR] Can't Finish networkidle") + wait_first_time = (time.perf_counter() - wait_first_start) * 1000 @@ -123,9 +127,11 @@ patchedGetter.toString();''') url = self.page.url.replace("?","?isCorrectAnswer=true&") try: - await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000) + await self.goto_url(url, wait_until="networkidle",timeout = self._timeout*1000) except: - await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*1000) + if "/place/" in self.page.url: + return self.page.url + logger.error(f"[ERROR] Can't Finish networkidle") wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000 logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")