# 디자인 패턴 기반 리팩토링 제안서 ## 목차 1. [현재 아키텍처 분석](#1-현재-아키텍처-분석) 2. [제안하는 디자인 패턴](#2-제안하는-디자인-패턴) 3. [상세 리팩토링 방안](#3-상세-리팩토링-방안) 4. [모듈별 구현 예시](#4-모듈별-구현-예시) 5. [기대 효과](#5-기대-효과) 6. [마이그레이션 전략](#6-마이그레이션-전략) --- ## 1. 현재 아키텍처 분석 ### 1.1 현재 구조 ``` app/ ├── {module}/ │ ├── models.py # SQLAlchemy 모델 │ ├── schemas/ # Pydantic 스키마 │ ├── services/ # 비즈니스 로직 (일부만 사용) │ ├── api/routers/v1/ # FastAPI 라우터 │ └── worker/ # 백그라운드 태스크 └── utils/ # 외부 API 클라이언트 (Suno, Creatomate, ChatGPT) ``` ### 1.2 현재 문제점 | 문제 | 설명 | 영향 | |------|------|------| | **Fat Controller** | 라우터에 비즈니스 로직이 직접 포함됨 | 테스트 어려움, 재사용 불가 | | **서비스 레이어 미활용** | services/ 폴더가 있지만 대부분 사용되지 않음 | 코드 중복, 일관성 부족 | | **외부 API 결합** | 라우터에서 직접 외부 API 호출 | 모킹 어려움, 의존성 강결합 | | **Repository 부재** | 데이터 접근 로직이 분산됨 | 쿼리 중복, 최적화 어려움 | | **트랜잭션 관리 분산** | 각 함수에서 개별적으로 세션 관리 | 일관성 부족 | | **에러 처리 비일관** | HTTPException이 여러 계층에서 발생 | 디버깅 어려움 | ### 1.3 현재 코드 예시 (문제점) ```python # app/lyric/api/routers/v1/lyric.py - 현재 구조 @router.post("/generate") async def generate_lyric( request_body: GenerateLyricRequest, background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), ) -> GenerateLyricResponse: task_id = request_body.task_id try: # 문제 1: 라우터에서 직접 비즈니스 로직 수행 service = ChatgptService( customer_name=request_body.customer_name, region=request_body.region, ... ) prompt = service.build_lyrics_prompt() # 문제 2: 라우터에서 직접 DB 조작 project = Project( store_name=request_body.customer_name, ... ) session.add(project) await session.commit() # 문제 3: 라우터에서 직접 모델 생성 lyric = Lyric( project_id=project.id, ... ) session.add(lyric) await session.commit() # 문제 4: 에러 처리가 각 함수마다 다름 background_tasks.add_task(generate_lyric_background, ...) return GenerateLyricResponse(...) except Exception as e: await session.rollback() return GenerateLyricResponse(success=False, ...) ``` --- ## 2. 제안하는 디자인 패턴 ### 2.1 Clean Architecture + 레이어드 아키텍처 ``` ┌─────────────────────────────────────────────────────────────────┐ │ Presentation Layer │ │ (FastAPI Routers - HTTP 요청/응답만 처리) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Application Layer │ │ (Use Cases / Services - 비즈니스 로직 오케스트레이션) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Domain Layer │ │ (Entities, Value Objects, Domain Services) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ Infrastructure Layer │ │ (Repositories, External APIs, Database) │ └─────────────────────────────────────────────────────────────────┘ ``` ### 2.2 적용할 디자인 패턴 | 패턴 | 적용 대상 | 목적 | |------|----------|------| | **Repository Pattern** | 데이터 접근 | DB 로직 캡슐화, 테스트 용이 | | **Service Pattern** | 비즈니스 로직 | 유스케이스 구현, 트랜잭션 관리 | | **Factory Pattern** | 객체 생성 | 복잡한 객체 생성 캡슐화 | | **Strategy Pattern** | 외부 API | API 클라이언트 교체 용이 | | **Unit of Work** | 트랜잭션 | 일관된 트랜잭션 관리 | | **Dependency Injection** | 전체 | 느슨한 결합, 테스트 용이 | | **DTO Pattern** | 계층 간 전달 | 명확한 데이터 경계 | --- ## 3. 상세 리팩토링 방안 ### 3.1 새로운 폴더 구조 ``` app/ ├── core/ │ ├── __init__.py │ ├── config.py # 설정 관리 (기존 config.py 이동) │ ├── exceptions.py # 도메인 예외 정의 │ ├── interfaces/ # 추상 인터페이스 │ │ ├── __init__.py │ │ ├── repository.py # IRepository 인터페이스 │ │ ├── service.py # IService 인터페이스 │ │ └── external_api.py # IExternalAPI 인터페이스 │ └── uow.py # Unit of Work │ ├── domain/ │ ├── __init__.py │ ├── entities/ # 도메인 엔티티 │ │ ├── __init__.py │ │ ├── project.py │ │ ├── lyric.py │ │ ├── song.py │ │ └── video.py │ ├── value_objects/ # 값 객체 │ │ ├── __init__.py │ │ ├── task_id.py │ │ └── status.py │ └── events/ # 도메인 이벤트 │ ├── __init__.py │ └── lyric_events.py │ ├── infrastructure/ │ ├── __init__.py │ ├── database/ │ │ ├── __init__.py │ │ ├── session.py # DB 세션 관리 │ │ ├── models/ # SQLAlchemy 모델 │ │ │ ├── __init__.py │ │ │ ├── project_model.py │ │ │ ├── lyric_model.py │ │ │ ├── song_model.py │ │ │ └── video_model.py │ │ └── repositories/ # Repository 구현 │ │ ├── __init__.py │ │ ├── base.py │ │ ├── project_repository.py │ │ ├── lyric_repository.py │ │ ├── song_repository.py │ │ └── video_repository.py │ ├── external/ # 외부 API 클라이언트 │ │ ├── __init__.py │ │ ├── chatgpt/ │ │ │ ├── __init__.py │ │ │ ├── client.py │ │ │ └── prompts.py │ │ ├── suno/ │ │ │ ├── __init__.py │ │ │ └── client.py │ │ ├── creatomate/ │ │ │ ├── __init__.py │ │ │ └── client.py │ │ └── azure_blob/ │ │ ├── __init__.py │ │ └── client.py │ └── cache/ │ ├── __init__.py │ └── redis.py │ ├── application/ │ ├── __init__.py │ ├── services/ # 애플리케이션 서비스 │ │ ├── __init__.py │ │ ├── lyric_service.py │ │ ├── song_service.py │ │ └── video_service.py │ ├── dto/ # Data Transfer Objects │ │ ├── __init__.py │ │ ├── lyric_dto.py │ │ ├── song_dto.py │ │ └── video_dto.py │ └── tasks/ # 백그라운드 태스크 │ ├── __init__.py │ ├── lyric_tasks.py │ ├── song_tasks.py │ └── video_tasks.py │ ├── presentation/ │ ├── __init__.py │ ├── api/ │ │ ├── __init__.py │ │ ├── v1/ │ │ │ ├── __init__.py │ │ │ ├── lyric_router.py │ │ │ ├── song_router.py │ │ │ └── video_router.py │ │ └── dependencies.py # FastAPI 의존성 │ ├── schemas/ # API 스키마 (요청/응답) │ │ ├── __init__.py │ │ ├── lyric_schema.py │ │ ├── song_schema.py │ │ └── video_schema.py │ └── middleware/ │ ├── __init__.py │ └── error_handler.py │ └── main.py ``` ### 3.2 Repository Pattern 구현 #### 3.2.1 추상 인터페이스 ```python # app/core/interfaces/repository.py from abc import ABC, abstractmethod from typing import Generic, TypeVar, Optional, List T = TypeVar("T") class IRepository(ABC, Generic[T]): """Repository 인터페이스 - 데이터 접근 추상화""" @abstractmethod async def get_by_id(self, id: int) -> Optional[T]: """ID로 엔티티 조회""" pass @abstractmethod async def get_by_task_id(self, task_id: str) -> Optional[T]: """task_id로 엔티티 조회""" pass @abstractmethod async def get_all( self, skip: int = 0, limit: int = 100, filters: dict = None ) -> List[T]: """전체 엔티티 조회 (페이지네이션)""" pass @abstractmethod async def create(self, entity: T) -> T: """엔티티 생성""" pass @abstractmethod async def update(self, entity: T) -> T: """엔티티 수정""" pass @abstractmethod async def delete(self, id: int) -> bool: """엔티티 삭제""" pass @abstractmethod async def count(self, filters: dict = None) -> int: """엔티티 개수 조회""" pass ``` #### 3.2.2 Base Repository 구현 ```python # app/infrastructure/database/repositories/base.py from typing import Generic, TypeVar, Optional, List, Type from sqlalchemy import select, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.core.interfaces.repository import IRepository from app.infrastructure.database.session import Base T = TypeVar("T", bound=Base) class BaseRepository(IRepository[T], Generic[T]): """Repository 기본 구현""" def __init__(self, session: AsyncSession, model: Type[T]): self._session = session self._model = model async def get_by_id(self, id: int) -> Optional[T]: result = await self._session.execute( select(self._model).where(self._model.id == id) ) return result.scalar_one_or_none() async def get_by_task_id(self, task_id: str) -> Optional[T]: result = await self._session.execute( select(self._model) .where(self._model.task_id == task_id) .order_by(self._model.created_at.desc()) .limit(1) ) return result.scalar_one_or_none() async def get_all( self, skip: int = 0, limit: int = 100, filters: dict = None ) -> List[T]: query = select(self._model) if filters: conditions = [ getattr(self._model, key) == value for key, value in filters.items() if hasattr(self._model, key) ] if conditions: query = query.where(and_(*conditions)) query = query.offset(skip).limit(limit).order_by( self._model.created_at.desc() ) result = await self._session.execute(query) return list(result.scalars().all()) async def create(self, entity: T) -> T: self._session.add(entity) await self._session.flush() await self._session.refresh(entity) return entity async def update(self, entity: T) -> T: await self._session.flush() await self._session.refresh(entity) return entity async def delete(self, id: int) -> bool: entity = await self.get_by_id(id) if entity: await self._session.delete(entity) return True return False async def count(self, filters: dict = None) -> int: query = select(func.count(self._model.id)) if filters: conditions = [ getattr(self._model, key) == value for key, value in filters.items() if hasattr(self._model, key) ] if conditions: query = query.where(and_(*conditions)) result = await self._session.execute(query) return result.scalar() or 0 ``` #### 3.2.3 특화된 Repository ```python # app/infrastructure/database/repositories/lyric_repository.py from typing import Optional, List from sqlalchemy import select from app.infrastructure.database.repositories.base import BaseRepository from app.infrastructure.database.models.lyric_model import LyricModel class LyricRepository(BaseRepository[LyricModel]): """Lyric 전용 Repository""" def __init__(self, session): super().__init__(session, LyricModel) async def get_by_project_id(self, project_id: int) -> List[LyricModel]: """프로젝트 ID로 가사 목록 조회""" result = await self._session.execute( select(self._model) .where(self._model.project_id == project_id) .order_by(self._model.created_at.desc()) ) return list(result.scalars().all()) async def get_completed_lyrics( self, skip: int = 0, limit: int = 100 ) -> List[LyricModel]: """완료된 가사만 조회""" return await self.get_all( skip=skip, limit=limit, filters={"status": "completed"} ) async def update_status( self, task_id: str, status: str, result: Optional[str] = None ) -> Optional[LyricModel]: """가사 상태 업데이트""" lyric = await self.get_by_task_id(task_id) if lyric: lyric.status = status if result is not None: lyric.lyric_result = result return await self.update(lyric) return None ``` ### 3.3 Unit of Work Pattern ```python # app/core/uow.py from abc import ABC, abstractmethod from typing import TYPE_CHECKING if TYPE_CHECKING: from app.infrastructure.database.repositories.project_repository import ProjectRepository from app.infrastructure.database.repositories.lyric_repository import LyricRepository from app.infrastructure.database.repositories.song_repository import SongRepository from app.infrastructure.database.repositories.video_repository import VideoRepository class IUnitOfWork(ABC): """Unit of Work 인터페이스""" projects: "ProjectRepository" lyrics: "LyricRepository" songs: "SongRepository" videos: "VideoRepository" @abstractmethod async def __aenter__(self): pass @abstractmethod async def __aexit__(self, exc_type, exc_val, exc_tb): pass @abstractmethod async def commit(self): pass @abstractmethod async def rollback(self): pass # app/infrastructure/database/uow.py from sqlalchemy.ext.asyncio import AsyncSession from app.core.uow import IUnitOfWork from app.infrastructure.database.session import AsyncSessionLocal from app.infrastructure.database.repositories.project_repository import ProjectRepository from app.infrastructure.database.repositories.lyric_repository import LyricRepository from app.infrastructure.database.repositories.song_repository import SongRepository from app.infrastructure.database.repositories.video_repository import VideoRepository class UnitOfWork(IUnitOfWork): """Unit of Work 구현 - 트랜잭션 관리""" def __init__(self, session_factory=AsyncSessionLocal): self._session_factory = session_factory self._session: AsyncSession = None async def __aenter__(self): self._session = self._session_factory() # Repository 인스턴스 생성 self.projects = ProjectRepository(self._session) self.lyrics = LyricRepository(self._session) self.songs = SongRepository(self._session) self.videos = VideoRepository(self._session) return self async def __aexit__(self, exc_type, exc_val, exc_tb): if exc_type is not None: await self.rollback() await self._session.close() async def commit(self): await self._session.commit() async def rollback(self): await self._session.rollback() ``` ### 3.4 Service Layer 구현 ```python # app/application/services/lyric_service.py from typing import Optional from dataclasses import dataclass from app.core.uow import IUnitOfWork from app.core.exceptions import ( EntityNotFoundError, ExternalAPIError, ValidationError ) from app.application.dto.lyric_dto import ( CreateLyricDTO, LyricResponseDTO, LyricStatusDTO ) from app.infrastructure.external.chatgpt.client import IChatGPTClient from app.infrastructure.database.models.lyric_model import LyricModel from app.infrastructure.database.models.project_model import ProjectModel @dataclass class LyricService: """Lyric 비즈니스 로직 서비스""" uow: IUnitOfWork chatgpt_client: IChatGPTClient async def create_lyric(self, dto: CreateLyricDTO) -> LyricResponseDTO: """가사 생성 요청 처리 1. 프롬프트 생성 2. Project 저장 3. Lyric 저장 (processing) 4. task_id 반환 (백그라운드 처리는 별도) """ async with self.uow: # 프롬프트 생성 prompt = self.chatgpt_client.build_lyrics_prompt( customer_name=dto.customer_name, region=dto.region, detail_region_info=dto.detail_region_info, language=dto.language ) # Project 생성 project = ProjectModel( store_name=dto.customer_name, region=dto.region, task_id=dto.task_id, detail_region_info=dto.detail_region_info, language=dto.language ) project = await self.uow.projects.create(project) # Lyric 생성 (processing 상태) lyric = LyricModel( project_id=project.id, task_id=dto.task_id, status="processing", lyric_prompt=prompt, language=dto.language ) lyric = await self.uow.lyrics.create(lyric) await self.uow.commit() return LyricResponseDTO( success=True, task_id=dto.task_id, lyric=None, # 백그라운드에서 생성 language=dto.language, prompt=prompt # 백그라운드 태스크에 전달 ) async def process_lyric_generation( self, task_id: str, prompt: str, language: str ) -> None: """백그라운드에서 가사 실제 생성 이 메서드는 백그라운드 태스크에서 호출됨 """ try: # ChatGPT로 가사 생성 result = await self.chatgpt_client.generate(prompt) # 실패 패턴 검사 is_failure = self._check_failure_patterns(result) async with self.uow: status = "failed" if is_failure else "completed" await self.uow.lyrics.update_status( task_id=task_id, status=status, result=result ) await self.uow.commit() except Exception as e: async with self.uow: await self.uow.lyrics.update_status( task_id=task_id, status="failed", result=f"Error: {str(e)}" ) await self.uow.commit() raise async def get_lyric_status(self, task_id: str) -> LyricStatusDTO: """가사 생성 상태 조회""" async with self.uow: lyric = await self.uow.lyrics.get_by_task_id(task_id) if not lyric: raise EntityNotFoundError( f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다." ) status_messages = { "processing": "가사 생성 중입니다.", "completed": "가사 생성이 완료되었습니다.", "failed": "가사 생성에 실패했습니다.", } return LyricStatusDTO( task_id=lyric.task_id, status=lyric.status, message=status_messages.get(lyric.status, "알 수 없는 상태입니다.") ) async def get_lyric_detail(self, task_id: str) -> LyricResponseDTO: """가사 상세 조회""" async with self.uow: lyric = await self.uow.lyrics.get_by_task_id(task_id) if not lyric: raise EntityNotFoundError( f"task_id '{task_id}'에 해당하는 가사를 찾을 수 없습니다." ) return LyricResponseDTO( id=lyric.id, task_id=lyric.task_id, project_id=lyric.project_id, status=lyric.status, lyric_prompt=lyric.lyric_prompt, lyric_result=lyric.lyric_result, language=lyric.language, created_at=lyric.created_at ) def _check_failure_patterns(self, result: str) -> bool: """ChatGPT 응답에서 실패 패턴 검사""" failure_patterns = [ "ERROR:", "I'm sorry", "I cannot", "I can't", "I apologize", "I'm unable", "I am unable", "I'm not able", "I am not able", ] return any( pattern.lower() in result.lower() for pattern in failure_patterns ) ``` ### 3.5 DTO (Data Transfer Objects) ```python # app/application/dto/lyric_dto.py from dataclasses import dataclass from datetime import datetime from typing import Optional @dataclass class CreateLyricDTO: """가사 생성 요청 DTO""" task_id: str customer_name: str region: str detail_region_info: Optional[str] = None language: str = "Korean" @dataclass class LyricResponseDTO: """가사 응답 DTO""" success: bool = True task_id: Optional[str] = None lyric: Optional[str] = None language: str = "Korean" error_message: Optional[str] = None # 상세 조회 시 추가 필드 id: Optional[int] = None project_id: Optional[int] = None status: Optional[str] = None lyric_prompt: Optional[str] = None lyric_result: Optional[str] = None created_at: Optional[datetime] = None prompt: Optional[str] = None # 백그라운드 태스크용 @dataclass class LyricStatusDTO: """가사 상태 조회 DTO""" task_id: str status: str message: str ``` ### 3.6 Strategy Pattern for External APIs ```python # app/core/interfaces/external_api.py from abc import ABC, abstractmethod from typing import Optional class ILLMClient(ABC): """LLM 클라이언트 인터페이스""" @abstractmethod def build_lyrics_prompt( self, customer_name: str, region: str, detail_region_info: str, language: str ) -> str: pass @abstractmethod async def generate(self, prompt: str) -> str: pass class IMusicGeneratorClient(ABC): """음악 생성 클라이언트 인터페이스""" @abstractmethod async def generate( self, prompt: str, genre: str, callback_url: Optional[str] = None ) -> str: """음악 생성 요청, task_id 반환""" pass @abstractmethod async def get_status(self, task_id: str) -> dict: pass class IVideoGeneratorClient(ABC): """영상 생성 클라이언트 인터페이스""" @abstractmethod async def get_template(self, template_id: str) -> dict: pass @abstractmethod async def render(self, source: dict) -> dict: pass @abstractmethod async def get_render_status(self, render_id: str) -> dict: pass # app/infrastructure/external/chatgpt/client.py from openai import AsyncOpenAI from app.core.interfaces.external_api import ILLMClient from app.infrastructure.external.chatgpt.prompts import LYRICS_PROMPT_TEMPLATE class ChatGPTClient(ILLMClient): """ChatGPT 클라이언트 구현""" def __init__(self, api_key: str, model: str = "gpt-4o"): self._client = AsyncOpenAI(api_key=api_key) self._model = model def build_lyrics_prompt( self, customer_name: str, region: str, detail_region_info: str, language: str ) -> str: return LYRICS_PROMPT_TEMPLATE.format( customer_name=customer_name, region=region, detail_region_info=detail_region_info, language=language ) async def generate(self, prompt: str) -> str: completion = await self._client.chat.completions.create( model=self._model, messages=[{"role": "user", "content": prompt}] ) return completion.choices[0].message.content or "" ``` ### 3.7 Presentation Layer (Thin Router) ```python # app/presentation/api/v1/lyric_router.py from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status from app.presentation.schemas.lyric_schema import ( GenerateLyricRequest, GenerateLyricResponse, LyricStatusResponse, LyricDetailResponse ) from app.application.services.lyric_service import LyricService from app.application.dto.lyric_dto import CreateLyricDTO from app.core.exceptions import EntityNotFoundError from app.presentation.api.dependencies import get_lyric_service router = APIRouter(prefix="/lyric", tags=["lyric"]) @router.post( "/generate", response_model=GenerateLyricResponse, summary="가사 생성", description="고객 정보를 기반으로 ChatGPT를 이용하여 가사를 생성합니다." ) async def generate_lyric( request: GenerateLyricRequest, background_tasks: BackgroundTasks, service: LyricService = Depends(get_lyric_service) ) -> GenerateLyricResponse: """ 라우터는 HTTP 요청/응답만 처리 비즈니스 로직은 서비스에 위임 """ # DTO로 변환 dto = CreateLyricDTO( task_id=request.task_id, customer_name=request.customer_name, region=request.region, detail_region_info=request.detail_region_info, language=request.language ) # 서비스 호출 result = await service.create_lyric(dto) # 백그라운드 태스크 등록 background_tasks.add_task( service.process_lyric_generation, task_id=result.task_id, prompt=result.prompt, language=result.language ) # 응답 반환 return GenerateLyricResponse( success=result.success, task_id=result.task_id, lyric=result.lyric, language=result.language, error_message=result.error_message ) @router.get( "/status/{task_id}", response_model=LyricStatusResponse, summary="가사 생성 상태 조회" ) async def get_lyric_status( task_id: str, service: LyricService = Depends(get_lyric_service) ) -> LyricStatusResponse: try: result = await service.get_lyric_status(task_id) return LyricStatusResponse( task_id=result.task_id, status=result.status, message=result.message ) except EntityNotFoundError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(e) ) @router.get( "/{task_id}", response_model=LyricDetailResponse, summary="가사 상세 조회" ) async def get_lyric_detail( task_id: str, service: LyricService = Depends(get_lyric_service) ) -> LyricDetailResponse: try: result = await service.get_lyric_detail(task_id) return LyricDetailResponse.model_validate(result.__dict__) except EntityNotFoundError as e: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(e) ) ``` ### 3.8 Dependency Injection 설정 ```python # app/presentation/api/dependencies.py from functools import lru_cache from fastapi import Depends from app.core.config import get_settings, Settings from app.infrastructure.database.uow import UnitOfWork from app.infrastructure.external.chatgpt.client import ChatGPTClient from app.infrastructure.external.suno.client import SunoClient from app.infrastructure.external.creatomate.client import CreatomateClient from app.application.services.lyric_service import LyricService from app.application.services.song_service import SongService from app.application.services.video_service import VideoService @lru_cache() def get_settings() -> Settings: return Settings() def get_chatgpt_client( settings: Settings = Depends(get_settings) ) -> ChatGPTClient: return ChatGPTClient( api_key=settings.CHATGPT_API_KEY, model="gpt-4o" ) def get_suno_client( settings: Settings = Depends(get_settings) ) -> SunoClient: return SunoClient( api_key=settings.SUNO_API_KEY, callback_url=settings.SUNO_CALLBACK_URL ) def get_creatomate_client( settings: Settings = Depends(get_settings) ) -> CreatomateClient: return CreatomateClient( api_key=settings.CREATOMATE_API_KEY ) def get_unit_of_work() -> UnitOfWork: return UnitOfWork() def get_lyric_service( uow: UnitOfWork = Depends(get_unit_of_work), chatgpt: ChatGPTClient = Depends(get_chatgpt_client) ) -> LyricService: return LyricService(uow=uow, chatgpt_client=chatgpt) def get_song_service( uow: UnitOfWork = Depends(get_unit_of_work), suno: SunoClient = Depends(get_suno_client) ) -> SongService: return SongService(uow=uow, suno_client=suno) def get_video_service( uow: UnitOfWork = Depends(get_unit_of_work), creatomate: CreatomateClient = Depends(get_creatomate_client) ) -> VideoService: return VideoService(uow=uow, creatomate_client=creatomate) ``` ### 3.9 도메인 예외 정의 ```python # app/core/exceptions.py class DomainException(Exception): """도메인 예외 기본 클래스""" def __init__(self, message: str, code: str = None): self.message = message self.code = code super().__init__(message) class EntityNotFoundError(DomainException): """엔티티를 찾을 수 없음""" def __init__(self, message: str): super().__init__(message, code="ENTITY_NOT_FOUND") class ValidationError(DomainException): """유효성 검증 실패""" def __init__(self, message: str): super().__init__(message, code="VALIDATION_ERROR") class ExternalAPIError(DomainException): """외부 API 호출 실패""" def __init__(self, message: str, service: str = None): self.service = service super().__init__(message, code="EXTERNAL_API_ERROR") class BusinessRuleViolation(DomainException): """비즈니스 규칙 위반""" def __init__(self, message: str): super().__init__(message, code="BUSINESS_RULE_VIOLATION") ``` ### 3.10 전역 예외 핸들러 ```python # app/presentation/middleware/error_handler.py from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from app.core.exceptions import ( DomainException, EntityNotFoundError, ValidationError, ExternalAPIError ) def setup_exception_handlers(app: FastAPI): """전역 예외 핸들러 설정""" @app.exception_handler(EntityNotFoundError) async def entity_not_found_handler( request: Request, exc: EntityNotFoundError ) -> JSONResponse: return JSONResponse( status_code=404, content={ "success": False, "error": { "code": exc.code, "message": exc.message } } ) @app.exception_handler(ValidationError) async def validation_error_handler( request: Request, exc: ValidationError ) -> JSONResponse: return JSONResponse( status_code=400, content={ "success": False, "error": { "code": exc.code, "message": exc.message } } ) @app.exception_handler(ExternalAPIError) async def external_api_error_handler( request: Request, exc: ExternalAPIError ) -> JSONResponse: return JSONResponse( status_code=503, content={ "success": False, "error": { "code": exc.code, "message": exc.message, "service": exc.service } } ) @app.exception_handler(DomainException) async def domain_exception_handler( request: Request, exc: DomainException ) -> JSONResponse: return JSONResponse( status_code=500, content={ "success": False, "error": { "code": exc.code or "UNKNOWN_ERROR", "message": exc.message } } ) ``` --- ## 4. 모듈별 구현 예시 ### 4.1 Song 모듈 리팩토링 ```python # app/application/services/song_service.py from dataclasses import dataclass from app.core.uow import IUnitOfWork from app.core.interfaces.external_api import IMusicGeneratorClient from app.application.dto.song_dto import CreateSongDTO, SongResponseDTO @dataclass class SongService: """Song 비즈니스 로직 서비스""" uow: IUnitOfWork suno_client: IMusicGeneratorClient async def create_song(self, dto: CreateSongDTO) -> SongResponseDTO: """음악 생성 요청""" async with self.uow: # Lyric 조회 lyric = await self.uow.lyrics.get_by_task_id(dto.task_id) if not lyric: raise EntityNotFoundError( f"task_id '{dto.task_id}'에 해당하는 가사를 찾을 수 없습니다." ) # Song 생성 song = SongModel( project_id=lyric.project_id, lyric_id=lyric.id, task_id=dto.task_id, status="processing", song_prompt=lyric.lyric_result, language=lyric.language ) song = await self.uow.songs.create(song) # Suno API 호출 suno_task_id = await self.suno_client.generate( prompt=lyric.lyric_result, genre=dto.genre, callback_url=dto.callback_url ) # suno_task_id 업데이트 song.suno_task_id = suno_task_id await self.uow.commit() return SongResponseDTO( success=True, task_id=dto.task_id, suno_task_id=suno_task_id ) async def handle_callback( self, suno_task_id: str, audio_url: str, duration: float ) -> None: """Suno 콜백 처리""" async with self.uow: song = await self.uow.songs.get_by_suno_task_id(suno_task_id) if song: song.status = "completed" song.song_result_url = audio_url song.duration = duration await self.uow.commit() ``` ### 4.2 Video 모듈 리팩토링 ```python # app/application/services/video_service.py from dataclasses import dataclass from app.core.uow import IUnitOfWork from app.core.interfaces.external_api import IVideoGeneratorClient from app.application.dto.video_dto import CreateVideoDTO, VideoResponseDTO @dataclass class VideoService: """Video 비즈니스 로직 서비스""" uow: IUnitOfWork creatomate_client: IVideoGeneratorClient async def create_video(self, dto: CreateVideoDTO) -> VideoResponseDTO: """영상 생성 요청""" async with self.uow: # 관련 데이터 조회 project = await self.uow.projects.get_by_task_id(dto.task_id) lyric = await self.uow.lyrics.get_by_task_id(dto.task_id) song = await self.uow.songs.get_by_task_id(dto.task_id) images = await self.uow.images.get_by_task_id(dto.task_id) # 유효성 검사 self._validate_video_creation(project, lyric, song, images) # Video 생성 video = VideoModel( project_id=project.id, lyric_id=lyric.id, song_id=song.id, task_id=dto.task_id, status="processing" ) video = await self.uow.videos.create(video) await self.uow.commit() # 외부 API 호출 (트랜잭션 외부) try: render_id = await self._render_video( images=[img.img_url for img in images], lyrics=song.song_prompt, music_url=song.song_result_url, duration=song.duration, orientation=dto.orientation ) # render_id 업데이트 async with self.uow: video = await self.uow.videos.get_by_id(video.id) video.creatomate_render_id = render_id await self.uow.commit() return VideoResponseDTO( success=True, task_id=dto.task_id, creatomate_render_id=render_id ) except Exception as e: async with self.uow: video = await self.uow.videos.get_by_id(video.id) video.status = "failed" await self.uow.commit() raise async def _render_video( self, images: list[str], lyrics: str, music_url: str, duration: float, orientation: str ) -> str: """Creatomate로 영상 렌더링""" # 템플릿 조회 template_id = self._get_template_id(orientation) template = await self.creatomate_client.get_template(template_id) # 템플릿 수정 modified_template = self._prepare_template( template, images, lyrics, music_url, duration ) # 렌더링 요청 result = await self.creatomate_client.render(modified_template) return result[0]["id"] if isinstance(result, list) else result["id"] def _validate_video_creation(self, project, lyric, song, images): """영상 생성 유효성 검사""" if not project: raise EntityNotFoundError("Project를 찾을 수 없습니다.") if not lyric: raise EntityNotFoundError("Lyric을 찾을 수 없습니다.") if not song: raise EntityNotFoundError("Song을 찾을 수 없습니다.") if not song.song_result_url: raise ValidationError("음악 URL이 없습니다.") if not images: raise EntityNotFoundError("이미지를 찾을 수 없습니다.") ``` --- ## 5. 기대 효과 ### 5.1 코드 품질 향상 | 측면 | 현재 | 개선 후 | 기대 효과 | |------|------|---------|----------| | **테스트 용이성** | 라우터에서 직접 DB/API 호출 | Repository/Service 모킹 가능 | 단위 테스트 커버리지 80%+ | | **코드 재사용** | 로직 중복 | 서비스 레이어 공유 | 중복 코드 50% 감소 | | **유지보수** | 변경 시 여러 파일 수정 | 단일 책임 원칙 | 수정 범위 최소화 | | **확장성** | 새 기능 추가 어려움 | 인터페이스 기반 확장 | 새 LLM/API 추가 용이 | ### 5.2 아키텍처 개선 ``` 변경 전: Router → DB + External API (강결합) 변경 후: Router → Service → Repository → DB ↓ Interface → External API (약결합) ``` ### 5.3 테스트 가능성 ```python # 단위 테스트 예시 import pytest from unittest.mock import AsyncMock, MagicMock from app.application.services.lyric_service import LyricService from app.application.dto.lyric_dto import CreateLyricDTO @pytest.fixture def mock_uow(): uow = MagicMock() uow.__aenter__ = AsyncMock(return_value=uow) uow.__aexit__ = AsyncMock(return_value=None) uow.commit = AsyncMock() uow.lyrics = MagicMock() uow.projects = MagicMock() return uow @pytest.fixture def mock_chatgpt(): client = MagicMock() client.build_lyrics_prompt = MagicMock(return_value="test prompt") client.generate = AsyncMock(return_value="생성된 가사") return client @pytest.mark.asyncio async def test_create_lyric_success(mock_uow, mock_chatgpt): # Given service = LyricService(uow=mock_uow, chatgpt_client=mock_chatgpt) dto = CreateLyricDTO( task_id="test-task-id", customer_name="테스트 업체", region="서울" ) mock_uow.projects.create = AsyncMock(return_value=MagicMock(id=1)) mock_uow.lyrics.create = AsyncMock(return_value=MagicMock(id=1)) # When result = await service.create_lyric(dto) # Then assert result.success is True assert result.task_id == "test-task-id" mock_uow.commit.assert_called_once() @pytest.mark.asyncio async def test_get_lyric_status_not_found(mock_uow, mock_chatgpt): # Given service = LyricService(uow=mock_uow, chatgpt_client=mock_chatgpt) mock_uow.lyrics.get_by_task_id = AsyncMock(return_value=None) # When & Then with pytest.raises(EntityNotFoundError): await service.get_lyric_status("non-existent-id") ``` ### 5.4 개발 생산성 | 항목 | 기대 개선 | |------|----------| | 새 기능 개발 | 템플릿 기반으로 30% 단축 | | 버그 수정 | 단일 책임으로 원인 파악 용이 | | 코드 리뷰 | 계층별 리뷰로 효율성 향상 | | 온보딩 | 명확한 구조로 학습 시간 단축 | ### 5.5 운영 안정성 | 항목 | 현재 | 개선 후 | |------|------|---------| | 트랜잭션 관리 | 분산되어 일관성 부족 | UoW로 일관된 관리 | | 에러 처리 | HTTPException 혼재 | 도메인 예외로 통일 | | 로깅 | 각 함수에서 개별 | 서비스 레벨에서 일관 | | 모니터링 | 어려움 | 서비스 경계에서 명확한 메트릭 | --- ## 6. 마이그레이션 전략 ### 6.1 단계별 접근 ``` Phase 1: 기반 구축 (1주) ├── core/ 인터페이스 정의 ├── 도메인 예외 정의 └── Base Repository 구현 Phase 2: Lyric 모듈 리팩토링 (1주) ├── LyricRepository 구현 ├── LyricService 구현 ├── 라우터 슬림화 └── 테스트 작성 Phase 3: Song 모듈 리팩토링 (1주) ├── SongRepository 구현 ├── SongService 구현 ├── Suno 클라이언트 인터페이스화 └── 테스트 작성 Phase 4: Video 모듈 리팩토링 (1주) ├── VideoRepository 구현 ├── VideoService 구현 ├── Creatomate 클라이언트 인터페이스화 └── 테스트 작성 Phase 5: 정리 및 최적화 (1주) ├── 기존 코드 제거 ├── 문서화 ├── 성능 테스트 └── 리뷰 및 배포 ``` ### 6.2 점진적 마이그레이션 전략 기존 코드를 유지하면서 새 구조로 점진적 이전: ```python # 1단계: 새 서비스를 기존 라우터에서 호출 @router.post("/generate") async def generate_lyric( request: GenerateLyricRequest, background_tasks: BackgroundTasks, session: AsyncSession = Depends(get_session), # 새 서비스 주입 (optional) service: LyricService = Depends(get_lyric_service) ) -> GenerateLyricResponse: # 피처 플래그로 분기 if settings.USE_NEW_ARCHITECTURE: return await _generate_lyric_new(request, background_tasks, service) else: return await _generate_lyric_legacy(request, background_tasks, session) ``` ### 6.3 리스크 관리 | 리스크 | 완화 전략 | |--------|----------| | 기능 회귀 | 기존 테스트 유지, 새 테스트 추가 | | 성능 저하 | 벤치마크 테스트 | | 배포 실패 | 피처 플래그로 롤백 가능 | | 학습 곡선 | 문서화 및 페어 프로그래밍 | --- ## 결론 이 리팩토링을 통해: 1. **명확한 책임 분리**: 각 계층이 하나의 역할만 수행 2. **높은 테스트 커버리지**: 비즈니스 로직 단위 테스트 가능 3. **유연한 확장성**: 새로운 LLM/API 추가 시 인터페이스만 구현 4. **일관된 에러 처리**: 도메인 예외로 통일된 에러 응답 5. **트랜잭션 안정성**: Unit of Work로 데이터 일관성 보장 현재 프로젝트가 잘 동작하고 있다면, 점진적 마이그레이션을 통해 리스크를 최소화하면서 아키텍처를 개선할 수 있습니다. --- **작성일**: 2024-12-29 **버전**: 1.0