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/ 하위에 같은 트리가 이중으로 존재 | 중간 — 패키지 루트 정리 필요 |