2) AI·백엔드 기술 구조

💡 쉽게 말하면: 코드가 도메인마다 똑같은 파일 세트로 반복되는 “프랙탈” 구조입니다. 새 기능을 붙일 때 참조할 패턴이 항상 하나로 정해져 있어, 사람도 AI도 코드를 다루기 쉽습니다. 그리고 핵심 로직(도메인)은 FastAPI도 SQLAlchemy도 모릅니다 — 프레임워크를 갈아 끼워도 안 바뀝니다.

8-1. 아키텍처 개요

flowchart TB
  subgraph IN["Inbound Adapter"]
    R["Router<br/>track_discover_router.py"]
    S["Schema (Pydantic)"]
    M1["Mapper (DTO→Schema)"]
  end
  subgraph APP["Application"]
    IP["Input Port<br/>TrackDiscoverUseCase"]
    IT["Interactor<br/>TrackDiscoverInteractor"]
    OP["Output Port<br/>TrackRepository · GeminiPort · EmbeddingPort"]
    D["DTO"]
  end
  subgraph DOM["Domain"]
    E["Entity<br/>GugakTrack"]
    V["Value Object<br/>Jangdan · EmotionTag · PublicLicense"]
  end
  subgraph OUT["Outbound Adapter"]
    PG["PgRepository"]
    GM["GeminiAdapter"]
    ORM["ORM + OrmMapper"]
  end
  DEP["dependencies/<br/>Composition Root"]

  R --> IP --> IT --> OP
  OP -.구현.-> PG
  OP -.구현.-> GM
  PG --> ORM --> E
  IT --> D
  IT --> E
  E --> V
  DEP -. 조립 .-> IT
  DEP -. 조립 .-> PG

의존 방향은 항상 바깥 → 안입니다. 도메인은 아무것도 참조하지 않습니다.

Router → UseCase(Port) → Interactor → Repository(Port) → PgRepository

8-2. 프랙탈 네이밍

파일 이름이 {domain}_{character}_{layer} 규칙을 따릅니다.

레이어 DISCOVER 도메인 CREATE 도메인
Input Port track_discover_use_case.py sample_create_use_case.py
Interactor track_discover_interactor.py sample_create_interactor.py
DTO track_discover_dto.py sample_create_dto.py
Router track_discover_router.py sample_create_router.py
Schema track_discover_schema.py (discover 스키마 공유)
Inbound Mapper track_discover_mapper.py sample_create_mapper.py
PG Repository track_discover_pg_repository.py sample_create_pg_repository.py
Provider track_discover_provider.py sample_create_provider.py

새 도메인을 추가할 때 어떤 파일을 몇 개 만들어야 하는지 고민할 필요가 없습니다. 이 규칙성은 AI에게 코드 생성을 맡길 때 특히 값이 큽니다 — 참조 예시가 항상 존재합니다.

8-3. 레이어별 금지 규칙

명세서 0장에 못 박아 둔 규칙입니다. 이게 지켜지지 않으면 구조가 무너집니다.

레이어 금지
Domain FastAPI · SQLAlchemy · 외부 API 패키지 import 전면 금지
Application (Interactor) ORM import 금지, HTTPException 금지, HTTP 상태코드 처리 금지
Inbound Router PgRepository 직접 import 금지, 직접 DB 쿼리 금지
Outbound Adapter 응답 형식(스키마) 결정 금지
전역 Dict[str, Any] 최소화, sync 함수 금지, MVP에서 v1.1 기능 구현 금지

에러는 이렇게 흐릅니다.

DomainException (infrastructure/exceptions.py)
    ↓ Interactor는 그대로 던짐 — HTTP를 모름
Router에서만 변환
    GeminiApiException  → 503 "AI 서비스 일시 오류"
    TrackNotFoundException → 404 "트랙을 찾을 수 없습니다"
    그 외 → 500

8-4. 도메인 레이어 — 계산은 여기 있다

@dataclass
class GugakTrack:
    id: UUID
    title: str
    artist: str
    instrument: Instrument
    jangdan: Jangdan
    emotion_tags: list[EmotionTag]   # track_emotion_tags에서 sort_order 순으로 주입
    bpm: int
    cue_points: list[CuePoint]       # 의도적 비정규화 JSONB
    audio_url: str
    public_license: PublicLicense
    description_ko: str
    description_en: str

    @property
    def loop_unit_beats(self) -> int:
        return self.jangdan.loop_unit_beats      # VO에서 계산, DB 참조 없음

    @property
    def is_commercial(self) -> bool:
        return LICENSE_IS_COMMERCIAL[self.public_license]

    @property
    def primary_emotion(self) -> EmotionTag | None:
        return self.emotion_tags[0] if self.emotion_tags else None

이 세 프로퍼티는 DB에 없는 값입니다. 데이터베이스를 바꿔도, 웹 프레임워크를 바꿔도 “중모리는 12박이다”와 “KOGL 제1유형은 상업 이용 가능하다”는 여기서 변하지 않습니다.

Value Object는 frozen=True dataclass로 불변입니다.

@dataclass(frozen=True)
class Jangdan:
    type: JangdanType

    @property
    def loop_unit_beats(self) -> int:
        return JANGDAN_LOOP_UNITS[self.type]

8-5. 포트 — 무엇을 요구하는가만 적는다

