대규모 타임라인에서도 부드럽게 동작하는 에디터 만들기
이진 탐색 기반 시간 인덱스와 rAF 기반 Signal 구독으로 Region 조회와 Canvas 갱신 경로를 분리하고, 측정 결과의 한계까지 설명합니다.
"다중 트랙에서 Region이 500개를 넘으면 화면이 너무 버벅여서 편집하기 어려워요."
모든 것은 이 제보에서 시작되었습니다. 저희가 개발 중인 타임라인 에디터에는 음성 생성과 오디오 겹침 처리를 하다 보면 하나의 프로젝트에 1,500개가 넘는 Region(편집 단위)이 생성되곤 했습니다. 트랙 18개, 리전 1,518개가 배치된 환경에서 가로 스크롤을 하거나 편집을 할 때마다 화면이 멈추는 듯한 심각한 지연이 발생하고 있었습니다.
처음에는 단순히 '보이지 않는 컴포넌트를 지우자(세로 가상화)'라고 생각했지만, 짐작만으로 최적화를 진행할 수는 없었습니다. 크롬 개발자 도구로 실행 경로를 뜯어본 결과, 스크롤할 때마다 두 가지 무거운 작업이 반복되고 있음을 발견했습니다.
- 현재 화면에 보이는 Region을 찾기 위해 전체 Region을 처음부터 끝까지 순회
- Canvas를 다시 그리기 위해 모든 TrackRow의 React 컴포넌트를 불필요하게 재실행
이 글에서는 익숙한 최적화 기법을 무작정 적용하는 대신, '현재 데이터와 실행 조건'에 맞춰 두 가지 핵심 구조를 변경한 이유를 공유하고자 합니다.
1. 왜 '이진 탐색 기반 시간 인덱스'를 선택했을까?
가로 스크롤은 현재 화면(Viewport)의 범위를 계산합니다. 문제는 범위를 알아낸 뒤에도, 그 범위에 속한 데이터를 찾으려면 전체 리전을 순회해야 했다는 점입니다. 이를 해결하기 위해 여러 자료구조를 고민했습니다.
1-1. 💡 Interval Tree 대신 '정렬 배열'을 선택한 이유
리전의 시간 구간을 다룰 때 흔히 Interval Tree를 떠올립니다. 하지만 저희 에디터의 사용 패턴을 분석해 보니, 사용자가 리전을 추가하거나 이동하는(쓰기) 횟수보다 가로 스크롤을 하며 화면을 갱신하는(읽기) 횟수가 압도적으로 많았습니다.
따라서 삽입/삭제 성능이 좋은 Interval Tree로 구현 복잡도를 높이기보다, 리전 목록이 변경될 때 인덱스를 한 번만 생성하고 스크롤 중 반복되는 조회 비용을 최소화하는 정렬 배열 기반의 이진 탐색이 훨씬 적합하다고 판단했습니다.
1-2. 💡 시작 시간 정렬의 한계와 prefixMaxEnd
단순히 리전의 '시작 시간' 기준으로만 정렬하면 치명적인 문제가 생깁니다. 현재 화면(Viewport)보다 훨씬 이전에 시작했지만 재생 시간이 길어서 현재 화면까지 이어지는 리전을 놓칠 수 있기 때문입니다.
이 문제를 해결하기 위해 시작 시간순 배열에 prefixMaxEnd (해당 위치까지의 누적 최대 종료 시간)를 함께 저장했습니다.
- 오른쪽 경계: 리전의 시작 시간이 뷰포트 종료 시간보다 늦은 첫 위치
- 왼쪽 경계:
prefixMaxEnd가 뷰포트 시작 시간보다 크거나 같은 첫 위치
이렇게 이진 탐색으로 양쪽 경계를 좁힌 결과, 매 스크롤마다 전체 데이터를 검사하던 비효율을 덜어내고 알고리즘이 검사하는 후보 수를 98.5%나 감소시킬 수 있었습니다.
2. 왜 'rAF 기반 Signal 구독'으로 렌더링을 분리했을까?
두 번째 병목은 React의 렌더링 구조에 있었습니다. Canvas를 스크롤 위치에 맞춰 다시 그리려면 최신 스크롤 값이 필요했는데, 이를 부모에서 자식(TrackRow)에게 Prop으로 내려주고 있었습니다.
분명 React.memo를 씌워두었지만, 스크롤할 때마다 Prop(스크롤 위치)이 매번 바뀌니 비교 조건이 무효화되어 모든 트랙이 렌더링되고 있었습니다.
2-1. 💡 상태(State)의 목적지를 분리하다
여기서 중요한 질문을 던졌습니다. "스크롤 위치가 변경되면 Track 데이터나 JSX 구조가 바뀌는가?" 정답은 '아니오'였습니다. 최신 스크롤 위치가 진짜로 필요한 곳은 React UI가 아니라 Canvas Draw 함수 단 한 곳이었습니다.
그래서 스크롤 위치를 React State가 아닌 Ref로 빼내어 관리하고, Signal 객체를 도입했습니다.
- React Prop은 변경하지 않아 TrackRow의 불필요한 재실행을 막습니다.
- Signal은 구독자(Canvas)에게 "지금 스크롤 위치가 바뀌었으니 캔버스를 다시 그려라"라는 알림만 전달합니다.
2-2. 💡 중복 갱신 요청을 한 번으로 (rAF)
마우스 휠 이벤트는 찰나의 순간에 수십 번씩 발생합니다. 이벤트마다 캔버스를 다시 그리면 이 또한 엄청난 낭비입니다. 따라서 requestAnimationFrame(rAF)을 활용해 같은 프레임 내에 들어온 캔버스 갱신 요청을 한 번으로 병합했습니다.
이로써 React가 관리해야 할 UI 상태와 Canvas의 갱신 명령을 완벽하게 분리해냈습니다.
3. 지표를 대하는 솔직한 자세: 우리는 무엇을 얻었나
위 두 가지 구조를 적용한 뒤, 동일한 테스트 환경(18개 트랙, 1,518개 리전)에서 측정 지표를 확인했습니다. 최적화의 성공 여부를 단순히 겉보기 프레임(FPS) 수치로 포장하고 싶지 않았기에, 사용자의 입력을 막아버리는 Long Task(메인 스레드를 50ms 이상 점유하는 작업)를 핵심 지표로 삼았습니다.
- Long Task 누적 시간: 4,418ms → 1,154ms (73.9% 감소!)
- DOM Wheel 입력 수신율: 83.8% → 91.7% (향상)
결과적으로 사용자가 체감하던 '버벅임'과 '입력 지연'은 획기적으로 개선되었습니다. 하지만 여기서 끝이 아닙니다. 저는 이 최적화가 모든 것을 해결했다고 포장하지 않으려 합니다.
⚠️ 측정 결과의 한계 메인 스레드의 전체 CPU 점유율이나 React Commit 횟수 자체는 줄어들지 않았거나 오히려 소폭 증가했습니다. 즉, 하나의 거대한 덩어리(Long Task)가 여러 개의 짧은 Task로 쪼개져 화면 반응성을 높였을 뿐, 전체 작업량 자체가 드라마틱하게 줄어든 것은 아닙니다.
4. 마무리하며: 최적화는 기법의 나열이 아니다
이번 작업을 통해 느낀 점은, 성능 최적화란 단순히 Virtual Scroll이나 React.memo, Interval Tree 같은 유명한 기법의 이름을 코드에 바르는 것이 아니라는 점입니다.
- 어떤 데이터가 자주 변경되는가?
- 그 변화가 React UI에 필요한가, Canvas 렌더링에 필요한가?
- 업데이트 비용을 언제 지불하고, 조회 비용을 언제 줄일 것인가?
이러한 실행 조건과 요구사항을 정확히 구분하고, 현재 데이터에 '왜' 이 구조가 필요한지 논리적으로 답할 수 있어야 합니다.
아직 화면 밖 Canvas Draw 횟수 조절이나 세로 트랙 가상화 등 풀어야 할 숙제(병목)가 남아 있습니다. 해결되지 않은 부분을 성공으로 포장하기보다는, 객관적인 지표를 바탕으로 다음 병목을 하나씩 부수어 나가는 과정을 계속 이어나가겠습니다.
댓글을 불러오는 중입니다…