대규모 타임라인에서도 부드럽게 동작하는 에디터 만들기

Region을 일반 가상 항목으로 다룰 수 없었던 이유부터 시작 시간 인덱스와 maxEndTree를 선택하기까지의 판단, 비용과 드래그 처리 방식을 설명합니다.

#react#performance#timeline#canvas#architecture

안녕하세요. 브라우저와 Electron 기반 멀티미디어 에디터를 개발하고 있는 프론트엔드 개발자 김승진입니다.

프론트엔드 개발을 하다 보면 '렌더링 최적화'는 피할 수 없는 숙명과도 같죠. 특히 수많은 오디오와 비디오 트랙이 겹겹이 쌓인 타임라인 UI를 다룰 때는 더욱 그렇습니다.

어느 날, 에디터를 사용하시던 분들로부터 이런 피드백이 들려왔어요.

"다중 트랙에서 클립이 500개를 넘어가면 타임라인 스크롤이 뚝뚝 끊겨서 편집하기가 너무 힘들어요."

실제 프로젝트와 유사하게 18개의 트랙, 1,518개의 클립(Region)이 배치된 환경을 구성해 테스트해 보았습니다. 스크롤이 마우스 포인터를 전혀 따라가지 못하고, 클립을 클릭해도 선택 상태가 한참 뒤에 뜨더라고요. 프로파일링을 해보니 스크롤을 할 때마다 화면에 그릴 클립을 찾기 위해 1,500개가 넘는 데이터를 매번 전체 순회(O(N))하고 있었습니다.

이 무거운 병목을 해결하기 위해 고민했던 과정, 그리고 이진 탐색만으로는 도저히 풀리지 않았던 타임라인 가상화의 한계를 어떻게 극복했는지 공유해 보려고 합니다.


1. 가상 스크롤 라이브러리, 왜 그냥 못 썼을까요?

가장 먼저 떠오른 해결책은 아주 단순했어요. "화면(Viewport)에 겹치는 클립만 찾아서 렌더링하자." 일반적인 목록 UI라면 잘 만들어진 가상 스크롤(Virtual Scroll) 라이브러리를 가져다 붙이면 끝날 일입니다. 앞 항목의 크기를 누적하면 다음 항목의 위치를 쉽게 알 수 있으니까요.

하지만 타임라인은 완전히 다른 세상이었습니다.

  1. 빈 공간: 클립과 클립 사이에 아무것도 없는 텅 빈 시간이 존재해요.
  2. 동시성: 길이가 5초인 클립 두 개가 있어도 하나는 0초에, 다른 하나는 100초에 시작할 수 있습니다.
  3. 겹침: 서로 다른 트랙에 있는 클립들은 같은 시간대에 겹쳐서 렌더링 되어야 합니다.

결국 클립들의 길이를 더하는 것만으로는 화면 안의 위치를 계산할 수 없었어요. 고민 끝에 저희는 범용적인 가상 스크롤 라이브러리를 완전히 걷어내기로 결정했습니다.

대신, 현재 스크롤 좌표와 줌 배율을 바탕으로 현재 화면(Viewport)이 포괄하는 정확한 시간 범위(시작~종료 시각)를 직접 측정하고, 이 범위에 들어오는 클립들만 고속으로 찾아내는 우리만의 탐색 아키텍처를 바닥부터 구축하기로 한 것이죠.

2. 시작 시각이 정렬되어 있다고, 종료 시각도 정렬된 건 아니다

화면과 겹치는 클립을 찾으려면 다음 두 가지 조건을 동시에 만족해야 합니다.

// 1. 클립이 화면이 끝나기 전에 시작해야 하고
region.start <= viewport.end;
 
// 2. 클립이 화면이 시작한 이후에 끝나야 한다
region.end >= viewport.start;

처음에는 데이터를 '시작 시각(start)' 기준으로 정렬해 두고 이진 탐색(Binary Search)을 돌렸어요. 화면 끝보다 늦게 시작하는 녀석들을 O(log N) 만에 쳐낼 수 있으니 꽤 훌륭해 보였죠.

그런데 문제는 두 번째 조건인 '종료 시각'이었습니다. 시작 시각 순서대로 정렬되어 있다고 해서, 종료 시각까지 예쁘게 정렬되어 있는 건 아니거든요.

예를 들어 0초에 시작하는 아주 거대한 배경음악(BGM) 클립이 하나 있다고 가정해 볼게요. 10초, 20초에 시작하는 짧은 효과음 클립들보다 훨씬 늦게 끝나게 됩니다. 시작 시각 기준으로 좁혀놓은 후보군 안에서 종료 시각을 찾으려면 결국 다시 전체를 뒤져야(순회해야) 하는 모순에 빠지고 말았습니다.

