Back

가사가 떨어지는 물리엔진을 구현하다

단어들이 둥근 필 형태로 화면 위에서 떨어지고, 부딪히고, 바닥에 쌓이는 애니메이션을 봤다. 이걸 노래 가사로 할 수 있겠다는 생각이 들었다. YouTube 영상에 맞춰서, 가사 타이밍에 맞춰서, 음악의 밀도가 시각적 혼돈으로 바뀌는 무언가.

라이브 데모

완성된 구조는 이렇다:

YouTube (오디오 재생) → LRCLIB (타이밍 데이터) → Matter.js (물리 시각화)

여기까지 오는 데 판단이 몇 번 필요했다.

첫 번째 판단: YouTube 자막을 포기한다

자연스러운 첫 발상은 YouTube에서 직접 자막을 가져오는 거였다. 영상에 자막이 있으니 그걸 파싱하면 되지 않을까.

안 됐다. YouTube는 자막 URL에 서명과 만료 시간을 붙인다. 서버 사이드에서 가져오면 서명이 안 맞고, 클라이언트에서 가져오면 CORS에 막힌다. 안정적인 경로가 없었다.

대안으로 찾은 게 LRCLIB이었다. [01:23.45] 가사 텍스트 형태로 밀리초 단위 타임스탬프가 붙은 가사를 무료로 제공하는 오픈소스 API. YouTube는 재생만 맡기고, 가사 데이터는 별도 소스에서 가져오는 구조로 분리했다.

물리엔진으로 단어를 떨어뜨린다

핵심 시각화는 Matter.js가 담당했다. 가사 줄이 나오면 단어들이 화면 중앙에 고정 바디로 나타나고, 다음 줄이 나오면 중력을 받아 떨어진다. 바닥에 닿으면 튕기고, 쌓이고, 서서히 색이 바랜다.

빠른 랩 구간에서는 단어가 폭포처럼 쏟아지고, 느린 발라드에서는 한두 단어가 천천히 내려온다. 노래의 템포가 시각적 밀도로 바뀌는 게 재밌었다.

문제는 성능이었다. 노래가 3~4분 진행되면 물리 바디가 수백 개 쌓인다. Matter.js는 매 프레임마다 모든 바디의 충돌을 시뮬레이션하니까, 500개를 넘어가면 프레임이 한 자릿수로 떨어졌다. 오래된 바디를 프레임당 1개씩 조용히 제거하고, 쌓인 높이가 화면의 55%를 넘으면 클린업을 시작하도록 해서 해결했다.

두 번째 판단: vanilla JS를 버린다

처음에는 vanilla JavaScript + Canvas + 간단한 Node.js 서버로 시작했다. 캔버스 렌더링에 프레임워크가 필요한가? 합리적인 판단이었고, 초반에는 잘 동작했다.

그런데 UI가 붙기 시작하면서 상황이 달라졌다. 2단계 검색 폼, 재생 컨트롤, 실시간 싱크 조절 슬라이더, 단어/문장 모드 전환, 모바일 반응형. YouTube 재생 상태, LRCLIB 데이터, 물리 월드, UI 가시성이 전부 얽히면서 버그가 연쇄적으로 터졌다.

대표적으로 — 모드를 전환하면 기존 물리 바디가 정리되지 않아서 이전 모드의 단어들이 유령처럼 남아있었다. 싱크 오프셋을 조절하면 두 곳에서 이중 적용되어 보정이 두 배로 먹었다.

갈림길이었다:

  • A: vanilla로 버그만 고치기
  • B: 프레임워크로 전환하면서 구조 정리 + 버그 수정 동시에

B를 선택했다. 이유는 단순하다. 버그의 원인이 “코드가 틀려서”가 아니라 “상태가 흩어져 있어서”였기 때문이다. 같은 구조에서 버그만 고치면 다음 기능 추가할 때 또 같은 종류의 버그가 터질 게 뻔했다.

Vite + React로 옮기고, API 라우트가 필요해서 Next.js로 최종 정착했다. LRCLIB 프록시를 서버 라우트로 분리하니 CORS 문제도 자연스럽게 없어졌다.

전환 후 잡은 버그들

프레임워크 전환이 구조만 바꾼 게 아니라 실제로 버그 수정을 쉽게 만들어줬다.

핀된 바디 소실 — 중앙에 표시 중인 단어가 랜덤하게 사라지는 문제. reposition과 cleanup이 같은 바디를 동시에 건드리고 있었다. 상태가 한 곳에 모이니까 릴리즈 시점에 핀 리스트에서 즉시 제거하는 것으로 깔끔하게 해결됐다.

라인 플리커링getCurrentTime()이 밀리초 단위로 흔들리면서 같은 줄이 여러 번 처리되는 문제. 줄 전진 로직을 ===에서 >로 바꿔서 시간 요동이 역방향으로 작용하지 않게 했다.

모바일 뷰포트100vh가 모바일에서 주소창을 포함한 높이라 캔버스가 잘렸다. dvh로 교체.

이 버그들 자체는 프레임워크와 무관하지만, 상태가 정리된 상태에서 원인을 찾는 건 훨씬 빨랐다.

돌아보며

“캔버스 렌더링에 프레임워크가 필요한가?”에 대한 답은 — 캔버스만 있으면 필요 없다. 문제는 캔버스 “만” 있는 프로젝트가 거의 없다는 것이다.

이번에 전환을 결정한 기준은 하나였다. 버그의 원인이 로직이 아니라 구조일 때, 구조를 바꿔야 한다. vanilla에서 버그만 고치는 건 증상 치료고, 상태 관리를 도입하는 게 원인 치료다. 사이드 프로젝트라 전환 비용이 낮았던 것도 판단에 영향을 줬다. 프로덕션이었으면 A를 골랐을 수도 있다.