3) 정규화 설계 v4.0
💡 쉽게 말하면: 처음에는 한 트랙의 감성 태그 여러 개를 한 칸에 배열로 몰아넣고, 장단마다 정해진 루프 길이도 트랙마다 따로 적어 두었습니다. 그러면 감성으로 검색하기가 어렵고, 장단 정의가 바뀌면 500개 행을 전부 고쳐야 합니다. v4.0에서 이 둘을 각각 별도의 표로 분리했습니다.
5-1. v3.1에서 무엇이 잘못돼 있었나
| 컬럼 | v3.1 형태 | 위반 | 실질적 문제 |
|---|---|---|---|
gugak_tracks.emotion_tags | ARRAY(String) | 1NF — 한 칸에 여러 값 | 감성 필터가 ARRAY 연산자를 써야 하고 B-tree 인덱스를 못 탐 |
gugak_tracks.loop_unit_beats | Integer | 3NF — 장단에 종속된 값을 트랙에 중복 저장 | 장단 정의 변경 시 전 행 갱신, 불일치 데이터 발생 가능 |
match_logs.similarity_score | String(20) | 타입 불일치 | 점수 범위 질의·정렬 불가, 저장 시 매번 str() 변환 |
5-2. 1NF — 감성 태그를 행으로 분리
변경 전
gugak_tracks
id | title | emotion_tags
… | … | {서정, 차분} ← 한 칸에 배열
변경 후
gugak_tracks track_emotion_tags
id | title id | track_id | emotion_tag | sort_order
… | … … | … | 서정 | 0
… | … | 차분 | 1
sort_order가 있어서 순서가 보존됩니다. 이게 중요한 이유는 sort_order = 0인 태그가 GugakTrack.primary_emotion이 되고, 프리셋 URL의 emotion 파라미터로 그대로 쓰이기 때문입니다.
인덱스는 두 개를 겁니다.
op.create_index("ix_track_emotion_tags_track_id", "track_emotion_tags", ["track_id"])
op.create_index("ix_track_emotion_tags_tag", "track_emotion_tags", ["emotion_tag"])
필터 쿼리의 변화
# v3.1 — ARRAY 연산자, 일반 인덱스 사용 불가
query.where(GugakTrackOrm.emotion_tags.overlap(emotions))
# v4.0 — JOIN, ix_track_emotion_tags_tag(B-tree) 사용
query.join(TrackEmotionTagOrm, TrackEmotionTagOrm.track_id == GugakTrackOrm.id) \
.where(TrackEmotionTagOrm.emotion_tag.in_(emotions)) \
.distinct()
5-3. 3NF — 장단을 마스터 테이블로 분리
루프 단위(박)는 장단에 종속된 값입니다. 중모리는 언제나 12박입니다. 그런데 v3.1은 이 값을 트랙마다 따로 저장했습니다. 이행 종속(transitive dependency), 즉 3NF 위반입니다.
트랙 → 장단 → 루프 단위 (장단을 거쳐야만 루프 단위가 정해짐)
변경 후 구조
# jangdan (마스터)
name VARCHAR(50) PK # "중모리"
loop_unit_beats INTEGER # 12
# gugak_tracks
jangdan_name VARCHAR(50) FK → jangdan.name
# loop_unit_beats 컬럼 제거
시드 데이터 6건은 db_init / 마이그레이션에서 삽입합니다.
INSERT INTO jangdan (name, loop_unit_beats) VALUES
('자진모리', 12), ('중모리', 12), ('굿거리', 12),
('휘모리', 4), ('세마치', 6), ('엇모리', 10);
도메인은 DB를 모른다
중요한 점은, 도메인 레이어에는 이 테이블이 존재하지 않는다는 것입니다.
# domain/value_objects/jangdan_vo.py
JANGDAN_LOOP_UNITS: dict[JangdanType, int] = {
JangdanType.JAJINMORI: 12, JangdanType.JUNGMORI: 12, JangdanType.GUTGEORI: 12,
JangdanType.HWIMORI: 4, JangdanType.SEMACHI: 6, JangdanType.EOTMORI: 10,
}
@dataclass(frozen=True)
class Jangdan:
type: JangdanType
@property
def loop_unit_beats(self) -> int:
return JANGDAN_LOOP_UNITS[self.type]
GugakTrack.loop_unit_beats는 이 VO를 통해 계산됩니다. DB를 조회하지 않습니다. jangdan 테이블은 이 VO의 영속화일 뿐이고, 조회 성능(필터 JOIN)을 위해 존재합니다.
일관성 규약:
jangdan_vo.py의JANGDAN_LOOP_UNITS,jangdan테이블 시드, 프론트엔드constants.ts의JANGDAN_LOOP_MAP— 이 세 곳의 값이 항상 같아야 합니다. 프론트엔드 상수 파일에는 이 규약이 주석으로 명시돼 있습니다.
5-4. 타입 수정 — similarity_score
# v3.1
sa.Column("similarity_score", sa.String(20), nullable=False)
# → 저장 시 str(score), 조회 시 float(...) 변환 필요, 범위 질의 불가
# v4.0
sa.Column("similarity_score", sa.Float(), nullable=False)
# → Repository에서 변환 없이 직접 저장
5-5. 의도적 비정규화 — cue_points
모든 것을 정규화하지는 않았습니다. gugak_tracks.cue_points는 JSONB로 남겨 두었습니다.
[{"time_sec": 12.4, "label": "A", "emotion": "고조"}]
| 정규화하지 않은 이유 | 설명 |
|---|---|
| 단독 조회가 없다 | CUE 포인트만 검색하거나 집계하는 유스케이스가 없음 |
| 전달 전용이다 | 프론트엔드 WaveSurfer.js에 통째로 넘기는 데이터 |
| 구조가 유동적이다 | v1.1 자동 추출에서 필드가 추가될 가능성이 높음 |
명세서에도 이 판단이 규칙으로 못 박혀 있습니다.
cue_pointsJSONB는 의도적 비정규화 — WaveSurfer.js 전달 전용, 단독 쿼리 없음
정규화는 목적이 아니라 수단입니다. 쿼리되지 않는 데이터는 정규화할 이유가 없습니다.
5-6. ORM 파일 분리 — 테이블 1개당 파일 1개
정규화로 테이블이 늘어난 만큼, ORM 파일도 프랙탈 네이밍 규칙에 따라 쪼갰습니다.
| 파일 | 담당 테이블 | 비고 |
|---|---|---|
track_orm.py | gugak_tracks | v4.0 변경 — 컬럼 2개 제거, relationship 2개 추가 |
track_emotion_tag_orm.py | track_emotion_tags | v4.0 신규 |
jangdan_orm.py | jangdan | v4.0 신규 |
match_log_orm.py | match_logs | v4.0 변경 — Float 타입 수정 |
GugakTrackOrm에 추가된 relationship 두 개:
emotion_tag_rows = relationship("TrackEmotionTagOrm", order_by="TrackEmotionTagOrm.sort_order")
jangdan_rel = relationship("JangdanOrm")
5-7. N+1 방어 — joinedload를 어디서 결정하는가
정규화하면 조회할 때 JOIN이 필요해집니다. 잘못 다루면 트랙 3건 조회에 쿼리 7번이 나갑니다(N+1).
해결은 joinedload인데, 어디서 결정하느냐가 아키텍처 문제입니다.
def _track_load_options() -> list:
"""
공통 joinedload 옵션.
joinedload 전략은 Repository 내부에서만 결정한다 (포트 인터페이스 노출 금지).
"""
return [
joinedload(GugakTrackOrm.emotion_tag_rows),
joinedload(GugakTrackOrm.jangdan_rel),
]
TrackRepository 포트의 시그니처에는 joinedload나 eager 같은 단어가 하나도 없습니다. Application 레이어는 “트랙을 가져와 달라”고만 말하고, 어떻게 가져올지는 어댑터가 정합니다. 이것이 Ports & Adapters의 핵심이고, 나중에 인메모리 저장소로 갈아 끼워도 Interactor가 바뀌지 않는 이유입니다.
joinedload를 쓰면 JOIN 결과로 행이 중복되므로 result.unique()가 필수입니다.
rows = result.unique().scalars().all()
5-8. 마이그레이션 검증 체크리스트
| 항목 | 확인 |
|---|---|
gugak_tracks에 emotion_tags ARRAY 컬럼 없음 | ✅ |
gugak_tracks에 loop_unit_beats 컬럼 없음 | ✅ |
track_emotion_tags 테이블 + sort_order 컬럼 존재 | ✅ |
jangdan 마스터 테이블 + 시드 6건 | ✅ |
match_logs.similarity_score Float | ✅ |
모든 트랙 조회에 joinedload 2종 포함 | ✅ |
result.unique() 호출 | ✅ |
upgrade / downgrade 양방향 테스트 | 명세서 체크리스트 항목 |
기존 emotion_tags 배열 → 행 마이그레이션 검증 | 명세서 체크리스트 항목 |