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_points JSONB는 의도적 비정규화 — 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 배열 → 행 마이그레이션 검증 명세서 체크리스트 항목

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

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