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
+
인프라 아키텍처 및 비용 산출 문서
+
+
+
+
+
+
+
+
+
현재 구현 현황 (단일 인스턴스)
+
+ - 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 Server |
+ LB |
+ DB ( MySQL Flexible) |
+
+
+
+
+ | S1 |
+ ~50명 |
+ x1 |
+ Nginx x1 |
+ Burstable B1ms |
+
+
+ | S2 |
+ 50~200명 |
+ x2~4 |
+ Nginx |
+ GP D2ds_v4 + Replica x1 |
+
+
+ | S3 |
+ 200~1,000명 |
+ API ServerxN + Scheduler |
+ Nginx |
+ BC D4ds_v4 + Replica x2 + Redis P1 |
+
+
+
+
+
+
+
커넥션 풀 수치 계산
+
+
+
+ | 항목 |
+ Stage 1 (1대) |
+ Stage 2 (4대) |
+ Stage 3 (8대) |
+
+
+
+
+ | 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 |
+
+
+
+
+
+ 핵심:
+ 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
+
+
+
+
+
+
+
+
+
+
+
+ - 로드밸런서: 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 업로드
+
+
+
+
+
+
+
+
+
+
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 1 |
+ Stage 2 |
+ Stage 3 |
+
+
+
+
+ | 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 |
+
+
+
+
+
+
+
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 SSD |
+ 128GB SSD |
+ 512GB 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
+```