# 디자인 패턴 기반 리팩토링 제안서

## 목차

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-5-mini"):
        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-5-mini"
    )

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
