개발자 Ruud van Asseldonk가 개인 음악 플레이어 Musium을 만들게 된 배경과, FLAC 디코더부터 PureScript 프런트엔드, SQLite 도구, 검색·통계·추천까지 11년에 걸친 기술적 여정과 시행착오를 소개한다.
Title: Musium의 이야기
written by Ruud van Asseldonk
published 21 September 2025
Musium은 내가 나를 위해 만든 음악 플레이어다. 거실 스피커에 연결된 라즈베리 파이에서 실행되고, 로컬 네트워크에서 웹 인터페이스로 제어한다. 수년 동안 매일 쓰고 있지만, 아직 완성과는 거리가 있다. 어떤 부분은 매우 다듬어졌지만, 일시정지와 건너뛰기 구현은 아직 손대지 못했다.

Musium은 내 궁극의 "야크 셰이빙"이다. 스택 전체가 NIH(Not Invented Here)로 가득하다. 내 FLAC 디코더를 쓰고, 음량 라우드니스 분석, 정규화, 하이패스 필터도 직접 구현했다. 애플리케이션은 커스텀 인메모리 인덱스를 중심으로 구축되어 있고, 데이터는 SQLite에 영속화한다. 이를 위해 SQL 쿼리에 대한 Rust 바인딩을 생성하는 코드 생성기를 만들었고, 프런트엔드는 내가 만든 HTML 빌더 라이브러리를 사용해 PureScript로 작성했다. 나는 디테일을 맞추는 데서 즐거움을 느낀다. 탐색 바는 단순한 선이 아니라 파형을 렌더링하고, UI 전체에 애니메이션을 넣었다. Musium은 재생 횟수에 관한 꽤 정교한 통계를 추적해 적절한 때에 흥미로운 음악을 표면화하고, 이를 위해 새로운 셔플 알고리즘도 개발했다. 만드는 재미도 크고, 무엇보다 Musium은 내가 원하는 것을 정확히 해낸다. Lobsters 블로그 카니발 출품작으로, 이것이 그 이야기다.
2014년, Rust가 내 뉴스 피드에 정기적으로 등장하기 시작했다. 당시 다른 언어들에서 답답했던 점들을 Rust는 많이 해결하고 있었고, 나는 배우고 싶어 안달이었다. 패스 트레이서 포팅으로 시작했는데, 그다음 프로젝트로 뭐가 좋을까? 저수준 메모리 안전 언어에 잘 맞는 것은? 코덱이다. 그때는 ClusterFuzz도 없었고, 손상된 파일 때문에 미디어 플레이어가 세그폴트 나는 일이 흔했다. 비디오 코덱은 과욕이겠지만, 오디오는 해볼 만하다고 생각했다.
이미 FLAC 파일 모음이 있었기에, 나는 코덱을 위한 디코더 Claxon을 썼다. 언제든 스트리밍 서비스에서 콘텐츠가 사라질 수 있는 세상에서, 내가 통제하는 디스크에 파일을 두고, 그것을 디코딩할 소프트웨어를 내가 직접 유지하는 것은 마음을 든든하게 한다. 아무도 그걸 빼앗을 수 없다.
Claxon을 테스트하고 디코딩된 샘플을 재생하려면 Hound도 작성해야 했다. wav 파일을 읽고 쓰는 라이브러리다. Rust 생태계의 초기였고, 그때는 그런 라이브러리가 아예 없었다! 1.0 이전이었고, Rust가 표준 라이브러리의 IO를 대대적으로 리팩터링하기 직전이었다 — 10년 후 Zig가 대중화하게 될 움직임이다.
그래서 FLAC 디코더는 생겼지만, 그걸 쓰는 직접적인 용도는 없었다. 재미는 있었지만, 당시엔 음악 플레이어를 만들 계획이 없었다.
몇 년 뒤, 잠시 가구가 갖춰진 아파트를 임대했는데 멀티룸 Sonos 시스템이 있었다. 그전까지는 집에서 주로 PC로 음악을 들었다. 집 안 "모든 방"에 음악이 흐르는 경험은 놀라웠고, 이사를 돌아가면 반드시 갖고 싶었다. 하지만 Sonos는 비쌌고, 앱은 믿을 수 없었다.
그러다 Chromecast Audio가 있었다. 이론상 완벽했다. FLAC을 재생하고, 멀티룸 오디오를 지원하며, 휴대폰으로 제어할 수 있었다. 회사 할인도 받아서 세 대를 샀다. 하지만 Chromecast는 완전한 해법이 아니었다. http로 미디어를 스트리밍하되, 외부에서 재생을 트리거해야 했다. 스스로 라이브러리 브라우저가 딸려 있지 않았다.
그래서 그걸 만들기로 했다. 내 FLAC 파일을 서비스하는 http 서버와, Chromecast에 트랙을 대기열에 올리는 라이브러리 브라우저. FLAC 파일에서 태그를 파싱하는 Claxon이 있었고, 시작 시 파일들을 순회해 메타데이터를 읽고, 인메모리 인덱스를 구축해 json으로 서비스하는 애플리케이션을 만들었다. 라즈베리 파이에서 실행할 계획이었기 때문에 코드를 효율적으로 짜고 싶었다 — 3세대 이전의 파이는 정말 느렸고, 메모리는 메가바이트 단위였다. 모든 게 성급한 최적화였지만, 만드는 건 즐거웠다!
그땐 SSD가 아직 꽤 비싸서 라이브러리를 회전 디스크에 뒀다. 디스크 접근 패턴을 최적화하는 여정은 재미있었다. 1만6천 개 파일에서 몇 킬로바이트씩 읽을 때는 깊은 IO 큐가 엄청난 이점을 준다. 헤드가 디스크를 가로지르며 가는 길에 여러 읽기를 처리할 수 있기 때문이다. /sys/block의 /queue/nr_requests를 올리고, 수백 개 스레드로 읽었더니, 콜드 페이지 캐시 상태에서 1만6천 개 파일 인덱싱 시간이 130초에서 70초로 줄었다.
이제 라이브러리를 서비스하는 서버가 생겼고, rust-cast로 트랙을 시작할 수도 있었지만, 여전히 음악 플레이어는 아니었다. UI가 필요했다.
휴대폰과 데스크톱 모두에서 쓸 수 있는 라이브러리 브라우저를 원했다. 안드로이드 앱과 웹앱을 만드는 여러 방식을 검토했다. 어느 것도 만족스럽지 않았다. 네이티브 안드로이드 앱은 데스크톱에서 쉽게 돌릴 수 없었다. Flutter는 그때의 유행이었지만, 고집 센 툴체인이 Nix와 맞지 않았고, 나는 코드를 쓰는 시간보다 몇 달마다 개발 환경을 고치는 데 더 많은 시간을 쓰고 싶지 않았다. 결국 웹으로 되돌아왔지만, JavaScript는 수백 줄을 넘는 코드엔 부적절하고, TypeScript는 내가 개인 프로젝트에서 제로 톨러런스 정책을 가진 NPM·nodejs 생태계에 의존했다. (typescript-go가 기대되지만, 그때는 없었다.) 다행히 Elm과 PureScript는 NPM도 nodejs도 필요치 않았다. Elm으로 시작했지만, Cast를 쓰기 위해 JavaScript와 상호작용해야 했는데 그 부분이 너무 제약적이었다. 그래서 PureScript로 갈아탔다. Elm이 프런트엔드 개발자를 위한 Haskell이라면, PureScript는 Haskell 개발자를 위한 프런트엔드다. PureScript로 굳었다.

