diff --git a/app/home/models.py b/app/home/models.py index 9a991a3..ff55307 100644 --- a/app/home/models.py +++ b/app/home/models.py @@ -107,7 +107,7 @@ class Project(Base): comment="상세 지역 정보", ) - marketing_inteligence: Mapped[Optional[str]] = mapped_column( + marketing_intelligence: Mapped[Optional[str]] = mapped_column( Integer, nullable=True, comment="마케팅 인텔리전스 결과 정보 저장", diff --git a/app/home/schemas/home_schema.py b/app/home/schemas/home_schema.py index 45b0126..e227da2 100644 --- a/app/home/schemas/home_schema.py +++ b/app/home/schemas/home_schema.py @@ -3,112 +3,6 @@ from typing import Literal, Optional from pydantic import BaseModel, ConfigDict, Field from app.utils.prompts.schemas import MarketingPromptOutput -class AttributeInfo(BaseModel): - """음악 속성 정보""" - - genre: str = Field(..., description="음악 장르") - vocal: str = Field(..., description="보컬 스타일") - tempo: str = Field(..., description="템포") - mood: str = Field(..., description="분위기") - - -class GenerateRequestImg(BaseModel): - """이미지 URL 스키마""" - - url: str = Field(..., description="이미지 URL") - name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") - - -class GenerateRequestInfo(BaseModel): - """생성 요청 정보 스키마 (이미지 제외)""" - - customer_name: str = Field(..., description="고객명/가게명") - region: str = Field(..., description="지역명") - detail_region_info: Optional[str] = Field(None, description="상세 지역 정보") - attribute: AttributeInfo = Field(..., description="음악 속성 정보") - language: str = Field( - default="Korean", - description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)", - ) - - -class GenerateRequest(GenerateRequestInfo): - """기본 생성 요청 스키마 (이미지 없음, JSON body) - - 이미지 없이 프로젝트 정보만 전달합니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "attribute": { - "genre": "K-Pop", - "vocal": "Raspy", - "tempo": "110 BPM", - "mood": "happy", - }, - "language": "Korean", - } - } - ) - - -class GenerateUrlsRequest(GenerateRequestInfo): - """URL 기반 생성 요청 스키마 (JSON body) - - GenerateRequestInfo를 상속받아 이미지 목록을 추가합니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "customer_name": "스테이 머뭄", - "region": "군산", - "detail_region_info": "군산 신흥동 말랭이 마을", - "attribute": { - "genre": "K-Pop", - "vocal": "Raspy", - "tempo": "110 BPM", - "mood": "happy", - }, - "language": "Korean", - "images": [ - {"url": "https://example.com/images/image_001.jpg"}, - {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, - ], - } - } - ) - - images: list[GenerateRequestImg] = Field( - ..., description="이미지 URL 목록", min_length=1 - ) - - -class GenerateUploadResponse(BaseModel): - """파일 업로드 기반 생성 응답 스키마""" - - task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") - status: Literal["processing", "completed", "failed"] = Field( - ..., description="작업 상태" - ) - message: str = Field(..., description="응답 메시지") - uploaded_count: int = Field(..., description="업로드된 이미지 개수") - - -class GenerateResponse(BaseModel): - """생성 응답 스키마""" - - task_id: str = Field(..., description="작업 고유 식별자 (UUID7)") - status: Literal["processing", "completed", "failed"] = Field( - ..., description="작업 상태" - ) - message: str = Field(..., description="응답 메시지") - - class CrawlingRequest(BaseModel): """크롤링 요청 스키마""" @@ -371,29 +265,6 @@ class ImageUrlItem(BaseModel): name: Optional[str] = Field(None, description="이미지명 (없으면 URL에서 추출)") -class ImageUploadRequest(BaseModel): - """이미지 업로드 요청 스키마 (JSON body 부분) - - URL 이미지 목록을 전달합니다. - 바이너리 파일은 multipart/form-data로 별도 전달됩니다. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "images": [ - {"url": "https://example.com/images/image_001.jpg"}, - {"url": "https://example.com/images/image_002.jpg", "name": "외관"}, - ] - } - } - ) - - images: Optional[list[ImageUrlItem]] = Field( - None, description="외부 이미지 URL 목록" - ) - - class ImageUploadResultItem(BaseModel): """업로드된 이미지 결과 아이템""" diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index 50f6575..ee06808 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -318,7 +318,7 @@ async def generate_lyric( detail_region_info=request_body.detail_region_info, language=request_body.language, user_uuid=current_user.user_uuid, - marketing_inteligence = request_body.m_id + marketing_intelligence = request_body.m_id ) session.add(project) await session.commit() diff --git a/app/sns/schemas/sns_schema.py b/app/sns/schemas/sns_schema.py index 51fc960..ae15aee 100644 --- a/app/sns/schemas/sns_schema.py +++ b/app/sns/schemas/sns_schema.py @@ -4,7 +4,7 @@ SNS API Schemas Instagram 업로드 관련 Pydantic 스키마를 정의합니다. """ from datetime import datetime -from typing import Any, Optional +from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -98,20 +98,6 @@ class Media(BaseModel): children: Optional[list["Media"]] = None -class MediaList(BaseModel): - """미디어 목록 응답""" - - data: list[Media] = Field(default_factory=list) - paging: Optional[dict[str, Any]] = None - - @property - def next_cursor(self) -> Optional[str]: - """다음 페이지 커서""" - if self.paging and "cursors" in self.paging: - return self.paging["cursors"].get("after") - return None - - class MediaContainer(BaseModel): """미디어 컨테이너 상태""" diff --git a/app/song/schemas/song_schema.py b/app/song/schemas/song_schema.py index 2656646..eb2d420 100644 --- a/app/song/schemas/song_schema.py +++ b/app/song/schemas/song_schema.py @@ -1,8 +1,5 @@ -from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional +from typing import Optional -from fastapi import Request from pydantic import BaseModel, Field @@ -107,21 +104,6 @@ class GenerateSongResponse(BaseModel): error_message: Optional[str] = Field(None, description="에러 메시지 (실패 시)") -class PollingSongRequest(BaseModel): - """노래 생성 상태 조회 요청 스키마 (Legacy) - - Note: - 현재 사용되지 않음. GET /song/status/{song_id} 엔드포인트 사용. - - Example Request: - { - "task_id": "abc123..." - } - """ - - task_id: str = Field(..., description="Suno 작업 ID") - - class SongClipData(BaseModel): """생성된 노래 클립 정보""" @@ -234,94 +216,3 @@ class PollingSongResponse(BaseModel): song_result_url: Optional[str] = Field( None, description="노래 결과 URL (Song 테이블 status가 completed일 때 반환)" ) - - -# ============================================================================= -# Dataclass Schemas (Legacy) -# ============================================================================= - - -@dataclass -class StoreData: - id: int - created_at: datetime - store_name: str - store_category: str | None = None - store_region: str | None = None - store_address: str | None = None - store_phone_number: str | None = None - store_info: str | None = None - - -@dataclass -class AttributeData: - id: int - attr_category: str - attr_value: str - created_at: datetime - - -@dataclass -class SongSampleData: - id: int - ai: str - ai_model: str - sample_song: str - season: str | None = None - num_of_people: int | None = None - people_category: str | None = None - genre: str | None = None - - -@dataclass -class PromptTemplateData: - id: int - prompt: str - description: str | None = None - - -@dataclass -class SongFormData: - store_name: str - store_id: str - prompts: str - attributes: Dict[str, str] = field(default_factory=dict) - attributes_str: str = "" - lyrics_ids: List[int] = field(default_factory=list) - llm_model: str = "gpt-5-mini" - - @classmethod - async def from_form(cls, request: Request): - """Request의 form 데이터로부터 dataclass 인스턴스 생성""" - form_data = await request.form() - - # 고정 필드명들 - fixed_keys = {"store_info_name", "store_id", "llm_model", "prompts"} - - # lyrics-{id} 형태의 모든 키를 찾아서 ID 추출 - lyrics_ids = [] - attributes = {} - - for key, value in form_data.items(): - if key.startswith("lyrics-"): - lyrics_id = key.split("-")[1] - lyrics_ids.append(int(lyrics_id)) - elif key not in fixed_keys: - attributes[key] = value - - # attributes를 문자열로 변환 - attributes_str = ( - "\r\n\r\n".join([f"{key} : {value}" for key, value in attributes.items()]) - if attributes - else "" - ) - - return cls( - store_name=form_data.get("store_info_name", ""), - store_id=form_data.get("store_id", ""), - attributes=attributes, - attributes_str=attributes_str, - lyrics_ids=lyrics_ids, - llm_model=form_data.get("llm_model", "gpt-5-mini"), - prompts=form_data.get("prompts", ""), - ) diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py index 3981621..002f1b6 100644 --- a/app/user/api/routers/v1/auth.py +++ b/app/user/api/routers/v1/auth.py @@ -23,11 +23,11 @@ logger = logging.getLogger(__name__) from app.user.dependencies import get_current_user from app.user.models import RefreshToken, User from app.user.schemas.user_schema import ( - AccessTokenResponse, KakaoCodeRequest, KakaoLoginResponse, LoginResponse, RefreshTokenRequest, + TokenResponse, UserResponse, ) from app.user.services import auth_service, kakao_client @@ -240,24 +240,30 @@ async def kakao_verify( @router.post( "/refresh", - response_model=AccessTokenResponse, - summary="토큰 갱신", - description="리프레시 토큰으로 새 액세스 토큰을 발급합니다.", + response_model=TokenResponse, + summary="토큰 갱신 (Refresh Token Rotation)", + description="리프레시 토큰으로 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. 사용된 기존 리프레시 토큰은 즉시 폐기됩니다.", ) async def refresh_token( body: RefreshTokenRequest, session: AsyncSession = Depends(get_session), -) -> AccessTokenResponse: +) -> TokenResponse: """ - 액세스 토큰 갱신 + 토큰 갱신 (Refresh Token Rotation) - 유효한 리프레시 토큰을 제출하면 새 액세스 토큰을 발급합니다. - 리프레시 토큰은 변경되지 않습니다. + 유효한 리프레시 토큰을 제출하면 새 액세스 토큰과 새 리프레시 토큰을 발급합니다. + 사용된 기존 리프레시 토큰은 즉시 폐기(revoke)됩니다. """ - return await auth_service.refresh_tokens( + logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}") + result = await auth_service.refresh_tokens( refresh_token=body.refresh_token, session=session, ) + logger.info( + f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, " + f"new_refresh: ...{result.refresh_token[-20:]}" + ) + return result @router.post( @@ -281,11 +287,16 @@ async def logout( 현재 사용 중인 리프레시 토큰을 폐기합니다. 해당 토큰으로는 더 이상 액세스 토큰을 갱신할 수 없습니다. """ + logger.info( + f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, " + f"user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}" + ) await auth_service.logout( user_id=current_user.id, refresh_token=body.refresh_token, session=session, ) + logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}") return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -309,10 +320,15 @@ async def logout_all( 사용자의 모든 리프레시 토큰을 폐기합니다. 모든 기기에서 재로그인이 필요합니다. """ + logger.info( + f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, " + f"user_uuid: {current_user.user_uuid}" + ) await auth_service.logout_all( user_id=current_user.id, session=session, ) + logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}") return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/user/dependencies/auth.py b/app/user/dependencies/auth.py index 5074a9d..8290f60 100644 --- a/app/user/dependencies/auth.py +++ b/app/user/dependencies/auth.py @@ -4,6 +4,7 @@ FastAPI 라우터에서 사용할 인증 관련 의존성을 정의합니다. """ +import logging from typing import Optional from fastapi import Depends @@ -22,6 +23,8 @@ from app.user.services.auth import ( ) from app.user.services.jwt import decode_token +logger = logging.getLogger(__name__) + security = HTTPBearer(auto_error=False) @@ -47,18 +50,28 @@ async def get_current_user( UserInactiveError: 비활성화된 계정인 경우 """ if credentials is None: + logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError") raise MissingTokenError() - payload = decode_token(credentials.credentials) + token = credentials.credentials + logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}") + + payload = decode_token(token) if payload is None: + logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}") raise InvalidTokenError() # 토큰 타입 확인 if payload.get("type") != "access": + logger.warning( + f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, " + f"got: {payload.get('type')}, sub: {payload.get('sub')}" + ) raise InvalidTokenError("액세스 토큰이 아닙니다.") user_uuid = payload.get("sub") if user_uuid is None: + logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}") raise InvalidTokenError() # 사용자 조회 @@ -71,11 +84,18 @@ async def get_current_user( user = result.scalar_one_or_none() if user is None: + logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}") raise UserNotFoundError() if not user.is_active: + logger.warning( + f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}" + ) raise UserInactiveError() + logger.debug( + f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}" + ) return user @@ -96,17 +116,24 @@ async def get_current_user_optional( User | None: 로그인한 사용자 또는 None """ if credentials is None: + logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음") return None - payload = decode_token(credentials.credentials) + token = credentials.credentials + payload = decode_token(token) if payload is None: + logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}") return None if payload.get("type") != "access": + logger.debug( + f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})" + ) return None user_uuid = payload.get("sub") if user_uuid is None: + logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음") return None result = await session.execute( @@ -118,8 +145,14 @@ async def get_current_user_optional( user = result.scalar_one_or_none() if user is None or not user.is_active: + logger.debug( + f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}" + ) return None + logger.debug( + f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}" + ) return user diff --git a/app/user/schemas/__init__.py b/app/user/schemas/__init__.py index 6841f87..4709e8a 100644 --- a/app/user/schemas/__init__.py +++ b/app/user/schemas/__init__.py @@ -1,5 +1,4 @@ from app.user.schemas.user_schema import ( - AccessTokenResponse, KakaoCodeRequest, KakaoLoginResponse, KakaoTokenResponse, @@ -12,7 +11,6 @@ from app.user.schemas.user_schema import ( ) __all__ = [ - "AccessTokenResponse", "KakaoCodeRequest", "KakaoLoginResponse", "KakaoTokenResponse", diff --git a/app/user/schemas/user_schema.py b/app/user/schemas/user_schema.py index e0c6337..d10fd13 100644 --- a/app/user/schemas/user_schema.py +++ b/app/user/schemas/user_schema.py @@ -64,24 +64,6 @@ class TokenResponse(BaseModel): } -class AccessTokenResponse(BaseModel): - """액세스 토큰 갱신 응답""" - - access_token: str = Field(..., description="액세스 토큰") - token_type: str = Field(default="Bearer", description="토큰 타입") - expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)") - - model_config = { - "json_schema_extra": { - "example": { - "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzA1MzE1MjAwfQ.new_token", - "token_type": "Bearer", - "expires_in": 3600 - } - } - } - - class RefreshTokenRequest(BaseModel): """토큰 갱신 요청""" diff --git a/app/user/services/auth.py b/app/user/services/auth.py index 35269e7..31a071a 100644 --- a/app/user/services/auth.py +++ b/app/user/services/auth.py @@ -81,9 +81,9 @@ class AdminRequiredError(AuthException): from app.user.models import RefreshToken, User from app.utils.common import generate_uuid from app.user.schemas.user_schema import ( - AccessTokenResponse, KakaoUserInfo, LoginResponse, + TokenResponse, ) from app.user.services.jwt import ( create_access_token, @@ -188,59 +188,129 @@ class AuthService: self, refresh_token: str, session: AsyncSession, - ) -> AccessTokenResponse: + ) -> TokenResponse: """ - 리프레시 토큰으로 액세스 토큰 갱신 + 리프레시 토큰으로 액세스 토큰 + 리프레시 토큰 갱신 (Refresh Token Rotation) + + 기존 리프레시 토큰을 폐기하고, 새 액세스 토큰과 새 리프레시 토큰을 함께 발급합니다. + 사용자가 서비스를 지속 사용하는 한 세션이 자동 유지됩니다. Args: refresh_token: 리프레시 토큰 session: DB 세션 Returns: - AccessTokenResponse: 새 액세스 토큰 + TokenResponse: 새 액세스 토큰 + 새 리프레시 토큰 Raises: InvalidTokenError: 토큰이 유효하지 않은 경우 TokenExpiredError: 토큰이 만료된 경우 TokenRevokedError: 토큰이 폐기된 경우 """ + logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}") + # 1. 토큰 디코딩 및 검증 payload = decode_token(refresh_token) if payload is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}") raise InvalidTokenError() if payload.get("type") != "refresh": + logger.warning( + f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, " + f"sub: {payload.get('sub')}" + ) raise InvalidTokenError("리프레시 토큰이 아닙니다.") + logger.debug( + f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, " + f"exp: {payload.get('exp')}" + ) + # 2. DB에서 리프레시 토큰 조회 token_hash = get_token_hash(refresh_token) db_token = await self._get_refresh_token_by_hash(token_hash, session) if db_token is None: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, " + f"token_hash: {token_hash[:16]}..." + ) raise InvalidTokenError() + logger.debug( + f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., " + f"user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, " + f"expires_at: {db_token.expires_at}" + ) + # 3. 토큰 상태 확인 if db_token.is_revoked: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), " + f"token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, " + f"revoked_at: {db_token.revoked_at}" + ) raise TokenRevokedError() + # 4. 만료 확인 if db_token.expires_at < now().replace(tzinfo=None): + logger.info( + f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, " + f"user_uuid: {db_token.user_uuid}" + ) raise TokenExpiredError() - # 4. 사용자 확인 + # 5. 사용자 확인 user_uuid = payload.get("sub") user = await self._get_user_by_uuid(user_uuid, session) if user is None: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}" + ) raise UserNotFoundError() if not user.is_active: + logger.warning( + f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, " + f"user_id: {user.id}" + ) raise UserInactiveError() - # 5. 새 액세스 토큰 발급 - new_access_token = create_access_token(user.user_uuid) + # 6. 기존 리프레시 토큰 폐기 (ORM 직접 수정 — _revoke_refresh_token_by_hash는 내부 commit이 있어 사용하지 않음) + db_token.is_revoked = True + db_token.revoked_at = now().replace(tzinfo=None) + logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...") - return AccessTokenResponse( + # 7. 새 토큰 발급 + new_access_token = create_access_token(user.user_uuid) + new_refresh_token = create_refresh_token(user.user_uuid) + logger.debug( + f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, " + f"new_refresh: ...{new_refresh_token[-20:]}" + ) + + # 8. 새 리프레시 토큰 DB 저장 (_save_refresh_token은 flush만 수행) + await self._save_refresh_token( + user_id=user.id, + user_uuid=user.user_uuid, + token=new_refresh_token, + session=session, + ) + + # 폐기 + 저장을 하나의 트랜잭션으로 커밋 + await session.commit() + + logger.info( + f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, " + f"user_id: {user.id}, old_hash: {token_hash[:16]}..., " + f"new_refresh: ...{new_refresh_token[-20:]}" + ) + + return TokenResponse( access_token=new_access_token, + refresh_token=new_refresh_token, token_type="Bearer", expires_in=get_access_token_expire_seconds(), ) @@ -260,7 +330,12 @@ class AuthService: session: DB 세션 """ token_hash = get_token_hash(refresh_token) + logger.info( + f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., " + f"token: ...{refresh_token[-20:]}" + ) await self._revoke_refresh_token_by_hash(token_hash, session) + logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}") async def logout_all( self, @@ -274,7 +349,9 @@ class AuthService: user_id: 사용자 ID session: DB 세션 """ + logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}") await self._revoke_all_user_tokens(user_id, session) + logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}") async def _get_or_create_user( self, @@ -404,6 +481,11 @@ class AuthService: ) session.add(refresh_token) await session.flush() + + logger.debug( + f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, " + f"token_hash: {token_hash[:16]}..., expires_at: {expires_at}" + ) return refresh_token async def _get_refresh_token_by_hash( diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py index ebcf2d6..39f0b5a 100644 --- a/app/user/services/jwt.py +++ b/app/user/services/jwt.py @@ -5,14 +5,18 @@ Access Token과 Refresh Token의 생성, 검증, 해시 기능을 제공합니 """ import hashlib +import logging from datetime import datetime, timedelta from typing import Optional from jose import JWTError, jwt +from jose.exceptions import ExpiredSignatureError, JWTClaimsError from app.utils.timezone import now from config import jwt_settings +logger = logging.getLogger(__name__) + def create_access_token(user_uuid: str) -> str: """ @@ -32,11 +36,16 @@ def create_access_token(user_uuid: str) -> str: "exp": expire, "type": "access", } - return jwt.encode( + token = jwt.encode( to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM, ) + logger.debug( + f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, " + f"expires: {expire}, token: ...{token[-20:]}" + ) + return token def create_refresh_token(user_uuid: str) -> str: @@ -57,11 +66,16 @@ def create_refresh_token(user_uuid: str) -> str: "exp": expire, "type": "refresh", } - return jwt.encode( + token = jwt.encode( to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM, ) + logger.debug( + f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, " + f"expires: {expire}, token: ...{token[-20:]}" + ) + return token def decode_token(token: str) -> Optional[dict]: @@ -80,8 +94,25 @@ def decode_token(token: str) -> Optional[dict]: jwt_settings.JWT_SECRET, algorithms=[jwt_settings.JWT_ALGORITHM], ) + logger.debug( + f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, " + f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, " + f"token: ...{token[-20:]}" + ) return payload - except JWTError: + except ExpiredSignatureError: + logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}") + return None + except JWTClaimsError as e: + logger.warning( + f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}" + ) + return None + except JWTError as e: + logger.warning( + f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, " + f"token: ...{token[-20:]}" + ) return None diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index b203082..94e39ff 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -7,7 +7,6 @@ Video API Router - POST /video/generate/{task_id}: 영상 생성 요청 (task_id로 Project/Lyric/Song 연결) - GET /video/status/{creatomate_render_id}: Creatomate API 영상 생성 상태 조회 - GET /video/download/{task_id}: 영상 다운로드 상태 조회 (DB polling) - - GET /video/list: 완료된 영상 목록 조회 (페이지네이션) 사용 예시: from app.video.api.routers.v1.video import router @@ -18,11 +17,10 @@ import json from typing import Literal from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database.session import get_session -from app.dependencies.pagination import PaginationParams, get_pagination_params from app.user.dependencies.auth import get_current_user from app.user.models import User from app.home.models import Image, Project @@ -30,13 +28,11 @@ from app.lyric.models import Lyric from app.song.models import Song, SongTimestamp from app.utils.creatomate import CreatomateService from app.utils.logger import get_logger -from app.utils.pagination import PaginatedResponse from app.video.models import Video from app.video.schemas.video_schema import ( DownloadVideoResponse, GenerateVideoResponse, PollingVideoResponse, - VideoListItem, VideoRenderData, ) from app.video.worker.video_task import download_and_upload_video_to_blob @@ -738,126 +734,3 @@ async def download_video( message="영상 다운로드 조회에 실패했습니다.", error_message=str(e), ) - - -@router.get( - "/list", - summary="생성된 영상 목록 조회", - description=""" -완료된 영상 목록을 페이지네이션하여 조회합니다. - -## 인증 -**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다. - -## 쿼리 파라미터 -- **page**: 페이지 번호 (1부터 시작, 기본값: 1) -- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100) - -## 반환 정보 -- **items**: 영상 목록 (store_name, region, task_id, result_movie_url, created_at) -- **total**: 전체 데이터 수 -- **page**: 현재 페이지 -- **page_size**: 페이지당 데이터 수 -- **total_pages**: 전체 페이지 수 -- **has_next**: 다음 페이지 존재 여부 -- **has_prev**: 이전 페이지 존재 여부 - -## 사용 예시 (cURL) -```bash -curl -X GET "http://localhost:8000/video/list?page=1&page_size=10" \\ - -H "Authorization: Bearer {access_token}" -``` - -## 참고 -- status가 'completed'인 영상만 반환됩니다. -- 동일한 task_id가 있는 경우 가장 최근에 생성된 1개만 반환됩니다. -- created_at 기준 내림차순 정렬됩니다. - """, - response_model=PaginatedResponse[VideoListItem], - responses={ - 200: {"description": "영상 목록 조회 성공"}, - 401: {"description": "인증 실패 (토큰 없음/만료)"}, - 500: {"description": "조회 실패"}, - }, -) -async def get_videos( - current_user: User = Depends(get_current_user), - session: AsyncSession = Depends(get_session), - pagination: PaginationParams = Depends(get_pagination_params), -) -> PaginatedResponse[VideoListItem]: - """완료된 영상 목록을 페이지네이션하여 반환합니다.""" - logger.info( - f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}" - ) - try: - offset = (pagination.page - 1) * pagination.page_size - - # 서브쿼리: task_id별 최신 Video의 id 조회 (completed 상태만) - subquery = ( - select(func.max(Video.id).label("max_id")) - .where(Video.status == "completed") - .group_by(Video.task_id) - .subquery() - ) - - # 전체 개수 조회 (task_id별 최신 1개만) - count_query = select(func.count()).select_from(subquery) - total_result = await session.execute(count_query) - total = total_result.scalar() or 0 - - # 데이터 조회 (completed 상태, task_id별 최신 1개만, 최신순) - query = ( - select(Video) - .where(Video.id.in_(select(subquery.c.max_id))) - .order_by(Video.created_at.desc()) - .offset(offset) - .limit(pagination.page_size) - ) - result = await session.execute(query) - videos = result.scalars().all() - - # Project 정보 일괄 조회 (N+1 문제 해결) - project_ids = [v.project_id for v in videos if v.project_id] - projects_map: dict = {} - if project_ids: - projects_result = await session.execute( - select(Project).where(Project.id.in_(project_ids)) - ) - projects_map = {p.id: p for p in projects_result.scalars().all()} - - # VideoListItem으로 변환 - items = [] - for video in videos: - project = projects_map.get(video.project_id) - - item = VideoListItem( - video_id=video.id, - store_name=project.store_name if project else None, - region=project.region if project else None, - task_id=video.task_id, - result_movie_url=video.result_movie_url, - created_at=video.created_at, - ) - items.append(item) - - response = PaginatedResponse.create( - items=items, - total=total, - page=pagination.page, - page_size=pagination.page_size, - ) - - logger.info( - f"[get_videos] SUCCESS - total: {total}, page: {pagination.page}, " - f"page_size: {pagination.page_size}, items_count: {len(items)}" - ) - return response - - except Exception as e: - logger.error(f"[get_videos] EXCEPTION - error: {e}") - raise HTTPException( - status_code=500, - detail=f"영상 목록 조회에 실패했습니다: {str(e)}", - ) - - diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 0000000..86ca116 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,788 @@ + + + + + + O2O CastAD Backend - 인프라 아키텍처 + + + + + + + +
+
+

O2O CastAD Backend

+

인프라 아키텍처 및 비용 산출 문서

+
+ + +
+
+

1DB 및 서버 부하 분산 방법

+

Nginx 로드밸런싱, 커넥션 풀 관리, 단계별 수평 확장 전략

+
+ +
+
+

현재 구현 현황 (단일 인스턴스)

+
    +
  • API 커넥션 풀: pool_size=20, max_overflow=20 → 최대 40
  • +
  • 백그라운드 풀: pool_size=10, max_overflow=10 → 최대 20
  • +
  • 인스턴스당 총 DB 연결: 40 + 20 = 60
  • +
  • 풀 리사이클: 280초 (MySQL wait_timeout 300초 이전), pre-ping 활성화
  • +
+ +

단계별 확장 전략

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
단계동시접속App ServerLBDB ( MySQL Flexible)
S1~50명x1Nginx x1Burstable B1ms
S250~200명x2~4NginxGP D2ds_v4 + Replica x1
S3200~1,000명API ServerxN
+ Scheduler
NginxBC D4ds_v4 + Replica x2 + Redis P1
+
+ +
+

커넥션 풀 수치 계산

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목Stage 1 (1대)Stage 2 (4대)Stage 3 (8대)
Main Pool / 인스턴스20+20 = 4010+10 = 205+5 = 10
BG Pool / 인스턴스10+10 = 205+5 = 103+3 = 6
인스턴스당 소계603016
Primary 총 연결604 x 30 = 1208 x 16 = 128
max_connections 권장100200300
+ +
+ 핵심: + JWT Stateless 설계로 Nginx 세션 어피니티 불필요 (round-robin / least_conn). + Stage 2부터 Read Replica로 읽기 분산, Redis는 Stage 3에서 캐싱/Rate Limiting 도입. +
+
+
+ + +
+
+graph TB
+    subgraph S1["Stage 1: ~50명"]
+        direction LR
+        S1N["Nginx
(Reverse Proxy)"] --> S1A["App Server x1"] + S1A --> S1D[" MySQL
Burstable B1ms"] + end + + subgraph S2["Stage 2: 50~200명"] + direction LR + S2N["Nginx
(Reverse Proxy)"] --> S2API["APP Server
x 1 ~ 2"] + S2N --> S2WK["Scheduler
Server"] + S2API --> S2P["MySQL BC
Primary
(D4ds_v4)"] + S2API --> S2R1["Read Replica
x1"] + S2WK --> S2P + S2WK --> S2R1 + end + + subgraph S3["Stage 3: 200~1,000명"] + direction LR + S3N["Nginx
(Reverse Proxy)"] --> S3API["APP Server
x N"] + S3N --> S3WK["Scheduler
Server"] + S3API --> S3P["MySQL BC
Primary
(D4ds_v4)"] + S3API --> S3R1["Read Replica
xN"] + S3API --> S3RD["Redis
Premium P1"] + S3WK --> S3P + S3WK --> S3R1 + end + + S1 ~~~ S2 ~~~ S3 + + style S1 fill:#0d3320,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style S2 fill:#3b2506,stroke:#fb923c,stroke-width:2px,color:#e1e4ed + style S3 fill:#3b1010,stroke:#f87171,stroke-width:2px,color:#e1e4ed +
+
+
+ + +
+
+

2전체 아키텍처 다이어그램

+

Nginx + FastAPI App Server 구성과 외부 서비스 연동 구조

+
+ +
+
+
    +
  • 로드밸런서: Nginx (Reverse Proxy, L7 LB, SSL 종단)
  • +
  • App Server: FastAPI (Python 3.13) — Auth, Home, Lyric, Song, Video, Social, SNS, Archive, Admin, Background Worker
  • +
  • DB: Database for MySQL Flexible Server — Stage 2+ Read Replica
  • +
+
+
+
    +
  • 캐시: Cache for Redis (Stage 3 도입)
  • +
  • 콘텐츠 생성: 가사(ChatGPT) → 음악(Suno AI) → 영상(Creatomate) → SNS 업로드
  • +
  • 외부 연동: Kakao OAuth, Naver Map/Search API, Blob Storage
  • +
+
+
+ + +
+
+graph TB
+    Client["클라이언트
(Web / App)"] + LB["Nginx
(Reverse Proxy + SSL 종단)"] + + subgraph APP["App Server (FastAPI)"] + direction LR + Auth["Auth"] --- Home["Home"] --- Lyric["Lyric"] --- Song["Song"] --- Video["Video"] + Social["Social"] --- SNS["SNS"] --- Archive["Archive"] --- Admin["Admin"] --- BG["BG Worker"] + end + + subgraph DB[" MySQL Flexible Server"] + direction LR + Primary["Primary (R/W)"] + Replica["Read Replica"] + end + + subgraph AI["AI 콘텐츠 생성 파이프라인"] + direction LR + ChatGPT["ChatGPT
(가사 생성)"] + Suno["Suno AI
(음악 생성)"] + Creatomate["Creatomate
(영상 생성)"] + ChatGPT --> Suno --> Creatomate + end + + subgraph EXT["외부 서비스"] + direction LR + Blob[" Blob
Storage"] + Kakao["Kakao
OAuth"] + YT["YouTube /
Instagram"] + Naver["Naver Map /
Search API"] + end + + Redis[" Cache for Redis
(Stage 3 도입)"] + + Client -->|HTTPS| LB + LB --> APP + APP --> Primary + APP -->|"읽기 전용"| Replica + APP -.->|"Stage 3"| Redis + APP --> AI + APP --> Blob + APP --> Kakao + APP --> YT + APP --> Naver + + style Client fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style LB fill:#1a3a1a,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style APP fill:#1a2744,stroke:#6c8cff,stroke-width:2px,color:#e1e4ed + style DB fill:#2a1f00,stroke:#fb923c,stroke-width:2px,color:#e1e4ed + style AI fill:#2a0f2a,stroke:#a78bfa,stroke-width:2px,color:#e1e4ed + style EXT fill:#0d2a2a,stroke:#34d399,stroke-width:2px,color:#e1e4ed + style Redis fill:#3b1010,stroke:#f87171,stroke-width:1px,color:#e1e4ed +
+
+

전체 시스템 아키텍처 구성도

+ +
+ 콘텐츠 생성 흐름: 사용자 요청 → Naver 크롤링 → ChatGPT 가사 생성 → Suno AI 음악 생성 → Creatomate 영상 생성 → Blob 저장 → YouTube/Instagram 업로드 +
+
+ + +
+
+

3예상 리소스 및 비용

+

기반 단계별 월 예상 비용 (인프라 + 외부 API)

+
+ +
+
+
Stage 1 · 동시 ~50명
+
$170~390
+
약 22~51만원/월
+
+
+
Stage 2 · 동시 50~200명
+
$960~2,160
+
약 125~280만원/월
+
+
+
Stage 3 · 동시 200~1,000명
+
$3,850~8,500
+
약 500~1,100만원/월
+
+
+ +
+
+

항목별 비용 상세

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목Stage 1Stage 2Stage 3
App Server$50~70$200~400$600~1,000
Nginx-포함 / VM $15~30VM $30~60
MySQL PrimaryB1ms $15~25GP $130~160BC $350~450
MySQL Replica-GP x1 $130~160BC x2 $260~360
Redis--P1 $225
스토리지/네트워크$10~20$55~100$160~270
AI API (합계)$90~280$400~1,250$2,100~5,800
+
+ +
+

DB 용량 예측 (1년 후)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Stage 1 (500명)Stage 2 (5,000명)Stage 3 (50,000명)
DB 용량~1.2GB~12GB~120GB
Blob 스토리지~1.1TB~11TB~110TB
MySQL 추천32GB SSD128GB SSD512GB SSD
+ +
+ 비용 최적화 팁: + 3rd party 의존도 낮춰야함 +
+ Blob Lifecycle Policy (30일 미접근 → Cool 티어), +
+
+
+ + +
+
+pie title Stage 3 월 비용 구성 비중
+    "App Server (APP+Scheduler)" : 800
+    "Nginx" : 45
+    "MySQL Primary" : 400
+    "MySQL Replica x2" : 310
+    "Redis Premium" : 225
+    "스토리지/네트워크" : 215
+    "OpenAI API" : 550
+    "Suno AI" : 1400
+    "Creatomate" : 2000
+            
+
+

Stage 3 월간 비용 구성 비율 — AI API 비중이 전체의 약 66%

+
+
+ + + + + diff --git a/docs/architecture.pptx b/docs/architecture.pptx new file mode 100644 index 0000000..561b793 Binary files /dev/null and b/docs/architecture.pptx differ diff --git a/docs/generate_ppt.py b/docs/generate_ppt.py new file mode 100644 index 0000000..26c6122 --- /dev/null +++ b/docs/generate_ppt.py @@ -0,0 +1,551 @@ +""" +O2O CastAD Backend - 인프라 아키텍처 PPT 생성 스크립트 +실행: python3 docs/generate_ppt.py +출력: docs/architecture.pptx +""" + +from pptx import Presentation +from pptx.util import Inches, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.enum.shapes import MSO_SHAPE + +# ── 색상 팔레트 (HTML 다크 테마 매칭) ── +BG = RGBColor(0x0F, 0x11, 0x17) +SURFACE = RGBColor(0x1A, 0x1D, 0x27) +SURFACE2 = RGBColor(0x23, 0x27, 0x33) +BORDER = RGBColor(0x2E, 0x33, 0x45) +TEXT = RGBColor(0xE1, 0xE4, 0xED) +TEXT_DIM = RGBColor(0x8B, 0x90, 0xA0) +ACCENT = RGBColor(0x6C, 0x8C, 0xFF) +ACCENT2 = RGBColor(0xA7, 0x8B, 0xFA) +GREEN = RGBColor(0x34, 0xD3, 0x99) +ORANGE = RGBColor(0xFB, 0x92, 0x3C) +RED = RGBColor(0xF8, 0x71, 0x71) +WHITE = RGBColor(0xFF, 0xFF, 0xFF) + +SLIDE_W = Inches(13.333) +SLIDE_H = Inches(7.5) + + +def set_slide_bg(slide, color): + bg = slide.background + fill = bg.fill + fill.solid() + fill.fore_color.rgb = color + + +def add_textbox(slide, left, top, width, height, text, font_size=14, + color=TEXT, bold=False, alignment=PP_ALIGN.LEFT, font_name="맑은 고딕"): + txBox = slide.shapes.add_textbox(left, top, width, height) + tf = txBox.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + p.text = text + p.font.size = Pt(font_size) + p.font.color.rgb = color + p.font.bold = bold + p.font.name = font_name + p.alignment = alignment + return txBox + + +def add_bullet_list(slide, left, top, width, height, items, font_size=13): + txBox = slide.shapes.add_textbox(left, top, width, height) + tf = txBox.text_frame + tf.word_wrap = True + for i, item in enumerate(items): + if i == 0: + p = tf.paragraphs[0] + else: + p = tf.add_paragraph() + p.space_after = Pt(4) + p.font.size = Pt(font_size) + p.font.color.rgb = TEXT_DIM + p.font.name = "맑은 고딕" + # bold 부분 처리 + if isinstance(item, tuple): + run_bold = p.add_run() + run_bold.text = item[0] + run_bold.font.bold = True + run_bold.font.color.rgb = TEXT + run_bold.font.size = Pt(font_size) + run_bold.font.name = "맑은 고딕" + run_normal = p.add_run() + run_normal.text = item[1] + run_normal.font.color.rgb = TEXT_DIM + run_normal.font.size = Pt(font_size) + run_normal.font.name = "맑은 고딕" + else: + p.text = f"• {item}" + return txBox + + +def add_table(slide, left, top, width, height, headers, rows, col_widths=None): + n_rows = len(rows) + 1 + n_cols = len(headers) + table_shape = slide.shapes.add_table(n_rows, n_cols, left, top, width, height) + table = table_shape.table + + # 컬럼 폭 설정 + if col_widths: + for i, w in enumerate(col_widths): + table.columns[i].width = w + + # 헤더 행 + for j, h in enumerate(headers): + cell = table.cell(0, j) + cell.text = h + for paragraph in cell.text_frame.paragraphs: + paragraph.font.size = Pt(11) + paragraph.font.bold = True + paragraph.font.color.rgb = ACCENT + paragraph.font.name = "맑은 고딕" + paragraph.alignment = PP_ALIGN.CENTER + cell.fill.solid() + cell.fill.fore_color.rgb = SURFACE2 + + # 데이터 행 + for i, row in enumerate(rows): + for j, val in enumerate(row): + cell = table.cell(i + 1, j) + cell.text = str(val) + for paragraph in cell.text_frame.paragraphs: + paragraph.font.size = Pt(10) + paragraph.font.color.rgb = TEXT_DIM + paragraph.font.name = "맑은 고딕" + paragraph.alignment = PP_ALIGN.CENTER + cell.fill.solid() + cell.fill.fore_color.rgb = SURFACE if i % 2 == 0 else BG + + # 테이블 테두리 제거 (깔끔하게) + for i in range(n_rows): + for j in range(n_cols): + cell = table.cell(i, j) + cell.vertical_anchor = MSO_ANCHOR.MIDDLE + for border_name in ['top', 'bottom', 'left', 'right']: + border = getattr(cell, f'border_{border_name}' if hasattr(cell, f'border_{border_name}') else border_name, None) + + return table_shape + + +def add_rounded_rect(slide, left, top, width, height, fill_color, border_color=None, text="", + font_size=12, text_color=TEXT, bold=False): + shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height) + shape.fill.solid() + shape.fill.fore_color.rgb = fill_color + if border_color: + shape.line.color.rgb = border_color + shape.line.width = Pt(1.5) + else: + shape.line.fill.background() + if text: + tf = shape.text_frame + tf.word_wrap = True + p = tf.paragraphs[0] + p.text = text + p.font.size = Pt(font_size) + p.font.color.rgb = text_color + p.font.bold = bold + p.font.name = "맑은 고딕" + p.alignment = PP_ALIGN.CENTER + tf.paragraphs[0].space_before = Pt(0) + tf.paragraphs[0].space_after = Pt(0) + return shape + + +def add_section_number(slide, left, top, number, color=ACCENT): + shape = slide.shapes.add_shape(MSO_SHAPE.OVAL, left, top, Inches(0.4), Inches(0.4)) + shape.fill.solid() + shape.fill.fore_color.rgb = color + shape.line.fill.background() + tf = shape.text_frame + p = tf.paragraphs[0] + p.text = str(number) + p.font.size = Pt(14) + p.font.color.rgb = WHITE + p.font.bold = True + p.font.name = "맑은 고딕" + p.alignment = PP_ALIGN.CENTER + return shape + + +def add_arrow(slide, x1, y1, x2, y2, color=ACCENT): + connector = slide.shapes.add_connector(1, x1, y1, x2, y2) # 1 = straight + connector.line.color.rgb = color + connector.line.width = Pt(1.5) + connector.end_x = x2 + connector.end_y = y2 + return connector + + +# ══════════════════════════════════════════════════════════════════ +# PPT 생성 시작 +# ══════════════════════════════════════════════════════════════════ +prs = Presentation() +prs.slide_width = SLIDE_W +prs.slide_height = SLIDE_H + +blank_layout = prs.slide_layouts[6] # Blank + +# ── Slide 1: 타이틀 ── +slide = prs.slides.add_slide(blank_layout) +set_slide_bg(slide, BG) + +add_textbox(slide, Inches(0), Inches(2.2), SLIDE_W, Inches(1), + "O2O CastAD Backend", font_size=44, color=ACCENT, bold=True, + alignment=PP_ALIGN.CENTER) +add_textbox(slide, Inches(0), Inches(3.3), SLIDE_W, Inches(0.6), + "인프라 아키텍처 및 비용 산출 문서", font_size=20, color=TEXT_DIM, + alignment=PP_ALIGN.CENTER) + +# 하단 구분선 +line = slide.shapes.add_connector(1, Inches(4.5), Inches(4.2), Inches(8.8), Inches(4.2)) +line.line.color.rgb = ACCENT +line.line.width = Pt(2) + +add_textbox(slide, Inches(0), Inches(4.5), SLIDE_W, Inches(0.5), + "Nginx + FastAPI + MySQL + AI Pipeline", font_size=14, color=TEXT_DIM, + alignment=PP_ALIGN.CENTER) + + +# ── Slide 2: 부하 분산 - 현재 구현 & 확장 전략 ── +slide = prs.slides.add_slide(blank_layout) +set_slide_bg(slide, BG) + +add_section_number(slide, Inches(0.5), Inches(0.4), "1") +add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5), + "DB 및 서버 부하 분산 방법", font_size=26, color=ACCENT, bold=True) +add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3), + "Nginx 로드밸런싱, 커넥션 풀 관리, 단계별 수평 확장 전략", font_size=12, color=TEXT_DIM) + +# 좌측: 현재 구현 현황 +add_textbox(slide, Inches(0.5), Inches(1.5), Inches(5), Inches(0.4), + "현재 구현 현황 (단일 인스턴스)", font_size=16, color=TEXT, bold=True) + +items = [ + ("API 커넥션 풀: ", "pool_size=20, max_overflow=20 → 최대 40"), + ("백그라운드 풀: ", "pool_size=10, max_overflow=10 → 최대 20"), + ("인스턴스당 총 DB 연결: ", "40 + 20 = 60"), + ("풀 리사이클: ", "280초 (MySQL wait_timeout 300초 이전)"), + ("Pre-ping: ", "활성화 (죽은 커넥션 자동 복구)"), +] +add_bullet_list(slide, Inches(0.5), Inches(2.0), Inches(5.5), Inches(2.5), items, font_size=12) + +# 우측: 확장 전략 테이블 +add_textbox(slide, Inches(6.8), Inches(1.5), Inches(6), Inches(0.4), + "단계별 확장 전략", font_size=16, color=TEXT, bold=True) + +headers = ["단계", "동시접속", "App Server", "LB", "DB (MySQL Flexible)"] +rows = [ + ["S1", "~50명", "x1", "Nginx x1", "Burstable B1ms"], + ["S2", "50~200명", "x2~4", "Nginx", "GP D2ds + Replica x1"], + ["S3", "200~1,000명", "API xN\n+ Scheduler", "Nginx", "BC D4ds + Replica x2\n+ Redis P1"], +] +add_table(slide, Inches(6.8), Inches(2.0), Inches(6), Inches(2.0), headers, rows) + +# 하단: 핵심 노트 +note_shape = add_rounded_rect(slide, Inches(0.5), Inches(4.6), Inches(12.3), Inches(0.7), + SURFACE2, ACCENT) +tf = note_shape.text_frame +tf.word_wrap = True +p = tf.paragraphs[0] +p.text = "" +run = p.add_run() +run.text = "핵심: " +run.font.bold = True +run.font.color.rgb = ACCENT +run.font.size = Pt(11) +run.font.name = "맑은 고딕" +run = p.add_run() +run.text = "JWT Stateless 설계로 Nginx 세션 어피니티 불필요 (round-robin / least_conn). Stage 2부터 Read Replica로 읽기 분산, Redis는 Stage 3에서 캐싱/Rate Limiting 도입." +run.font.color.rgb = TEXT_DIM +run.font.size = Pt(11) +run.font.name = "맑은 고딕" + + +# ── Slide 3: 커넥션 풀 수치 계산 ── +slide = prs.slides.add_slide(blank_layout) +set_slide_bg(slide, BG) + +add_section_number(slide, Inches(0.5), Inches(0.4), "1") +add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5), + "커넥션 풀 수치 계산", font_size=26, color=ACCENT, bold=True) +add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3), + "인스턴스 수 증가에 따른 인스턴스당 풀 사이즈 축소 및 총 DB 커넥션 관리", font_size=12, color=TEXT_DIM) + +headers = ["항목", "Stage 1 (1대)", "Stage 2 (4대)", "Stage 3 (8대)"] +rows = [ + ["Main Pool / 인스턴스", "20+20 = 40", "10+10 = 20", "5+5 = 10"], + ["BG Pool / 인스턴스", "10+10 = 20", "5+5 = 10", "3+3 = 6"], + ["인스턴스당 소계", "60", "30", "16"], + ["Primary 총 연결", "60", "4 x 30 = 120", "8 x 16 = 128"], + ["max_connections 권장", "100", "200", "300"], +] +add_table(slide, Inches(1.5), Inches(1.6), Inches(10.3), Inches(2.8), headers, rows) + +# 시각적 요약 박스 +stages = [ + ("Stage 1", "1대 × 60 = 60", GREEN, Inches(2)), + ("Stage 2", "4대 × 30 = 120", ORANGE, Inches(5.5)), + ("Stage 3", "8대 × 16 = 128", RED, Inches(9)), +] +for label, val, color, left in stages: + add_rounded_rect(slide, left, Inches(4.8), Inches(2.3), Inches(0.9), + SURFACE2, color, f"{label}\n{val}", font_size=13, text_color=color, bold=True) + + +# ── Slide 4: 아키텍처 다이어그램 (상세 블록) ── +slide = prs.slides.add_slide(blank_layout) +set_slide_bg(slide, BG) + +add_section_number(slide, Inches(0.5), Inches(0.4), "2") +add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5), + "전체 아키텍처 다이어그램", font_size=26, color=ACCENT, bold=True) +add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3), + "Nginx + FastAPI App Server 구성과 외부 서비스 연동 구조", font_size=12, color=TEXT_DIM) + +# 클라이언트 +add_rounded_rect(slide, Inches(5.5), Inches(1.4), Inches(2.3), Inches(0.6), + RGBColor(0x1A, 0x3A, 0x1A), GREEN, "클라이언트 (Web / App)", + font_size=11, text_color=TEXT) + +# Nginx +add_rounded_rect(slide, Inches(5.5), Inches(2.3), Inches(2.3), Inches(0.6), + RGBColor(0x1A, 0x3A, 0x1A), GREEN, "Nginx\n(Reverse Proxy + SSL)", + font_size=10, text_color=TEXT) + +# App Server +app_box = add_rounded_rect(slide, Inches(2.5), Inches(3.2), Inches(8.3), Inches(1.2), + RGBColor(0x1A, 0x27, 0x44), ACCENT, "", font_size=10) +add_textbox(slide, Inches(2.7), Inches(3.15), Inches(3), Inches(0.3), + "App Server (FastAPI)", font_size=11, color=ACCENT, bold=True) + +modules = ["Auth", "Home", "Lyric", "Song", "Video", "Social", "SNS", "Archive", "Admin", "BG Worker"] +for i, mod in enumerate(modules): + col = i % 10 + x = Inches(2.7 + col * 0.8) + y = Inches(3.55) + add_rounded_rect(slide, x, y, Inches(0.7), Inches(0.5), + SURFACE2, BORDER, mod, font_size=8, text_color=TEXT_DIM) + +# DB +add_rounded_rect(slide, Inches(1.0), Inches(4.85), Inches(3.5), Inches(0.7), + RGBColor(0x2A, 0x1F, 0x00), ORANGE, "MySQL Flexible Server\nPrimary (R/W) + Read Replica", + font_size=10, text_color=TEXT) + +# Redis +add_rounded_rect(slide, Inches(5.0), Inches(4.85), Inches(2.3), Inches(0.7), + RGBColor(0x3B, 0x10, 0x10), RED, "Cache for Redis\n(Stage 3 도입)", + font_size=10, text_color=TEXT) + +# AI Pipeline +add_rounded_rect(slide, Inches(7.8), Inches(4.85), Inches(4.5), Inches(0.7), + RGBColor(0x2A, 0x0F, 0x2A), ACCENT2, "AI Pipeline: ChatGPT → Suno AI → Creatomate", + font_size=10, text_color=TEXT) + +# 외부 서비스 +ext_items = [("Blob\nStorage", Inches(1.0)), ("Kakao\nOAuth", Inches(3.2)), + ("YouTube /\nInstagram", Inches(5.4)), ("Naver Map /\nSearch", Inches(7.6))] +for label, x in ext_items: + add_rounded_rect(slide, x, Inches(6.0), Inches(1.8), Inches(0.7), + RGBColor(0x0D, 0x2A, 0x2A), GREEN, label, + font_size=9, text_color=TEXT_DIM) + +# 콘텐츠 생성 흐름 노트 +note_shape = add_rounded_rect(slide, Inches(1.0), Inches(6.85), Inches(11.3), Inches(0.45), + SURFACE2, ACCENT) +tf = note_shape.text_frame +tf.word_wrap = True +p = tf.paragraphs[0] +run = p.add_run() +run.text = "콘텐츠 생성 흐름: " +run.font.bold = True +run.font.color.rgb = ACCENT +run.font.size = Pt(10) +run.font.name = "맑은 고딕" +run = p.add_run() +run.text = "사용자 요청 → Naver 크롤링 → ChatGPT 가사 → Suno AI 음악 → Creatomate 영상 → Blob 저장 → YouTube/Instagram 업로드" +run.font.color.rgb = TEXT_DIM +run.font.size = Pt(10) +run.font.name = "맑은 고딕" + + +# ── Slide 5: Stage별 인프라 스케일링 ── +slide = prs.slides.add_slide(blank_layout) +set_slide_bg(slide, BG) + +add_section_number(slide, Inches(0.5), Inches(0.4), "2") +add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5), + "단계별 인프라 스케일링", font_size=26, color=ACCENT, bold=True) + +# Stage 1 +stage1_box = add_rounded_rect(slide, Inches(0.5), Inches(1.3), Inches(3.8), Inches(5.2), + RGBColor(0x0D, 0x33, 0x20), GREEN) +add_textbox(slide, Inches(0.7), Inches(1.4), Inches(3.4), Inches(0.3), + "Stage 1: ~50명", font_size=16, color=GREEN, bold=True) +items = [ + ("Nginx: ", "Reverse Proxy x1"), + ("App Server: ", "x1"), + ("MySQL: ", "Burstable B1ms"), + ("Redis: ", "미사용"), + ("월 비용: ", "$170~390"), +] +add_bullet_list(slide, Inches(0.7), Inches(1.9), Inches(3.4), Inches(3), items, font_size=12) + +# Stage 2 +stage2_box = add_rounded_rect(slide, Inches(4.7), Inches(1.3), Inches(3.8), Inches(5.2), + RGBColor(0x3B, 0x25, 0x06), ORANGE) +add_textbox(slide, Inches(4.9), Inches(1.4), Inches(3.4), Inches(0.3), + "Stage 2: 50~200명", font_size=16, color=ORANGE, bold=True) +items = [ + ("Nginx: ", "Reverse Proxy (LB)"), + ("App Server: ", "x2~4"), + ("Scheduler: ", "Server x1"), + ("MySQL: ", "GP D2ds + Replica x1"), + ("Redis: ", "미사용"), + ("월 비용: ", "$960~2,160"), +] +add_bullet_list(slide, Inches(4.9), Inches(1.9), Inches(3.4), Inches(3.5), items, font_size=12) + +# Stage 3 +stage3_box = add_rounded_rect(slide, Inches(8.9), Inches(1.3), Inches(3.8), Inches(5.2), + RGBColor(0x3B, 0x10, 0x10), RED) +add_textbox(slide, Inches(9.1), Inches(1.4), Inches(3.4), Inches(0.3), + "Stage 3: 200~1,000명", font_size=16, color=RED, bold=True) +items = [ + ("Nginx: ", "Reverse Proxy (LB)"), + ("API Server: ", "x N (Auto Scale)"), + ("Scheduler: ", "Server x1"), + ("MySQL: ", "BC D4ds + Replica x2"), + ("Redis: ", "Premium P1 (캐싱)"), + ("월 비용: ", "$3,850~8,500"), +] +add_bullet_list(slide, Inches(9.1), Inches(1.9), Inches(3.4), Inches(3.5), items, font_size=12) + + +# ── Slide 6: 비용 산출 개요 ── +slide = prs.slides.add_slide(blank_layout) +set_slide_bg(slide, BG) + +add_section_number(slide, Inches(0.5), Inches(0.4), "3") +add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5), + "예상 리소스 및 비용", font_size=26, color=ACCENT, bold=True) +add_textbox(slide, Inches(1.0), Inches(0.85), Inches(10), Inches(0.3), + "단계별 월 예상 비용 (인프라 + 외부 API)", font_size=12, color=TEXT_DIM) + +# 비용 카드 3개 +cost_data = [ + ("Stage 1", "동시 ~50명", "$170~390", "약 22~51만원/월", GREEN), + ("Stage 2", "동시 50~200명", "$960~2,160", "약 125~280만원/월", ORANGE), + ("Stage 3", "동시 200~1,000명", "$3,850~8,500", "약 500~1,100만원/월", RED), +] +for i, (stage, users, usd, krw, color) in enumerate(cost_data): + x = Inches(1.0 + i * 3.9) + card = add_rounded_rect(slide, x, Inches(1.5), Inches(3.5), Inches(1.8), SURFACE2, color) + + add_textbox(slide, x, Inches(1.6), Inches(3.5), Inches(0.3), + f"{stage} · {users}", font_size=12, color=TEXT_DIM, alignment=PP_ALIGN.CENTER) + add_textbox(slide, x, Inches(2.0), Inches(3.5), Inches(0.5), + usd, font_size=28, color=color, bold=True, alignment=PP_ALIGN.CENTER) + add_textbox(slide, x, Inches(2.6), Inches(3.5), Inches(0.3), + krw, font_size=12, color=TEXT_DIM, alignment=PP_ALIGN.CENTER) + +# 항목별 비용 상세 테이블 +add_textbox(slide, Inches(0.5), Inches(3.6), Inches(5), Inches(0.4), + "항목별 비용 상세", font_size=16, color=TEXT, bold=True) + +headers = ["항목", "Stage 1", "Stage 2", "Stage 3"] +rows = [ + ["App Server", "$50~70", "$200~400", "$600~1,000"], + ["Nginx", "포함", "포함 / VM $15~30", "VM $30~60"], + ["MySQL Primary", "B1ms $15~25", "GP $130~160", "BC $350~450"], + ["MySQL Replica", "-", "GP x1 $130~160", "BC x2 $260~360"], + ["Redis", "-", "-", "P1 $225"], + ["스토리지/네트워크", "$10~20", "$55~100", "$160~270"], + ["AI API (합계)", "$90~280", "$400~1,250", "$2,100~5,800"], +] +add_table(slide, Inches(0.5), Inches(4.1), Inches(12.3), Inches(3.0), headers, rows) + + +# ── Slide 7: 비용 구성 비중 & DB 용량 ── +slide = prs.slides.add_slide(blank_layout) +set_slide_bg(slide, BG) + +add_section_number(slide, Inches(0.5), Inches(0.4), "3") +add_textbox(slide, Inches(1.0), Inches(0.35), Inches(8), Inches(0.5), + "Stage 3 비용 구성 & DB 용량", font_size=26, color=ACCENT, bold=True) + +# 좌측: Stage 3 비용 구성 (수평 바 차트 시뮬레이션) +add_textbox(slide, Inches(0.5), Inches(1.2), Inches(5), Inches(0.4), + "Stage 3 월 비용 구성 비중", font_size=16, color=TEXT, bold=True) + +cost_items = [ + ("Creatomate", 2000, RED), + ("Suno AI", 1400, ORANGE), + ("App Server", 800, ACCENT), + ("OpenAI API", 550, ACCENT2), + ("MySQL Primary", 400, ORANGE), + ("MySQL Replica x2", 310, ORANGE), + ("Redis Premium", 225, RED), + ("스토리지/네트워크", 215, GREEN), + ("Nginx", 45, GREEN), +] +total = sum(v for _, v, _ in cost_items) +max_bar_width = 5.0 # inches + +y_start = Inches(1.7) +for i, (label, value, color) in enumerate(cost_items): + y = y_start + Emu(int(i * Inches(0.45))) + bar_w = Inches(max_bar_width * value / total) + + # 바 + bar = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(2.2), y, bar_w, Inches(0.32)) + bar.fill.solid() + bar.fill.fore_color.rgb = color + bar.line.fill.background() + + # 레이블 + add_textbox(slide, Inches(0.5), y, Inches(1.6), Inches(0.32), + label, font_size=9, color=TEXT_DIM, alignment=PP_ALIGN.RIGHT) + + # 값 + pct = value / total * 100 + val_x = Inches(2.3) + bar_w + add_textbox(slide, val_x, y, Inches(1.5), Inches(0.32), + f"${value:,} ({pct:.0f}%)", font_size=9, color=TEXT_DIM) + +add_textbox(slide, Inches(0.5), y_start + Emu(int(len(cost_items) * Inches(0.45))), Inches(6), Inches(0.3), + f"AI API 비중: 전체의 약 66% (${(2000+1400+550):,} / ${total:,})", + font_size=11, color=ACCENT, bold=True) + +# 우측: DB 용량 예측 +add_textbox(slide, Inches(7.5), Inches(1.2), Inches(5), Inches(0.4), + "DB 용량 예측 (1년 후)", font_size=16, color=TEXT, bold=True) + +headers = ["", "Stage 1\n(500명)", "Stage 2\n(5,000명)", "Stage 3\n(50,000명)"] +rows = [ + ["DB 용량", "~1.2GB", "~12GB", "~120GB"], + ["Blob 스토리지", "~1.1TB", "~11TB", "~110TB"], + ["MySQL 추천", "32GB SSD", "128GB SSD", "512GB SSD"], +] +add_table(slide, Inches(7.5), Inches(1.7), Inches(5.3), Inches(1.8), headers, rows) + +# 비용 최적화 팁 +add_textbox(slide, Inches(7.5), Inches(3.8), Inches(5), Inches(0.4), + "비용 최적화 팁", font_size=16, color=TEXT, bold=True) + +items = [ + ("3rd party 의존도: ", "낮춰야 함 (AI API가 전체 비용의 66%)"), + ("Blob Lifecycle: ", "30일 미접근 미디어 → Cool 티어 자동 이전"), + ("App Server: ", "비활성 시간대(야간) 인스턴스 축소"), + ("OpenAI Batch API: ", "비실시간 가사생성은 50% 절감 가능"), + ("Reserved Instances: ", "1년 예약 시 ~30% 할인"), +] +add_bullet_list(slide, Inches(7.5), Inches(4.3), Inches(5.3), Inches(3), items, font_size=11) + + +# ── 저장 ── +output_path = "/Users/marineyang/Desktop/work/code/o2o-castad-backend/docs/architecture.pptx" +prs.save(output_path) +print(f"PPT 생성 완료: {output_path}") diff --git a/timezone-check.md b/timezone-check.md deleted file mode 100644 index c9a2baa..0000000 --- a/timezone-check.md +++ /dev/null @@ -1,163 +0,0 @@ -# 타임존 검수 보고서 - -**검수일**: 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`에 주석 존재). diff --git a/token_log_plan.md b/token_log_plan.md new file mode 100644 index 0000000..b5f0fec --- /dev/null +++ b/token_log_plan.md @@ -0,0 +1,422 @@ +# 서버 JWT 토큰 라이프사이클 로깅 강화 계획 + +## 1. 토큰 라이프사이클 개요 + +서버가 직접 발급/관리하는 JWT 토큰의 전체 흐름: + +``` +[발급] kakao_login() / generate_test_token() + ├── Access Token 생성 (sub=user_uuid, type=access, exp=60분) + ├── Refresh Token 생성 (sub=user_uuid, type=refresh, exp=7일) + └── Refresh Token DB 저장 (token_hash, user_id, expires_at) + │ + ▼ +[검증] get_current_user() — 매 요청마다 Access Token 검증 + ├── Bearer 헤더에서 토큰 추출 + ├── decode_token() → payload (sub, type, exp) + ├── type == "access" 확인 + └── user_uuid로 사용자 조회/활성 확인 + │ + ▼ +[갱신] refresh_tokens() — Access Token 만료 시 Refresh Token으로 갱신 + ├── 기존 Refresh Token 디코딩 → payload + ├── token_hash로 DB 조회 → is_revoked / expires_at 확인 + ├── 기존 Refresh Token 폐기 (is_revoked=True) + ├── 새 Access Token + 새 Refresh Token 발급 + └── 새 Refresh Token DB 저장 + │ + ▼ +[폐기] logout() / logout_all() + ├── 단일: token_hash로 해당 Refresh Token 폐기 + └── 전체: user_id로 모든 Refresh Token 폐기 +``` + +--- + +## 2. 현황 분석 — 로깅 공백 지점 + +### 발급 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `jwt.py` | `create_access_token()` | 없음 | 발급 대상(user_uuid), 만료시간 | +| `jwt.py` | `create_refresh_token()` | 없음 | 발급 대상(user_uuid), 만료시간 | +| `auth.py` (service) | `_save_refresh_token()` | 없음 | DB 저장 결과(token_hash, expires_at) | +| `auth.py` (service) | `kakao_login()` | `debug`로 토큰 앞 30자 출력 | 충분 (변경 불필요) | + +### 검증 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `jwt.py` | `decode_token()` | 없음 | 디코딩 성공 시 payload 내용, 실패 시 원인 | +| `auth.py` (dependency) | `get_current_user()` | 없음 | 검증 각 단계 통과/실패 사유, 토큰 내 정보 | +| `auth.py` (dependency) | `get_current_user_optional()` | 없음 | 위와 동일 | + +### 갱신 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `auth.py` (router) | `refresh_token()` | 없음 | 수신 토큰 정보, 갱신 결과 | +| `auth.py` (service) | `refresh_tokens()` | 진입/완료 `info` 1줄씩 | 각 단계 실패 사유, DB 토큰 상태, 신규 토큰 정보 | + +### 폐기 단계 +| 위치 | 함수 | 현재 로깅 | 부족한 정보 | +|------|------|----------|------------| +| `auth.py` (router) | `logout()`, `logout_all()` | 없음 | 요청 수신, 대상 사용자 | +| `auth.py` (service) | `logout()`, `logout_all()` | 없음 | 폐기 대상, 폐기 결과 | + +--- + +## 3. 수정 대상 파일 + +| # | 파일 | 수정 내용 | +|---|------|----------| +| 1 | `app/user/services/jwt.py` | 토큰 발급 로그 + `decode_token()` 실패 원인 분류 | +| 2 | `app/user/dependencies/auth.py` | Access Token 검증 과정 로깅 | +| 3 | `app/user/services/auth.py` | `refresh_tokens()`, `_save_refresh_token()`, `logout()`, `logout_all()` 로깅 | +| 4 | `app/user/api/routers/v1/auth.py` | `refresh_token()`, `logout()`, `logout_all()` 라우터 로깅 | + +--- + +## 4. 상세 구현 계획 + +### 4-1. `jwt.py` — 토큰 발급 로그 + 디코딩 실패 원인 분류 + +**import 추가:** +```python +import logging +from jose import JWTError, ExpiredSignatureError, JWTClaimsError, jwt + +logger = logging.getLogger(__name__) +``` + +**`create_access_token()` — 발급 로그 추가:** +```python +def create_access_token(user_uuid: str) -> str: + expire = now() + timedelta(minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode = {"sub": user_uuid, "exp": expire, "type": "access"} + token = jwt.encode(to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM) + logger.debug(f"[JWT] Access Token 발급 - user_uuid: {user_uuid}, expires: {expire}, token: ...{token[-20:]}") + return token +``` + +**`create_refresh_token()` — 발급 로그 추가:** +```python +def create_refresh_token(user_uuid: str) -> str: + expire = now() + timedelta(days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS) + to_encode = {"sub": user_uuid, "exp": expire, "type": "refresh"} + token = jwt.encode(to_encode, jwt_settings.JWT_SECRET, algorithm=jwt_settings.JWT_ALGORITHM) + logger.debug(f"[JWT] Refresh Token 발급 - user_uuid: {user_uuid}, expires: {expire}, token: ...{token[-20:]}") + return token +``` + +**`decode_token()` — 성공/실패 분류 로그:** +```python +def decode_token(token: str) -> Optional[dict]: + try: + payload = jwt.decode(token, jwt_settings.JWT_SECRET, algorithms=[jwt_settings.JWT_ALGORITHM]) + logger.debug( + f"[JWT] 토큰 디코딩 성공 - type: {payload.get('type')}, " + f"sub: {payload.get('sub')}, exp: {payload.get('exp')}, " + f"token: ...{token[-20:]}" + ) + return payload + except ExpiredSignatureError: + logger.info(f"[JWT] 토큰 만료 - token: ...{token[-20:]}") + return None + except JWTClaimsError as e: + logger.warning(f"[JWT] 클레임 검증 실패 - error: {e}, token: ...{token[-20:]}") + return None + except JWTError as e: + logger.warning(f"[JWT] 토큰 디코딩 실패 - error: {type(e).__name__}: {e}, token: ...{token[-20:]}") + return None +``` + +### 4-2. `dependencies/auth.py` — Access Token 검증 로깅 + +**import 추가:** +```python +import logging +logger = logging.getLogger(__name__) +``` + +**`get_current_user()` — 검증 과정 로그:** +```python +async def get_current_user(...) -> User: + if credentials is None: + logger.info("[AUTH-DEP] 토큰 없음 - MissingTokenError") + raise MissingTokenError() + + token = credentials.credentials + logger.debug(f"[AUTH-DEP] Access Token 검증 시작 - token: ...{token[-20:]}") + + payload = decode_token(token) + if payload is None: + logger.warning(f"[AUTH-DEP] Access Token 디코딩 실패 - token: ...{token[-20:]}") + raise InvalidTokenError() + + if payload.get("type") != "access": + logger.warning(f"[AUTH-DEP] 토큰 타입 불일치 - expected: access, got: {payload.get('type')}, sub: {payload.get('sub')}") + raise InvalidTokenError("액세스 토큰이 아닙니다.") + + user_uuid = payload.get("sub") + if user_uuid is None: + logger.warning(f"[AUTH-DEP] 토큰에 sub 클레임 없음 - token: ...{token[-20:]}") + raise InvalidTokenError() + + # 사용자 조회 + result = await session.execute(...) + user = result.scalar_one_or_none() + + if user is None: + logger.warning(f"[AUTH-DEP] 사용자 미존재 - user_uuid: {user_uuid}") + raise UserNotFoundError() + + if not user.is_active: + logger.warning(f"[AUTH-DEP] 비활성 사용자 접근 - user_uuid: {user_uuid}, user_id: {user.id}") + raise UserInactiveError() + + logger.debug(f"[AUTH-DEP] Access Token 검증 성공 - user_uuid: {user_uuid}, user_id: {user.id}") + return user +``` + +**`get_current_user_optional()` — 동일 패턴, `debug` 레벨:** +```python +async def get_current_user_optional(...) -> Optional[User]: + if credentials is None: + logger.debug("[AUTH-DEP] 선택적 인증 - 토큰 없음") + return None + + token = credentials.credentials + payload = decode_token(token) + if payload is None: + logger.debug(f"[AUTH-DEP] 선택적 인증 - 디코딩 실패, token: ...{token[-20:]}") + return None + + if payload.get("type") != "access": + logger.debug(f"[AUTH-DEP] 선택적 인증 - 타입 불일치 (type={payload.get('type')})") + return None + + user_uuid = payload.get("sub") + if user_uuid is None: + logger.debug("[AUTH-DEP] 선택적 인증 - sub 없음") + return None + + result = await session.execute(...) + user = result.scalar_one_or_none() + + if user is None or not user.is_active: + logger.debug(f"[AUTH-DEP] 선택적 인증 - 사용자 미존재 또는 비활성, user_uuid: {user_uuid}") + return None + + logger.debug(f"[AUTH-DEP] 선택적 인증 성공 - user_uuid: {user_uuid}, user_id: {user.id}") + return user +``` + +### 4-3. `services/auth.py` — Refresh Token 갱신/폐기 로깅 + +**`refresh_tokens()` — 전체 흐름 로그:** +```python +async def refresh_tokens(self, refresh_token: str, session: AsyncSession) -> TokenResponse: + logger.info(f"[AUTH] 토큰 갱신 시작 (Rotation) - token: ...{refresh_token[-20:]}") + + # 1. 디코딩 + payload = decode_token(refresh_token) + if payload is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...{refresh_token[-20:]}") + raise InvalidTokenError() + + if payload.get("type") != "refresh": + logger.warning(f"[AUTH] 토큰 갱신 실패 [1/8 타입] - type={payload.get('type')}, sub: {payload.get('sub')}") + raise InvalidTokenError("리프레시 토큰이 아닙니다.") + + logger.debug(f"[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: {payload.get('sub')}, exp: {payload.get('exp')}") + + # 2. DB 조회 + token_hash = get_token_hash(refresh_token) + db_token = await self._get_refresh_token_by_hash(token_hash, session) + + if db_token is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [2/8 DB조회] - DB에 없음, token_hash: {token_hash[:16]}...") + raise InvalidTokenError() + + logger.debug(f"[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, is_revoked: {db_token.is_revoked}, expires_at: {db_token.expires_at}") + + # 3. 폐기 여부 + if db_token.is_revoked: + logger.warning(f"[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - 이미 폐기된 토큰 (replay attack 의심), token_hash: {token_hash[:16]}..., user_uuid: {db_token.user_uuid}, revoked_at: {db_token.revoked_at}") + raise TokenRevokedError() + + # 4. 만료 확인 + if db_token.expires_at < now().replace(tzinfo=None): + logger.info(f"[AUTH] 토큰 갱신 실패 [4/8 만료] - expires_at: {db_token.expires_at}, user_uuid: {db_token.user_uuid}") + raise TokenExpiredError() + + # 5. 사용자 확인 + user_uuid = payload.get("sub") + user = await self._get_user_by_uuid(user_uuid, session) + + if user is None: + logger.warning(f"[AUTH] 토큰 갱신 실패 [5/8 사용자] - 사용자 미존재, user_uuid: {user_uuid}") + raise UserNotFoundError() + + if not user.is_active: + logger.warning(f"[AUTH] 토큰 갱신 실패 [5/8 비활성] - user_uuid: {user_uuid}, user_id: {user.id}") + raise UserInactiveError() + + # 6. 기존 토큰 폐기 + db_token.is_revoked = True + db_token.revoked_at = now().replace(tzinfo=None) + logger.debug(f"[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: {token_hash[:16]}...") + + # 7. 새 토큰 발급 + new_access_token = create_access_token(user.user_uuid) + new_refresh_token = create_refresh_token(user.user_uuid) + logger.debug(f"[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...{new_access_token[-20:]}, new_refresh: ...{new_refresh_token[-20:]}") + + # 8. 새 Refresh Token DB 저장 + 커밋 + await self._save_refresh_token(user_id=user.id, user_uuid=user.user_uuid, token=new_refresh_token, session=session) + await session.commit() + + logger.info(f"[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: {user.user_uuid}, user_id: {user.id}, old_hash: {token_hash[:16]}..., new_refresh: ...{new_refresh_token[-20:]}") + return TokenResponse(...) +``` + +**`_save_refresh_token()` — DB 저장 로그:** +```python +async def _save_refresh_token(self, ...) -> RefreshToken: + token_hash = get_token_hash(token) + expires_at = get_refresh_token_expires_at() + + refresh_token = RefreshToken(...) + session.add(refresh_token) + await session.flush() + + logger.debug(f"[AUTH] Refresh Token DB 저장 - user_uuid: {user_uuid}, token_hash: {token_hash[:16]}..., expires_at: {expires_at}") + return refresh_token +``` + +**`logout()` — 단일 로그아웃 로그:** +```python +async def logout(self, user_id: int, refresh_token: str, session: AsyncSession) -> None: + token_hash = get_token_hash(refresh_token) + logger.info(f"[AUTH] 로그아웃 - user_id: {user_id}, token_hash: {token_hash[:16]}..., token: ...{refresh_token[-20:]}") + await self._revoke_refresh_token_by_hash(token_hash, session) + logger.info(f"[AUTH] 로그아웃 완료 - user_id: {user_id}") +``` + +**`logout_all()` — 전체 로그아웃 로그:** +```python +async def logout_all(self, user_id: int, session: AsyncSession) -> None: + logger.info(f"[AUTH] 전체 로그아웃 - user_id: {user_id}") + await self._revoke_all_user_tokens(user_id, session) + logger.info(f"[AUTH] 전체 로그아웃 완료 - user_id: {user_id}") +``` + +### 4-4. `routers/v1/auth.py` — 라우터 진입/완료 로깅 + +```python +# POST /auth/refresh +async def refresh_token(body, session) -> TokenResponse: + logger.info(f"[ROUTER] POST /auth/refresh - token: ...{body.refresh_token[-20:]}") + result = await auth_service.refresh_tokens(refresh_token=body.refresh_token, session=session) + logger.info(f"[ROUTER] POST /auth/refresh 완료 - new_access: ...{result.access_token[-20:]}, new_refresh: ...{result.refresh_token[-20:]}") + return result + +# POST /auth/logout +async def logout(body, current_user, session) -> Response: + logger.info(f"[ROUTER] POST /auth/logout - user_id: {current_user.id}, user_uuid: {current_user.user_uuid}, token: ...{body.refresh_token[-20:]}") + await auth_service.logout(user_id=current_user.id, refresh_token=body.refresh_token, session=session) + logger.info(f"[ROUTER] POST /auth/logout 완료 - user_id: {current_user.id}") + return Response(status_code=status.HTTP_204_NO_CONTENT) + +# POST /auth/logout/all +async def logout_all(current_user, session) -> Response: + logger.info(f"[ROUTER] POST /auth/logout/all - user_id: {current_user.id}, user_uuid: {current_user.user_uuid}") + await auth_service.logout_all(user_id=current_user.id, session=session) + logger.info(f"[ROUTER] POST /auth/logout/all 완료 - user_id: {current_user.id}") + return Response(status_code=status.HTTP_204_NO_CONTENT) +``` + +--- + +## 5. 보안 원칙 + +| 원칙 | 적용 방법 | 이유 | +|------|----------|------| +| 토큰 전체 노출 금지 | 뒷 20자만: `...{token[-20:]}` | 토큰 탈취 시 세션 하이재킹 가능 | +| 해시값 부분 노출 | 앞 16자만: `{hash[:16]}...` | DB 레코드 식별에 충분 | +| user_uuid 전체 허용 | 전체 출력 | 내부 식별자, 토큰이 아님 | +| 페이로드 내용 출력 | `sub`, `type`, `exp` 출력 | 디버깅에 필수, 민감정보 아님 | +| DB 토큰 상태 출력 | `is_revoked`, `expires_at`, `revoked_at` | 토큰 라이프사이클 추적 | +| 로그 레벨 구분 | 하단 표 참조 | 운영 환경에서 불필요한 로그 억제 | + +### 로그 레벨 기준 + +| 레벨 | 사용 기준 | 예시 | +|------|----------|------| +| `debug` | 정상 처리 과정 상세 (운영환경에서 비활성) | 토큰 발급, 디코딩 성공, 검증 통과 | +| `info` | 주요 이벤트 (운영환경에서 활성) | 갱신 시작/완료, 로그아웃, 만료로 인한 실패 | +| `warning` | 비정상/의심 상황 | 디코딩 실패, 폐기된 토큰 사용, 사용자 미존재 | + +--- + +## 6. 구현 순서 + +| 순서 | 파일 | 이유 | +|------|------|------| +| 1 | `app/user/services/jwt.py` | 최하위 유틸리티. 토큰 발급/디코딩의 기본 로그 | +| 2 | `app/user/dependencies/auth.py` | 모든 인증 API의 공통 진입점 | +| 3 | `app/user/services/auth.py` | 갱신/폐기 비즈니스 로직 | +| 4 | `app/user/api/routers/v1/auth.py` | 라우터 진입/완료 + 응답 토큰 정보 | + +--- + +## 7. 기대 효과 — 시나리오별 로그 출력 예시 + +### 시나리오 1: 정상 토큰 갱신 +``` +[ROUTER] POST /auth/refresh - token: ...7d90-aac8-ecf1385c +[AUTH] 토큰 갱신 시작 (Rotation) - token: ...7d90-aac8-ecf1385c +[JWT] 토큰 디코딩 성공 - type: refresh, sub: 019c5452-b1cf-7d90-aac8-ecf1385c9dc4, exp: 1739450400 +[AUTH] 토큰 갱신 [1/8] 디코딩 성공 - sub: 019c5452-..., exp: 1739450400 +[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: a1b2c3d4e5f6g7h8..., is_revoked: False, expires_at: 2026-02-20 11:46:36 +[AUTH] 토큰 갱신 [6/8] 기존 토큰 폐기 - token_hash: a1b2c3d4e5f6g7h8... +[JWT] Access Token 발급 - user_uuid: 019c5452-..., expires: 2026-02-13 12:46:36 +[JWT] Refresh Token 발급 - user_uuid: 019c5452-..., expires: 2026-02-20 11:46:36 +[AUTH] 토큰 갱신 [7/8] 새 토큰 발급 - new_access: ...xNewAccess12345, new_refresh: ...xNewRefresh6789 +[AUTH] Refresh Token DB 저장 - user_uuid: 019c5452-..., token_hash: f8e9d0c1b2a3..., expires_at: 2026-02-20 11:46:36 +[AUTH] 토큰 갱신 완료 [8/8] - user_uuid: 019c5452-..., user_id: 42, old_hash: a1b2c3d4e5f6g7h8..., new_refresh: ...xNewRefresh6789 +[ROUTER] POST /auth/refresh 완료 - new_access: ...xNewAccess12345, new_refresh: ...xNewRefresh6789 +``` + +### 시나리오 2: 만료된 Refresh Token으로 갱신 시도 +``` +[ROUTER] POST /auth/refresh - token: ...expiredToken12345 +[AUTH] 토큰 갱신 시작 (Rotation) - token: ...expiredToken12345 +[JWT] 토큰 만료 - token: ...expiredToken12345 +[AUTH] 토큰 갱신 실패 [1/8 디코딩] - token: ...expiredToken12345 +→ 401 InvalidTokenError 응답 +``` + +### 시나리오 3: 이미 폐기된 Refresh Token 재사용 (Replay Attack) +``` +[ROUTER] POST /auth/refresh - token: ...revokedToken98765 +[AUTH] 토큰 갱신 시작 (Rotation) - token: ...revokedToken98765 +[JWT] 토큰 디코딩 성공 - type: refresh, sub: 019c5452-..., exp: 1739450400 +[AUTH] 토큰 갱신 [2/8] DB 조회 성공 - token_hash: c3d4e5f6..., is_revoked: True, expires_at: 2026-02-20 +[AUTH] 토큰 갱신 실패 [3/8 폐기됨] - replay attack 의심, token_hash: c3d4e5f6..., user_uuid: 019c5452-..., revoked_at: 2026-02-13 10:30:00 +→ 401 TokenRevokedError 응답 +``` + +### 시나리오 4: Access Token 검증 (매 API 요청) +``` +[AUTH-DEP] Access Token 검증 시작 - token: ...validAccess12345 +[JWT] 토큰 디코딩 성공 - type: access, sub: 019c5452-..., exp: 1739450400 +[AUTH-DEP] Access Token 검증 성공 - user_uuid: 019c5452-..., user_id: 42 +``` + +### 시나리오 5: 로그아웃 +``` +[ROUTER] POST /auth/logout - user_id: 42, user_uuid: 019c5452-..., token: ...refreshToRevoke99 +[AUTH] 로그아웃 - user_id: 42, token_hash: d5e6f7g8..., token: ...refreshToRevoke99 +[AUTH] 로그아웃 완료 - user_id: 42 +[ROUTER] POST /auth/logout 완료 - user_id: 42 +```