add logger

insta
Dohyun Lim 2026-01-14 17:46:45 +09:00
parent 1acd8846ab
commit bf7b53c8e8
41 changed files with 1832 additions and 694 deletions

7
.gitignore vendored
View File

@ -27,3 +27,10 @@ build/
*.mp3
*.mp4
media/
# Static files
static/
# Log files
*.log
logs/

View File

@ -4,12 +4,16 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.utils.logger import get_logger
logger = get_logger("core")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""FastAPI 애플리케이션 생명주기 관리"""
# Startup - 애플리케이션 시작 시
print("Starting up...")
logger.info("Starting up...")
try:
from config import prj_settings
@ -19,20 +23,20 @@ async def lifespan(app: FastAPI):
from app.database.session import create_db_tables
await create_db_tables()
print("Database tables created (DEBUG mode)")
logger.info("Database tables created (DEBUG mode)")
except asyncio.TimeoutError:
print("Database initialization timed out")
logger.error("Database initialization timed out")
# 타임아웃 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise
except Exception as e:
print(f"Database initialization failed: {e}")
logger.error(f"Database initialization failed: {e}")
# 에러 시 앱 시작 중단하려면 raise, 계속하려면 pass
raise
yield # 애플리케이션 실행 중
# Shutdown - 애플리케이션 종료 시
print("Shutting down...")
logger.info("Shutting down...")
# 공유 HTTP 클라이언트 종료
from app.utils.creatomate import close_shared_client

View File

@ -1,4 +1,3 @@
import logging
import traceback
from functools import wraps
from typing import Any, Callable, TypeVar
@ -7,8 +6,10 @@ from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.responses import JSONResponse
from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("core")
T = TypeVar("T")
@ -159,16 +160,14 @@ def handle_db_exceptions(
raise
except SQLAlchemyError as e:
logger.error(f"[DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[DB Error] {func.__name__}: {e}")
logger.debug(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=error_message,
)
except Exception as e:
logger.error(f"[Unexpected Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[Unexpected Error] {func.__name__}: {e}")
logger.debug(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 예기치 않은 오류가 발생했습니다.",
@ -205,8 +204,7 @@ def handle_external_service_exceptions(
except Exception as e:
msg = error_message or f"{service_name} 서비스 호출 중 오류가 발생했습니다."
logger.error(f"[{service_name} Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[{service_name} Error] {func.__name__}: {e}")
logger.debug(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=msg,
@ -240,16 +238,14 @@ def handle_api_exceptions(
raise
except SQLAlchemyError as e:
logger.error(f"[API DB Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[API DB Error] {func.__name__}: {e}")
logger.debug(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
logger.error(f"[API Error] {func.__name__}: {e}")
logger.error(traceback.format_exc())
print(f"[API Error] {func.__name__}: {e}")
logger.debug(traceback.format_exc())
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=error_message,
@ -263,16 +259,7 @@ def handle_api_exceptions(
def _get_handler(status: int, detail: str):
# Define
def handler(request: Request, exception: Exception) -> Response:
# DEBUG PRINT STATEMENT 👇
from rich import print, panel
print(
panel.Panel(
exception.__class__.__name__,
title="Handled Exception",
border_style="red",
),
)
# DEBUG PRINT STATEMENT 👆
logger.debug(f"Handled Exception: {exception.__class__.__name__}")
# Raise HTTPException with given status and detail
# can return JSONResponse as well

View File

@ -9,8 +9,11 @@ from sqlalchemy.ext.asyncio import (
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import AsyncQueuePool # 비동기 풀 클래스
from app.utils.logger import get_logger
from config import db_settings
logger = get_logger("database")
# Base 클래스 정의
class Base(DeclarativeBase):
@ -61,7 +64,7 @@ async def create_db_tables() -> None:
async with engine.begin() as conn:
# from app.database.models import Shipment, Seller # noqa: F401
await conn.run_sync(Base.metadata.create_all)
print("MySQL tables created successfully")
logger.info("MySQL tables created successfully")
# 세션 제너레이터 (FastAPI Depends에 사용)
@ -80,13 +83,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
# FastAPI 요청 완료 시 자동 commit (예외 발생 시 rollback)
except Exception as e:
await session.rollback() # 명시적 롤백 (선택적)
print(f"Session rollback due to: {e}") # 로깅
logger.error(f"Session rollback due to: {e}")
raise
finally:
# 명시적 세션 종료 (Connection Pool에 반환)
# context manager가 자동 처리하지만, 명시적으로 유지
await session.close()
print("session closed successfully")
logger.debug("session closed successfully")
# 또는 session.aclose() - Python 3.10+
@ -94,4 +97,4 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
async def dispose_engine() -> None:
"""애플리케이션 종료 시 모든 연결 해제"""
await engine.dispose()
print("Database engine disposed")
logger.info("Database engine disposed")

View File

@ -4,8 +4,11 @@ from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.utils.logger import get_logger
from config import db_settings
logger = get_logger("database")
class Base(DeclarativeBase):
pass
@ -74,7 +77,7 @@ async def create_db_tables():
from app.song.models import Song # noqa: F401
from app.video.models import Video # noqa: F401
print("Creating database tables...")
logger.info("Creating database tables...")
async with asyncio.timeout(10):
async with engine.begin() as connection:
@ -87,7 +90,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
pool = engine.pool
# 커넥션 풀 상태 로깅 (디버깅용)
print(
logger.debug(
f"[get_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}"
@ -95,7 +98,7 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
acquire_time = time.perf_counter()
print(
logger.debug(
f"[get_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms"
)
@ -103,14 +106,14 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
yield session
except Exception as e:
await session.rollback()
print(
logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise e
finally:
total_time = time.perf_counter() - start_time
print(
logger.debug(
f"[get_session] RELEASE - duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}"
)
@ -121,7 +124,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
start_time = time.perf_counter()
pool = background_engine.pool
print(
logger.debug(
f"[get_background_session] ACQUIRE - pool_size: {pool.size()}, "
f"in: {pool.checkedin()}, out: {pool.checkedout()}, "
f"overflow: {pool.overflow()}"
@ -129,7 +132,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
async with BackgroundSessionLocal() as session:
acquire_time = time.perf_counter()
print(
logger.debug(
f"[get_background_session] Session acquired in "
f"{(acquire_time - start_time)*1000:.1f}ms"
)
@ -137,7 +140,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
yield session
except Exception as e:
await session.rollback()
print(
logger.error(
f"[get_background_session] ROLLBACK - "
f"error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
@ -145,7 +148,7 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
raise e
finally:
total_time = time.perf_counter() - start_time
print(
logger.debug(
f"[get_background_session] RELEASE - "
f"duration: {total_time*1000:.1f}ms, "
f"pool_out: {pool.checkedout()}"
@ -154,8 +157,8 @@ async def get_background_session() -> AsyncGenerator[AsyncSession, None]:
# 앱 종료 시 엔진 리소스 정리 함수
async def dispose_engine() -> None:
print("[dispose_engine] Disposing database engines...")
logger.info("[dispose_engine] Disposing database engines...")
await engine.dispose()
print("[dispose_engine] Main engine disposed")
logger.info("[dispose_engine] Main engine disposed")
await background_engine.dispose()
print("[dispose_engine] Background engine disposed - ALL DONE")
logger.info("[dispose_engine] Background engine disposed - ALL DONE")

View File

@ -1,6 +1,5 @@
import json
import logging
import traceback
import time
from datetime import date
from pathlib import Path
from typing import Optional
@ -26,12 +25,12 @@ from app.home.schemas.home_schema import (
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.common import generate_task_id
from app.utils.logger import get_logger
from app.utils.nvMapScraper import NvMapScraper, GraphQLException
from config import MEDIA_ROOT
# 로거 설정
logger = logging.getLogger(__name__)
MEDIA_ROOT = Path("media")
logger = get_logger("home")
# 전국 시 이름 목록 (roadAddress에서 region 추출용)
# fmt: off
@ -106,16 +105,13 @@ def _extract_region_from_address(road_address: str | None) -> str:
)
async def crawling(request_body: CrawlingRequest):
"""네이버 지도 장소 크롤링"""
import time
request_start = time.perf_counter()
logger.info(f"[crawling] START - url: {request_body.url[:80]}...")
print(f"[crawling] ========== START ==========")
print(f"[crawling] URL: {request_body.url[:80]}...")
logger.info("[crawling] ========== START ==========")
logger.info(f"[crawling] URL: {request_body.url[:80]}...")
# ========== Step 1: 네이버 지도 크롤링 ==========
step1_start = time.perf_counter()
print(f"[crawling] Step 1: 네이버 지도 크롤링 시작...")
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
try:
scraper = NvMapScraper(request_body.url)
@ -123,7 +119,6 @@ async def crawling(request_body: CrawlingRequest):
except GraphQLException as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(f"[crawling] Step 1 FAILED - GraphQL 크롤링 실패: {e} ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"네이버 지도 크롤링에 실패했습니다: {e}",
@ -131,8 +126,7 @@ async def crawling(request_body: CrawlingRequest):
except Exception as e:
step1_elapsed = (time.perf_counter() - step1_start) * 1000
logger.error(f"[crawling] Step 1 FAILED - 크롤링 중 예기치 않은 오류: {e} ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 FAILED - {e} ({step1_elapsed:.1f}ms)")
traceback.print_exc()
logger.exception("[crawling] Step 1 상세 오류:")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="네이버 지도 크롤링 중 오류가 발생했습니다.",
@ -141,11 +135,10 @@ async def crawling(request_body: CrawlingRequest):
step1_elapsed = (time.perf_counter() - step1_start) * 1000
image_count = len(scraper.image_link_list) if scraper.image_link_list else 0
logger.info(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
print(f"[crawling] Step 1 완료 - 이미지 {image_count}개 ({step1_elapsed:.1f}ms)")
# ========== Step 2: 정보 가공 ==========
step2_start = time.perf_counter()
print(f"[crawling] Step 2: 정보 가공 시작...")
logger.info("[crawling] Step 2: 정보 가공 시작...")
processed_info = None
marketing_analysis = None
@ -163,11 +156,10 @@ async def crawling(request_body: CrawlingRequest):
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
print(f"[crawling] Step 2 완료 - {customer_name}, {region} ({step2_elapsed:.1f}ms)")
# ========== Step 3: ChatGPT 마케팅 분석 ==========
step3_start = time.perf_counter()
print(f"[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
logger.info("[crawling] Step 3: ChatGPT 마케팅 분석 시작...")
try:
# Step 3-1: ChatGPT 서비스 초기화
@ -178,59 +170,54 @@ async def crawling(request_body: CrawlingRequest):
detail_region_info=road_address or "",
)
step3_1_elapsed = (time.perf_counter() - step3_1_start) * 1000
print(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)")
logger.debug(f"[crawling] Step 3-1: 서비스 초기화 완료 ({step3_1_elapsed:.1f}ms)")
# Step 3-2: 프롬프트 생성
step3_2_start = time.perf_counter()
prompt = chatgpt_service.build_market_analysis_prompt()
step3_2_elapsed = (time.perf_counter() - step3_2_start) * 1000
print(f"[crawling] Step 3-2: 프롬프트 생성 완료 - {len(prompt)}자 ({step3_2_elapsed:.1f}ms)")
logger.debug(f"[crawling] Step 3-2: 프롬프트 생성 완료 - {len(prompt)}자 ({step3_2_elapsed:.1f}ms)")
# Step 3-3: GPT API 호출
step3_3_start = time.perf_counter()
raw_response = await chatgpt_service.generate(prompt)
step3_3_elapsed = (time.perf_counter() - step3_3_start) * 1000
logger.info(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
print(f"[crawling] Step 3-3: GPT API 호출 완료 - 응답 {len(raw_response)}자 ({step3_3_elapsed:.1f}ms)")
# Step 3-4: 응답 파싱 (크롤링에서 가져온 facility_info 전달)
step3_4_start = time.perf_counter()
print(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}")
logger.debug(f"[crawling] Step 3-4: 응답 파싱 시작 - facility_info: {scraper.facility_info}")
parsed = await chatgpt_service.parse_marketing_analysis(
raw_response
)
marketing_analysis = MarketingAnalysis(**parsed)
step3_4_elapsed = (time.perf_counter() - step3_4_start) * 1000
print(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)")
logger.debug(f"[crawling] Step 3-4: 응답 파싱 완료 ({step3_4_elapsed:.1f}ms)")
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.info(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
print(f"[crawling] Step 3 완료 - 마케팅 분석 성공 ({step3_elapsed:.1f}ms)")
except Exception as e:
step3_elapsed = (time.perf_counter() - step3_start) * 1000
logger.error(f"[crawling] Step 3 FAILED - GPT 마케팅 분석 중 오류: {e} ({step3_elapsed:.1f}ms)")
print(f"[crawling] Step 3 FAILED - {e} ({step3_elapsed:.1f}ms)")
traceback.print_exc()
logger.exception("[crawling] Step 3 상세 오류:")
# GPT 실패 시에도 크롤링 결과는 반환
marketing_analysis = None
else:
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.warning(f"[crawling] Step 2 - base_info 없음 ({step2_elapsed:.1f}ms)")
print(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
logger.warning(f"[crawling] Step 2 - base_info 없음, 마케팅 분석 스킵 ({step2_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000
logger.info(f"[crawling] COMPLETE - 총 소요시간: {total_elapsed:.1f}ms")
print(f"[crawling] ========== COMPLETE ==========")
print(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
print(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
logger.info("[crawling] ========== COMPLETE ==========")
logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
if scraper.base_info:
print(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
if 'step3_elapsed' in locals():
print(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
logger.info(f"[crawling] - Step 3 (GPT 분석): {step3_elapsed:.1f}ms")
if 'step3_3_elapsed' in locals():
print(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
logger.info(f"[crawling] - GPT API 호출: {step3_3_elapsed:.1f}ms")
return {
"image_list": scraper.image_link_list,
@ -612,12 +599,11 @@ async def upload_images_blob(
- Stage 2: Azure Blob 업로드 (세션 없음)
- Stage 3: DB 저장 ( 세션으로 빠르게 처리)
"""
import time
request_start = time.perf_counter()
# task_id 생성
task_id = await generate_task_id()
print(f"[upload_images_blob] START - task_id: {task_id}")
logger.info(f"[upload_images_blob] START - task_id: {task_id}")
# ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ==========
has_images_json = images_json is not None and images_json.strip() != ""
@ -671,9 +657,9 @@ async def upload_images_blob(
)
stage1_time = time.perf_counter()
print(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
f"files: {len(valid_files_data)}, "
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms")
logger.info(f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
f"files: {len(valid_files_data)}, "
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms")
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
@ -692,8 +678,8 @@ async def upload_images_blob(
)
filename = f"{name_without_ext}_{img_order:03d}{ext}"
print(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
f"{filename} ({len(file_content)} bytes)")
logger.debug(f"[upload_images_blob] Uploading file {idx+1}/{total_files}: "
f"{filename} ({len(file_content)} bytes)")
# Azure Blob Storage에 직접 업로드
upload_success = await uploader.upload_image_bytes(file_content, filename)
@ -702,18 +688,18 @@ async def upload_images_blob(
blob_url = uploader.public_url
blob_upload_results.append((original_name, blob_url))
img_order += 1
print(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS")
logger.debug(f"[upload_images_blob] File {idx+1}/{total_files} SUCCESS")
else:
skipped_files.append(filename)
print(f"[upload_images_blob] File {idx+1}/{total_files} FAILED")
logger.warning(f"[upload_images_blob] File {idx+1}/{total_files} FAILED")
stage2_time = time.perf_counter()
print(f"[upload_images_blob] Stage 2 done - blob uploads: "
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
logger.info(f"[upload_images_blob] Stage 2 done - blob uploads: "
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
f"elapsed: {(stage2_time - stage1_time)*1000:.1f}ms")
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
print("[upload_images_blob] Stage 3 starting - DB save...")
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
result_images: list[ImageUploadResultItem] = []
img_order = 0
@ -769,21 +755,21 @@ async def upload_images_blob(
await session.commit()
stage3_time = time.perf_counter()
print(f"[upload_images_blob] Stage 3 done - "
f"saved: {len(result_images)}, "
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
logger.info(f"[upload_images_blob] Stage 3 done - "
f"saved: {len(result_images)}, "
f"elapsed: {(stage3_time - stage2_time)*1000:.1f}ms")
except SQLAlchemyError as e:
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.exception("[upload_images_blob] DB 상세 오류:")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
)
except Exception as e:
logger.error(f"[upload_images_blob] Stage 3 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
traceback.print_exc()
f"task_id: {task_id}, error: {type(e).__name__}: {e}")
logger.exception("[upload_images_blob] Stage 3 상세 오류:")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="이미지 업로드 중 오류가 발생했습니다.",
@ -793,8 +779,8 @@ async def upload_images_blob(
image_urls = [img.img_url for img in result_images]
total_time = time.perf_counter() - request_start
print(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
logger.info(f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
f"total: {saved_count}, total_time: {total_time*1000:.1f}ms")
return ImageUploadResponse(
task_id=task_id,

View File

@ -2,6 +2,10 @@ import pytest
from sqlalchemy import text
from app.database.session import AsyncSessionLocal, engine
from app.utils.logger import get_logger
# 로거 설정
logger = get_logger("test_db")
@pytest.mark.asyncio
@ -27,4 +31,4 @@ async def test_database_version():
result = await session.execute(text("SELECT VERSION()"))
version = result.scalar()
assert version is not None
print(f"MySQL Version: {version}")
logger.info(f"MySQL Version: {version}")

View File

@ -11,8 +11,6 @@ from fastapi import UploadFile
from app.utils.upload_blob_as_request import AzureBlobUploader
MEDIA_ROOT = Path("media")
async def save_upload_file(file: UploadFile, save_path: Path) -> None:
"""업로드 파일을 지정된 경로에 저장"""

View File

@ -41,8 +41,12 @@ from app.lyric.schemas.lyric import (
)
from app.lyric.worker.lyric_task import generate_lyric_background
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse, get_paginated
# 로거 설정
logger = get_logger("lyric")
router = APIRouter(prefix="/lyric", tags=["lyric"])
@ -74,7 +78,7 @@ async def get_lyric_status_by_task_id(
if status_info.status == "completed":
# 완료 처리
"""
print(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
logger.info(f"[get_lyric_status_by_task_id] START - task_id: {task_id}")
result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
@ -84,7 +88,7 @@ async def get_lyric_status_by_task_id(
lyric = result.scalar_one_or_none()
if not lyric:
print(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
logger.warning(f"[get_lyric_status_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
@ -96,7 +100,7 @@ async def get_lyric_status_by_task_id(
"failed": "가사 생성에 실패했습니다.",
}
print(
logger.info(
f"[get_lyric_status_by_task_id] SUCCESS - task_id: {task_id}, status: {lyric.status}"
)
return LyricStatusResponse(
@ -127,7 +131,7 @@ async def get_lyric_by_task_id(
lyric = await get_lyric_by_task_id(session, task_id)
"""
print(f"[get_lyric_by_task_id] START - task_id: {task_id}")
logger.info(f"[get_lyric_by_task_id] START - task_id: {task_id}")
result = await session.execute(
select(Lyric)
.where(Lyric.task_id == task_id)
@ -137,13 +141,13 @@ async def get_lyric_by_task_id(
lyric = result.scalar_one_or_none()
if not lyric:
print(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
logger.warning(f"[get_lyric_by_task_id] NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다.",
)
print(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
logger.info(f"[get_lyric_by_task_id] SUCCESS - task_id: {task_id}, lyric_id: {lyric.id}")
return LyricDetailResponse(
id=lyric.id,
task_id=lyric.task_id,
@ -224,8 +228,8 @@ async def generate_lyric(
request_start = time.perf_counter()
task_id = request_body.task_id
print(f"[generate_lyric] ========== START ==========")
print(
logger.info(f"[generate_lyric] ========== START ==========")
logger.info(
f"[generate_lyric] task_id: {task_id}, "
f"customer_name: {request_body.customer_name}, "
f"region: {request_body.region}"
@ -234,7 +238,7 @@ async def generate_lyric(
try:
# ========== Step 1: ChatGPT 서비스 초기화 및 프롬프트 생성 ==========
step1_start = time.perf_counter()
print(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
service = ChatgptService(
customer_name=request_body.customer_name,
@ -245,11 +249,11 @@ async def generate_lyric(
prompt = service.build_lyrics_prompt()
step1_elapsed = (time.perf_counter() - step1_start) * 1000
print(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
# ========== Step 2: Project 테이블에 데이터 저장 ==========
step2_start = time.perf_counter()
print(f"[generate_lyric] Step 2: Project 저장...")
logger.debug(f"[generate_lyric] Step 2: Project 저장...")
project = Project(
store_name=request_body.customer_name,
@ -263,11 +267,11 @@ async def generate_lyric(
await session.refresh(project)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
print(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
logger.debug(f"[generate_lyric] Step 2 완료 - project_id: {project.id} ({step2_elapsed:.1f}ms)")
# ========== Step 3: Lyric 테이블에 데이터 저장 ==========
step3_start = time.perf_counter()
print(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
lyric = Lyric(
project_id=project.id,
@ -282,11 +286,11 @@ async def generate_lyric(
await session.refresh(lyric)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
logger.debug(f"[generate_lyric] Step 3 완료 - lyric_id: {lyric.id} ({step3_elapsed:.1f}ms)")
# ========== Step 4: 백그라운드 태스크 스케줄링 ==========
step4_start = time.perf_counter()
print(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
logger.debug(f"[generate_lyric] Step 4: 백그라운드 태스크 스케줄링...")
background_tasks.add_task(
generate_lyric_background,
@ -296,17 +300,17 @@ async def generate_lyric(
)
step4_elapsed = (time.perf_counter() - step4_start) * 1000
print(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
logger.debug(f"[generate_lyric] Step 4 완료 ({step4_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] ========== COMPLETE ==========")
print(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
print(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
print(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
logger.info(f"[generate_lyric] ========== COMPLETE ==========")
logger.info(f"[generate_lyric] API 응답 소요시간: {total_elapsed:.1f}ms")
logger.debug(f"[generate_lyric] - Step 1 (프롬프트 생성): {step1_elapsed:.1f}ms")
logger.debug(f"[generate_lyric] - Step 2 (Project 저장): {step2_elapsed:.1f}ms")
logger.debug(f"[generate_lyric] - Step 3 (Lyric 저장): {step3_elapsed:.1f}ms")
logger.debug(f"[generate_lyric] - Step 4 (태스크 스케줄링): {step4_elapsed:.1f}ms")
logger.debug(f"[generate_lyric] (GPT API 호출은 백그라운드에서 별도 진행)")
# 5. 즉시 응답 반환
return GenerateLyricResponse(
@ -319,7 +323,7 @@ async def generate_lyric(
except Exception as e:
elapsed = (time.perf_counter() - request_start) * 1000
print(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
logger.error(f"[generate_lyric] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
await session.rollback()
return GenerateLyricResponse(
success=False,

View File

@ -14,6 +14,10 @@ from app.lyric.schemas.lyrics_schema import (
StoreData,
)
from app.utils.chatgpt_prompt import chatgpt_api
from app.utils.logger import get_logger
# 로거 설정
logger = get_logger("lyric")
async def get_store_info(conn: Connection) -> List[StoreData]:
@ -38,13 +42,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
result.close()
return all_store_info
except SQLAlchemyError as e:
print(e)
logger.error(f"Database error in get_store_info: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_store_info: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -69,13 +73,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close()
return all_attribute
except SQLAlchemyError as e:
print(e)
logger.error(f"Database error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -100,13 +104,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close()
return all_attribute
except SQLAlchemyError as e:
print(e)
logger.error(f"Database error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -132,13 +136,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
result.close()
return all_sample_song
except SQLAlchemyError as e:
print(e)
logger.error(f"Database error in get_sample_song: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_sample_song: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -162,13 +166,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
result.close()
return all_prompt_template
except SQLAlchemyError as e:
print(e)
logger.error(f"Database error in get_prompt_template: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_prompt_template: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -192,13 +196,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
result.close()
return all_prompt_template
except SQLAlchemyError as e:
print(e)
logger.error(f"Database error in get_song_result: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_song_result: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -210,11 +214,11 @@ async def make_song_result(request: Request, conn: Connection):
# 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}")
print(f"Store ID: {form_data.store_id}")
print(f"Lyrics IDs: {form_data.lyrics_ids}")
print(f"Prompt IDs: {form_data.prompts}")
print(f"{'=' * 60}\n")
logger.info(f"{'=' * 60}")
logger.info(f"Store ID: {form_data.store_id}")
logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
logger.info(f"Prompt IDs: {form_data.prompts}")
logger.info(f"{'=' * 60}")
# 2. Store 정보 조회
store_query = """
@ -243,7 +247,7 @@ async def make_song_result(request: Request, conn: Connection):
)
store_info = all_store_info[0]
print(f"Store: {store_info.store_name}")
logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
@ -251,7 +255,7 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = None
if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """
SELECT sample_song FROM song_sample
@ -270,11 +274,11 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("선택된 lyrics가 없습니다")
logger.info("선택된 lyrics가 없습니다")
# 5. 템플릿 가져오기
if not form_data.prompts:
@ -283,7 +287,7 @@ async def make_song_result(request: Request, conn: Connection):
detail="프롬프트 ID가 필요합니다",
)
print("템플릿 가져오기")
logger.info("템플릿 가져오기")
prompts_query = """
SELECT * FROM prompt_template WHERE id=:id;
@ -310,7 +314,7 @@ async def make_song_result(request: Request, conn: Connection):
)
prompt = prompts_info[0]
print(f"Prompt Template: {prompt.prompt}")
logger.debug(f"Prompt Template: {prompt.prompt}")
# ✅ 6. 프롬프트 조합
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
@ -329,7 +333,7 @@ async def make_song_result(request: Request, conn: Connection):
{combined_sample_song}
"""
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
# 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -348,13 +352,12 @@ async def make_song_result(request: Request, conn: Connection):
전체 글자 (공백 포함): {total_chars_with_space}
전체 글자 (공백 제외): {total_chars_without_space}\r\n\r\n{generated_lyrics}"""
print("=" * 40)
print("[translate:form_data.attributes_str:] ", form_data.attributes_str)
print("[translate:total_chars_with_space:] ", total_chars_with_space)
print("[translate:total_chars_without_space:] ", total_chars_without_space)
print("[translate:final_lyrics:]")
print(final_lyrics)
print("=" * 40)
logger.debug("=" * 40)
logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
logger.debug("=" * 40)
# 8. DB 저장
insert_query = """
@ -396,9 +399,9 @@ async def make_song_result(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params)
await conn.commit()
print("결과 저장 완료")
logger.info("결과 저장 완료")
print("\n전체 결과 조회 중...")
logger.info("전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순)
select_query = """
@ -430,26 +433,20 @@ async def make_song_result(request: Request, conn: Connection):
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException:
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",
@ -490,25 +487,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException: # HTTPException은 그대로 raise
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",
@ -520,9 +511,9 @@ async def make_automation(request: Request, conn: Connection):
# 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}")
print(f"Store ID: {form_data.store_id}")
print(f"{'=' * 60}\n")
logger.info(f"{'=' * 60}")
logger.info(f"Store ID: {form_data.store_id}")
logger.info(f"{'=' * 60}")
# 2. Store 정보 조회
store_query = """
@ -551,7 +542,7 @@ async def make_automation(request: Request, conn: Connection):
)
store_info = all_store_info[0]
print(f"Store: {store_info.store_name}")
logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
attribute_query = """
@ -596,13 +587,13 @@ async def make_automation(request: Request, conn: Connection):
# 최종 문자열 생성
formatted_attributes = "\n".join(formatted_pairs)
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
else:
print("속성 데이터가 없습니다")
logger.info("속성 데이터가 없습니다")
formatted_attributes = ""
# 4. 템플릿 가져오기
print("템플릿 가져오기 (ID=1)")
logger.info("템플릿 가져오기 (ID=1)")
prompts_query = """
SELECT * FROM prompt_template WHERE id=1;
@ -624,7 +615,7 @@ async def make_automation(request: Request, conn: Connection):
prompt=row[2],
)
print(f"Prompt Template: {prompt.prompt}")
logger.debug(f"Prompt Template: {prompt.prompt}")
# 5. 템플릿 조합
@ -635,17 +626,17 @@ async def make_automation(request: Request, conn: Connection):
description=store_info.store_info or "",
)
print("\n" + "=" * 80)
print("업데이트된 프롬프트")
print("=" * 80)
print(updated_prompt)
print("=" * 80 + "\n")
logger.debug("=" * 80)
logger.debug("업데이트된 프롬프트")
logger.debug("=" * 80)
logger.debug(updated_prompt)
logger.debug("=" * 80)
# 4. Sample Song 조회 및 결합
combined_sample_song = None
if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """
SELECT sample_song FROM song_sample
@ -664,14 +655,14 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("선택된 lyrics가 없습니다")
logger.info("선택된 lyrics가 없습니다")
# 1. song_sample 테이블의 모든 ID 조회
print("\n[샘플 가사 랜덤 선택]")
logger.info("[샘플 가사 랜덤 선택]")
all_ids_query = """
SELECT id FROM song_sample;
@ -679,7 +670,7 @@ async def make_automation(request: Request, conn: Connection):
ids_result = await conn.execute(text(all_ids_query))
all_ids = [row.id for row in ids_result.fetchall()]
print(f"전체 샘플 가사 개수: {len(all_ids)}")
logger.info(f"전체 샘플 가사 개수: {len(all_ids)}")
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
combined_sample_song = None
@ -689,7 +680,7 @@ async def make_automation(request: Request, conn: Connection):
sample_count = min(3, len(all_ids))
selected_ids = random.sample(all_ids, sample_count)
print(f"랜덤 선택된 ID: {selected_ids}")
logger.info(f"랜덤 선택된 ID: {selected_ids}")
# 3. 선택된 ID로 샘플 가사 조회
lyrics_query = """
@ -710,11 +701,11 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("song_sample 테이블에 데이터가 없습니다")
logger.info("song_sample 테이블에 데이터가 없습니다")
# 5. 프롬프트에 샘플 가사 추가
if combined_sample_song:
@ -726,11 +717,11 @@ async def make_automation(request: Request, conn: Connection):
{combined_sample_song}
"""
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
else:
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
logger.info(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
# 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -763,10 +754,9 @@ async def make_automation(request: Request, conn: Connection):
:sample_song, :result_song, NOW()
);
"""
print("\n[insert_params 선택된 속성 확인]")
print(f"Categories: {selected_categories}")
print(f"Values: {selected_values}")
print()
logger.debug("[insert_params 선택된 속성 확인]")
logger.debug(f"Categories: {selected_categories}")
logger.debug(f"Values: {selected_values}")
# attr_category, attr_value
insert_params = {
@ -792,9 +782,9 @@ async def make_automation(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params)
await conn.commit()
print("결과 저장 완료")
logger.info("결과 저장 완료")
print("\n전체 결과 조회 중...")
logger.info("전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순)
select_query = """
@ -826,26 +816,20 @@ async def make_automation(request: Request, conn: Connection):
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException:
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",

View File

@ -4,7 +4,6 @@ Lyric Background Tasks
가사 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
from sqlalchemy import select
@ -13,9 +12,10 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.lyric.models import Lyric
from app.utils.chatgpt_prompt import ChatgptService
from app.utils.logger import get_logger
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("lyric")
async def _update_lyric_status(
@ -49,20 +49,16 @@ async def _update_lyric_status(
lyric.lyric_result = result
await session.commit()
logger.info(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
print(f"[Lyric] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
print(f"[Lyric] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Lyric] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
@ -82,15 +78,15 @@ async def generate_lyric_background(
task_start = time.perf_counter()
logger.info(f"[generate_lyric_background] START - task_id: {task_id}")
print(f"[generate_lyric_background] ========== START ==========")
print(f"[generate_lyric_background] task_id: {task_id}")
print(f"[generate_lyric_background] language: {language}")
print(f"[generate_lyric_background] prompt length: {len(prompt)}")
logger.debug(f"[generate_lyric_background] ========== START ==========")
logger.debug(f"[generate_lyric_background] task_id: {task_id}")
logger.debug(f"[generate_lyric_background] language: {language}")
logger.debug(f"[generate_lyric_background] prompt length: {len(prompt)}")
try:
# ========== Step 1: ChatGPT 서비스 초기화 ==========
step1_start = time.perf_counter()
print(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
logger.debug(f"[generate_lyric_background] Step 1: ChatGPT 서비스 초기화...")
service = ChatgptService(
customer_name="", # 프롬프트가 이미 생성되었으므로 빈 값
@ -100,47 +96,42 @@ async def generate_lyric_background(
)
step1_elapsed = (time.perf_counter() - step1_start) * 1000
print(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)")
logger.debug(f"[generate_lyric_background] Step 1 완료 ({step1_elapsed:.1f}ms)")
# ========== Step 2: ChatGPT API 호출 (가사 생성) ==========
step2_start = time.perf_counter()
logger.info(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작 - task_id: {task_id}")
print(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...")
logger.debug(f"[generate_lyric_background] Step 2: ChatGPT API 호출 시작...")
result = await service.generate(prompt=prompt)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
print(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
# ========== Step 3: DB 상태 업데이트 ==========
step3_start = time.perf_counter()
print(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
await _update_lyric_status(task_id, "completed", result)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
print(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - task_start) * 1000
logger.info(f"[generate_lyric_background] SUCCESS - task_id: {task_id}, 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] ========== SUCCESS ==========")
print(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
print(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
logger.debug(f"[generate_lyric_background] ========== SUCCESS ==========")
logger.debug(f"[generate_lyric_background] 총 소요시간: {total_elapsed:.1f}ms")
logger.debug(f"[generate_lyric_background] - Step 1 (서비스 초기화): {step1_elapsed:.1f}ms")
logger.debug(f"[generate_lyric_background] - Step 2 (GPT API 호출): {step2_elapsed:.1f}ms")
logger.debug(f"[generate_lyric_background] - Step 3 (DB 업데이트): {step3_elapsed:.1f}ms")
except SQLAlchemyError as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
print(f"[generate_lyric_background] DB ERROR - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
logger.error(f"[generate_lyric_background] DB ERROR - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Database Error: {str(e)}")
except Exception as e:
elapsed = (time.perf_counter() - task_start) * 1000
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)")
print(f"[generate_lyric_background] EXCEPTION - {e} ({elapsed:.1f}ms)")
traceback.print_exc()
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}")

View File

@ -32,9 +32,11 @@ from app.song.schemas.song_schema import (
PollingSongResponse,
SongListItem,
)
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse
from app.utils.suno import SunoService
logger = get_logger("song")
router = APIRouter(prefix="/song", tags=["song"])
@ -99,7 +101,7 @@ async def generate_song(
from app.database.session import AsyncSessionLocal
request_start = time.perf_counter()
print(
logger.info(
f"[generate_song] START - task_id: {task_id}, "
f"genre: {request_body.genre}, language: {request_body.language}"
)
@ -124,7 +126,7 @@ async def generate_song(
project = project_result.scalar_one_or_none()
if not project:
print(f"[generate_song] Project NOT FOUND - task_id: {task_id}")
logger.warning(f"[generate_song] Project NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
@ -141,7 +143,7 @@ async def generate_song(
lyric = lyric_result.scalar_one_or_none()
if not lyric:
print(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
logger.warning(f"[generate_song] Lyric NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
@ -149,7 +151,7 @@ async def generate_song(
lyric_id = lyric.id
query_time = time.perf_counter()
print(
logger.info(
f"[generate_song] Queries completed - task_id: {task_id}, "
f"project_id: {project_id}, lyric_id: {lyric_id}, "
f"elapsed: {(query_time - request_start)*1000:.1f}ms"
@ -174,7 +176,7 @@ async def generate_song(
song_id = song.id
stage1_time = time.perf_counter()
print(
logger.info(
f"[generate_song] Stage 1 DONE - Song saved - "
f"task_id: {task_id}, song_id: {song_id}, "
f"elapsed: {(stage1_time - request_start)*1000:.1f}ms"
@ -184,7 +186,7 @@ async def generate_song(
except HTTPException:
raise
except Exception as e:
print(
logger.error(
f"[generate_song] Stage 1 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
)
@ -203,7 +205,7 @@ async def generate_song(
suno_task_id: str | None = None
try:
print(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}")
logger.info(f"[generate_song] Stage 2 START - Suno API - task_id: {task_id}")
suno_service = SunoService()
suno_task_id = await suno_service.generate(
prompt=request_body.lyrics,
@ -211,14 +213,14 @@ async def generate_song(
)
stage2_time = time.perf_counter()
print(
logger.info(
f"[generate_song] Stage 2 DONE - task_id: {task_id}, "
f"suno_task_id: {suno_task_id}, "
f"elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
)
except Exception as e:
print(
logger.error(
f"[generate_song] Stage 2 EXCEPTION - Suno API failed - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
)
@ -244,7 +246,7 @@ async def generate_song(
# 3단계: suno_task_id 업데이트 (새 세션으로 빠르게 처리)
# ==========================================================================
stage3_start = time.perf_counter()
print(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}")
logger.info(f"[generate_song] Stage 3 START - DB update - task_id: {task_id}")
try:
async with AsyncSessionLocal() as update_session:
@ -258,11 +260,11 @@ async def generate_song(
stage3_time = time.perf_counter()
total_time = stage3_time - request_start
print(
logger.info(
f"[generate_song] Stage 3 DONE - task_id: {task_id}, "
f"elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
)
print(
logger.info(
f"[generate_song] SUCCESS - task_id: {task_id}, "
f"suno_task_id: {suno_task_id}, "
f"total_time: {total_time*1000:.1f}ms"
@ -277,7 +279,7 @@ async def generate_song(
)
except Exception as e:
print(
logger.error(
f"[generate_song] Stage 3 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
)
@ -341,12 +343,12 @@ async def get_song_status(
Azure Blob Storage에 업로드한 Song 테이블의 status를 completed로,
song_result_url을 Blob URL로 업데이트합니다.
"""
print(f"[get_song_status] START - suno_task_id: {suno_task_id}")
logger.info(f"[get_song_status] START - suno_task_id: {suno_task_id}")
try:
suno_service = SunoService()
result = await suno_service.get_task_status(suno_task_id)
parsed_response = suno_service.parse_status_response(result)
print(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
logger.info(f"[get_song_status] Suno API response - suno_task_id: {suno_task_id}, status: {parsed_response.status}")
# SUCCESS 상태인 경우 첫 번째 클립 정보를 DB에 직접 저장
if parsed_response.status == "SUCCESS" and parsed_response.clips:
@ -354,7 +356,7 @@ async def get_song_status(
first_clip = parsed_response.clips[0]
audio_url = first_clip.audio_url
clip_duration = first_clip.duration
print(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}")
logger.debug(f"[get_song_status] Using first clip - id: {first_clip.id}, audio_url: {audio_url}, duration: {clip_duration}")
if audio_url:
# suno_task_id로 Song 조회
@ -373,17 +375,17 @@ async def get_song_status(
if clip_duration is not None:
song.duration = clip_duration
await session.commit()
print(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}")
logger.info(f"[get_song_status] Song updated - suno_task_id: {suno_task_id}, status: completed, song_result_url: {audio_url}, duration: {clip_duration}")
elif song and song.status == "completed":
print(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
logger.info(f"[get_song_status] SKIPPED - Song already completed, suno_task_id: {suno_task_id}")
print(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
logger.info(f"[get_song_status] SUCCESS - suno_task_id: {suno_task_id}")
return parsed_response
except Exception as e:
import traceback
print(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
logger.error(f"[get_song_status] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
return PollingSongResponse(
success=False,
status="error",
@ -438,7 +440,7 @@ async def download_song(
session: AsyncSession = Depends(get_session),
) -> DownloadSongResponse:
"""task_id로 Song 상태를 polling하고 completed 시 Project 정보와 노래 URL을 반환합니다."""
print(f"[download_song] START - task_id: {task_id}")
logger.info(f"[download_song] START - task_id: {task_id}")
try:
# task_id로 Song 조회 (여러 개 있을 경우 가장 최근 것 선택)
song_result = await session.execute(
@ -450,7 +452,7 @@ async def download_song(
song = song_result.scalar_one_or_none()
if not song:
print(f"[download_song] Song NOT FOUND - task_id: {task_id}")
logger.warning(f"[download_song] Song NOT FOUND - task_id: {task_id}")
return DownloadSongResponse(
success=False,
status="not_found",
@ -458,11 +460,11 @@ async def download_song(
error_message="Song not found",
)
print(f"[download_song] Song found - task_id: {task_id}, status: {song.status}")
logger.info(f"[download_song] Song found - task_id: {task_id}, status: {song.status}")
# processing 상태인 경우
if song.status == "processing":
print(f"[download_song] PROCESSING - task_id: {task_id}")
logger.info(f"[download_song] PROCESSING - task_id: {task_id}")
return DownloadSongResponse(
success=True,
status="processing",
@ -472,7 +474,7 @@ async def download_song(
# failed 상태인 경우
if song.status == "failed":
print(f"[download_song] FAILED - task_id: {task_id}")
logger.warning(f"[download_song] FAILED - task_id: {task_id}")
return DownloadSongResponse(
success=False,
status="failed",
@ -487,7 +489,7 @@ async def download_song(
)
project = project_result.scalar_one_or_none()
print(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}")
logger.info(f"[download_song] COMPLETED - task_id: {task_id}, song_result_url: {song.song_result_url}")
return DownloadSongResponse(
success=True,
status="completed",
@ -502,7 +504,7 @@ async def download_song(
)
except Exception as e:
print(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}")
logger.error(f"[download_song] EXCEPTION - task_id: {task_id}, error: {e}")
return DownloadSongResponse(
success=False,
status="error",
@ -550,7 +552,7 @@ async def get_songs(
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[SongListItem]:
"""완료된 노래 목록을 페이지네이션하여 반환합니다."""
print(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}")
logger.info(f"[get_songs] START - page: {pagination.page}, page_size: {pagination.page_size}")
try:
offset = (pagination.page - 1) * pagination.page_size
@ -622,14 +624,14 @@ async def get_songs(
page_size=pagination.page_size,
)
print(
logger.info(
f"[get_songs] SUCCESS - total: {total}, page: {pagination.page}, "
f"page_size: {pagination.page_size}, items_count: {len(items)}"
)
return response
except Exception as e:
print(f"[get_songs] EXCEPTION - error: {e}")
logger.error(f"[get_songs] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"노래 목록 조회에 실패했습니다: {str(e)}",

View File

@ -6,6 +6,7 @@ from fastapi.exceptions import HTTPException
from sqlalchemy import Connection, text
from sqlalchemy.exc import SQLAlchemyError
from app.utils.logger import get_logger
from app.lyrics.schemas.lyrics_schema import (
AttributeData,
PromptTemplateData,
@ -15,6 +16,8 @@ from app.lyrics.schemas.lyrics_schema import (
)
from app.utils.chatgpt_prompt import chatgpt_api
logger = get_logger("song")
async def get_store_info(conn: Connection) -> List[StoreData]:
try:
@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
result.close()
return all_store_info
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemyError in get_store_info: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_store_info: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close()
return all_attribute
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemyError in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close()
return all_attribute
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemyError in get_attribute (duplicate): {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_attribute (duplicate): {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
result.close()
return all_sample_song
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemyError in get_sample_song: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_sample_song: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
result.close()
return all_prompt_template
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemyError in get_prompt_template: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_prompt_template: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
result.close()
return all_prompt_template
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemyError in get_song_result (prompt_template): {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_song_result (prompt_template): {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection):
# 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}")
print(f"Store ID: {form_data.store_id}")
print(f"Lyrics IDs: {form_data.lyrics_ids}")
print(f"Prompt IDs: {form_data.prompts}")
print(f"{'=' * 60}\n")
logger.info(f"{'=' * 60}")
logger.info(f"Store ID: {form_data.store_id}")
logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
logger.info(f"Prompt IDs: {form_data.prompts}")
logger.info(f"{'=' * 60}")
# 2. Store 정보 조회
store_query = """
@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection):
)
store_info = all_store_info[0]
print(f"Store: {store_info.store_name}")
logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = None
if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """
SELECT sample_song FROM song_sample
@ -270,11 +273,11 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("선택된 lyrics가 없습니다")
logger.info("선택된 lyrics가 없습니다")
# 5. 템플릿 가져오기
if not form_data.prompts:
@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection):
detail="프롬프트 ID가 필요합니다",
)
print("템플릿 가져오기")
logger.info("템플릿 가져오기")
prompts_query = """
SELECT * FROM prompt_template WHERE id=:id;
@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection):
)
prompt = prompts_info[0]
print(f"Prompt Template: {prompt.prompt}")
logger.debug(f"Prompt Template: {prompt.prompt}")
# ✅ 6. 프롬프트 조합
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
@ -329,7 +332,7 @@ async def make_song_result(request: Request, conn: Connection):
{combined_sample_song}
"""
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
# 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -348,13 +351,12 @@ async def make_song_result(request: Request, conn: Connection):
전체 글자 (공백 포함): {total_chars_with_space}
전체 글자 (공백 제외): {total_chars_without_space}\r\n\r\n{generated_lyrics}"""
print("=" * 40)
print("[translate:form_data.attributes_str:] ", form_data.attributes_str)
print("[translate:total_chars_with_space:] ", total_chars_with_space)
print("[translate:total_chars_without_space:] ", total_chars_without_space)
print("[translate:final_lyrics:]")
print(final_lyrics)
print("=" * 40)
logger.debug("=" * 40)
logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
logger.debug("=" * 40)
# 8. DB 저장
insert_query = """
@ -396,9 +398,9 @@ async def make_song_result(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params)
await conn.commit()
print("결과 저장 완료")
logger.info("make_song_result 결과 저장 완료")
print("\n전체 결과 조회 중...")
logger.info("make_song_result 전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순)
select_query = """
@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection):
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"make_song_result 전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException:
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"make_song_result Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"make_song_result Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",
@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"get_song_result 전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException: # HTTPException은 그대로 raise
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"get_song_result Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"get_song_result Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",
@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection):
# 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}")
print(f"Store ID: {form_data.store_id}")
print(f"{'=' * 60}\n")
logger.info(f"{'=' * 60}")
logger.info(f"make_automation Store ID: {form_data.store_id}")
logger.info(f"{'=' * 60}")
# 2. Store 정보 조회
store_query = """
@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection):
)
store_info = all_store_info[0]
print(f"Store: {store_info.store_name}")
logger.info(f"make_automation Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
attribute_query = """
@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection):
# 최종 문자열 생성
formatted_attributes = "\n".join(formatted_pairs)
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
else:
print("속성 데이터가 없습니다")
logger.info("속성 데이터가 없습니다")
formatted_attributes = ""
# 4. 템플릿 가져오기
print("템플릿 가져오기 (ID=1)")
logger.info("템플릿 가져오기 (ID=1)")
prompts_query = """
SELECT * FROM prompt_template WHERE id=1;
@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection):
prompt=row[2],
)
print(f"Prompt Template: {prompt.prompt}")
logger.debug(f"Prompt Template: {prompt.prompt}")
# 5. 템플릿 조합
@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection):
description=store_info.store_info or "",
)
print("\n" + "=" * 80)
print("업데이트된 프롬프트")
print("=" * 80)
print(updated_prompt)
print("=" * 80 + "\n")
logger.debug("=" * 80)
logger.debug("업데이트된 프롬프트")
logger.debug("=" * 80)
logger.debug(updated_prompt)
logger.debug("=" * 80)
# 4. Sample Song 조회 및 결합
combined_sample_song = None
if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """
SELECT sample_song FROM song_sample
@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("선택된 lyrics가 없습니다")
logger.info("선택된 lyrics가 없습니다")
# 1. song_sample 테이블의 모든 ID 조회
print("\n[샘플 가사 랜덤 선택]")
logger.info("[샘플 가사 랜덤 선택]")
all_ids_query = """
SELECT id FROM song_sample;
@ -679,7 +669,7 @@ async def make_automation(request: Request, conn: Connection):
ids_result = await conn.execute(text(all_ids_query))
all_ids = [row.id for row in ids_result.fetchall()]
print(f"전체 샘플 가사 개수: {len(all_ids)}")
logger.info(f"전체 샘플 가사 개수: {len(all_ids)}")
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
combined_sample_song = None
@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection):
sample_count = min(3, len(all_ids))
selected_ids = random.sample(all_ids, sample_count)
print(f"랜덤 선택된 ID: {selected_ids}")
logger.debug(f"랜덤 선택된 ID: {selected_ids}")
# 3. 선택된 ID로 샘플 가사 조회
lyrics_query = """
@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("song_sample 테이블에 데이터가 없습니다")
logger.info("song_sample 테이블에 데이터가 없습니다")
# 5. 프롬프트에 샘플 가사 추가
if combined_sample_song:
@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection):
{combined_sample_song}
"""
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
else:
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
logger.info(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
# 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -763,10 +753,9 @@ async def make_automation(request: Request, conn: Connection):
:sample_song, :result_song, NOW()
);
"""
print("\n[insert_params 선택된 속성 확인]")
print(f"Categories: {selected_categories}")
print(f"Values: {selected_values}")
print()
logger.debug("[insert_params 선택된 속성 확인]")
logger.debug(f"Categories: {selected_categories}")
logger.debug(f"Values: {selected_values}")
# attr_category, attr_value
insert_params = {
@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params)
await conn.commit()
print("결과 저장 완료")
logger.info("make_automation 결과 저장 완료")
print("\n전체 결과 조회 중...")
logger.info("make_automation 전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순)
select_query = """
@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection):
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"make_automation 전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException:
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"make_automation Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"make_automation Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",

View File

@ -4,7 +4,6 @@ Song Background Tasks
노래 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
from datetime import date
from pathlib import Path
@ -17,11 +16,12 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.song.models import Song
from app.utils.common import generate_task_id
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader
from config import prj_settings
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("song")
# HTTP 요청 설정
REQUEST_TIMEOUT = 120.0 # 초
@ -73,20 +73,16 @@ async def _update_song_status(
song.duration = duration
await session.commit()
logger.info(f"[Song] Status updated - task_id: {task_id}, status: {status}")
print(f"[Song] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Song] NOT FOUND in DB - task_id: {task_id}")
print(f"[Song] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Song] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Song] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
@ -104,14 +100,12 @@ async def _download_audio(url: str, task_id: str) -> bytes:
httpx.HTTPError: 다운로드 실패
"""
logger.info(f"[Download] Downloading - task_id: {task_id}")
print(f"[Download] Downloading - task_id: {task_id}")
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
logger.info(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
print(f"[Download] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content
@ -128,7 +122,6 @@ async def download_and_save_song(
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_save_song] START - task_id: {task_id}, store_name: {store_name}")
try:
# 저장 경로 생성: media/song/{날짜}/{uuid7}/{store_name}.mp3
@ -146,11 +139,9 @@ async def download_and_save_song(
media_dir.mkdir(parents=True, exist_ok=True)
file_path = media_dir / file_name
logger.info(f"[download_and_save_song] Directory created - path: {file_path}")
print(f"[download_and_save_song] Directory created - path: {file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_save_song] Downloading audio - task_id: {task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id)
@ -158,36 +149,27 @@ async def download_and_save_song(
await f.write(content)
logger.info(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
print(f"[download_and_save_song] File saved - task_id: {task_id}, path: {file_path}")
# 프론트엔드에서 접근 가능한 URL 생성
relative_path = f"/media/song/{today}/{unique_id}/{file_name}"
base_url = f"http://{prj_settings.PROJECT_DOMAIN}"
file_url = f"{base_url}{relative_path}"
logger.info(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
print(f"[download_and_save_song] URL generated - task_id: {task_id}, url: {file_url}")
# Song 테이블 업데이트
await _update_song_status(task_id, "completed", file_url)
logger.info(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
print(f"[download_and_save_song] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_save_song] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_save_song] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_save_song] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
@ -204,7 +186,6 @@ async def download_and_upload_song_to_blob(
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_song_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
try:
@ -220,11 +201,9 @@ async def download_and_upload_song_to_blob(
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
print(f"[download_and_upload_song_to_blob] Downloading audio - task_id: {task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id)
@ -232,7 +211,6 @@ async def download_and_upload_song_to_blob(
await f.write(content)
logger.info(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
@ -244,29 +222,21 @@ async def download_and_upload_song_to_blob(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_song_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Song 테이블 업데이트
await _update_song_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_song_to_blob] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_song_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_song_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_song_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
await _update_song_status(task_id, "failed")
finally:
@ -275,10 +245,8 @@ async def download_and_upload_song_to_blob(
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_song_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_song_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id
@ -304,7 +272,6 @@ async def download_and_upload_song_by_suno_task_id(
duration: 노래 재생 시간 ()
"""
logger.info(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
print(f"[download_and_upload_song_by_suno_task_id] START - suno_task_id: {suno_task_id}, store_name: {store_name}, duration: {duration}")
temp_file_path: Path | None = None
task_id: str | None = None
@ -321,12 +288,10 @@ async def download_and_upload_song_by_suno_task_id(
if not song:
logger.warning(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
print(f"[download_and_upload_song_by_suno_task_id] Song NOT FOUND - suno_task_id: {suno_task_id}")
return
task_id = song.task_id
logger.info(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
print(f"[download_and_upload_song_by_suno_task_id] Song found - suno_task_id: {suno_task_id}, task_id: {task_id}")
# 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join(
@ -340,11 +305,9 @@ async def download_and_upload_song_by_suno_task_id(
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] Temp directory created - path: {temp_file_path}")
# 오디오 파일 다운로드
logger.info(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
print(f"[download_and_upload_song_by_suno_task_id] Downloading audio - suno_task_id: {suno_task_id}, url: {audio_url}")
content = await _download_audio(audio_url, task_id)
@ -352,7 +315,6 @@ async def download_and_upload_song_by_suno_task_id(
await f.write(content)
logger.info(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] File downloaded - suno_task_id: {suno_task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
@ -364,7 +326,6 @@ async def download_and_upload_song_by_suno_task_id(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
print(f"[download_and_upload_song_by_suno_task_id] Uploaded to Blob - suno_task_id: {suno_task_id}, url: {blob_url}")
# Song 테이블 업데이트
await _update_song_status(
@ -375,26 +336,19 @@ async def download_and_upload_song_by_suno_task_id(
duration=duration,
)
logger.info(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
print(f"[download_and_upload_song_by_suno_task_id] SUCCESS - suno_task_id: {suno_task_id}, duration: {duration}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
print(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_song_by_suno_task_id] DOWNLOAD ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
print(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_song_by_suno_task_id] DB ERROR - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
except Exception as e:
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
print(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_song_by_suno_task_id] EXCEPTION - suno_task_id: {suno_task_id}, error: {e}", exc_info=True)
if task_id:
await _update_song_status(task_id, "failed", suno_task_id=suno_task_id)
@ -404,10 +358,8 @@ async def download_and_upload_song_by_suno_task_id(
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_song_by_suno_task_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
print(f"[download_and_upload_song_by_suno_task_id] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
if task_id:

0
app/user/__init__.py Normal file
View File

0
app/user/api/__init__.py Normal file
View File

View File

View File

View File

View File

0
app/user/dependency.py Normal file
View File

141
app/user/exceptions.py Normal file
View File

@ -0,0 +1,141 @@
"""
User 모듈 커스텀 예외 정의
인증 사용자 관련 에러를 처리하기 위한 예외 클래스들입니다.
"""
from fastapi import HTTPException, status
class AuthException(HTTPException):
"""인증 관련 기본 예외"""
def __init__(
self,
status_code: int,
code: str,
message: str,
):
super().__init__(
status_code=status_code,
detail={"code": code, "message": message},
)
# =============================================================================
# 카카오 OAuth 관련 예외
# =============================================================================
class InvalidAuthCodeError(AuthException):
"""유효하지 않은 인가 코드"""
def __init__(self, message: str = "유효하지 않은 인가 코드입니다."):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
code="INVALID_CODE",
message=message,
)
class KakaoAuthFailedError(AuthException):
"""카카오 인증 실패"""
def __init__(self, message: str = "카카오 인증에 실패했습니다."):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
code="KAKAO_AUTH_FAILED",
message=message,
)
class KakaoAPIError(AuthException):
"""카카오 API 호출 오류"""
def __init__(self, message: str = "카카오 API 호출 중 오류가 발생했습니다."):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
code="KAKAO_API_ERROR",
message=message,
)
# =============================================================================
# JWT 토큰 관련 예외
# =============================================================================
class TokenExpiredError(AuthException):
"""토큰 만료"""
def __init__(self, message: str = "토큰이 만료되었습니다. 다시 로그인해주세요."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_EXPIRED",
message=message,
)
class InvalidTokenError(AuthException):
"""유효하지 않은 토큰"""
def __init__(self, message: str = "유효하지 않은 토큰입니다."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="INVALID_TOKEN",
message=message,
)
class TokenRevokedError(AuthException):
"""취소된 토큰"""
def __init__(self, message: str = "취소된 토큰입니다. 다시 로그인해주세요."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="TOKEN_REVOKED",
message=message,
)
class MissingTokenError(AuthException):
"""토큰 누락"""
def __init__(self, message: str = "인증 토큰이 필요합니다."):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
code="MISSING_TOKEN",
message=message,
)
# =============================================================================
# 사용자 관련 예외
# =============================================================================
class UserNotFoundError(AuthException):
"""사용자 없음"""
def __init__(self, message: str = "사용자를 찾을 수 없습니다."):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
code="USER_NOT_FOUND",
message=message,
)
class UserInactiveError(AuthException):
"""비활성화된 계정"""
def __init__(self, message: str = "비활성화된 계정입니다. 관리자에게 문의하세요."):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
code="USER_INACTIVE",
message=message,
)
class AdminRequiredError(AuthException):
"""관리자 권한 필요"""
def __init__(self, message: str = "관리자 권한이 필요합니다."):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
code="ADMIN_REQUIRED",
message=message,
)

159
app/user/models.py Normal file
View File

@ -0,0 +1,159 @@
"""
User 모듈 SQLAlchemy 모델 정의
카카오 소셜 로그인 기반 사용자 관리 모델입니다.
주의: 모델은 현재 개발 중이므로 create_db_tables()에서 import하지 않습니다.
테이블 생성이 필요할 app/database/session.py의 create_db_tables()
아래 import를 추가하세요:
from app.user.models import User # noqa: F401
"""
from datetime import datetime
from typing import Optional
from sqlalchemy import BigInteger, Boolean, DateTime, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database.session import Base
class User(Base):
"""
사용자 테이블 (카카오 소셜 로그인)
카카오 로그인을 통해 인증된 사용자 정보를 저장합니다.
Attributes:
id: 고유 식별자 (자동 증가)
kakao_id: 카카오 고유 ID (필수, 유니크)
email: 이메일 주소 (선택, 카카오에서 제공 )
nickname: 카카오 닉네임 (선택)
profile_image_url: 카카오 프로필 이미지 URL (선택)
thumbnail_image_url: 카카오 썸네일 이미지 URL (선택)
is_active: 계정 활성화 상태 (기본 True)
is_admin: 관리자 여부 (기본 False)
last_login_at: 마지막 로그인 일시
created_at: 계정 생성 일시
updated_at: 계정 정보 수정 일시
카카오 API 응답 필드 매핑:
- kakao_id: id (카카오 회원번호)
- email: kakao_account.email
- nickname: kakao_account.profile.nickname 또는 properties.nickname
- profile_image_url: kakao_account.profile.profile_image_url
- thumbnail_image_url: kakao_account.profile.thumbnail_image_url
"""
__tablename__ = "user"
__table_args__ = (
Index("idx_user_kakao_id", "kakao_id", unique=True),
Index("idx_user_email", "email"),
Index("idx_user_is_active", "is_active"),
Index("idx_user_created_at", "created_at"),
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_unicode_ci",
},
)
# ==========================================================================
# 기본 식별자
# ==========================================================================
id: Mapped[int] = mapped_column(
BigInteger,
primary_key=True,
nullable=False,
autoincrement=True,
comment="고유 식별자",
)
# ==========================================================================
# 카카오 소셜 로그인 필수 정보
# ==========================================================================
kakao_id: Mapped[int] = mapped_column(
BigInteger,
nullable=False,
unique=True,
comment="카카오 고유 ID (회원번호)",
)
# ==========================================================================
# 카카오에서 제공하는 사용자 정보 (선택적)
# ==========================================================================
email: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="이메일 주소 (카카오 계정 이메일, 동의 시 제공)",
)
nickname: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="카카오 닉네임",
)
profile_image_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="카카오 프로필 이미지 URL",
)
thumbnail_image_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="카카오 썸네일 이미지 URL",
)
# ==========================================================================
# 계정 상태 관리
# ==========================================================================
is_active: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=True,
comment="계정 활성화 상태 (비활성화 시 로그인 차단)",
)
is_admin: Mapped[bool] = mapped_column(
Boolean,
nullable=False,
default=False,
comment="관리자 권한 여부",
)
# ==========================================================================
# 시간 정보
# ==========================================================================
last_login_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="마지막 로그인 일시",
)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
comment="계정 생성 일시",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=func.now(),
onupdate=func.now(),
comment="계정 정보 수정 일시",
)
def __repr__(self) -> str:
return (
f"<User("
f"id={self.id}, "
f"kakao_id={self.kakao_id}, "
f"nickname='{self.nickname}', "
f"is_active={self.is_active}"
f")>"
)

View File

@ -0,0 +1,25 @@
from app.user.schemas.user_schema import (
AccessTokenResponse,
KakaoCallbackRequest,
KakaoLoginResponse,
KakaoTokenResponse,
KakaoUserInfo,
LoginResponse,
RefreshTokenRequest,
TokenResponse,
UserBriefResponse,
UserResponse,
)
__all__ = [
"AccessTokenResponse",
"KakaoCallbackRequest",
"KakaoLoginResponse",
"KakaoTokenResponse",
"KakaoUserInfo",
"LoginResponse",
"RefreshTokenRequest",
"TokenResponse",
"UserBriefResponse",
"UserResponse",
]

View File

@ -0,0 +1,130 @@
"""
User 모듈 Pydantic 스키마 정의
API 요청/응답 검증을 위한 스키마들입니다.
"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# =============================================================================
# 카카오 OAuth 스키마
# =============================================================================
class KakaoLoginResponse(BaseModel):
"""카카오 로그인 URL 응답"""
auth_url: str = Field(..., description="카카오 인증 페이지 URL")
class KakaoCallbackRequest(BaseModel):
"""카카오 콜백 요청 (인가 코드)"""
code: str = Field(..., min_length=1, description="카카오 인가 코드")
# =============================================================================
# JWT 토큰 스키마
# =============================================================================
class TokenResponse(BaseModel):
"""토큰 발급 응답"""
access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
class AccessTokenResponse(BaseModel):
"""액세스 토큰 갱신 응답"""
access_token: str = Field(..., description="액세스 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
class RefreshTokenRequest(BaseModel):
"""토큰 갱신 요청"""
refresh_token: str = Field(..., min_length=1, description="리프레시 토큰")
# =============================================================================
# 사용자 정보 스키마
# =============================================================================
class UserResponse(BaseModel):
"""사용자 정보 응답"""
id: int = Field(..., description="사용자 ID")
kakao_id: int = Field(..., description="카카오 회원번호")
email: Optional[str] = Field(None, description="이메일")
nickname: Optional[str] = Field(None, description="닉네임")
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
thumbnail_image_url: Optional[str] = Field(None, description="썸네일 이미지 URL")
is_active: bool = Field(..., description="계정 활성화 상태")
is_admin: bool = Field(..., description="관리자 여부")
last_login_at: Optional[datetime] = Field(None, description="마지막 로그인 일시")
created_at: datetime = Field(..., description="가입 일시")
model_config = {"from_attributes": True}
class UserBriefResponse(BaseModel):
"""사용자 간략 정보 (토큰 응답에 포함)"""
id: int = Field(..., description="사용자 ID")
nickname: Optional[str] = Field(None, description="닉네임")
email: Optional[str] = Field(None, description="이메일")
profile_image_url: Optional[str] = Field(None, description="프로필 이미지 URL")
is_new_user: bool = Field(..., description="신규 가입 여부")
model_config = {"from_attributes": True}
class LoginResponse(BaseModel):
"""로그인 응답 (토큰 + 사용자 정보)"""
access_token: str = Field(..., description="액세스 토큰")
refresh_token: str = Field(..., description="리프레시 토큰")
token_type: str = Field(default="Bearer", description="토큰 타입")
expires_in: int = Field(..., description="액세스 토큰 만료 시간 (초)")
user: UserBriefResponse = Field(..., description="사용자 정보")
# =============================================================================
# 내부 사용 스키마 (카카오 API 응답 파싱)
# =============================================================================
class KakaoTokenResponse(BaseModel):
"""카카오 토큰 응답 (내부 사용)"""
access_token: str
token_type: str
refresh_token: Optional[str] = None
expires_in: int
scope: Optional[str] = None
refresh_token_expires_in: Optional[int] = None
class KakaoProfile(BaseModel):
"""카카오 프로필 정보 (내부 사용)"""
nickname: Optional[str] = None
profile_image_url: Optional[str] = None
thumbnail_image_url: Optional[str] = None
is_default_image: Optional[bool] = None
class KakaoAccount(BaseModel):
"""카카오 계정 정보 (내부 사용)"""
email: Optional[str] = None
profile: Optional[KakaoProfile] = None
class KakaoUserInfo(BaseModel):
"""카카오 사용자 정보 (내부 사용)"""
id: int
kakao_account: Optional[KakaoAccount] = None

View File

View File

View File

View File

View File

View File

View File

@ -1,13 +1,13 @@
import json
import logging
import re
from openai import AsyncOpenAI
from app.utils.logger import get_logger
from config import apikey_settings
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("chatgpt")
# fmt: off
LYRICS_PROMPT_TEMPLATE_ORI = """
@ -298,13 +298,12 @@ class ChatgptService:
if prompt is None:
prompt = self.build_lyrics_prompt()
print(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
logger.debug(f"[ChatgptService] Generated Prompt (length: {len(prompt)})")
logger.info(f"[ChatgptService] Starting GPT request with model: {self.model}")
# GPT API 호출
response = await self._call_gpt_api(prompt)
print(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
logger.info(f"[ChatgptService] SUCCESS - Response length: {len(response)}")
return response
@ -332,7 +331,7 @@ class ChatgptService:
[OUTPUT REQUIREMENTS]
- 5 항목으로 구분: 타겟 고객, 핵심 차별점, 지역 특성, 시즌별 포인트
- 항목은 줄바꿈으로 구분
- 500 이내로 요약
- 800 이내로 요약
- 내용의 누락이 있어서는 안된다
- 문장이 자연스러워야 한다
- 핵심 정보만 간결하게 포함

View File

@ -30,16 +30,16 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
"""
import copy
import logging
import time
from typing import Literal
import httpx
from app.utils.logger import get_logger
from config import apikey_settings, creatomate_settings
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("creatomate")
# Orientation 타입 정의
@ -76,14 +76,14 @@ async def close_shared_client() -> None:
if _shared_client is not None and not _shared_client.is_closed:
await _shared_client.aclose()
_shared_client = None
print("[CreatomateService] Shared HTTP client closed")
logger.info("[CreatomateService] Shared HTTP client closed")
def clear_template_cache() -> None:
"""템플릿 캐시를 전체 삭제합니다."""
global _template_cache
_template_cache.clear()
print("[CreatomateService] Template cache cleared")
logger.info("[CreatomateService] Template cache cleared")
def _is_cache_valid(cached_at: float) -> bool:
@ -164,7 +164,6 @@ class CreatomateService:
httpx.HTTPError: 요청 실패
"""
logger.info(f"[Creatomate] {method} {url}")
print(f"[Creatomate] {method} {url}")
client = await get_shared_client()
@ -180,7 +179,6 @@ class CreatomateService:
raise ValueError(f"Unsupported HTTP method: {method}")
logger.info(f"[Creatomate] Response - Status: {response.status_code}")
print(f"[Creatomate] Response - Status: {response.status_code}")
return response
async def get_all_templates_data(self) -> dict:
@ -210,12 +208,12 @@ class CreatomateService:
if use_cache and template_id in _template_cache:
cached = _template_cache[template_id]
if _is_cache_valid(cached["cached_at"]):
print(f"[CreatomateService] Cache HIT - {template_id}")
logger.debug(f"[CreatomateService] Cache HIT - {template_id}")
return copy.deepcopy(cached["data"])
else:
# 만료된 캐시 삭제
del _template_cache[template_id]
print(f"[CreatomateService] Cache EXPIRED - {template_id}")
logger.debug(f"[CreatomateService] Cache EXPIRED - {template_id}")
# API 호출
url = f"{self.BASE_URL}/v1/templates/{template_id}"
@ -228,7 +226,7 @@ class CreatomateService:
"data": data,
"cached_at": time.time(),
}
print(f"[CreatomateService] Cache MISS - {template_id} (cached)")
logger.debug(f"[CreatomateService] Cache MISS - {template_id} (cached)")
return copy.deepcopy(data)
@ -444,12 +442,13 @@ class CreatomateService:
if animation["transition"]:
total_template_duration -= animation["duration"]
except Exception as e:
print(f"[calc_scene_duration] Error processing element: {elem}, {e}")
logger.error(f"[calc_scene_duration] Error processing element: {elem}, {e}")
return total_template_duration
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
"""템플릿의 duration을 target_duration으로 확장합니다."""
target_duration += 0.1 # 수동으로 직접 변경 및 테스트 필요 : 파란박스 생기는것
template["duration"] = target_duration
total_template_duration = self.calc_scene_duration(template)
extend_rate = target_duration / total_template_duration
@ -466,7 +465,7 @@ class CreatomateService:
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
animation["duration"] = animation["duration"] * extend_rate
except Exception as e:
print(
logger.error(
f"[extend_template_duration] Error processing element: {elem}, {e}"
)

337
app/utils/logger.py Normal file
View File

@ -0,0 +1,337 @@
"""
FastAPI용 로깅 모듈
Django 로거 구조를 참고하여 FastAPI에 최적화된 로깅 시스템.
사용 예시:
from app.utils.logger import get_logger
logger = get_logger("song")
logger.info("노래 생성 완료")
logger.error("오류 발생", exc_info=True)
로그 레벨:
1. DEBUG: 디버깅 목적
2. INFO: 일반 정보
3. WARNING: 경고 정보 (작은 문제)
4. ERROR: 오류 정보 ( 문제)
5. CRITICAL: 아주 심각한 문제
"""
import logging
import sys
from datetime import datetime
from functools import lru_cache
from logging.handlers import RotatingFileHandler
from typing import Literal
from config import log_settings
# 로그 디렉토리 설정 (config.py의 LogSettings에서 관리)
LOG_DIR = log_settings.get_log_dir()
# 로그 레벨 타입
LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class LoggerConfig:
"""로거 설정 클래스 (config.py의 LogSettings 참조)"""
# 출력 대상 설정 (LogSettings에서 가져옴)
CONSOLE_ENABLED: bool = log_settings.LOG_CONSOLE_ENABLED
FILE_ENABLED: bool = log_settings.LOG_FILE_ENABLED
# 기본 설정 (LogSettings에서 가져옴)
DEFAULT_LEVEL: str = log_settings.LOG_LEVEL
CONSOLE_LEVEL: str = log_settings.LOG_CONSOLE_LEVEL
FILE_LEVEL: str = log_settings.LOG_FILE_LEVEL
MAX_BYTES: int = log_settings.LOG_MAX_SIZE_MB * 1024 * 1024
BACKUP_COUNT: int = log_settings.LOG_BACKUP_COUNT
ENCODING: str = "utf-8"
# 포맷 설정 (LogSettings에서 가져옴)
CONSOLE_FORMAT: str = log_settings.LOG_CONSOLE_FORMAT
FILE_FORMAT: str = log_settings.LOG_FILE_FORMAT
DATE_FORMAT: str = log_settings.LOG_DATE_FORMAT
def _create_console_handler() -> logging.StreamHandler:
"""콘솔 핸들러 생성"""
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(getattr(logging, LoggerConfig.CONSOLE_LEVEL))
formatter = logging.Formatter(
fmt=LoggerConfig.CONSOLE_FORMAT,
datefmt=LoggerConfig.DATE_FORMAT,
style="{",
)
handler.setFormatter(formatter)
return handler
# =============================================================================
# 공유 파일 핸들러 (싱글톤)
# 모든 로거가 동일한 파일 핸들러를 공유하여 하나의 로그 파일에 기록
# =============================================================================
_shared_file_handler: RotatingFileHandler | None = None
_shared_error_handler: RotatingFileHandler | None = None
def _get_shared_file_handler() -> RotatingFileHandler:
"""
공유 파일 핸들러 반환 (싱글톤)
모든 로거가 하나의 기본 로그 파일(app.log) 기록합니다.
파일명: logs/{날짜}_app.log
"""
global _shared_file_handler
if _shared_file_handler is None:
today = datetime.today().strftime("%Y-%m-%d")
log_file = LOG_DIR / f"{today}_app.log"
_shared_file_handler = RotatingFileHandler(
filename=log_file,
maxBytes=LoggerConfig.MAX_BYTES,
backupCount=LoggerConfig.BACKUP_COUNT,
encoding=LoggerConfig.ENCODING,
)
_shared_file_handler.setLevel(getattr(logging, LoggerConfig.FILE_LEVEL))
formatter = logging.Formatter(
fmt=LoggerConfig.FILE_FORMAT,
datefmt=LoggerConfig.DATE_FORMAT,
style="{",
)
_shared_file_handler.setFormatter(formatter)
return _shared_file_handler
def _get_shared_error_handler() -> RotatingFileHandler:
"""
공유 에러 파일 핸들러 반환 (싱글톤)
모든 로거의 ERROR 이상 로그가 하나의 에러 로그 파일(error.log) 기록됩니다.
파일명: logs/{날짜}_error.log
"""
global _shared_error_handler
if _shared_error_handler is None:
today = datetime.today().strftime("%Y-%m-%d")
log_file = LOG_DIR / f"{today}_error.log"
_shared_error_handler = RotatingFileHandler(
filename=log_file,
maxBytes=LoggerConfig.MAX_BYTES,
backupCount=LoggerConfig.BACKUP_COUNT,
encoding=LoggerConfig.ENCODING,
)
_shared_error_handler.setLevel(logging.ERROR)
formatter = logging.Formatter(
fmt=LoggerConfig.FILE_FORMAT,
datefmt=LoggerConfig.DATE_FORMAT,
style="{",
)
_shared_error_handler.setFormatter(formatter)
return _shared_error_handler
@lru_cache(maxsize=32)
def get_logger(name: str = "app") -> logging.Logger:
"""
로거 인스턴스 반환 (캐싱 적용)
Args:
name: 로거 이름 (모듈명 권장: "song", "lyric", "video" )
Returns:
설정된 로거 인스턴스
Example:
logger = get_logger("song")
logger.info("노래 처리 시작")
"""
logger = logging.getLogger(name)
# 이미 핸들러가 설정된 경우 반환
if logger.handlers:
return logger
# 로그 레벨 설정
logger.setLevel(getattr(logging, LoggerConfig.DEFAULT_LEVEL))
# 핸들러 추가 (설정에 따라 선택적으로 추가)
if LoggerConfig.CONSOLE_ENABLED:
logger.addHandler(_create_console_handler())
if LoggerConfig.FILE_ENABLED:
logger.addHandler(_get_shared_file_handler())
logger.addHandler(_get_shared_error_handler())
# 상위 로거로 전파 방지 (중복 출력 방지)
logger.propagate = False
return logger
def setup_uvicorn_logging() -> dict:
"""
Uvicorn 서버의 로깅 설정을 반환합니다.
============================================================
언제 사용하는가?
============================================================
Uvicorn 서버를 Python 코드로 직접 실행할 사용합니다.
CLI 명령어(uvicorn main:app --reload) 실행할 때는 적용되지 않습니다.
============================================================
사용 방법
============================================================
1. Python 코드에서 uvicorn.run() 호출 :
# run.py 또는 main.py 하단
import uvicorn
from app.utils.logger import setup_uvicorn_logging
if __name__ == "__main__":
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=True,
log_config=setup_uvicorn_logging(), # 여기서 적용
)
2. 실행:
python run.py
또는
python main.py
============================================================
어떤 동작을 하는가?
============================================================
Uvicorn의 기본 로깅 형식을 애플리케이션의 LogSettings와 일치시킵니다.
- formatters: 로그 출력 형식 정의
- default: 일반 로그용 (서버 시작/종료, 에러 )
- access: HTTP 요청 로그용 (클라이언트 IP, 요청 경로, 상태 코드)
- handlers: 로그 출력 대상 설정
- stdout으로 콘솔에 출력
- loggers: Uvicorn 내부 로거 설정
- uvicorn: 메인 로거
- uvicorn.error: 에러/시작/종료 로그
- uvicorn.access: HTTP 요청 로그
============================================================
출력 예시
============================================================
적용 (Uvicorn 기본):
INFO: 127.0.0.1:52341 - "GET /docs HTTP/1.1" 200 OK
INFO: Uvicorn running on http://0.0.0.0:8000
적용 :
[2026-01-14 15:30:00] INFO [uvicorn.access] 127.0.0.1 - "GET /docs HTTP/1.1" 200
[2026-01-14 15:30:00] INFO [uvicorn:startup:45] Uvicorn running on http://0.0.0.0:8000
============================================================
반환값 구조 (Python logging.config.dictConfig 형식)
============================================================
{
"version": 1, # dictConfig 버전 (항상 1)
"disable_existing_loggers": False, # 기존 로거 유지
"formatters": { ... }, # 포맷터 정의
"handlers": { ... }, # 핸들러 정의
"loggers": { ... }, # 로거 정의
}
Returns:
dict: Uvicorn log_config 파라미터에 전달할 설정 딕셔너리
"""
return {
# --------------------------------------------------------
# dictConfig 버전 (필수, 항상 1)
# --------------------------------------------------------
"version": 1,
# --------------------------------------------------------
# 기존 로거 비활성화 여부
# False: 기존 로거 유지 (권장)
# True: 기존 로거 모두 비활성화
# --------------------------------------------------------
"disable_existing_loggers": False,
# --------------------------------------------------------
# 포맷터 정의
# 로그 메시지의 출력 형식을 지정합니다.
# --------------------------------------------------------
"formatters": {
# 일반 로그용 포맷터 (서버 시작/종료, 에러 등)
"default": {
"format": LoggerConfig.CONSOLE_FORMAT,
"datefmt": LoggerConfig.DATE_FORMAT,
"style": "{", # {변수명} 스타일 사용
},
# HTTP 요청 로그용 포맷터
# 사용 가능한 변수: client_addr, request_line, status_code
"access": {
"format": "[{asctime}] {levelname:8} [{name}] {client_addr} - \"{request_line}\" {status_code}",
"datefmt": LoggerConfig.DATE_FORMAT,
"style": "{",
},
},
# --------------------------------------------------------
# 핸들러 정의
# 로그를 어디에 출력할지 지정합니다.
# --------------------------------------------------------
"handlers": {
# 일반 로그 핸들러 (stdout 출력)
"default": {
"formatter": "default",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
# HTTP 요청 로그 핸들러 (stdout 출력)
"access": {
"formatter": "access",
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
},
},
# --------------------------------------------------------
# 로거 정의
# Uvicorn 내부에서 사용하는 로거들을 설정합니다.
# --------------------------------------------------------
"loggers": {
# Uvicorn 메인 로거
"uvicorn": {
"handlers": ["default"],
"level": "INFO",
"propagate": False, # 상위 로거로 전파 방지
},
# 에러/시작/종료 로그
"uvicorn.error": {
"handlers": ["default"],
"level": "INFO",
"propagate": False,
},
# HTTP 요청 로그 (GET /path HTTP/1.1 200 등)
"uvicorn.access": {
"handlers": ["access"],
"level": "INFO",
"propagate": False,
},
},
}
# 편의를 위한 사전 정의된 로거 이름 상수
HOME_LOGGER = "home"
LYRIC_LOGGER = "lyric"
SONG_LOGGER = "song"
VIDEO_LOGGER = "video"
CELERY_LOGGER = "celery"
APP_LOGGER = "app"

View File

@ -1,15 +1,15 @@
import asyncio
import json
import logging
import re
import aiohttp
import bs4
from app.utils.logger import get_logger
from config import crawler_settings
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("scraper")
class GraphQLException(Exception):
@ -109,7 +109,7 @@ query getAccommodation($id: String!, $deviceType: String) {
self.scrap_type = "GraphQL"
except GraphQLException:
print("fallback")
logger.debug("GraphQL failed, fallback to Playwright")
self.scrap_type = "Playwright"
pass # 나중에 pw 이용한 crawling으로 fallback 추가
@ -138,7 +138,6 @@ query getAccommodation($id: String!, $deviceType: String) {
try:
logger.info(f"[NvMapScraper] Requesting place_id: {place_id}")
print(f"[NvMapScraper] Requesting place_id: {place_id}")
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(
@ -148,24 +147,20 @@ query getAccommodation($id: String!, $deviceType: String) {
) as response:
if response.status == 200:
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
print(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
return await response.json()
# 실패 상태 코드
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
print(f"[NvMapScraper] 실패 상태 코드: {response.status}")
raise GraphQLException(
f"Request failed with status {response.status}"
)
except (TimeoutError, asyncio.TimeoutError):
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
print(f"[NvMapScraper] Timeout - place_id: {place_id}")
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
except aiohttp.ClientError as e:
logger.error(f"[NvMapScraper] Client error: {e}")
print(f"[NvMapScraper] Client error: {e}")
raise GraphQLException(f"Client error: {e}")
async def _get_facility_string(self, place_id: str) -> str | None:

View File

@ -32,17 +32,17 @@ URL 경로 형식:
"""
import asyncio
import logging
import time
from pathlib import Path
import aiofiles
import httpx
from app.utils.logger import get_logger
from config import azure_blob_settings
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("blob")
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
@ -56,13 +56,13 @@ async def get_shared_blob_client() -> httpx.AsyncClient:
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
global _shared_blob_client
if _shared_blob_client is None or _shared_blob_client.is_closed:
print("[AzureBlobUploader] Creating shared HTTP client...")
logger.info("[AzureBlobUploader] Creating shared HTTP client...")
_shared_blob_client = httpx.AsyncClient(
timeout=httpx.Timeout(180.0, connect=10.0),
limits=httpx.Limits(max_keepalive_connections=10, max_connections=20),
)
print("[AzureBlobUploader] Shared HTTP client created - "
"max_connections: 20, max_keepalive: 10")
logger.info("[AzureBlobUploader] Shared HTTP client created - "
"max_connections: 20, max_keepalive: 10")
return _shared_blob_client
@ -72,7 +72,7 @@ async def close_shared_blob_client() -> None:
if _shared_blob_client is not None and not _shared_blob_client.is_closed:
await _shared_blob_client.aclose()
_shared_blob_client = None
print("[AzureBlobUploader] Shared HTTP client closed")
logger.info("[AzureBlobUploader] Shared HTTP client closed")
class AzureBlobUploader:
@ -158,15 +158,15 @@ class AzureBlobUploader:
try:
logger.info(f"[{log_prefix}] Starting upload")
print(f"[{log_prefix}] Getting shared client...")
logger.debug(f"[{log_prefix}] Getting shared client...")
client = await get_shared_blob_client()
client_time = time.perf_counter()
elapsed_ms = (client_time - start_time) * 1000
print(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
logger.debug(f"[{log_prefix}] Client acquired in {elapsed_ms:.1f}ms")
print(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)")
logger.debug(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)")
response = await asyncio.wait_for(
client.put(upload_url, content=file_content, headers=headers),
@ -176,44 +176,38 @@ class AzureBlobUploader:
duration_ms = (upload_time - start_time) * 1000
if response.status_code in [200, 201]:
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}")
print(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Public URL: {self._last_public_url}")
logger.info(f"[{log_prefix}] SUCCESS - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
logger.debug(f"[{log_prefix}] Public URL: {self._last_public_url}")
return True
# 업로드 실패
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}")
print(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
print(f"[{log_prefix}] Response: {response.text[:500]}")
logger.error(f"[{log_prefix}] FAILED - Status: {response.status_code}, "
f"Duration: {duration_ms:.1f}ms")
logger.error(f"[{log_prefix}] Response: {response.text[:500]}")
return False
except asyncio.TimeoutError:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
print(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
return False
except httpx.ConnectError as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] CONNECT_ERROR: {e}")
print(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
logger.error(f"[{log_prefix}] CONNECT_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except httpx.ReadError as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] READ_ERROR: {e}")
print(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
logger.error(f"[{log_prefix}] READ_ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
except Exception as e:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] ERROR: {type(e).__name__}: {e}")
print(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
logger.error(f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(e).__name__}: {e}")
return False
async def _upload_file(
@ -241,7 +235,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url(category, file_name)
self._last_public_url = self._build_public_url(category, file_name)
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
@ -306,7 +300,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url("song", file_name)
self._last_public_url = self._build_public_url("song", file_name)
log_prefix = "upload_music_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "audio/mpeg", "x-ms-blob-type": "BlockBlob"}
@ -368,7 +362,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url("video", file_name)
self._last_public_url = self._build_public_url("video", file_name)
log_prefix = "upload_video_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": "video/mp4", "x-ms-blob-type": "BlockBlob"}
@ -434,7 +428,7 @@ class AzureBlobUploader:
upload_url = self._build_upload_url("image", file_name)
self._last_public_url = self._build_public_url("image", file_name)
log_prefix = "upload_image_bytes"
print(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}

View File

@ -39,7 +39,9 @@ from app.video.schemas.video_schema import (
from app.video.worker.video_task import download_and_upload_video_to_blob
from app.utils.creatomate import CreatomateService
from app.utils.pagination import PaginatedResponse
from app.utils.logger import get_logger
logger = get_logger("video")
router = APIRouter(prefix="/video", tags=["video"])
@ -115,7 +117,7 @@ async def generate_video(
from app.database.session import AsyncSessionLocal
request_start = time.perf_counter()
print(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}")
logger.info(f"[generate_video] START - task_id: {task_id}, orientation: {orientation}")
# ==========================================================================
# 1단계: DB 조회 및 초기 데이터 저장 (세션을 명시적으로 열고 닫음)
@ -168,13 +170,13 @@ async def generate_video(
)
query_time = time.perf_counter()
print(f"[generate_video] Queries completed - task_id: {task_id}, "
logger.debug(f"[generate_video] Queries completed - task_id: {task_id}, "
f"elapsed: {(query_time - request_start)*1000:.1f}ms")
# ===== 결과 처리: Project =====
project = project_result.scalar_one_or_none()
if not project:
print(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
@ -184,7 +186,7 @@ async def generate_video(
# ===== 결과 처리: Lyric =====
lyric = lyric_result.scalar_one_or_none()
if not lyric:
print(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}")
logger.warning(f"[generate_video] Lyric NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Lyric을 찾을 수 없습니다.",
@ -194,7 +196,7 @@ async def generate_video(
# ===== 결과 처리: Song =====
song = song_result.scalar_one_or_none()
if not song:
print(f"[generate_video] Song NOT FOUND - task_id: {task_id}")
logger.warning(f"[generate_video] Song NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 Song을 찾을 수 없습니다.",
@ -220,14 +222,14 @@ async def generate_video(
# ===== 결과 처리: Image =====
images = image_result.scalars().all()
if not images:
print(f"[generate_video] Image NOT FOUND - task_id: {task_id}")
logger.warning(f"[generate_video] Image NOT FOUND - task_id: {task_id}")
raise HTTPException(
status_code=404,
detail=f"task_id '{task_id}'에 해당하는 이미지를 찾을 수 없습니다.",
)
image_urls = [img.img_url for img in images]
print(
logger.info(
f"[generate_video] Data loaded - task_id: {task_id}, "
f"project_id: {project_id}, lyric_id: {lyric_id}, "
f"song_id: {song_id}, images: {len(image_urls)}"
@ -246,14 +248,14 @@ async def generate_video(
await session.commit()
video_id = video.id
stage1_time = time.perf_counter()
print(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, "
logger.info(f"[generate_video] Video saved - task_id: {task_id}, id: {video_id}, "
f"stage1_elapsed: {(stage1_time - request_start)*1000:.1f}ms")
# 세션이 여기서 자동으로 닫힘 (async with 블록 종료)
except HTTPException:
raise
except Exception as e:
print(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
return GenerateVideoResponse(
success=False,
task_id=task_id,
@ -267,16 +269,16 @@ async def generate_video(
# ==========================================================================
stage2_start = time.perf_counter()
try:
print(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}")
logger.info(f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}")
creatomate_service = CreatomateService(
orientation=orientation,
target_duration=song_duration,
)
print(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})")
logger.debug(f"[generate_video] Using template_id: {creatomate_service.template_id}, duration: {creatomate_service.target_duration} (song duration: {song_duration})")
# 6-1. 템플릿 조회 (비동기)
template = await creatomate_service.get_one_template_data_async(creatomate_service.template_id)
print(f"[generate_video] Template fetched - task_id: {task_id}")
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
# 6-2. elements에서 리소스 매핑 생성
modifications = creatomate_service.elements_connect_resource_blackbox(
@ -285,7 +287,7 @@ async def generate_video(
lyric=lyrics,
music_url=music_url,
)
print(f"[generate_video] Modifications created - task_id: {task_id}")
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
# 6-3. elements 수정
new_elements = creatomate_service.modify_element(
@ -293,20 +295,20 @@ async def generate_video(
modifications,
)
template["source"]["elements"] = new_elements
print(f"[generate_video] Elements modified - task_id: {task_id}")
logger.debug(f"[generate_video] Elements modified - task_id: {task_id}")
# 6-4. duration 확장
final_template = creatomate_service.extend_template_duration(
template,
creatomate_service.target_duration,
)
print(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}")
logger.debug(f"[generate_video] Duration extended to {creatomate_service.target_duration}s - task_id: {task_id}")
# 6-5. 커스텀 렌더링 요청 (비동기)
render_response = await creatomate_service.make_creatomate_custom_call_async(
final_template["source"],
)
print(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}")
logger.debug(f"[generate_video] Creatomate API response - task_id: {task_id}, response: {render_response}")
# 렌더 ID 추출
if isinstance(render_response, list) and len(render_response) > 0:
@ -317,14 +319,14 @@ async def generate_video(
creatomate_render_id = None
stage2_time = time.perf_counter()
print(
logger.info(
f"[generate_video] Stage 2 DONE - task_id: {task_id}, "
f"render_id: {creatomate_render_id}, "
f"stage2_elapsed: {(stage2_time - stage2_start)*1000:.1f}ms"
)
except Exception as e:
print(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}")
logger.error(f"[generate_video] Creatomate API EXCEPTION - task_id: {task_id}, error: {e}")
# 외부 API 실패 시 Video 상태를 failed로 업데이트
from app.database.session import AsyncSessionLocal
async with AsyncSessionLocal() as update_session:
@ -347,7 +349,7 @@ async def generate_video(
# 3단계: creatomate_render_id 업데이트 (새 세션으로 빠르게 처리)
# ==========================================================================
stage3_start = time.perf_counter()
print(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}")
logger.info(f"[generate_video] Stage 3 START - DB update - task_id: {task_id}")
try:
from app.database.session import AsyncSessionLocal
async with AsyncSessionLocal() as update_session:
@ -361,11 +363,11 @@ async def generate_video(
stage3_time = time.perf_counter()
total_time = stage3_time - request_start
print(
logger.debug(
f"[generate_video] Stage 3 DONE - task_id: {task_id}, "
f"stage3_elapsed: {(stage3_time - stage3_start)*1000:.1f}ms"
)
print(
logger.info(
f"[generate_video] SUCCESS - task_id: {task_id}, "
f"render_id: {creatomate_render_id}, "
f"total_time: {total_time*1000:.1f}ms"
@ -380,7 +382,7 @@ async def generate_video(
)
except Exception as e:
print(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}")
logger.error(f"[generate_video] Update EXCEPTION - task_id: {task_id}, error: {e}")
return GenerateVideoResponse(
success=False,
task_id=task_id,
@ -439,11 +441,11 @@ async def get_video_status(
succeeded 상태인 경우 백그라운드에서 MP4 파일을 다운로드하고
Video 테이블의 status를 completed로, result_movie_url을 업데이트합니다.
"""
print(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}")
logger.info(f"[get_video_status] START - creatomate_render_id: {creatomate_render_id}")
try:
creatomate_service = CreatomateService()
result = await creatomate_service.get_render_status_async(creatomate_render_id)
print(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}")
logger.debug(f"[get_video_status] Creatomate API response - creatomate_render_id: {creatomate_render_id}, status: {result.get('status')}")
status = result.get("status", "unknown")
video_url = result.get("url")
@ -481,7 +483,7 @@ async def get_video_status(
store_name = project.store_name if project else "video"
# 백그라운드 태스크로 MP4 다운로드 → Blob 업로드 → DB 업데이트 → 임시 파일 삭제
print(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}")
logger.info(f"[get_video_status] Background task args - task_id: {video.task_id}, video_url: {video_url}, store_name: {store_name}")
background_tasks.add_task(
download_and_upload_video_to_blob,
task_id=video.task_id,
@ -489,7 +491,7 @@ async def get_video_status(
store_name=store_name,
)
elif video and video.status == "completed":
print(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}")
logger.debug(f"[get_video_status] SKIPPED - Video already completed, creatomate_render_id: {creatomate_render_id}")
render_data = VideoRenderData(
id=result.get("id"),
@ -498,7 +500,7 @@ async def get_video_status(
snapshot_url=result.get("snapshot_url"),
)
print(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}")
logger.info(f"[get_video_status] SUCCESS - creatomate_render_id: {creatomate_render_id}")
return PollingVideoResponse(
success=True,
status=status,
@ -511,7 +513,7 @@ async def get_video_status(
except Exception as e:
import traceback
print(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
logger.error(f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
return PollingVideoResponse(
success=False,
status="error",
@ -563,7 +565,7 @@ async def download_video(
session: AsyncSession = Depends(get_session),
) -> DownloadVideoResponse:
"""task_id로 Video 상태를 polling하고 completed 시 Project 정보와 영상 URL을 반환합니다."""
print(f"[download_video] START - task_id: {task_id}")
logger.info(f"[download_video] START - task_id: {task_id}")
try:
# task_id로 Video 조회 (여러 개 있을 경우 가장 최근 것 선택)
video_result = await session.execute(
@ -575,7 +577,7 @@ async def download_video(
video = video_result.scalar_one_or_none()
if not video:
print(f"[download_video] Video NOT FOUND - task_id: {task_id}")
logger.warning(f"[download_video] Video NOT FOUND - task_id: {task_id}")
return DownloadVideoResponse(
success=False,
status="not_found",
@ -583,11 +585,11 @@ async def download_video(
error_message="Video not found",
)
print(f"[download_video] Video found - task_id: {task_id}, status: {video.status}")
logger.debug(f"[download_video] Video found - task_id: {task_id}, status: {video.status}")
# processing 상태인 경우
if video.status == "processing":
print(f"[download_video] PROCESSING - task_id: {task_id}")
logger.debug(f"[download_video] PROCESSING - task_id: {task_id}")
return DownloadVideoResponse(
success=True,
status="processing",
@ -597,7 +599,7 @@ async def download_video(
# failed 상태인 경우
if video.status == "failed":
print(f"[download_video] FAILED - task_id: {task_id}")
logger.error(f"[download_video] FAILED - task_id: {task_id}")
return DownloadVideoResponse(
success=False,
status="failed",
@ -612,7 +614,7 @@ async def download_video(
)
project = project_result.scalar_one_or_none()
print(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}")
logger.info(f"[download_video] COMPLETED - task_id: {task_id}, result_movie_url: {video.result_movie_url}")
return DownloadVideoResponse(
success=True,
status="completed",
@ -625,7 +627,7 @@ async def download_video(
)
except Exception as e:
print(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}")
logger.error(f"[download_video] EXCEPTION - task_id: {task_id}, error: {e}")
return DownloadVideoResponse(
success=False,
status="error",
@ -674,7 +676,7 @@ async def get_videos(
pagination: PaginationParams = Depends(get_pagination_params),
) -> PaginatedResponse[VideoListItem]:
"""완료된 영상 목록을 페이지네이션하여 반환합니다."""
print(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}")
logger.info(f"[get_videos] START - page: {pagination.page}, page_size: {pagination.page_size}")
try:
offset = (pagination.page - 1) * pagination.page_size
@ -732,14 +734,14 @@ async def get_videos(
page_size=pagination.page_size,
)
print(
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:
print(f"[get_videos] EXCEPTION - error: {e}")
logger.error(f"[get_videos] EXCEPTION - error: {e}")
raise HTTPException(
status_code=500,
detail=f"영상 목록 조회에 실패했습니다: {str(e)}",

View File

@ -14,6 +14,9 @@ from app.lyrics.schemas.lyrics_schema import (
StoreData,
)
from app.utils.chatgpt_prompt import chatgpt_api
from app.utils.logger import get_logger
logger = get_logger("video")
async def get_store_info(conn: Connection) -> List[StoreData]:
@ -38,13 +41,13 @@ async def get_store_info(conn: Connection) -> List[StoreData]:
result.close()
return all_store_info
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemy error in get_store_info: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_store_info: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -69,13 +72,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close()
return all_attribute
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemy error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -100,13 +103,13 @@ async def get_attribute(conn: Connection) -> List[AttributeData]:
result.close()
return all_attribute
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemy error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_attribute: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -132,13 +135,13 @@ async def get_sample_song(conn: Connection) -> List[SongSampleData]:
result.close()
return all_sample_song
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemy error in get_sample_song: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_sample_song: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -162,13 +165,13 @@ async def get_prompt_template(conn: Connection) -> List[PromptTemplateData]:
result.close()
return all_prompt_template
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemy error in get_prompt_template: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_prompt_template: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -192,13 +195,13 @@ async def get_song_result(conn: Connection) -> List[PromptTemplateData]:
result.close()
return all_prompt_template
except SQLAlchemyError as e:
print(e)
logger.error(f"SQLAlchemy error in get_song_result: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="요청하신 서비스가 잠시 내부적으로 문제가 발생하였습니다.",
)
except Exception as e:
print(e)
logger.error(f"Unexpected error in get_song_result: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="알수없는 이유로 서비스 오류가 발생하였습니다",
@ -210,11 +213,11 @@ async def make_song_result(request: Request, conn: Connection):
# 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}")
print(f"Store ID: {form_data.store_id}")
print(f"Lyrics IDs: {form_data.lyrics_ids}")
print(f"Prompt IDs: {form_data.prompts}")
print(f"{'=' * 60}\n")
logger.info(f"{'=' * 60}")
logger.info(f"Store ID: {form_data.store_id}")
logger.info(f"Lyrics IDs: {form_data.lyrics_ids}")
logger.info(f"Prompt IDs: {form_data.prompts}")
logger.info(f"{'=' * 60}")
# 2. Store 정보 조회
store_query = """
@ -243,7 +246,7 @@ async def make_song_result(request: Request, conn: Connection):
)
store_info = all_store_info[0]
print(f"Store: {store_info.store_name}")
logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
@ -251,7 +254,7 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = None
if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """
SELECT sample_song FROM song_sample
@ -270,11 +273,11 @@ async def make_song_result(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("선택된 lyrics가 없습니다")
logger.info("선택된 lyrics가 없습니다")
# 5. 템플릿 가져오기
if not form_data.prompts:
@ -283,7 +286,7 @@ async def make_song_result(request: Request, conn: Connection):
detail="프롬프트 ID가 필요합니다",
)
print("템플릿 가져오기")
logger.info("템플릿 가져오기")
prompts_query = """
SELECT * FROM prompt_template WHERE id=:id;
@ -310,7 +313,7 @@ async def make_song_result(request: Request, conn: Connection):
)
prompt = prompts_info[0]
print(f"Prompt Template: {prompt.prompt}")
logger.debug(f"Prompt Template: {prompt.prompt}")
# ✅ 6. 프롬프트 조합
updated_prompt = prompt.prompt.replace("###", form_data.attributes_str).format(
@ -329,7 +332,7 @@ async def make_song_result(request: Request, conn: Connection):
{combined_sample_song}
"""
print(f"\n[업데이트된 프롬프트]\n{updated_prompt}\n")
logger.debug(f"[업데이트된 프롬프트]\n{updated_prompt}")
# 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -348,13 +351,12 @@ async def make_song_result(request: Request, conn: Connection):
전체 글자 (공백 포함): {total_chars_with_space}
전체 글자 (공백 제외): {total_chars_without_space}\r\n\r\n{generated_lyrics}"""
print("=" * 40)
print("[translate:form_data.attributes_str:] ", form_data.attributes_str)
print("[translate:total_chars_with_space:] ", total_chars_with_space)
print("[translate:total_chars_without_space:] ", total_chars_without_space)
print("[translate:final_lyrics:]")
print(final_lyrics)
print("=" * 40)
logger.debug("=" * 40)
logger.debug(f"[translate:form_data.attributes_str:] {form_data.attributes_str}")
logger.debug(f"[translate:total_chars_with_space:] {total_chars_with_space}")
logger.debug(f"[translate:total_chars_without_space:] {total_chars_without_space}")
logger.debug(f"[translate:final_lyrics:]\n{final_lyrics}")
logger.debug("=" * 40)
# 8. DB 저장
insert_query = """
@ -396,9 +398,9 @@ async def make_song_result(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params)
await conn.commit()
print("결과 저장 완료")
logger.info("결과 저장 완료")
print("\n전체 결과 조회 중...")
logger.info("전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순)
select_query = """
@ -430,26 +432,20 @@ async def make_song_result(request: Request, conn: Connection):
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException:
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",
@ -490,25 +486,19 @@ async def get_song_result(conn: Connection): # 반환 타입 수정
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException: # HTTPException은 그대로 raise
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",
@ -520,9 +510,9 @@ async def make_automation(request: Request, conn: Connection):
# 1. Form 데이터 파싱
form_data = await SongFormData.from_form(request)
print(f"\n{'=' * 60}")
print(f"Store ID: {form_data.store_id}")
print(f"{'=' * 60}\n")
logger.info(f"{'=' * 60}")
logger.info(f"Store ID: {form_data.store_id}")
logger.info(f"{'=' * 60}")
# 2. Store 정보 조회
store_query = """
@ -551,7 +541,7 @@ async def make_automation(request: Request, conn: Connection):
)
store_info = all_store_info[0]
print(f"Store: {store_info.store_name}")
logger.info(f"Store: {store_info.store_name}")
# 3. 속성 조회 -- 단계별 선택 프로세서시 구현 필요 없음
attribute_query = """
@ -596,13 +586,13 @@ async def make_automation(request: Request, conn: Connection):
# 최종 문자열 생성
formatted_attributes = "\n".join(formatted_pairs)
print(f"\n[포맷팅된 문자열 속성 정보]\n{formatted_attributes}\n")
logger.debug(f"[포맷팅된 문자열 속성 정보]\n{formatted_attributes}")
else:
print("속성 데이터가 없습니다")
logger.info("속성 데이터가 없습니다")
formatted_attributes = ""
# 4. 템플릿 가져오기
print("템플릿 가져오기 (ID=1)")
logger.info("템플릿 가져오기 (ID=1)")
prompts_query = """
SELECT * FROM prompt_template WHERE id=1;
@ -624,7 +614,7 @@ async def make_automation(request: Request, conn: Connection):
prompt=row[2],
)
print(f"Prompt Template: {prompt.prompt}")
logger.debug(f"Prompt Template: {prompt.prompt}")
# 5. 템플릿 조합
@ -635,17 +625,17 @@ async def make_automation(request: Request, conn: Connection):
description=store_info.store_info or "",
)
print("\n" + "=" * 80)
print("업데이트된 프롬프트")
print("=" * 80)
print(updated_prompt)
print("=" * 80 + "\n")
logger.debug("=" * 80)
logger.debug("업데이트된 프롬프트")
logger.debug("=" * 80)
logger.debug(updated_prompt)
logger.debug("=" * 80)
# 4. Sample Song 조회 및 결합
combined_sample_song = None
if form_data.lyrics_ids:
print(f"\n[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
logger.info(f"[샘플 가사 조회] - {len(form_data.lyrics_ids)}")
lyrics_query = """
SELECT sample_song FROM song_sample
@ -664,14 +654,14 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("선택된 lyrics가 없습니다")
logger.info("선택된 lyrics가 없습니다")
# 1. song_sample 테이블의 모든 ID 조회
print("\n[샘플 가사 랜덤 선택]")
logger.info("[샘플 가사 랜덤 선택]")
all_ids_query = """
SELECT id FROM song_sample;
@ -679,7 +669,7 @@ async def make_automation(request: Request, conn: Connection):
ids_result = await conn.execute(text(all_ids_query))
all_ids = [row.id for row in ids_result.fetchall()]
print(f"전체 샘플 가사 개수: {len(all_ids)}")
logger.info(f"전체 샘플 가사 개수: {len(all_ids)}")
# 2. 랜덤하게 3개 선택 (또는 전체 개수가 3개 미만이면 전체)
combined_sample_song = None
@ -689,7 +679,7 @@ async def make_automation(request: Request, conn: Connection):
sample_count = min(3, len(all_ids))
selected_ids = random.sample(all_ids, sample_count)
print(f"랜덤 선택된 ID: {selected_ids}")
logger.info(f"랜덤 선택된 ID: {selected_ids}")
# 3. 선택된 ID로 샘플 가사 조회
lyrics_query = """
@ -710,11 +700,11 @@ async def make_automation(request: Request, conn: Connection):
combined_sample_song = "\n\n".join(
[f"[샘플 {i + 1}]\n{song}" for i, song in enumerate(sample_songs)]
)
print(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
logger.info(f"{len(sample_songs)}개의 샘플 가사 조회 완료")
else:
print("샘플 가사가 비어있습니다")
logger.info("샘플 가사가 비어있습니다")
else:
print("song_sample 테이블에 데이터가 없습니다")
logger.info("song_sample 테이블에 데이터가 없습니다")
# 5. 프롬프트에 샘플 가사 추가
if combined_sample_song:
@ -726,11 +716,11 @@ async def make_automation(request: Request, conn: Connection):
{combined_sample_song}
"""
print("샘플 가사 정보가 프롬프트에 추가되었습니다")
logger.info("샘플 가사 정보가 프롬프트에 추가되었습니다")
else:
print("샘플 가사가 없어 기본 프롬프트만 사용합니다")
logger.info("샘플 가사가 없어 기본 프롬프트만 사용합니다")
print(f"\n[최종 프롬프트 길이: {len(updated_prompt)} 자]\n")
logger.debug(f"[최종 프롬프트 길이: {len(updated_prompt)} 자]")
# 7. 모델에게 요청
generated_lyrics = await chatgpt_api.generate(prompt=updated_prompt)
@ -763,10 +753,9 @@ async def make_automation(request: Request, conn: Connection):
:sample_song, :result_song, NOW()
);
"""
print("\n[insert_params 선택된 속성 확인]")
print(f"Categories: {selected_categories}")
print(f"Values: {selected_values}")
print()
logger.debug("[insert_params 선택된 속성 확인]")
logger.debug(f"Categories: {selected_categories}")
logger.debug(f"Values: {selected_values}")
# attr_category, attr_value
insert_params = {
@ -792,9 +781,9 @@ async def make_automation(request: Request, conn: Connection):
await conn.execute(text(insert_query), insert_params)
await conn.commit()
print("결과 저장 완료")
logger.info("결과 저장 완료")
print("\n전체 결과 조회 중...")
logger.info("전체 결과 조회 중...")
# 9. 생성 결과 가져오기 (created_at 역순)
select_query = """
@ -826,26 +815,20 @@ async def make_automation(request: Request, conn: Connection):
for row in all_results.fetchall()
]
print(f"전체 {len(results_list)}개의 결과 조회 완료\n")
logger.info(f"전체 {len(results_list)}개의 결과 조회 완료")
return results_list
except HTTPException:
raise
except SQLAlchemyError as e:
print(f"Database Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Database Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="데이터베이스 연결에 문제가 발생했습니다.",
)
except Exception as e:
print(f"Unexpected Error: {e}")
import traceback
traceback.print_exc()
logger.error(f"Unexpected Error: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="서비스 처리 중 오류가 발생했습니다.",

View File

@ -4,7 +4,6 @@ Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다.
"""
import logging
import traceback
from pathlib import Path
@ -16,9 +15,10 @@ from sqlalchemy.exc import SQLAlchemyError
from app.database.session import BackgroundSessionLocal
from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.logger import get_logger
# 로거 설정
logger = logging.getLogger(__name__)
logger = get_logger("video")
# HTTP 요청 설정
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
@ -66,20 +66,16 @@ async def _update_video_status(
video.result_movie_url = video_url
await session.commit()
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
print(f"[Video] Status updated - task_id: {task_id}, status: {status}")
return True
else:
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
print(f"[Video] NOT FOUND in DB - task_id: {task_id}")
return False
except SQLAlchemyError as e:
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
return False
except Exception as e:
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
print(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return False
@ -97,14 +93,12 @@ async def _download_video(url: str, task_id: str) -> bytes:
httpx.HTTPError: 다운로드 실패
"""
logger.info(f"[VideoDownload] Downloading - task_id: {task_id}")
print(f"[VideoDownload] Downloading - task_id: {task_id}")
async with httpx.AsyncClient() as client:
response = await client.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
logger.info(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
print(f"[VideoDownload] SUCCESS - task_id: {task_id}, size: {len(response.content)} bytes")
return response.content
@ -121,7 +115,6 @@ async def download_and_upload_video_to_blob(
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
print(f"[download_and_upload_video_to_blob] START - task_id: {task_id}, store_name: {store_name}")
temp_file_path: Path | None = None
try:
@ -136,12 +129,10 @@ async def download_and_upload_video_to_blob(
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
logger.debug(f"[download_and_upload_video_to_blob] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
logger.info(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
print(f"[download_and_upload_video_to_blob] Downloading video - task_id: {task_id}, url: {video_url}")
content = await _download_video(video_url, task_id)
@ -149,7 +140,6 @@ async def download_and_upload_video_to_blob(
await f.write(content)
logger.info(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] File downloaded - task_id: {task_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
@ -161,29 +151,21 @@ async def download_and_upload_video_to_blob(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
print(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
# Video 테이블 업데이트
await _update_video_status(task_id, "completed", blob_url)
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
print(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed")
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed")
except Exception as e:
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
print(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
await _update_video_status(task_id, "failed")
finally:
@ -191,11 +173,9 @@ async def download_and_upload_video_to_blob(
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
logger.debug(f"[download_and_upload_video_to_blob] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_to_blob] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
temp_dir = Path("media") / "temp" / task_id
@ -219,7 +199,6 @@ async def download_and_upload_video_by_creatomate_render_id(
store_name: 저장할 파일명에 사용할 업체명
"""
logger.info(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
print(f"[download_and_upload_video_by_creatomate_render_id] START - creatomate_render_id: {creatomate_render_id}, store_name: {store_name}")
temp_file_path: Path | None = None
task_id: str | None = None
@ -236,12 +215,10 @@ async def download_and_upload_video_by_creatomate_render_id(
if not video:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video NOT FOUND - creatomate_render_id: {creatomate_render_id}")
return
task_id = video.task_id
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] Video found - creatomate_render_id: {creatomate_render_id}, task_id: {task_id}")
# 파일명에 사용할 수 없는 문자 제거
safe_store_name = "".join(
@ -254,12 +231,10 @@ async def download_and_upload_video_by_creatomate_render_id(
temp_dir = Path("media") / "temp" / task_id
temp_dir.mkdir(parents=True, exist_ok=True)
temp_file_path = temp_dir / file_name
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp directory created - path: {temp_file_path}")
# 영상 파일 다운로드
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Downloading video - creatomate_render_id: {creatomate_render_id}, url: {video_url}")
content = await _download_video(video_url, task_id)
@ -267,7 +242,6 @@ async def download_and_upload_video_by_creatomate_render_id(
await f.write(content)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] File downloaded - creatomate_render_id: {creatomate_render_id}, path: {temp_file_path}")
# Azure Blob Storage에 업로드
uploader = AzureBlobUploader(task_id=task_id)
@ -279,7 +253,6 @@ async def download_and_upload_video_by_creatomate_render_id(
# SAS 토큰이 제외된 public_url 사용
blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
print(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
# Video 테이블 업데이트
await _update_video_status(
@ -289,26 +262,19 @@ async def download_and_upload_video_by_creatomate_render_id(
creatomate_render_id=creatomate_render_id,
)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
print(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except SQLAlchemyError as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
except Exception as e:
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}")
traceback.print_exc()
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
if task_id:
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
@ -317,11 +283,9 @@ async def download_and_upload_video_by_creatomate_render_id(
if temp_file_path and temp_file_path.exists():
try:
temp_file_path.unlink()
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
print(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
logger.debug(f"[download_and_upload_video_by_creatomate_render_id] Temp file deleted - path: {temp_file_path}")
except Exception as e:
logger.warning(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
print(f"[download_and_upload_video_by_creatomate_render_id] Failed to delete temp file: {e}")
# 임시 디렉토리 삭제 시도
if task_id:

223
config.py
View File

@ -5,6 +5,10 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_DIR = Path(__file__).resolve().parent
# 미디어 파일 저장 디렉토리
MEDIA_ROOT = PROJECT_DIR / "media"
MEDIA_ROOT.mkdir(exist_ok=True)
_base_config = SettingsConfigDict(
env_file=PROJECT_DIR / ".env",
env_ignore_empty=True,
@ -95,32 +99,6 @@ class DatabaseSettings(BaseSettings):
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{db}"
class SecuritySettings(BaseSettings):
JWT_SECRET: str = "your-jwt-secret-key" # 기본값 추가 (필수 필드 안전)
JWT_ALGORITHM: str = "HS256" # 기본값 추가 (필수 필드 안전)
model_config = _base_config
class NotificationSettings(BaseSettings):
MAIL_USERNAME: str = "your-email@example.com" # 기본값 추가
MAIL_PASSWORD: str = "your-email-password" # 기본값 추가
MAIL_FROM: str = "your-email@example.com" # 기본값 추가
MAIL_PORT: int = 587 # 기본값 추가
MAIL_SERVER: str = "smtp.gmail.com" # 기본값 추가
MAIL_FROM_NAME: str = "FastPOC App" # 기본값 추가
MAIL_STARTTLS: bool = True
MAIL_SSL_TLS: bool = False
USE_CREDENTIALS: bool = True
VALIDATE_CERTS: bool = True
TWILIO_SID: str = "your-twilio-sid" # 기본값 추가
TWILIO_AUTH_TOKEN: str = "your-twilio-token" # 기본값 추가
TWILIO_NUMBER: str = "+1234567890" # 기본값 추가
model_config = _base_config
class CrawlerSettings(BaseSettings):
NAVER_COOKIES: str = Field(default="")
@ -168,12 +146,205 @@ class CreatomateSettings(BaseSettings):
model_config = _base_config
class LogSettings(BaseSettings):
"""
로깅 설정 클래스
애플리케이션의 로깅 동작을 제어하는 설정들을 관리합니다.
모든 설정은 .env 파일 또는 환경변수로 오버라이드 가능합니다.
사용 예시 (.env 파일):
LOG_LEVEL=INFO
LOG_CONSOLE_LEVEL=WARNING
LOG_FILE_LEVEL=DEBUG
LOG_DIR=/var/log/myapp
"""
# ============================================================
# 로그 디렉토리 설정
# ============================================================
# 로그 파일이 저장될 디렉토리 경로입니다.
# - 기본값: 프로젝트 루트의 logs 폴더
# - 운영 환경에서는 /www/log/uvicorn 또는 /var/log/app 등으로 설정 권장
# - 디렉토리가 존재하지 않으면 자동으로 생성됩니다.
# - .env 파일에서 LOG_DIR 환경변수로 오버라이드 가능
LOG_DIR: str = Field(
default="logs",
description="로그 파일 저장 디렉토리 (절대 경로 또는 상대 경로)",
)
# ============================================================
# 로그 출력 대상 설정
# ============================================================
# 콘솔 출력 활성화 여부
# - True: 터미널/콘솔에 로그 출력
# - False: 콘솔 출력 비활성화 (파일에만 기록)
LOG_CONSOLE_ENABLED: bool = Field(
default=True,
description="콘솔 로그 출력 활성화 여부",
)
# 파일 출력 활성화 여부
# - True: 로그 파일에 기록 (app.log, error.log)
# - False: 파일 출력 비활성화 (콘솔에만 출력)
LOG_FILE_ENABLED: bool = Field(
default=True,
description="파일 로그 출력 활성화 여부",
)
# ============================================================
# 로그 레벨 설정
# ============================================================
# 로그 레벨 우선순위 (낮음 → 높음):
# DEBUG < INFO < WARNING < ERROR < CRITICAL
#
# 설정된 레벨 이상의 로그만 출력됩니다.
# 예: INFO로 설정 시 DEBUG는 무시되고, INFO, WARNING, ERROR, CRITICAL만 출력
# ============================================================
# 기본 로그 레벨
# - 로거 자체의 최소 로그 레벨을 설정합니다.
# - 이 레벨보다 낮은 로그는 핸들러(콘솔/파일)로 전달되지 않습니다.
# - 가능한 값: DEBUG, INFO, WARNING, ERROR, CRITICAL
# - DEBUG: 개발 시 상세 디버깅 정보 (변수 값, 흐름 추적 등)
# - INFO: 일반적인 작업 진행 상황 (요청 시작/완료 등)
# - WARNING: 잠재적 문제 또는 주의가 필요한 상황
# - ERROR: 오류 발생, 하지만 애플리케이션은 계속 실행
# - CRITICAL: 심각한 오류, 애플리케이션 중단 가능성
LOG_LEVEL: str = Field(
default="DEBUG",
description="기본 로그 레벨",
)
# 콘솔 출력 로그 레벨
# - 터미널/콘솔에 출력되는 로그의 최소 레벨을 설정합니다.
# - 개발 환경: DEBUG 권장 (모든 로그 확인)
# - 운영 환경: INFO 또는 WARNING 권장 (중요한 정보만 출력)
# - LOG_LEVEL보다 낮게 설정해도 LOG_LEVEL이 우선 적용됩니다.
LOG_CONSOLE_LEVEL: str = Field(
default="DEBUG",
description="콘솔 출력 로그 레벨",
)
# 파일 출력 로그 레벨
# - 로그 파일에 기록되는 로그의 최소 레벨을 설정합니다.
# - 파일에는 더 상세한 로그를 남기고 싶을 때 DEBUG로 설정
# - 파일 저장 위치: logs/{날짜}_{모듈명}.log
# - 에러 로그는 별도로 logs/{날짜}_error.log에도 기록됩니다.
LOG_FILE_LEVEL: str = Field(
default="DEBUG",
description="파일 출력 로그 레벨",
)
# ============================================================
# 로그 파일 관리 설정
# ============================================================
# 로그 파일 최대 크기 (MB)
# - 파일이 이 크기를 초과하면 자동으로 새 파일로 롤오버됩니다.
# - 기존 파일은 .1, .2 등의 접미사가 붙어 백업됩니다.
# - 예: 15MB 설정 시, 파일이 15MB를 넘으면 새 파일 생성
LOG_MAX_SIZE_MB: int = Field(
default=15,
description="로그 파일 최대 크기 (MB)",
)
# 로그 파일 백업 개수
# - 롤오버 시 보관할 백업 파일의 최대 개수입니다.
# - 이 개수를 초과하면 가장 오래된 백업 파일이 삭제됩니다.
# - 예: 30 설정 시, 최대 30개의 백업 파일 유지
# - 디스크 용량 관리를 위해 적절한 값 설정 권장
LOG_BACKUP_COUNT: int = Field(
default=30,
description="로그 파일 백업 개수",
)
# ============================================================
# 로그 포맷 설정
# ============================================================
# 사용 가능한 포맷 변수:
# {asctime} - 로그 발생 시간 (LOG_DATE_FORMAT 형식)
# {levelname} - 로그 레벨 (DEBUG, INFO 등)
# {name} - 로거 이름 (home, song 등)
# {filename} - 소스 파일명
# {funcName} - 함수명
# {lineno} - 라인 번호
# {message} - 로그 메시지
# {module} - 모듈명
# {pathname} - 파일 전체 경로
#
# 포맷 예시:
# "[{asctime}] {levelname} {message}"
# 출력: [2024-01-14 15:30:00] INFO 서버 시작
# ============================================================
# 콘솔 로그 포맷
# - 터미널에 출력되는 로그의 형식을 지정합니다.
# - [{levelname}]은 로그 레벨을 대괄호로 감싸서 출력합니다.
LOG_CONSOLE_FORMAT: str = Field(
default="[{asctime}] [{levelname}] [{name}:{funcName}:{lineno}] {message}",
description="콘솔 로그 포맷",
)
# 파일 로그 포맷
# - 파일에 기록되는 로그의 형식을 지정합니다.
# - 파일에는 더 상세한 정보(filename 등)를 포함할 수 있습니다.
LOG_FILE_FORMAT: str = Field(
default="[{asctime}] [{levelname}] [{filename}:{name} -> {funcName}():{lineno}] {message}",
description="파일 로그 포맷",
)
# 날짜 포맷
# - {asctime}에 표시되는 시간의 형식을 지정합니다.
# - Python strftime 형식을 따릅니다.
# - 예시:
# "%Y-%m-%d %H:%M:%S" -> 2024-01-14 15:30:00
# "%Y/%m/%d %H:%M:%S.%f" -> 2024/01/14 15:30:00.123456
# "%d-%b-%Y %H:%M:%S" -> 14-Jan-2024 15:30:00
LOG_DATE_FORMAT: str = Field(
default="%Y-%m-%d %H:%M:%S",
description="로그 날짜 포맷",
)
model_config = _base_config
def get_log_dir(self) -> Path:
"""
로그 디렉토리 경로를 반환합니다.
우선순위:
1. .env의 LOG_DIR 설정값 (절대 경로인 경우)
2. /www/log/uvicorn 폴더가 존재하면 사용 (운영 서버)
3. 프로젝트 루트의 logs 폴더 (개발 환경 기본값)
Returns:
Path: 로그 디렉토리 경로 (존재하지 않으면 자동 생성)
"""
# 1. .env에서 설정한 경로가 절대 경로인 경우 우선 사용
log_dir_path = Path(self.LOG_DIR)
if log_dir_path.is_absolute():
log_dir_path.mkdir(parents=True, exist_ok=True)
return log_dir_path
# 2. 운영 서버 경로 확인 (/www/log/uvicorn)
production_log_dir = Path("/www/log/uvicorn")
if production_log_dir.exists():
return production_log_dir
# 3. 기본값: 프로젝트 루트의 logs 폴더
default_log_dir = PROJECT_DIR / self.LOG_DIR
default_log_dir.mkdir(parents=True, exist_ok=True)
return default_log_dir
prj_settings = ProjectSettings()
apikey_settings = APIKeySettings()
db_settings = DatabaseSettings()
security_settings = SecuritySettings()
kakao_settings = KakaoSettings()
notification_settings = NotificationSettings()
cors_settings = CORSSettings()
crawler_settings = CrawlerSettings()
azure_blob_settings = AzureBlobSettings()
creatomate_settings = CreatomateSettings()
log_settings = LogSettings()

331
docs/user/kakao.md Normal file
View File

@ -0,0 +1,331 @@
# 카카오 소셜 로그인 구현 가이드
## 목차
1. [개요](#1-개요)
2. [인증 흐름](#2-인증-흐름)
3. [User 모델 구조](#3-user-모델-구조)
4. [API 엔드포인트](#4-api-엔드포인트)
5. [환경 설정](#5-환경-설정)
6. [에러 처리](#6-에러-처리)
---
## 1. 개요
CastAD는 카카오 소셜 로그인만 지원하며, 인증 후 자체 JWT 토큰을 발급합니다.
### 인증 방식
| 항목 | 설명 |
|------|------|
| 소셜 로그인 | 카카오 OAuth 2.0 |
| 자체 인증 | JWT (Access Token + Refresh Token) |
| 카카오 토큰 저장 | X (1회 검증 후 폐기) |
---
## 2. 인증 흐름
### 2.1 전체 흐름도
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Client │ │ Backend │ │ Kakao │ │ DB │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │
│ 1. 로그인 요청 │ │ │
│──────────────>│ │ │
│ │ │ │
│ 2. 카카오 로그인 URL 반환 │ │
<──────────────│ │ │
│ │ │ │
│ 3. 카카오 로그인 페이지로 이동 │ │
│──────────────────────────────>│ │
│ │ │ │
│ 4. 사용자 인증 후 code 반환 │ │
<──────────────────────────────│ │
│ │ │ │
│ 5. code 전달 │ │ │
│──────────────>│ │ │
│ │ │ │
│ │ 6. code로 토큰 요청 │
│ │──────────────>│ │
│ │ │ │
│ │ 7. access_token 반환 │
│ │<──────────────│ │
│ │ │ │
│ │ 8. 사용자 정보 조회 │
│ │──────────────>│ │
│ │ │ │
│ │ 9. 사용자 정보 반환 │
│ │<──────────────│ │
│ │ │ │
│ │ 10. kakao_id로 회원 조회 │
│ │──────────────────────────────>│
│ │ │ │
│ │ 11. 회원 정보 반환 (없으면 생성) │
│ │<──────────────────────────────│
│ │ │ │
│ 12. 자체 JWT 발급 및 반환 │ │
<──────────────│ │ │
│ │ │ │
```
### 2.2 단계별 설명
| 단계 | 설명 | 관련 API |
|------|------|----------|
| 1-2 | 클라이언트가 로그인 요청, 백엔드가 카카오 인증 URL 생성 | `GET /api/v1/auth/kakao/login` |
| 3-4 | 사용자가 카카오에서 로그인, 인가 코드(code) 발급 | 카카오 OAuth |
| 5-9 | 백엔드가 code로 카카오 토큰/사용자정보 획득 | 카카오 API |
| 10-11 | DB에서 회원 조회, 없으면 신규 가입 | 내부 처리 |
| 12 | 자체 JWT 토큰 발급 후 클라이언트에 반환 | `POST /api/v1/auth/kakao/callback` |
---
## 3. User 모델 구조
### 3.1 테이블 스키마
```
┌─────────────────────────────────────────────────────────────┐
│ user │
├─────────────────────┬───────────────┬───────────────────────┤
│ Column │ Type │ Description │
├─────────────────────┼───────────────┼───────────────────────┤
│ id │ BIGINT (PK) │ 고유 식별자 (자동증가) │
│ kakao_id │ BIGINT (UQ) │ 카카오 회원번호 │
│ email │ VARCHAR(255) │ 이메일 (선택) │
│ nickname │ VARCHAR(100) │ 닉네임 (선택) │
│ profile_image_url │ VARCHAR(2048) │ 프로필 이미지 URL │
│ thumbnail_image_url │ VARCHAR(2048) │ 썸네일 이미지 URL │
│ is_active │ BOOLEAN │ 계정 활성화 상태 │
│ is_admin │ BOOLEAN │ 관리자 권한 │
│ last_login_at │ DATETIME │ 마지막 로그인 일시 │
│ created_at │ DATETIME │ 생성 일시 │
│ updated_at │ DATETIME │ 수정 일시 │
└─────────────────────┴───────────────┴───────────────────────┘
```
### 3.2 카카오 API 응답 매핑
```json
// 카카오 API 응답 예시
{
"id": 1234567890,
"kakao_account": {
"email": "user@kakao.com",
"profile": {
"nickname": "홍길동",
"profile_image_url": "https://k.kakaocdn.net/.../profile.jpg",
"thumbnail_image_url": "https://k.kakaocdn.net/.../thumb.jpg"
}
}
}
```
| User 필드 | 카카오 응답 경로 |
|-----------|-----------------|
| `kakao_id` | `id` |
| `email` | `kakao_account.email` |
| `nickname` | `kakao_account.profile.nickname` |
| `profile_image_url` | `kakao_account.profile.profile_image_url` |
| `thumbnail_image_url` | `kakao_account.profile.thumbnail_image_url` |
---
## 4. API 엔드포인트
### 4.1 카카오 로그인 URL 요청
```
GET /api/v1/auth/kakao/login
```
**Response:**
```json
{
"auth_url": "https://kauth.kakao.com/oauth/authorize?client_id=...&redirect_uri=...&response_type=code"
}
```
### 4.2 카카오 콜백 (로그인/가입 처리)
```
POST /api/v1/auth/kakao/callback
```
**Request:**
```json
{
"code": "인가코드"
}
```
**Response (성공):**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"user": {
"id": 1,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://...",
"is_new_user": false
}
}
```
### 4.3 토큰 갱신
```
POST /api/v1/auth/refresh
```
**Request:**
```json
{
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
```
**Response:**
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600
}
```
### 4.4 로그아웃
```
POST /api/v1/auth/logout
Authorization: Bearer {access_token}
```
### 4.5 내 정보 조회
```
GET /api/v1/users/me
Authorization: Bearer {access_token}
```
**Response:**
```json
{
"id": 1,
"kakao_id": 1234567890,
"nickname": "홍길동",
"email": "user@kakao.com",
"profile_image_url": "https://...",
"is_admin": false,
"created_at": "2026-01-14T16:00:00"
}
```
---
## 5. 환경 설정
### 5.1 카카오 개발자 설정
1. [카카오 개발자 콘솔](https://developers.kakao.com) 접속
2. 애플리케이션 생성
3. 플랫폼 > Web 사이트 도메인 등록
4. 카카오 로그인 > Redirect URI 등록
5. 동의항목 > 필요한 정보 설정
### 5.2 .env 설정
```env
# 카카오 OAuth
KAKAO_CLIENT_ID=your_rest_api_key
KAKAO_CLIENT_SECRET=your_client_secret # 선택
KAKAO_REDIRECT_URI=https://your-domain.com/api/v1/auth/kakao/callback
# JWT
JWT_SECRET=your-super-secret-key-min-32-characters
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
```
### 5.3 config.py 설정
```python
class KakaoSettings(BaseSettings):
KAKAO_CLIENT_ID: str = Field(...)
KAKAO_CLIENT_SECRET: str = Field(default="")
KAKAO_REDIRECT_URI: str = Field(...)
model_config = _base_config
class JWTSettings(BaseSettings):
JWT_SECRET: str = Field(...)
JWT_ALGORITHM: str = Field(default="HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=60)
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
model_config = _base_config
```
---
## 6. 에러 처리
### 6.1 에러 코드 정의
| HTTP Status | Error Code | 설명 |
|-------------|------------|------|
| 400 | `INVALID_CODE` | 유효하지 않은 인가 코드 |
| 400 | `KAKAO_AUTH_FAILED` | 카카오 인증 실패 |
| 401 | `TOKEN_EXPIRED` | 토큰 만료 |
| 401 | `INVALID_TOKEN` | 유효하지 않은 토큰 |
| 401 | `TOKEN_REVOKED` | 취소된 토큰 |
| 403 | `USER_INACTIVE` | 비활성화된 계정 |
| 403 | `ADMIN_REQUIRED` | 관리자 권한 필요 |
| 404 | `USER_NOT_FOUND` | 사용자 없음 |
| 500 | `KAKAO_API_ERROR` | 카카오 API 오류 |
### 6.2 에러 응답 형식
```json
{
"detail": {
"code": "TOKEN_EXPIRED",
"message": "토큰이 만료되었습니다. 다시 로그인해주세요."
}
}
```
---
## 부록: 파일 구조
```
app/user/
├── __init__.py
├── models.py # User 모델
├── schemas/
│ ├── __init__.py
│ └── user_schema.py # Pydantic 스키마
├── services/
│ ├── __init__.py
│ ├── auth.py # 인증 서비스
│ ├── jwt.py # JWT 서비스
│ └── kakao.py # 카카오 OAuth 서비스
├── api/
│ ├── __init__.py
│ └── routers/
│ └── v1/
│ ├── __init__.py
│ └── auth.py # 인증 API 라우터
└── exceptions.py # 사용자 정의 예외
```