BrainStream
추천 다양성·메타데이터 정확성·1인 운영 가능한 단순성을 동시에 만족시키도록 설계한 개인 음악 수집·정규화 파이프라인
기술 스택
개요
ListenBrainz에 쌓이는 본인의 청취 패턴에서 나오는 추천을, 셀프 호스팅 스트리밍 서버(Navidrome) 라이브러리로 자동으로 흘려보내는 1인 음악 수집·정규화 파이프라인입니다. Python(FastAPI) + 단일 SQLite + Docker Compose 위에 BrainStream과 Navidrome을 함께 띄우고, 추천 → 정규화 → 수집 → 라이브러리 반영 → 외부 클라이언트 응답을 한 컨테이너 스택 안에서 닫습니다.
상용 SaaS 없이 같은 루프를 굴리려면 — 추천이 echo chamber에 갇히지 않을 것, 메타데이터가 라이브러리 전체에서 일관될 것, 다단계 파이프라인이 임의 단계에서 죽어도 안전하게 이어지면서 1인이 운영 가능한 수준의 단순성을 유지할 것 — 세 가지를 동시에 만족시켜야 했고, 그 위에 설계 결정을 쌓아 올렸습니다.
기술 스택
- 런타임: Python 3, FastAPI (Web UI · REST · SSE), 단일 SQLite 상태 저장소, mutagen 태깅
- 외부 데이터: ListenBrainz CF + LB Radio, MusicBrainz, Cover Art Archive, iTunes Search, Deezer
- 패키징: Docker Compose (
prod/local분리), GHCR 이미지 배포, Navidrome 동거
역할
1인 개인 사이드 프로젝트. 파이프라인 설계, 외부 API 통합, 메타데이터·태깅 로직, FastAPI Web UI, Subsonic 프록시, Docker Compose 패키징과 GHCR 릴리즈 워크플로까지 직접 만들고 운영합니다.
주요 기여
-
추천 풀 다양화 + 외부 API의 모델 갱신을 명시적으로 감지하는 사전 확인.
- ListenBrainz 협업 필터링(80%)과 본인 탑 아티스트 시드 라디오(20%)를 함께 폴링해 같은 클러스터 안에서만 추천이 채워지는 echo chamber를 막았습니다. 더 중요한 건 — 추천 모델이 주기적으로 재학습되면서 추천 순서 앞쪽이 통째로 바뀌는데, 단순히 옛 페이지 위치를 이어받아 다음 페이지를 보면 새 추천을 영영 못 보고 옛 추천 꼬리만 따라가는 침묵 실패가 발생합니다. 매 실행 시 추천 결과 첫 곡의 식별자를 한 건만 미리 가져와 저장된 값과 비교하고 다르면 페이지 위치를 처음으로 되돌려, 외부 API의 모델 변화 자체를 명시적으로 감지하도록 했습니다.
-
MusicBrainz를 정규화 기준으로, 외부 소스의 신뢰 수준에 맞춘 비대칭 폴백 체인.
- 외부에서 받은 가공되지 않은 메타데이터(“Artist - Title (Official Audio) [Remastered 2023]” 같은 것)는 라이브러리 일관성을 망가뜨리므로, 모든 표기와 경로는 MusicBrainz의 정규화 값을 기준으로 둡니다. 단 MusicBrainz는 컴필레이션·라이브 release를 첫 결과로 잡는 경우가 많아 정규 앨범과 다른 커버를 가져오는 문제가 있어, 앨범명은 iTunes → Deezer → MusicBrainz → “Unknown Album” 순으로 폴백하고, 커버는 iTunes/Deezer가 앨범을 매칭한 경우 Cover Art Archive를 건너뛰는 비대칭 분기로 구성했습니다. 태그 작성은 staging 단계에서 두 번에 걸쳐 진행한 뒤 라이브러리로 한 번에 옮기는 방식으로, 태그를 쓰던 도중 죽어도 반쯤 태깅된 파일이 라이브러리에 남지 않게 했습니다.
-
state.db를 처리 대기 테이블로 둔 saga + 단계별 체크포인트 복구로 1인 운영 가능한 단순성 확보.
- 한 트랙의 처리는 다운로드 → 검증 → 태그 작성 → 라이브러리 복사 → Navidrome 색인 갱신의 다단계 saga이고, 각 단계는 단일 SQLite 파일에 진행 상황과 파일 경로를 체크포인트로 남깁니다. 워커가 임의 단계에서 죽어도 부팅 시 미완료 행을 다시 큐에 올리고, 단계별 중복 처리 방지(예: 파일이 이미 staging에 있으면 다운로드는 건너뛰고 태깅부터 이어감)로 작업이 중복되지 않습니다. PostgreSQL/Redis 없이 SQLite 한 파일만 쓰고 마이그레이션 도구 없이 스키마를 진화시키며, Navidrome은 Docker 내부 네트워크에만 두고 외부는 BrainStream의 프록시로만 접근하게 — 운영 평면 자체를 일부러 작게 잡았습니다.
트러블슈팅
-
ListenBrainz 모델 재학습 시 stale offset으로 새 추천을 영영 못 보는 침묵 실패.
- 문제: 초기에는 마지막 사용한 offset을 단순히 다음 실행에서 이어 받아 paginate했습니다. 그러나 ListenBrainz CF 모델은 주기적으로 재학습되면서 추천 결과 앞쪽 순서가 통째로 바뀌는데, 옛 offset 뒤만 계속 paginate하면 갱신된 모델의 새 추천은 영영 보지 못한 채 옛 추천 꼬리만 따라가게 됩니다. 에러도 안 나고 모델이 갱신됐다는 신호도 없는 침묵 실패라 한참 뒤에야 발견됐습니다.
- 해결: 매 실행 시 CF 첫 결과의 MBID를 probe로 한 건만 가져와 저장된 값과 비교하고, 다르면 모델이 재학습된 것으로 보고 offset을 0으로 리셋하도록 추가해, 외부 API의 모델 변화 자체를 명시적으로 감지하도록 했습니다.
-
수동 다운로드 동시 클릭으로 같은 트랙이 중복 등록되던 race.
- 문제: Web UI에서 동일 트랙에 대해 두 요청이 짧은 간격으로 들어오면, 두 핸들러가 모두 “현재 처리 중인 작업 없음”을 확인한 뒤 각자 신규 작업을 등록하면서 race가 발생해 같은 트랙에 대해 처리 대기 행이 두 건 생기고 다운로드까지 이중으로 도는 일이 있었습니다. 전형적인 “확인 후 등록” 사이의 race(TOCTOU) 결함이었습니다.
- 해결: 중복 검사와 등록을 SQLite 단일 트랜잭션 안에서 한 번에 수행하도록 묶어, race에서 진 쪽이 자동으로 기존 행을 받아 충돌 응답으로 종료되도록 했습니다. 같은 패턴을 부팅 시 미완료 행 복구 경로에도 적용해, 재기동 후 곧바로 이어지는 스케줄 폴링이 같은 트랙을 한 번 더 등록하지 못하게 한 번에 정렬했습니다 — 최소 한 번 처리 모델 위에 중복 방지를 명시적으로 얹은 셈입니다.
의의
상용 SaaS 없이 “내 청취 패턴 → 내 라이브러리”를 코드로 닫아본 개인 자동화 사례입니다. 추천 품질·메타데이터 정확성·다단계 saga 복구 가능성 세 축이 서로 깎아먹지 않게, 외부 API의 신뢰 수준에 맞춰 비대칭 폴백을 구성하고 도메인 정규화의 책임은 MusicBrainz에 위임하며 운영 평면은 일부러 작게 잡은 점이 가장 만족스러웠습니다.