diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py
index e0cb037..ceafe89 100644
--- a/app/home/api/routers/v1/home.py
+++ b/app/home/api/routers/v1/home.py
@@ -33,7 +33,7 @@ from app.utils.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.common import generate_task_id
from app.utils.logger import get_logger
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
-from app.utils.nvMapPwScraper import NvMapPwScraper
+from app.utils.nvMapPwScraper import NvMapPwScraper
from app.utils.prompts.prompts import marketing_prompt
from config import MEDIA_ROOT
diff --git a/app/sns/api/routers/v1/sns.py b/app/sns/api/routers/v1/sns.py
index e282b79..3d398ea 100644
--- a/app/sns/api/routers/v1/sns.py
+++ b/app/sns/api/routers/v1/sns.py
@@ -10,6 +10,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database.session import get_session
from app.sns.schemas.sns_schema import InstagramUploadRequest, InstagramUploadResponse
+from app.user.dependencies.auth import get_current_user
+from app.user.models import Platform, SocialAccount, User
+from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
+from app.utils.logger import get_logger
+from app.video.models import Video
+
+logger = get_logger(__name__)
# =============================================================================
@@ -80,13 +87,7 @@ class InstagramContainerError(SNSException):
def __init__(self, message: str = "Instagram 미디어 처리에 실패했습니다."):
super().__init__(status.HTTP_500_INTERNAL_SERVER_ERROR, "INSTAGRAM_CONTAINER_ERROR", message)
-from app.user.dependencies.auth import get_current_user
-from app.user.models import Platform, SocialAccount, User
-from app.utils.instagram import ErrorState, InstagramClient, parse_instagram_error
-from app.utils.logger import get_logger
-from app.video.models import Video
-logger = get_logger(__name__)
router = APIRouter(prefix="/sns", tags=["SNS"])
diff --git a/app/social/services.py b/app/social/services.py
index 2ee4fcd..6e7936b 100644
--- a/app/social/services.py
+++ b/app/social/services.py
@@ -6,10 +6,12 @@ Social Account Service
import logging
import secrets
-from datetime import datetime, timedelta
+from datetime import timedelta
from typing import Optional
from sqlalchemy import select
+
+from app.utils.timezone import now
from sqlalchemy.ext.asyncio import AsyncSession
from redis.asyncio import Redis
@@ -305,7 +307,7 @@ class SocialAccountService:
if account.token_expires_at is None:
should_refresh = True
else:
- buffer_time = datetime.now() + timedelta(hours=1)
+ buffer_time = now() + timedelta(hours=1)
if account.token_expires_at <= buffer_time:
should_refresh = True
@@ -512,7 +514,7 @@ class SocialAccountService:
)
should_refresh = True
else:
- buffer_time = datetime.now() + timedelta(minutes=10)
+ buffer_time = now() + timedelta(minutes=10)
if account.token_expires_at <= buffer_time:
logger.info(
f"[SOCIAL] 토큰 만료 임박, 갱신 시작 - account_id: {account.id}"
@@ -571,7 +573,7 @@ class SocialAccountService:
if token_response.refresh_token:
account.refresh_token = token_response.refresh_token
if token_response.expires_in:
- account.token_expires_at = datetime.now() + timedelta(
+ account.token_expires_at = now() + timedelta(
seconds=token_response.expires_in
)
@@ -632,7 +634,7 @@ class SocialAccountService:
# 토큰 만료 시간 계산
token_expires_at = None
if token_response.expires_in:
- token_expires_at = datetime.now() + timedelta(
+ token_expires_at = now() + timedelta(
seconds=token_response.expires_in
)
@@ -685,7 +687,7 @@ class SocialAccountService:
if token_response.refresh_token:
account.refresh_token = token_response.refresh_token
if token_response.expires_in:
- account.token_expires_at = datetime.now() + timedelta(
+ account.token_expires_at = now() + timedelta(
seconds=token_response.expires_in
)
if token_response.scope:
@@ -701,7 +703,7 @@ class SocialAccountService:
# 재연결 시 연결 시간 업데이트
if update_connected_at:
- account.connected_at = datetime.now()
+ account.connected_at = now()
await session.commit()
await session.refresh(account)
diff --git a/app/social/worker/upload_task.py b/app/social/worker/upload_task.py
index 7e00a90..5dd727a 100644
--- a/app/social/worker/upload_task.py
+++ b/app/social/worker/upload_task.py
@@ -7,11 +7,12 @@ Social Upload Background Task
import logging
import os
import tempfile
-from datetime import datetime
from pathlib import Path
from typing import Optional
import aiofiles
+
+from app.utils.timezone import now
import httpx
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
@@ -70,7 +71,7 @@ async def _update_upload_status(
if error_message:
upload.error_message = error_message
if status == UploadStatus.COMPLETED:
- upload.uploaded_at = datetime.now()
+ upload.uploaded_at = now()
await session.commit()
logger.info(
diff --git a/app/user/api/routers/v1/auth.py b/app/user/api/routers/v1/auth.py
index 79796a2..9a6e22c 100644
--- a/app/user/api/routers/v1/auth.py
+++ b/app/user/api/routers/v1/auth.py
@@ -6,10 +6,11 @@
import logging
import random
-from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
+
+from app.utils.timezone import now
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy import select
@@ -460,7 +461,7 @@ async def generate_test_token(
session.add(db_refresh_token)
# 마지막 로그인 시간 업데이트
- user.last_login_at = datetime.now(timezone.utc)
+ user.last_login_at = now()
await session.commit()
logger.info(
diff --git a/app/user/models.py b/app/user/models.py
index 79cba9a..2c734dc 100644
--- a/app/user/models.py
+++ b/app/user/models.py
@@ -344,7 +344,6 @@ class RefreshToken(Base):
token_hash: Mapped[str] = mapped_column(
String(64),
nullable=False,
- unique=True,
comment="리프레시 토큰 SHA-256 해시값",
)
@@ -522,7 +521,7 @@ class SocialAccount(Base):
# ==========================================================================
# 플랫폼 계정 식별 정보
# ==========================================================================
- platform_user_id: Mapped[str] = mapped_column(
+ platform_user_id: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="플랫폼 내 사용자 고유 ID",
diff --git a/app/user/services/auth.py b/app/user/services/auth.py
index bdea105..aa648c1 100644
--- a/app/user/services/auth.py
+++ b/app/user/services/auth.py
@@ -5,10 +5,11 @@
"""
import logging
-from datetime import datetime
from typing import Optional
from fastapi import HTTPException, status
+
+from app.utils.timezone import now
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -167,7 +168,7 @@ class AuthService:
logger.debug(f"[AUTH] 리프레시 토큰 저장 완료 - user_id: {user.id}, user_uuid: {user.user_uuid}")
# 7. 마지막 로그인 시간 업데이트
- user.last_login_at = datetime.now()
+ user.last_login_at = now()
await session.commit()
redirect_url = f"{prj_settings.PROJECT_DOMAIN}"
@@ -222,7 +223,7 @@ class AuthService:
if db_token.is_revoked:
raise TokenRevokedError()
- if db_token.expires_at < datetime.now():
+ if db_token.expires_at < now():
raise TokenExpiredError()
# 4. 사용자 확인
@@ -482,7 +483,7 @@ class AuthService:
.where(RefreshToken.token_hash == token_hash)
.values(
is_revoked=True,
- revoked_at=datetime.now(),
+ revoked_at=now(),
)
)
await session.commit()
@@ -507,7 +508,7 @@ class AuthService:
)
.values(
is_revoked=True,
- revoked_at=datetime.now(),
+ revoked_at=now(),
)
)
await session.commit()
diff --git a/app/user/services/jwt.py b/app/user/services/jwt.py
index 12b60c6..4f38658 100644
--- a/app/user/services/jwt.py
+++ b/app/user/services/jwt.py
@@ -10,6 +10,7 @@ from typing import Optional
from jose import JWTError, jwt
+from app.utils.timezone import now
from config import jwt_settings
@@ -23,7 +24,7 @@ def create_access_token(user_uuid: str) -> str:
Returns:
JWT 액세스 토큰 문자열
"""
- expire = datetime.now() + timedelta(
+ expire = now() + timedelta(
minutes=jwt_settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {
@@ -48,7 +49,7 @@ def create_refresh_token(user_uuid: str) -> str:
Returns:
JWT 리프레시 토큰 문자열
"""
- expire = datetime.now() + timedelta(
+ expire = now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
to_encode = {
@@ -106,7 +107,7 @@ def get_refresh_token_expires_at() -> datetime:
Returns:
리프레시 토큰 만료 datetime (로컬 시간)
"""
- return datetime.now() + timedelta(
+ return now() + timedelta(
days=jwt_settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py
index abbfd51..3e99f20 100644
--- a/app/utils/creatomate.py
+++ b/app/utils/creatomate.py
@@ -122,7 +122,38 @@ text_template_v_2 = {
}
]
}
-
+text_template_v_3 = {
+ "type": "composition",
+ "track": 3,
+ "elements": [
+ {
+ "type": "text",
+ "time": 0,
+ "x": "0%",
+ "y": "80%",
+ "width": "100%",
+ "height": "15%",
+ "x_anchor": "0%",
+ "y_anchor": "0%",
+ "x_alignment": "50%",
+ "y_alignment": "50%",
+ "font_family": "Noto Sans",
+ "font_weight": "700",
+ "font_size_maximum": "7 vmin",
+ "fill_color": "#ffffff",
+ "animations": [
+ {
+ "time": 0,
+ "duration": 1,
+ "easing": "quadratic-out",
+ "type": "text-wave",
+ "split": "line",
+ "overlap": "50%"
+ }
+ ]
+ }
+ ]
+}
text_template_h_1 = {
"type": "composition",
"track": 3,
@@ -408,6 +439,7 @@ class CreatomateService:
image_url_list: list[str],
lyric: str,
music_url: str,
+ address: str = None
) -> dict:
"""템플릿 정보와 이미지/가사/음악 리소스를 매핑합니다.
@@ -434,9 +466,8 @@ class CreatomateService:
idx % len(image_url_list)
]
case "text":
- modifications[template_component_name] = lyric_splited[
- idx % len(lyric_splited)
- ]
+ if "address_input" in template_component_name:
+ modifications[template_component_name] = address
modifications["audio-music"] = music_url
@@ -448,12 +479,11 @@ class CreatomateService:
image_url_list: list[str],
lyric: str,
music_url: str,
+ address: str = None
) -> dict:
"""elements 정보와 이미지/가사/음악 리소스를 매핑합니다."""
template_component_data = self.parse_template_component_name(elements)
- lyric = lyric.replace("\r", "")
- lyric_splited = lyric.split("\n")
modifications = {}
for idx, (template_component_name, template_type) in enumerate(
@@ -465,9 +495,8 @@ class CreatomateService:
idx % len(image_url_list)
]
case "text":
- modifications[template_component_name] = lyric_splited[
- idx % len(lyric_splited)
- ]
+ if "address_input" in template_component_name:
+ modifications[template_component_name] = address
modifications["audio-music"] = music_url
@@ -486,7 +515,8 @@ class CreatomateService:
case "video":
element["source"] = modification[element["name"]]
case "text":
- element["source"] = modification.get(element["name"], "")
+ #element["source"] = modification[element["name"]]
+ element["text"] = modification.get(element["name"], "")
case "composition":
for minor in element["elements"]:
recursive_modify(minor)
@@ -704,8 +734,8 @@ class CreatomateService:
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다."""
- target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
- template["duration"] = target_duration
+ template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
+ target_duration += 1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration
new_template = copy.deepcopy(template)
@@ -727,7 +757,7 @@ class CreatomateService:
return new_template
- def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float) -> dict:
+ def lining_lyric(self, text_template: dict, lyric_index: int, lyric_text: str, start_sec: float, end_sec: float, font_family: str = "Noto Sans") -> dict:
duration = end_sec - start_sec
text_scene = copy.deepcopy(text_template)
text_scene["name"] = f"Caption-{lyric_index}"
@@ -735,6 +765,7 @@ class CreatomateService:
text_scene["time"] = start_sec
text_scene["elements"][0]["name"] = f"lyric-{lyric_index}"
text_scene["elements"][0]["text"] = lyric_text
+ text_scene["elements"][0]["font_family"] = font_family
return text_scene
def auto_lyric(self, auto_text_template : dict):
@@ -744,7 +775,7 @@ class CreatomateService:
def get_text_template(self):
match self.orientation:
case "vertical":
- return text_template_v_2
+ return text_template_v_3
case "horizontal":
return text_template_h_1
diff --git a/app/utils/logger.py b/app/utils/logger.py
index 24ac7e4..2226a1a 100644
--- a/app/utils/logger.py
+++ b/app/utils/logger.py
@@ -20,11 +20,11 @@ Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
import logging
import sys
-from datetime import datetime
from functools import lru_cache
from logging.handlers import RotatingFileHandler
from typing import Literal
+from app.utils.timezone import today_str
from config import log_settings
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
@@ -86,7 +86,7 @@ def _get_shared_file_handler() -> RotatingFileHandler:
global _shared_file_handler
if _shared_file_handler is None:
- today = datetime.today().strftime("%Y-%m-%d")
+ today = today_str()
log_file = LOG_DIR / f"{today}_app.log"
_shared_file_handler = RotatingFileHandler(
@@ -116,7 +116,7 @@ def _get_shared_error_handler() -> RotatingFileHandler:
global _shared_error_handler
if _shared_error_handler is None:
- today = datetime.today().strftime("%Y-%m-%d")
+ today = today_str()
log_file = LOG_DIR / f"{today}_error.log"
_shared_error_handler = RotatingFileHandler(
diff --git a/app/utils/nvMapPwScraper.py b/app/utils/nvMapPwScraper.py
index cadc492..2885242 100644
--- a/app/utils/nvMapPwScraper.py
+++ b/app/utils/nvMapPwScraper.py
@@ -1,6 +1,11 @@
import asyncio
from playwright.async_api import async_playwright
from urllib import parse
+import time
+from app.utils.logger import get_logger
+
+# 로거 설정
+logger = get_logger("pwscraper")
class NvMapPwScraper():
# cls vars
@@ -91,25 +96,46 @@ patchedGetter.toString();''')
async def get_place_id_url(self, selected):
count = 0
+ get_place_id_url_start = time.perf_counter()
while (count <= 1):
title = selected['title'].replace("", "").replace("", "")
address = selected.get('roadAddress', selected['address']).replace("", "").replace("", "")
encoded_query = parse.quote(f"{address} {title}")
url = f"https://map.naver.com/p/search/{encoded_query}"
+
+ wait_first_start = time.perf_counter()
- await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
+ try:
+ await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
+ except:
+ await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*1000)
+
+ wait_first_time = (time.perf_counter() - wait_first_start) * 1000
+
+ logger.debug(f"[DEBUG] Try {count+1} : Wait for perfect matching : {wait_first_time}ms")
if "/place/" in self.page.url:
return self.page.url
+
+ logger.debug(f"[DEBUG] Try {count+1} : url place id not found, retry for forced collect answer")
+ wait_forced_correct_start = time.perf_counter()
+
url = self.page.url.replace("?","?isCorrectAnswer=true&")
- await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
+ try:
+ await self.goto_url(url, wait_until="networkidle",timeout = self._max_retry/2*1000)
+ except:
+ await self.page.reload(wait_until="networkidle", timeout = self._max_retry/2*1000)
+
+ wait_forced_correct_time = (time.perf_counter() - wait_forced_correct_start) * 1000
+ logger.debug(f"[DEBUG] Try {count+1} : Wait for forced isCorrectAnswer flag : {wait_forced_correct_time}ms")
if "/place/" in self.page.url:
return self.page.url
count += 1
- print("Not found url for {selected}")
+ logger.error("[ERROR] Not found url for {selected}")
+
return None # 404
diff --git a/app/utils/suno.py b/app/utils/suno.py
index 87611ef..22e4af7 100644
--- a/app/utils/suno.py
+++ b/app/utils/suno.py
@@ -163,7 +163,8 @@ class SunoService:
if data.get("code") != 200:
error_msg = data.get("msg", "Unknown error")
- raise SunoResponseError(f"Suno API error: {error_msg}", original_response=data)
+ logger.error(f"[Suno] API error: {error_msg} | response: {data}")
+ raise SunoResponseError("api 에러입니다.", original_response=data)
response_data = data.get("data")
if response_data is None:
diff --git a/app/utils/timezone.py b/app/utils/timezone.py
new file mode 100644
index 0000000..1ba1091
--- /dev/null
+++ b/app/utils/timezone.py
@@ -0,0 +1,42 @@
+"""
+타임존 유틸리티
+
+프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다.
+모든 datetime.now() 호출은 이 모듈의 함수로 대체해야 합니다.
+"""
+
+from datetime import datetime
+
+from config import TIMEZONE
+
+
+def now() -> datetime:
+ """
+ 서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다.
+
+ Returns:
+ datetime: 서울 타임존이 적용된 현재 시간 (aware datetime)
+
+ Example:
+ >>> from app.utils.timezone import now
+ >>> current_time = now() # 2024-01-15 15:30:00+09:00
+ """
+ return datetime.now(TIMEZONE)
+
+
+def today_str(fmt: str = "%Y-%m-%d") -> str:
+ """
+ 서울 타임존 기준 오늘 날짜를 문자열로 반환합니다.
+
+ Args:
+ fmt: 날짜 포맷 (기본값: YYYY-MM-DD)
+
+ Returns:
+ str: 포맷된 날짜 문자열
+
+ Example:
+ >>> from app.utils.timezone import today_str
+ >>> today_str() # "2024-01-15"
+ >>> today_str("%Y/%m/%d") # "2024/01/15"
+ """
+ return datetime.now(TIMEZONE).strftime(fmt)
diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py
index 15f32f4..b203082 100644
--- a/app/video/api/routers/v1/video.py
+++ b/app/video/api/routers/v1/video.py
@@ -201,6 +201,7 @@ async def generate_video(
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
)
project_id = project.id
+ store_address = project.detail_region_info
# ===== 결과 처리: Lyric =====
lyric = lyric_result.scalar_one_or_none()
@@ -211,6 +212,7 @@ async def generate_video(
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
)
lyric_id = lyric.id
+ lyric_language = lyric.language
# ===== 결과 처리: Song =====
song = song_result.scalar_one_or_none()
@@ -313,6 +315,7 @@ async def generate_video(
image_url_list=image_urls,
lyric=lyrics,
music_url=music_url,
+ address=store_address
)
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
@@ -333,8 +336,6 @@ async def generate_video(
f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}"
)
- # 이런거 추가해야하는데 AI가 자꾸 번호 달면 제가 번호를 다 밀어야 하나요?
-
song_timestamp_result = await session.execute(
select(SongTimestamp).where(
SongTimestamp.suno_audio_id == song.suno_audio_id
@@ -350,6 +351,13 @@ async def generate_video(
f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}"
)
+ match lyric_language:
+ case "English" :
+ lyric_font = "Noto Sans"
+ # lyric_font = "Pretendard" # 없어요
+ case _ :
+ lyric_font = "Noto Sans"
+
# LYRIC AUTO 결정부
if (creatomate_settings.DEBUG_AUTO_LYRIC):
auto_text_template = creatomate_service.get_auto_text_template()
@@ -363,6 +371,7 @@ async def generate_video(
aligned.lyric_line,
aligned.start_time,
aligned.end_time,
+ lyric_font
)
final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부
diff --git a/config.py b/config.py
index 003de5f..94c7c7c 100644
--- a/config.py
+++ b/config.py
@@ -1,10 +1,19 @@
+import os
from pathlib import Path
+from zoneinfo import ZoneInfo
+from dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent
+# .env 파일 로드 (Settings 클래스보다 먼저 TIMEZONE을 사용하기 위함)
+load_dotenv(PROJECT_DIR / ".env")
+
+# 프로젝트 전역 타임존 설정 (기본값: 서울)
+TIMEZONE = ZoneInfo(os.getenv("TIMEZONE", "Asia/Seoul"))
+
# 미디어 파일 저장 디렉토리
MEDIA_ROOT = PROJECT_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True)
@@ -23,6 +32,10 @@ class ProjectSettings(BaseSettings):
DESCRIPTION: str = Field(default="FastAPI 기반 CastAD 프로젝트")
ADMIN_BASE_URL: str = Field(default="/admin")
DEBUG: bool = Field(default=True)
+ TIMEZONE: str = Field(
+ default="Asia/Seoul",
+ description="프로젝트 전역 타임존 (예: Asia/Seoul, UTC, America/New_York)",
+ )
model_config = _base_config
@@ -156,8 +169,8 @@ class CreatomateSettings(BaseSettings):
description="가로형 템플릿 기본 duration (초)",
)
DEBUG_AUTO_LYRIC: bool = Field(
- default = False,
- description = "Creatomate 자동 가사 생성 기능 사용 여부"
+ default=False,
+ description="Creatomate 자동 가사 생성 기능 사용 여부",
)
model_config = _base_config
diff --git a/docs/plan/timezone_plan.md b/docs/plan/timezone_plan.md
new file mode 100644
index 0000000..00d215c
--- /dev/null
+++ b/docs/plan/timezone_plan.md
@@ -0,0 +1,226 @@
+# 타임존 설정 작업 계획
+
+## 개요
+프로젝트 전체에 서울 타임존(Asia/Seoul, UTC+9)을 일관되게 적용합니다.
+
+---
+
+## 현재 상태
+
+### 완료된 설정
+| 구성 요소 | 상태 | 비고 |
+|----------|------|------|
+| **MySQL 데이터베이스** | ✅ 완료 | DB 생성 시 Asia/Seoul로 설정됨 |
+| **config.py** | ✅ 완료 | `TIMEZONE = ZoneInfo("Asia/Seoul")` 설정됨 |
+| **SQLAlchemy 모델** | ✅ 정상 | `server_default=func.now()` → DB 타임존(서울) 따름 |
+
+### 문제점
+- Python 코드에서 `datetime.now()` (naive) 또는 `datetime.now(timezone.utc)` (UTC) 혼용
+- 타임존이 적용된 현재 시간을 얻는 공통 유틸리티 없음
+
+---
+
+## 수정 대상 파일
+
+| 파일 | 라인 | 현재 코드 | 문제 |
+|------|------|----------|------|
+| `app/user/services/jwt.py` | 26, 51, 109 | `datetime.now()` | naive datetime |
+| `app/user/services/auth.py` | 170, 225, 485, 510 | `datetime.now()` | naive datetime |
+| `app/user/api/routers/v1/auth.py` | 437 | `datetime.now(timezone.utc)` | UTC 사용 (서울 아님) |
+| `app/social/services.py` | 408, 448, 509, 562, 578 | `datetime.now()` | naive datetime |
+| `app/social/worker/upload_task.py` | 73 | `datetime.now()` | naive datetime |
+| `app/utils/logger.py` | 89, 119 | `datetime.today()` | naive datetime |
+
+---
+
+## 작업 단계
+
+### 1단계: 타임존 유틸리티 생성 (신규 파일)
+
+**파일**: `app/utils/timezone.py`
+
+```python
+"""
+타임존 유틸리티
+
+프로젝트 전역에서 일관된 서울 타임존(Asia/Seoul) 시간을 사용하기 위한 유틸리티입니다.
+모든 datetime.now() 호출은 이 모듈의 함수로 대체해야 합니다.
+"""
+from datetime import datetime
+
+from config import TIMEZONE
+
+
+def now() -> datetime:
+ """
+ 서울 타임존(Asia/Seoul) 기준 현재 시간을 반환합니다.
+
+ Returns:
+ datetime: 서울 타임존이 적용된 현재 시간 (aware datetime)
+
+ Example:
+ >>> from app.utils.timezone import now
+ >>> current_time = now() # 2024-01-15 15:30:00+09:00
+ """
+ return datetime.now(TIMEZONE)
+
+
+def today_str(fmt: str = "%Y-%m-%d") -> str:
+ """
+ 서울 타임존 기준 오늘 날짜를 문자열로 반환합니다.
+
+ Args:
+ fmt: 날짜 포맷 (기본값: YYYY-MM-DD)
+
+ Returns:
+ str: 포맷된 날짜 문자열
+
+ Example:
+ >>> from app.utils.timezone import today_str
+ >>> today_str() # "2024-01-15"
+ >>> today_str("%Y/%m/%d") # "2024/01/15"
+ """
+ return datetime.now(TIMEZONE).strftime(fmt)
+```
+
+### 2단계: 기존 코드 수정
+
+모든 `datetime.now()`, `datetime.today()`, `datetime.now(timezone.utc)`를 타임존 유틸리티 함수로 교체합니다.
+
+#### 2.1 app/user/services/jwt.py
+
+```python
+# Before
+from datetime import datetime, timedelta
+
+expire = datetime.now() + timedelta(...)
+
+# After
+from datetime import timedelta
+from app.utils.timezone import now
+
+expire = now() + timedelta(...)
+```
+
+#### 2.2 app/user/services/auth.py
+
+```python
+# Before
+from datetime import datetime
+
+user.last_login_at = datetime.now()
+if db_token.expires_at < datetime.now():
+revoked_at=datetime.now()
+
+# After
+from app.utils.timezone import now
+
+user.last_login_at = now()
+if db_token.expires_at < now():
+revoked_at=now()
+```
+
+#### 2.3 app/user/api/routers/v1/auth.py
+
+```python
+# Before
+from datetime import datetime, timezone
+
+user.last_login_at = datetime.now(timezone.utc)
+
+# After
+from app.utils.timezone import now
+
+user.last_login_at = now()
+```
+
+#### 2.4 app/social/services.py
+
+```python
+# Before
+from datetime import datetime, timedelta
+
+buffer_time = datetime.now() + timedelta(minutes=10)
+account.token_expires_at = datetime.now() + timedelta(...)
+account.connected_at = datetime.now()
+
+# After
+from datetime import timedelta
+from app.utils.timezone import now
+
+buffer_time = now() + timedelta(minutes=10)
+account.token_expires_at = now() + timedelta(...)
+account.connected_at = now()
+```
+
+#### 2.5 app/social/worker/upload_task.py
+
+```python
+# Before
+from datetime import datetime
+
+upload.uploaded_at = datetime.now()
+
+# After
+from app.utils.timezone import now
+
+upload.uploaded_at = now()
+```
+
+#### 2.6 app/utils/logger.py
+
+```python
+# Before
+from datetime import datetime
+
+today = datetime.today().strftime("%Y-%m-%d")
+
+# After
+from app.utils.timezone import today_str
+
+today = today_str()
+```
+
+> **참고**: logger.py에서는 **날짜만 사용하는 것이 맞습니다.**
+> 로그 파일명이 `{날짜}_app.log`, `{날짜}_error.log` 형식이므로 일별 로그 파일 관리에 적합합니다.
+> 시간까지 포함하면 매 시간/분마다 새 파일이 생성되어 로그 관리가 어려워집니다.
+
+---
+
+## 작업 체크리스트
+
+| 순서 | 작업 | 파일 | 상태 |
+|------|------|------|------|
+| 1 | 타임존 유틸리티 생성 | `app/utils/timezone.py` | ✅ 완료 |
+| 2 | JWT 서비스 수정 | `app/user/services/jwt.py` | ✅ 완료 |
+| 3 | Auth 서비스 수정 | `app/user/services/auth.py` | ✅ 완료 |
+| 4 | Auth 라우터 수정 | `app/user/api/routers/v1/auth.py` | ✅ 완료 |
+| 5 | Social 서비스 수정 | `app/social/services.py` | ✅ 완료 |
+| 6 | Upload Task 수정 | `app/social/worker/upload_task.py` | ✅ 완료 |
+| 7 | Logger 유틸리티 수정 | `app/utils/logger.py` | ✅ 완료 |
+
+---
+
+## 예상 작업 범위
+
+- **신규 파일**: 1개 (`app/utils/timezone.py`)
+- **수정 파일**: 6개
+- **수정 위치**: 약 15곳
+
+---
+
+## 참고 사항
+
+### naive datetime vs aware datetime
+- **naive datetime**: 타임존 정보가 없는 datetime (예: `datetime.now()`)
+- **aware datetime**: 타임존 정보가 있는 datetime (예: `datetime.now(TIMEZONE)`)
+
+### 왜 서울 타임존을 사용하는가?
+1. 데이터베이스가 Asia/Seoul로 설정됨
+2. 서비스 대상 지역이 한국
+3. Python 코드와 DB 간 시간 일관성 확보
+
+### 주의사항
+- 기존 DB에 저장된 시간 데이터는 이미 서울 시간이므로 마이그레이션 불필요
+- JWT 토큰 만료 시간 비교 시 타임존 일관성 필수
+- 로그 파일명에 사용되는 날짜도 서울 기준으로 통일
diff --git a/pyproject.toml b/pyproject.toml
index 4380915..751a21c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,7 @@ dependencies = [
"openai>=2.13.0",
"playwright>=1.57.0",
"pydantic-settings>=2.12.0",
+ "python-dotenv>=1.0.0",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.21",
"redis>=7.1.0",
diff --git a/uv.lock b/uv.lock
index 2d00834..35c2838 100644
--- a/uv.lock
+++ b/uv.lock
@@ -657,6 +657,7 @@ dependencies = [
{ name = "openai" },
{ name = "playwright" },
{ name = "pydantic-settings" },
+ { name = "python-dotenv" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "redis" },
@@ -685,6 +686,7 @@ requires-dist = [
{ name = "openai", specifier = ">=2.13.0" },
{ name = "playwright", specifier = ">=1.57.0" },
{ name = "pydantic-settings", specifier = ">=2.12.0" },
+ { name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.21" },
{ name = "redis", specifier = ">=7.1.0" },