2019년 7월, 처음으로 Chromium이 노출하는 Cast API를 사용해 내 웹앱에서 트랙을 캐스트할 수 있었다. 위는 그때 찍어둔 영상의 한 프레임이다.
Elm의 DOM 트리 정의 방식, Haskell의 blaze-html과 비슷한 접근이 정말 마음에 들었다. PureScript의 상응물은 Halogen이어서 그걸로 시작했지만, 내가 원하는 것을 만들기엔 애를 먹었다. 애플리케이션을 순수 함수 State -> Html로 보는 Halogen의 모델은 모든 변화가 즉각적일 때는 잘 맞지만, 그게 내가 하려던 건 아니었다. 브라우저의 DOM 노드는 선택 상태나 CSS 전환 같은 상태를 가진다. 현재와 새로운 트리의 차이를 계산해 라이브러리가 반영해 주는 선언적 DOM 명세만으로는 부족했고, 노드를 제어할 수 있어야 했다. 아마 내가 Halogen을 잘못 쓴 걸 수도 있지만, 결국 직접 DOM 조작 라이브러리를 쓰기로 했고, 그 뒤로 매우 만족하고 있다. 나중에는 식물 물주기 트래커에도 사용했다. 다음은 볼륨 슬라이더 구현에서 단순화한 작은 예시다:
type VolumeControl = { valueSpan :: Element }
volumeControl :: Decibel -> Html VolumeControl
volumeControl (Decibel currentVolume) = Html.div $ do
Html.addClass "volume-control"
Html.text "Volume: "
valueSpan <- Html.span $ do
Html.text $ show currentVolume <> " dB"
ask
pure { valueSpan }
겉보기엔 선언적이고, 상태에서 DOM 노드로 렌더링하는 함수형 워크플로를 유지한다. 자세히 보면 명령형이다. Html은 주변 노드를 저장하는 리더 모나다. div와 span 같은 함수는 새 노드를 만들고, 그 노드를 컨텍스트로 본문을 실행한 뒤, 마지막에 부모에 appendChild를 호출한다. 이 접근으로 전체 트리를 다시 만들 수도 있지만, ask로 노드를 저장해 두고 이후 더 국소적인 변경을 적용할 수도 있다:
updateVolume :: VolumeControl -> Decibel -> Effect Unit
updateVolume control (Decibel currentVolume) =
Html.withElement control.valueSpan $ do
Html.clear
Html.text $ show currentVolume <> " dB"
이 라이브러리는 이제 6년이 되었고, 여전히 이 방식이 아주 마음에 든다. UI를 수정할 때마다, 변경이 이렇게 쉽다는 사실에 놀란다.
나는 Chromium의 Web Sender API를 통해 Google Cast를 사용했는데, 이 API가 심각하게 방치되어 있다는 게 분명했다. 안드로이드 버전에 비해 빠진 기능이 많았고, 아예 고장 난 것도 있었다. 게다가 Chromecast가 가끔 네트워크에서 무작위로 끊기거나 사라지곤 했다. 휴대폰 앱에서 스피커로 캐스트할 때가 있는데, 재생이 그냥 멈추거나, 갑자기 캐스트 재생에서 휴대폰 스피커로 바뀌기도 했다. Chromecast는 아마 내가 써 본 것 중 가장 믿을 수 없는 소프트웨어일 것이다. 그 위에 음악 플레이어를 얹는 건 무리였다.
그 시점에서, 그냥 데몬에서 소리를 직접 재생하기로 했다. USB 오디오 인터페이스를 사서 라즈베리에 연결하고, 스피커를 오디오 인터페이스에 물렸다. 멀티룸 오디오는 아니지만, 적어도 잘 동작한다. 처음 의도한 건 아니었지만, 이제 내 서버는 파일과 메타데이터만 제공하는 걸 넘어, MPD와 Mopidy 같은 미디어 서버가 되었다.
Mopidy도 있는데, 왜 굳이 음악 플레이어를 계속 만들까? 우선 정말 재미있고, 배우고 실험하기 좋다. 하지만 무엇보다, 직접 소프트웨어를 만들면 동작 방식을 완전히 이해하게 되고, 원하는 기능을 정확히, 원하는 방식으로 구현할 수 있다. Musium에 추가한 기능들 중 일부는 다음과 같다.
재생 전/후 훅. Musium은 재생 시작 전과 일정 시간 무음 후에 프로그램을 실행할 수 있다. 나는 Ikea Trådfri 콘센트와 coap-client를 이용해 재생을 시작하면 스피커 전원을 켜고, 끝나면 꺼지게 쓴다.
Last.fm 스크로블링. Last.fm에는 2007년부터의 전체 청취 기록이 있다. 엄밀히 말하면, 그때는 아직 음악 취향이 형성되기도 전이었다. 새 음악을 발견하는 데 도움이 되고, 시간이 지남에 따른 추세를 보는 것도 흥미롭다. 이걸 끊을 수는 없었기에, 가장 먼저 추가한 기능 중 하나다. 나중엔 가져오기(import)도 넣었다. 내가 통제하는 곳에 데이터 백업을 보장하고, 다른 기기의 재생 횟수도 Musium으로 가져올 수 있게 한다. 다만 그 부분은 한동안 머지되지 않은 브랜치에 머물러 있다.
하이패스 필터. 내 스피커는 저주파를 잘 재생하고, 내 거실은 거의 정사각형이라 저음이 공진하기 쉽다. 특히 2020년대 음악은 볼륨을 올리면 금방 소리가 밀집되고, 이웃에 민폐가 아닐까 걱정된다. 언젠가 룸 응답을 제대로 측정해 보정하고 싶다. 그때까지는 55 Hz 하이패스 필터만으로도 효과가 대단하다.
입력 즉시 검색(Search as you type). Musium은 아티스트명, 앨범명, 트랙 제목에 등장하는 단어의 인덱스를 유지한다. 단어를 정규화하기 때문에 ‘royksopp’이나 ‘dadi’라고 쳐도 Röyksopp과 Daði Freyr를 찾을 수 있다. 검색은 이 모든 인덱스에서 접두어 일치를 찾는다. 그래서 하나의 검색창으로 아티스트, 앨범, 트랙을 모두 검색할 수 있다. 결과는 일치 속성(접두어 길이, 라이브러리에서 단어의 일반성, 제목 내 단어의 위치 등)을 기반으로 랭크한다. 이게 매우 잘 작동한다. 보통 몇 타이핑만으로 원하는 것을 찾을 수 있고, 쿼리는 밀리초 단위로 끝나 입력 즉시 검색이 즉각적으로 느껴진다.
지배색 추출. 검색하거나 라이브러리를 스크롤하면 서로 다른 커버 아트 썸네일이 보이게 된다. 브라우저에 캐시되어 있어도 이미지를 디코딩하는 데 시간이 들고, 이 때문에 깜빡임이 생긴다. 스크롤의 경우 곧 화면에 들어올 썸네일에 대해 <img> 노드를 미리 만들어 해결하지만, 검색은 다음에 무엇이 보일지 예측할 수 없다. 이를 완화하려고 각 앨범 커버에서 지배색을 계산해 폴백으로 사용한다. 덕분에 깜빡임이 훨씬 덜 거슬린다. 비교 영상은 여기.
어릴 땐 앨범에서 어떤 트랙이 좋은지 그냥 외워졌다. 안타깝게도 이제는 아무거나 집어넣으면 자동 저장되는 나이가 아니고, 라이브러리가 천천히 커지면서 점점 올바른 트랙을 찾기가 어려워진다. 트랙에 평점을 매기는 기능을 넣어 앨범에서 트랙을 고르는 데는 도움이 되지만, 애초에 앨범을 찾는 데는 도움이 안 된다. 그래서 앨범 목록에 여러 정렬 모드를 추가했다.
Musium은 모든 트랙, 앨범, 아티스트에 대해 지수 감쇠 재생 수를 추적한다. 여러 시간 척도에서 이를 수행해, 최근 몇 주, 몇 달, 몇 년에 무엇이 인기였는지 감을 잡는다. 이것으로 과거엔 인기였지만 최근엔 재생되지 않은 앨범을 표면화하는 “발견(discover)” 정렬 모드를 구동한다. 카운터에는 속도 제한이 있다. 10트랙짜리 앨범을 다 듣고, 이어서 12트랙짜리 앨범을 다 들었다고 해서, 두 번째 앨범이 20% 더 인기 있어서는 안 된다.
나는 음악이 계절을 많이 탄다. 따뜻한 여름밤에는 Com Truise나 Roosevelt, 비 내리는 가을엔 A Moon Shaped Pool을 듣는다. 토요일 아침에는 칠아웃과 재즈, 금요일 저녁엔 에너지 넘치는 드럼 앤 베이스를 원한다. 이를 포착하려고, Musium은 앨범마다 _시간 벡터_를 추적한다. 청취가 일어난 시각의 하루/요일/연중을 인코딩한 6차원 임베딩이다. 이를 이용해 “지금을 위한(for now)” 정렬 모드에서 앨범을 랭크하고, 발견 모드에도 가중치로 반영한다. 이 기능은 실제로 놀라울 만큼 잘 작동한다.
요즘 시도 중인 것은 내 청취 기록으로부터 word2vec처럼 임베딩 벡터를 학습하는 것이다. 함께 들으면 잘 어울리는 음악을 군집화해, 플레이리스트를 만들 때 관련 앨범을 제안해 주길 바란다. 내 기록만으로 충분할지는 모르겠지만, 경사하강법의 힘은 놀랍다. 2018년에 이 아이디어를 시도했지만 잘 되지 않아 포기했었다. 이제 트랜스포머가 힘을 보여줬기에 그것도 써 보고 싶었지만, 그럴 필요도 없었다. 이번에는 임베딩 차원이 35에 불과한 훨씬 단순한 모델이 과적합해, 학습에 사용한 4만 트랙 전체 기록을 그대로 외워 버렸다. 여기엔 신경망이 전혀 없다! 과거 300회 청취의 가중합과, 기록에 있는 1만2천 개의 서로 다른 트랙 사이의 코사인 거리를 최소화하는 것뿐이다! 차원이 10–15쯤이면, 지금까지는 주로 같은 시기에 들은 음악을 군집화하는 경향이 있는데, 그건 그럴듯하지만 내가 바랐던 장르 학습은 아니다. 여기서 더 시도해 볼 아이디어가 있고, ListenBrainz와 Last.fm의 다른 사용자 데이터도 들여올 수 있다. 어쨌든 기계 학습에 대한 직관을 더 쌓기에 좋은 문제다.
처음부터 계획한 건 아니었지만, 지난 11년 동안 결국 내가 원하는 방식으로 정확히 동작하는 나만의 음악 플레이어를 만들게 됐다. 이건 분야가 매우 다양한 훌륭한 사이드 프로젝트다. 오디오와 디지털 신호처리를 다루고, 인덱스 자료구조와 전문 검색 같은 데이터베이스스러운 컴포넌트도 구현한다. 저수준 최적화의 여지도 있고, 사용자 인터페이스와 디자인 작업도 있다. 흥미로운 수학과 기계 학습 문제도 있다.
Musium은 내가 만든 다른 도구들의 영감과 테스트베드로도 훌륭하다. Rust에서 SQLite를 쓰는 번거로움은 SQL 쿼리 바인딩을 생성하는 내 도구 Squiller를 만든 주된 동기였다. 이제 다른 프로젝트에서도 여러 곳에 쓰고 있고, 기껏해야 베타 품질이지만, 경험에는 매우 만족한다. Squiller의 렉서와 파서는 Pris를 만들 때 썼던 것의 개선판이어서, RCL을 만들 무렵에는 렉서와 파서를 꽤 능숙하게 쓰게 됐다. 프런트엔드 라이브러리는 내가 매일 쓰는 Sempervivum — 식물 물주기 트래커 — 에서도 유용했고, 거기서 한 개선이 다시 Musium으로 돌아왔다. Musium은 데이터를 SQLite에 영속화하지만, 라이브러리와 쿼리는 내 토이 데이터베이스 Noblit을 테스트하기에도 훌륭한 데이터셋이었다.
그리고 이 모든 것에 질릴 때가 오면, 아직 구현해야 할 일시정지 기능이 남아 있다.