o2o-castad-backend/docs/analysis/refactoring.md

1489 lines
46 KiB
Markdown

# 디자인 패턴 기반 리팩토링 제안서
## 목차
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