4) 프리셋 브릿지 설계
💡 쉽게 말하면: DISCOVER에서 마음에 드는 국악을 찾았는데, CREATE로 넘어가서 필터를 처음부터 다시 고르게 하면 사용자는 거기서 이탈합니다. 그래서 매칭된 트랙의 악기·감성·BPM을 주소창 파라미터로 만들어 넘깁니다. CREATE 페이지가 열릴 때 필터가 이미 채워져 있습니다.
6-1. 왜 URL인가
브릿지를 만드는 방법은 여러 가지입니다. 전역 상태, 세션 저장소, 서버 세션. 그런데 URL을 골랐습니다.
| 방식 | 문제 |
|---|---|
| 전역 상태(Context/Zustand) | 새로고침하면 사라짐, 링크 공유 불가 |
| sessionStorage | 탭 간 공유 불가, 브라우저 정책 영향 |
| 서버 세션 | 익명 사용자에게 세션을 발급해야 함 (MVP에 인증 없음) |
| 쿼리 파라미터 | 공유 가능 · 새로고침 견딤 · 서버 상태 0 · 북마크 가능 |
프리셋은 상태가 아니라 주소입니다. 그래서 URL이 맞습니다.
6-2. 변환 규칙
class CreatePresetInteractor(CreatePresetUseCase):
"""
DISCOVER 매칭 결과(악기·감성·BPM)를 CREATE 페이지
쿼리 파라미터 URL로 변환한다.
외부 IO 없음 — 순수 도메인 로직.
"""
BPM_MARGIN = 20
BPM_MIN_FLOOR = 60
BPM_MAX_CEIL = 200
def build_preset_url(self, command: CreatePresetCommand) -> CreatePresetResult:
bpm_min = max(self.BPM_MIN_FLOOR, command.bpm - self.BPM_MARGIN)
bpm_max = min(self.BPM_MAX_CEIL, command.bpm + self.BPM_MARGIN)
...
return CreatePresetResult(..., full_url=f"/create?{query_string}")
| 파라미터 | 값 | 규칙 |
|---|---|---|
instrument | 트랙의 악기 | 있을 때만 포함 |
emotion | emotion_tags[0] | sort_order = 0인 대표 감성 |
bpm_min | max(60, bpm − 20) | 하한 60 |
bpm_max | min(200, bpm + 20) | 상한 200 |
변환 예시
| 매칭 트랙 | 생성되는 URL |
|---|---|
| 가야금 · 서정 · BPM 100 | /create?instrument=가야금&emotion=서정&bpm_min=80&bpm_max=120 |
| 장구 · 신남 · BPM 70 | /create?instrument=장구&emotion=신남&bpm_min=60&bpm_max=90 |
| 대금 · 신비 · BPM 190 | /create?instrument=대금&emotion=신비&bpm_min=170&bpm_max=200 |
±20 마진을 두는 이유
정확히 같은 BPM만 걸면 결과가 0건이 되기 쉽습니다. 500건 규모에서 특정 악기 + 특정 감성 + 정확한 BPM 교집합은 대부분 비어 있습니다. ±20은 “체감상 같은 템포”의 범위이면서 결과가 남는 폭입니다.
하한 60·상한 200은 프론트엔드 FilterPanel의 BPM 슬라이더 범위와 일치시켰습니다. 범위를 벗어난 값을 넘기면 슬라이더가 표현할 수 없는 상태가 되기 때문입니다.
6-3. 왜 외부 IO가 없는가
CreatePresetInteractor는 DB도, API도, 캐시도 건드리지 않습니다. 입력을 받아 문자열을 만들 뿐입니다.
이 설계의 실질적 이득:
| 이득 | 설명 |
|---|---|
| 단위 테스트가 즉시 가능 | 목(mock) 없이 build_preset_url()만 호출하면 검증 끝 |
| 개발 순서를 앞당김 | 명세서 Phase 3에서 DB·Gemini 연동 이전에 완성 |
| 프론트 병렬 개발 | URL 규격이 먼저 확정되면 프론트가 백엔드 없이 CREATE를 만들 수 있음 |
명세서의 구현 순서에도 이 판단이 그대로 반영돼 있습니다.
Phase 3 — CREATE 프리셋 유스케이스
외부 IO 없음 — 순수 로직. 프론트 연동 전에 단독 테스트 완료
6-4. 서버 측 부착 지점
프리셋 URL은 DISCOVER Router에서 응답에 붙습니다.
result = await use_case.discover(command=command)
for track in result.tracks:
if track.emotion_tags:
preset = preset_use_case.build_preset_url(
CreatePresetCommand(
track_id=track.track_id,
instrument=track.instrument,
emotion=track.emotion_tags[0],
bpm=track.bpm,
)
)
track.preset_url = preset.full_url
emotion_tags가 비어 있으면 프리셋을 만들지 않습니다(preset_url은None). 프론트엔드CreateBridgeButton은 이 값이 없으면 버튼을 렌더하지 않습니다. 감성 태그가 없는 트랙에는 브릿지가 걸리지 않는다는 뜻입니다.
6-5. 클라이언트 측 수신
프론트엔드는 useCreatePreset 훅으로 쿼리를 읽습니다.
// lib/constants.ts — 서버와 합의된 파라미터 키
export const PRESET_PARAMS = {
instrument: 'instrument',
emotion: 'emotion',
bpmMin: 'bpm_min',
bpmMax: 'bpm_max',
} as const;
// app/(public)/create/page.tsx
useEffect(() => {
if (hasPreset) {
setFilters({
instruments: preset.instrument ? [preset.instrument] : [],
jangdans: [],
emotions: preset.emotion ? [preset.emotion] : [],
bpmMin: preset.bpmMin || 60,
bpmMax: preset.bpmMax || 200,
loopUnit: null,
license: 'all',
});
setShowPresetBanner(true);
}
}, [hasPreset, preset.instrument, preset.emotion, preset.bpmMin, preset.bpmMax]);
PresetBanner가 상단에 떠서 “DISCOVER에서 넘어온 필터”임을 알려 줍니다. 사용자가 필터를 직접 만진 게 아닌데 값이 채워져 있으면 혼란스럽기 때문에, 출처를 명시합니다.
6-6. 계약을 지키는 방법
프리셋 URL은 백엔드와 프론트엔드가 문자열로 맺은 계약입니다. 타입 시스템이 지켜 주지 않습니다.
| 위험 | 방어 |
|---|---|
| 파라미터 키 오타 | 양쪽 모두 상수로 선언 (PRESET_PARAMS / Interactor의 params 딕셔너리) |
| 감성 값 불일치 | 프론트 EMOTIONS 상수와 도메인 EmotionTag enum이 같은 6종 |
| 악기 값 불일치 | 프론트 INSTRUMENTS 상수와 도메인 Instrument enum 대조 |
| BPM 범위 이탈 | Interactor에서 max/min으로 클램프, 프론트에서 \|\| 60 / \|\| 200 폴백 |
잔여 과제: 파라미터 키·감성·악기 목록의 일치는 현재 문서와 코드 리뷰로만 보장됩니다. 계약 테스트(백엔드가 만든 URL을 프론트 파서에 넣어 왕복 검증)를 추가하면 회귀를 자동으로 잡을 수 있습니다. v1.1 과제입니다.