class GeminiPort(ABC):
    @abstractmethod
    async def analyze_emotion(self, user_input: str, lang: str) -> EmotionAnalysisResult: ...
    @abstractmethod
    async def explain_match(self, user_input: str, tracks: list, lang: str) -> list[MatchExplanation]: ...
    @abstractmethod
    async def extract_cue_points(self, audio_features: dict) -> "CueExtractionResult": ...   # [v1.1]

class EmbeddingPort(ABC):
    @abstractmethod
    async def embed_text(self, text: str) -> list[float]: ...            # 1536차원
    @abstractmethod
    async def find_similar_tracks(self, query_vector: list[float],
                                  top_k: int = 3, filters: dict | None = None) -> list[UUID]: ...

포트 시그니처에 joinedload, session, HTTPException 같은 단어가 하나도 없습니다. 그래서 TrackDiscoverPgRepository를 인메모리 구현으로 갈아 끼워도 Interactor가 바뀌지 않습니다.

명세서 v3.1에서 AI 엔진을 Claude API → Gemini API로 통째로 교체했는데, Interactor 코드는 손대지 않았습니다. 어댑터 하나만 바꿨습니다. 포트를 둔 값이 실제로 회수된 사례입니다. (다만 Interactor의 생성자 파라미터명이 claude로 남아 있어 정리가 필요합니다 — 아래 잔여 과제 참조.)

8-6. Composition Root — 조립은 한 곳에서만

의존성을 실제로 이어 붙이는 곳은 dependencies/ 디렉터리뿐입니다.

# dependencies/track_discover_provider.py
async def get_track_discover_use_case(
    session: AsyncSession = Depends(get_session),
) -> TrackDiscoverUseCase:
    repo = TrackDiscoverPgRepository(session)
    return TrackDiscoverInteractor(
        track_repo=repo,
        claude=GeminiAdapter(),
        embedding=repo,
        redis=get_redis(),
    )

Router는 Depends(get_track_discover_use_case)로 인터페이스만 받습니다. 어떤 구현이 들어오는지 모릅니다. 테스트에서 이 Provider만 오버라이드하면 전체가 목으로 바뀝니다.

TrackDiscoverPgRepository가 TrackRepository와 EmbeddingPort를 동시에 구현하는 것도 눈여겨볼 점입니다. 둘 다 같은 세션·같은 테이블을 쓰기 때문에 하나의 클래스로 묶었고, 포트는 여전히 둘로 나뉘어 있어 나중에 임베딩만 외부 벡터 DB로 떼어낼 수 있습니다.

8-7. 인프라 계층

파일 책임
infrastructure/settings.py 환경변수 SSOT (pydantic-settings)
infrastructure/database.py AsyncSession 팩토리, pool_pre_ping=True, dispose_engine()
infrastructure/redis_client.py Redis 캐시 클라이언트 (없어도 서비스 동작)
infrastructure/exceptions.py DomainException 계층 — HTTP 무관
infrastructure/secret_manager.py keymaker — Gemini 키 부트스트랩·갱신

main.py의 lifespan이 시작 순서를 고정합니다.

@asynccontextmanager
async def lifespan(app: FastAPI):
    keymaker.bootstrap()              # 키 로드
    await create_soundbridge_tables() # 테이블 생성 + 장단 시드
    yield
    await dispose_engine()            # 커넥션 정리

DB 스킴은 postgresql+psycopg:// (psycopg 3), NeonDB 사용 시 sslmode=require가 필요합니다.

8-8. 컨테이너 구성

services:
  db:        pgvector/pgvector:pg16    # healthcheck: pg_isready
  redis:     redis:7-alpine            # healthcheck: redis-cli ping
  api:       ./backend/Dockerfile      :8000   depends_on: db·redis (healthy)
  frontend:  ./frontend/Dockerfile     :3000   depends_on: api
             build arg NEXT_PUBLIC_API_URL=http://localhost:8000

db는 로컬 개발용이고, 실제로는 .env의 DATABASE_URL(NeonDB)을 씁니다 — docker-compose.yaml에 그 전환 방법이 주석으로 남아 있습니다.

8-9. 잔여 과제

문서를 정직하게 유지하기 위해, 현재 코드에서 확인된 미완 지점을 적어 둡니다.

항목 내용 우선순위
임베딩 차원 불일치 스키마 Vector(1536) vs embed_pipeline.py VECTOR_DIM = 768 높음 — 적재 차단 요인
embed_text() 미구현 TrackDiscoverPgRepository.embed_text()가 TODO 상태 높음
캐시 직렬화 json.dumps(result.__dict__) → dataclasses.asdict()로 교체 필요 중간
결과 조립 _build_result() · _to_track_result()가 TODO 주석 중간
파라미터명 Interactor 생성자의 claude → gemini로 정리 낮음
감성 enum 정합 수집 규칙이 EmotionTag enum 밖 값 생성 (공공데이터 활용 §3-7) 중간
explain_match 병렬화 트랙 3건 순차 호출 → asyncio.gather 낮음
디렉터리 중복 backend/ 하위와 backend/soundbridge/ 하위에 같은 트리가 이중으로 존재 중간 — 패키지 루트 정리 필요

SoundBridge — 당신의 음악 언어로 국악을 만나세요. 본 사이트는 개발 문서 자료입니다.

This site uses Just the Docs, a documentation theme for Jekyll.