스크롤을 할 때마다 좁혀진 후보들을 종료 시각 기준으로 '다시 정렬'하는 방법도 고민해 봤지만, 렌더링 루프 안에 정렬(O(M log M)) 비용을 태우는 건 아무리 생각해도 좋은 구조가 아니었어요.

3. 이진 탐색 기반 시간 인덱스 구축

이 딜레마를 해결하기 위해 저희가 선택한 방법은 바로 '이진 탐색 기반의 시간 인덱스 구축'이었습니다.

화면을 이동할 때마다 1,500개가 넘는 데이터를 전체 순회(O(N))하는 대신, 먼저 모든 클립 데이터를 시작 시간순으로 정렬해 두었습니다. 그리고 스크롤이 발생할 때 뷰포트(화면)의 시작점과 끝점 양쪽 모두에 이진 탐색(Binary Search) 알고리즘을 적용했습니다.

시작점 이진 탐색을 통해 화면 끝부분보다 늦게 시작하는 클립들을 쳐내고, 끝점 이진 탐색을 통해 화면 시작점보다 일찍 끝난 클립들을 걸러내는 방식입니다. 양 끝단을 기준으로 O(log N) 만에 탐색 범위를 대폭 좁히면서, 화면에 실제로 그려져야 할 후보군을 아주 빠르고 가볍게 찾아낼 수 있었습니다.

4. 탐색 속도보다 무서운 건 '갱신 비용'이었습니다

조회 속도는 엄청나게 빨라졌지만, 에디터의 꽃은 결국 '드래그 앤 드롭(Drag & Drop)' 이잖아요? 클립을 마우스로 잡고 이리저리 옮기는 순간, 애써 만들어둔 정렬 배열과 트리의 무결성이 와장창 깨집니다.

마우스를 1픽셀 움직일 때마다 정렬을 다시 하고 트리를 새로 만든다면, 에디터는 그대로 멈춰버리고 말 거예요. 탐색(Read)을 빠르게 만들었더니, 갱신(Write) 비용이 발목을 잡은 셈이죠.

그래서 저희는 렌더링 상태를 미리보기(Preview)와 확정(Commit)으로 완전히 분리했습니다.

  • 드래그 중일 때 (Preview): 사용자가 마우스로 클립을 움직일 때는 원본 데이터와 인덱스 트리를 절대 건드리지 않습니다. 마우스를 따라다니는 '잔상(미리보기)'만 별도의 시각적 레이어에서 가볍게 그려줍니다.
  • 마우스를 놓았을 때 (Commit): 드래그가 끝나고 위치가 최종 확정되는 단 한 번의 순간에만 원본 데이터를 업데이트하고, 이때 트리를 딱 1번 재구성합니다.

이 구조 덕분에 스크롤 할 때는 쾌적하게 트리를 타고 탐색하면서도, 드래그할 때는 무거운 트리 갱신 비용을 피할 수 있게 되었습니다.

5. 마치며: Long Task 73.9% 감소, 그러나 끝이 아닌 시작

여기에 더해 React의 상태 렌더링과 Canvas 갱신 주기를 requestAnimationFrame과 Signal 패턴으로 분리하는 작업까지 함께 적용했습니다. (이 과정도 할 얘기가 많지만, 글이 너무 길어질 것 같네요!)

결과적으로 저희 에디터는 메인 스레드를 50ms 이상 꽉 잡고 놓아주지 않던 Long Task 누적 시간을 기존 4,418ms에서 1,154ms로 약 73.9%나 줄이는 데 성공했습니다. 이제 1,500개의 클립이 깔려 있어도 사용자의 마우스 휠 입력에 즉각적으로 화면이 반응하게 되었죠.

이번 타임라인 최적화 과정을 거치며 한 가지 확실하게 배운 점이 있습니다. 단순히 "이진 탐색을 쓰면 O(log N)이니까 무조건 빠르겠지?"라는 공식은 실무에서 늘 통하지 않는다는 사실입니다. 내 프로덕트에서 화면 이동(조회)이 잦은지, 아니면 데이터 수정(갱신)이 잦은지를 철저히 분석하고, 그 도메인의 성격에 딱 맞는 자료구조와 상태 관리 생명 주기를 직접 설계해야 한다는 것을요.

물론 이게 끝은 아닙니다. 장거리 드래그 스크롤 시의 렌더링 생명 주기 등 아직 다듬어야 할 엣지 케이스들이 남아있거든요. 앞으로도 더 쾌적한 에디터 경험을 만들기 위한 딥다이브는 계속될 예정입니다. 긴 글 읽어주셔서 감사합니다!

    관련 글

    댓글 0

    닉네임과 댓글은 공개됩니다.

    0/1000

    댓글을 불러오는 중입니다…