diff --git a/.gitignore b/.gitignore index 9d68df5..5740c31 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,11 @@ build/ # Media files *.mp3 *.mp4 -media/ \ No newline at end of file +media/ + +# Static files +static/ + +# Log files +*.log +logs/ \ No newline at end of file diff --git a/app/core/common.py b/app/core/common.py index 1d6d621..520fab4 100644 --- a/app/core/common.py +++ b/app/core/common.py @@ -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 diff --git a/app/core/exceptions.py b/app/core/exceptions.py index e0399c1..63cba13 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -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,17 +259,8 @@ 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 raise HTTPException( diff --git a/app/database/session-prod.py b/app/database/session-prod.py index cae7289..02f01c9 100644 --- a/app/database/session-prod.py +++ b/app/database/session-prod.py @@ -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") diff --git a/app/database/session.py b/app/database/session.py index dd8c6db..cb4ef52 100644 --- a/app/database/session.py +++ b/app/database/session.py @@ -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") diff --git a/app/home/api/routers/v1/home.py b/app/home/api/routers/v1/home.py index b24b52f..91a4c48 100644 --- a/app/home/api/routers/v1/home.py +++ b/app/home/api/routers/v1/home.py @@ -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, diff --git a/app/home/tests/test_db.py b/app/home/tests/test_db.py index 04c8733..f85716d 100644 --- a/app/home/tests/test_db.py +++ b/app/home/tests/test_db.py @@ -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}") diff --git a/app/home/worker/home_task.py b/app/home/worker/home_task.py index 69adfca..d352c16 100644 --- a/app/home/worker/home_task.py +++ b/app/home/worker/home_task.py @@ -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: """업로드 파일을 지정된 경로에 저장""" diff --git a/app/lyric/api/routers/v1/lyric.py b/app/lyric/api/routers/v1/lyric.py index d485d0c..b1bd34b 100644 --- a/app/lyric/api/routers/v1/lyric.py +++ b/app/lyric/api/routers/v1/lyric.py @@ -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, diff --git a/app/lyric/services/lyrics.py b/app/lyric/services/lyrics.py index 99c6e78..eec9263 100644 --- a/app/lyric/services/lyrics.py +++ b/app/lyric/services/lyrics.py @@ -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,13 +399,13 @@ 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 = """ - SELECT * FROM song_results_all + SELECT * FROM song_results_all ORDER BY created_at DESC; """ @@ -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="서비스 처리 중 오류가 발생했습니다.", diff --git a/app/lyric/worker/lyric_task.py b/app/lyric/worker/lyric_task.py index 886b8fe..094f4fc 100644 --- a/app/lyric/worker/lyric_task.py +++ b/app/lyric/worker/lyric_task.py @@ -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)}") diff --git a/app/song/api/routers/v1/song.py b/app/song/api/routers/v1/song.py index 50ad447..8400660 100644 --- a/app/song/api/routers/v1/song.py +++ b/app/song/api/routers/v1/song.py @@ -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)}", diff --git a/app/song/services/song.py b/app/song/services/song.py index fd2c6c0..9955e6b 100644 --- a/app/song/services/song.py +++ b/app/song/services/song.py @@ -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,13 +398,13 @@ 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 = """ - SELECT * FROM song_results_all + SELECT * FROM song_results_all ORDER BY created_at DESC; """ @@ -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="서비스 처리 중 오류가 발생했습니다.", diff --git a/app/song/worker/song_task.py b/app/song/worker/song_task.py index ec663e9..a264145 100644 --- a/app/song/worker/song_task.py +++ b/app/song/worker/song_task.py @@ -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: diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/__init__.py b/app/user/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/routers/__init__.py b/app/user/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/routers/v1/__init__.py b/app/user/api/routers/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/routers/v1/user.py b/app/user/api/routers/v1/user.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/api/user_admin.py b/app/user/api/user_admin.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/dependency.py b/app/user/dependency.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/exceptions.py b/app/user/exceptions.py new file mode 100644 index 0000000..41752ae --- /dev/null +++ b/app/user/exceptions.py @@ -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, + ) diff --git a/app/user/models.py b/app/user/models.py new file mode 100644 index 0000000..f753f5a --- /dev/null +++ b/app/user/models.py @@ -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"" + ) diff --git a/app/user/schemas/__init__.py b/app/user/schemas/__init__.py new file mode 100644 index 0000000..25dc83a --- /dev/null +++ b/app/user/schemas/__init__.py @@ -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", +] diff --git a/app/user/schemas/user_schema.py b/app/user/schemas/user_schema.py new file mode 100644 index 0000000..b2c6ab0 --- /dev/null +++ b/app/user/schemas/user_schema.py @@ -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 diff --git a/app/user/services/__init__.py b/app/user/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/services/user.py b/app/user/services/user.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/tests/__init__.py b/app/user/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/tests/conftest.py b/app/user/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/worker/__init__.py b/app/user/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/worker/user_task.py b/app/user/worker/user_task.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/chatgpt_prompt.py b/app/utils/chatgpt_prompt.py index 54d189b..c8cd5aa 100644 --- a/app/utils/chatgpt_prompt.py +++ b/app/utils/chatgpt_prompt.py @@ -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자 이내로 요약 - 내용의 누락이 있어서는 안된다 - 문장이 자연스러워야 한다 - 핵심 정보만 간결하게 포함 diff --git a/app/utils/creatomate.py b/app/utils/creatomate.py index 69ec3b9..35d65fd 100644 --- a/app/utils/creatomate.py +++ b/app/utils/creatomate.py @@ -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}" ) diff --git a/app/utils/logger.py b/app/utils/logger.py new file mode 100644 index 0000000..18bbbae --- /dev/null +++ b/app/utils/logger.py @@ -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" diff --git a/app/utils/nvMapScraper.py b/app/utils/nvMapScraper.py index 7eec1bf..d16f8f1 100644 --- a/app/utils/nvMapScraper.py +++ b/app/utils/nvMapScraper.py @@ -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: diff --git a/app/utils/upload_blob_as_request.py b/app/utils/upload_blob_as_request.py index 921e4e9..da4b01b 100644 --- a/app/utils/upload_blob_as_request.py +++ b/app/utils/upload_blob_as_request.py @@ -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"} diff --git a/app/video/api/routers/v1/video.py b/app/video/api/routers/v1/video.py index 053ca03..afd5b0e 100644 --- a/app/video/api/routers/v1/video.py +++ b/app/video/api/routers/v1/video.py @@ -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)}", diff --git a/app/video/services/video.py b/app/video/services/video.py index fd2c6c0..ba9ea5f 100644 --- a/app/video/services/video.py +++ b/app/video/services/video.py @@ -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,13 +398,13 @@ 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 = """ - SELECT * FROM song_results_all + SELECT * FROM song_results_all ORDER BY created_at DESC; """ @@ -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="서비스 처리 중 오류가 발생했습니다.", diff --git a/app/video/worker/video_task.py b/app/video/worker/video_task.py index cca4b17..7edc2c2 100644 --- a/app/video/worker/video_task.py +++ b/app/video/worker/video_task.py @@ -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: diff --git a/config.py b/config.py index 9ba8503..5a22d87 100644 --- a/config.py +++ b/config.py @@ -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() diff --git a/docs/user/kakao.md b/docs/user/kakao.md new file mode 100644 index 0000000..df299dc --- /dev/null +++ b/docs/user/kakao.md @@ -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 # 사용자 정의 예외 +```