1) 지능형 매칭 파이프라인
💡 쉽게 말하면: 사용자가 검색 버튼을 누른 순간부터 화면에 국악 3곡이 뜰 때까지, 요청이 어떤 계층을 어떤 순서로 지나가는지를 한 줄기로 그린 문서입니다. 핵심은 “AI가 하는 일”과 “계산이 하는 일”이 명확히 나뉘어 있다는 점입니다.
7-1. 요청 하나의 전 구간
sequenceDiagram
participant U as 브라우저
participant R as Router<br/>(Inbound Adapter)
participant I as TrackDiscoverInteractor<br/>(Application)
participant G as GeminiAdapter<br/>(Outbound)
participant P as PgRepository<br/>(Outbound)
participant C as CreatePresetInteractor
U->>R: POST /api/soundbridge/discover<br/>{input, lang}
R->>I: DiscoverCommand
I->>I: Redis 캐시 조회
I->>G: analyze_emotion(input, lang)
G-->>I: EmotionAnalysisResult
I->>P: embed_text(embed_text)
P-->>I: float[1536]
I->>P: find_similar_tracks(vec, top_k=3)
P-->>I: UUID[3]
I->>P: find_by_ids(ids) + joinedload
P-->>I: GugakTrack[3]
I->>G: explain_match(input, tracks, lang)
G-->>I: MatchExplanation[3]
I->>P: save_match_log() × 3
I->>I: Redis SETEX 3600
I-->>R: DiscoverResult
R->>C: build_preset_url() × 3
C-->>R: preset_url
R-->>U: DiscoverResponseSchema
7-2. 계층별 책임
| 계층 | 파일 | 하는 일 | 하지 않는 일 |
|---|---|---|---|
| Inbound Router | track_discover_router.py | HTTP 파싱, 예외 → 상태코드 변환, 프리셋 부착 | DB 쿼리, 비즈니스 판단 |
| Application Interactor | track_discover_interactor.py | 순서 조율, 캐시, 결과 조립 | ORM 접근, HTTP 상태코드 |
| Outbound Gemini | gemini_adapter.py | 프롬프트 조립, JSON 파싱 | 어떤 트랙을 고를지 결정 |
| Outbound PG | track_discover_pg_repository.py | 쿼리, joinedload, 벡터 검색 | 응답 형식 결정 |
| Domain | track_entity.py, *_vo.py | 불변식, 파생 값(loop_unit_beats, is_commercial) | 외부 패키지 import |
7-3. AI와 계산의 경계
이 파이프라인의 설계 핵심은 LLM이 사실을 만들지 않는다는 것입니다.
| 산출물 | 만드는 주체 | 성격 |
|---|---|---|
| 감성 키워드 · 임베딩용 텍스트 | Gemini | 해석 (비결정론적, 허용) |
| 어떤 트랙이 매칭되는가 | pgvector | 계산 (결정론적) |
| 유사도 점수 | pgvector | 계산 (결정론적) |
| BPM · 장단 · 루프 단위 · 라이선스 | PostgreSQL | 사실 (DB 값) |
| 왜 닮았는지 설명 | Gemini | 서술 (이미 확정된 결과에 대한 설명) |
Gemini가 할 수 있는 것 : 번역 · 해석 · 설명
Gemini가 할 수 없는 것 : 선택 · 계산 · 사실 주장
만약 Gemini에게 “이 500곡 중 뭐가 어울릴까?”를 직접 물었다면, 존재하지 않는 곡명을 만들어 내거나 매번 다른 답을 낼 수 있습니다. 매칭을 벡터 연산으로 고정했기 때문에 같은 입력에는 같은 트랙이 나옵니다.
7-4. 응답 스키마
class TrackResponseSchema(BaseModel):
id: UUID
title: str
artist: str
instrument: str
jangdan: str
emotion_tags: list[str]
bpm: int
loop_unit_beats: int # 도메인 프로퍼티에서 계산
cue_points: list[CuePointSchema]
audio_url: str
license_type: str # KOGL_1 | KOGL_2
license_label_en: str # CC-BY (Commercial OK) | CC-BY-NC …
description_ko: str
description_en: str
score: float | None = None
explanation: str | None = None
preset_url: str | None = None # 브릿지
loop_unit_beats는 DB 컬럼이 아니라 도메인 프로퍼티입니다(3NF 분리, 정규화 설계). license_label_en도 컬럼이 아니라 LICENSE_EN_LABEL 딕셔너리에서 나옵니다. DB에 없는 값이 응답에 있다는 것 자체가 도메인 레이어가 살아 있다는 증거입니다.
7-5. 엔드포인트 전체 목록
[DISCOVER] /api/soundbridge/discover [MVP]
POST / 감성 매칭 검색 (preset_url 포함)
GET /popular 인기 트랙 목록
GET /{track_id} 트랙 상세 [v1.2]
[CREATE] /api/soundbridge/create [MVP]
GET /samples 샘플 목록 (필터 쿼리)
POST /download-log 다운로드 이력 저장 [v1.1]
GET /download-logs 내 다운로드 이력 [v1.1]
[AUTH] /api/soundbridge/auth [v1.1]
POST /register · /login · /login/google
POST /verify-email · /resend-verification
POST /forgot-password · /reset-password
GET /me · PATCH /me · DELETE /me
[SAVED] /api/soundbridge/saved [v1.1]
GET /tracks · POST /{track_id} · DELETE /{track_id}
[CUE] /api/soundbridge/cue [v1.1]
POST /extract/{track_id} CUE 마커 추출 (관리자용)
[EVENTS] /api/soundbridge/events [v1.1]
GET /performances KOPIS 공연 목록
GET /places 관광 체험 장소
/health는 prefix 밖에 별도로 있습니다 (main.py).
7-6. 구현 순서 (Phase)
명세서 13장의 순서를 그대로 따랐습니다. 외부 IO가 없는 것부터 만듭니다.
Phase 1 — 기반
디렉터리, 환경변수, DB·Redis 연결, 예외, ORM Base, db_init
jangdan_orm · track_emotion_tag_orm 생성 + 장단 시드 6건
Phase 2 — Domain + App Core
Value Objects, Track Entity, Ports, DTOs
(프레임워크 없이 단위 테스트 가능)
Phase 3 — CREATE 프리셋 유스케이스
외부 IO 없음 — 순수 로직. 프론트 연동 전에 단독 테스트 완료
Phase 4 — DISCOVER 핵심
Interactor · Router(preset_url 포함) · PgRepository · GeminiAdapter
→ POST /discover + preset_url 포함 응답 확인
Phase 5 — CREATE 샘플 필터
emotions 필터 JOIN, loop_unit 필터 jangdan JOIN
→ GET /samples 동작 확인 → 프론트 FilterPanel 연동
v1.1 이후는 인증(Phase 6), 저장·다운로드 이력(Phase 7), 외부 API 연동(Phase 8), 최적화(Phase 9 — HNSW 인덱스·Redis 전면 적용)로 이어집니다.
7-7. 성능 특성
| 구간 | 지연 | 비고 |
|---|---|---|
| Redis 캐시 HIT | ~수 ms | Gemini 호출 4회를 통째로 회피 |
Gemini analyze_emotion | 1회 호출 | 파이프라인 최대 지연 구간 |
Gemini explain_match | 3회 순차 호출 | 트랙별 개별 호출 |
| pgvector 검색 | 500건 순차 스캔 | 현 규모에서 체감 지연 없음 |
find_by_ids | 1회 쿼리 | joinedload로 N+1 제거 |
개선 여지:
explain_match가 트랙마다 순차 호출이라 캐시 MISS 시 지연이 3배로 붙습니다.asyncio.gather로 병렬화하면 체감 응답이 크게 줄어듭니다. 데이터가 수천 건으로 늘면 pgvector에 HNSW 인덱스도 필요합니다(명세서 Phase 9).