rAF로 오디오 에디터 줌 기능 버벅거림 잡기

오디오 에디터 줌 핸들 드래그가 버벅이던 문제를 rAF 기반 업데이트 분리로 줄인 과정을 정리합니다.

#performance#request-animation-frame#audio-editor#frontend

rAF에 대한 추가적인 정보는 이 글을 참고해주세요: TIL | rAF란?

1. 문제 상황

오디오 에디터에 미니맵 만들어서 뷰포트 핸들 드래그하면 줌 영역 조정되게 했는데, 막상 써보니까 화면이 엄청 버벅거렸다. 핸들 드래그할 때마다 화면이 끊기는 느낌이 들어서 UX가 정말 안좋았다.

2. 기존 코드의 문제

const onMove = (e2: PointerEvent) => {
  const rect = minimapRef.value!.getBoundingClientRect();
  const t = ((e2.clientX - rect.left) / rect.width) * audioDuration.value;
  const start = Math.max(0, Math.min(t, visibleEndTime.value - 0.5));
  viewportStartOverride.value = start;
  setViewportRange(start, visibleEndTime.value); // 이게 문제였음
};

처음엔 pointermove 이벤트 발생할 때마다 그냥 setViewportRange를 바로바로 호출했다. 근데 pointermove가 초당 몇십~몇백 회씩 발생하더라. 그걸 다 처리하려니까 버벅일 수밖에.

3. 병목 지점

setViewportRange 안에서 무슨 일이 일어나는지 보면:

function setViewportRange(start: number, end: number) {
  // 1. 줌 레벨 계산
  const minPxPerSec = viewportWidth.value / (end - start);
 
  // 2. 파형 전체 리렌더링 (이게 진짜 무거움!)
  ws.zoom(minPxPerSec);
 
  // 3. 스크롤 위치 조정
  const scrollLeft = start * minPxPerSec;
  setScroll(scrollLeft, ws);
}

특히 ws.zoom()이 진짜 문제였다. 파형 전체를 다시 그리는 작업인데, DOM 조작에 Canvas 드로잉까지. 이걸 초당 100번씩 하니까 안 버벅거는게 이상하지.

이게 얼마나 많이 호출됐냐면:

pointermove 이벤트 (초당 100회)

setViewportRange 호출 (초당 100회)

ws.zoom() + setScroll() (초당 100회)

파형 리렌더링 (초당 100회) ❌ 당연히 버벅이지

4. RequestAnimationFrame으로 갱신 횟수 제한하기

4-1. rAF란?

requestAnimationFrame(RAF)는 브라우저한테 "다음 화면 그릴 때 이 함수 실행해줘"라고 부탁하는 API다.

requestAnimationFrame(() => {
  // 다음 프레임 때 (약 16ms 후) 실행됨
  console.log('다음 프레임!');
});

4-2. rAF 특징

항목설명
실행 시점다음 리페인트 직전
주기약 16ms (60fps 기준)
반환값숫자 ID (나중에 취소할 때 씀)
취소cancelAnimationFrame(id)
탭 비활성화자동으로 멈춤 (배터리 절약됨)

4-3. 스로틀링 구현

생각해보니까 드래그할 때마다 바로바로 파형을 다시 그릴 필요가 없더라. 어차피 사람 눈엔 60fps 정도면 충분히 부드럽게 보이니까, **"프레임당 최대 1번만 호출하자"**는 전략을 세웠다.

1단계: 변수 설정

/** RAF ID 저장용 */
let rafId: number | null = null;
 
/** 가장 최신 값 저장용 */
let pendingStart: number | null = null;
let pendingEnd: number | null = null;
  • rafId: 지금 RAF가 예약되어 있는지 체크하는 용도
  • pendingStart/pendingEnd: 드래그하면서 계속 바뀌는 값을 임시로 저장

2단계: 두 단계로 업데이트

const onMove = (e2: PointerEvent) => {
  const rect = minimapRef.value!.getBoundingClientRect();
  const t = ((e2.clientX - rect.left) / rect.width) * audioDuration.value;
  const start = Math.max(0, Math.min(t, visibleEndTime.value - 0.5));
 
  // 1단계: UI 오버레이는 바로바로 업데이트 (반응성 유지)
  viewportStartOverride.value = start;
 
  // 2단계: 무거운 파형 렌더링은 RAF로 스로틀링
  pendingStart = start;
  pendingEnd = visibleEndTime.value;
 
  if (rafId === null) {
    rafId = requestAnimationFrame(() => {
      if (pendingStart !== null && pendingEnd !== null) {
        setViewportRange(pendingStart, pendingEnd);
        pendingStart = null;
        pendingEnd = null;
      }
      rafId = null;
    });
  }
};
  • UI 오버레이는 즉시 업데이트: 사용자한테 "반응하고 있어요!"라고 보여줘야 하니까
  • 무거운 파형 렌더링만 스로틀링: 프레임당 1번만 호출되게

4-4. rAF 스로틀링의 동작 순서

pointermove 1회 → pending 업데이트 → RAF 예약 (rafId에 저장)
pointermove 2회 → pending 업데이트 → rafId 있음 → RAF 새로 안 예약
pointermove 3회 → pending 업데이트 → rafId 있음 → RAF 새로 안 예약
...
다음 프레임 도착 → RAF 콜백 실행 → setViewportRange 딱 1번 호출 → rafId = null

핵심: 이미 RAF가 예약되어 있으면(rafId !== null) 새로 예약 안 하고, pending 값만 계속 갱신한다.

상황동작
첫 pointermoverafId === null이니까 RAF 예약
같은 프레임의 다음 pointermove들rafId !== null이니까 RAF 예약 안 함, pending만 업데이트
다음 프레임RAF 콜백 실행 → setViewportRange 1번 호출 → rafId = null
그 다음 pointermove다시 rafId === null → RAF 예약

4-5. 드래그 종료 시 마지막 상태 반영

const onUp = () => {
  // 마지막 업데이트 확실하게 처리
  if (rafId !== null) {
    cancelAnimationFrame(rafId); // 대기 중인 RAF 취소
    rafId = null;
  }
 
  if (pendingStart !== null && pendingEnd !== null) {
    setViewportRange(pendingStart, pendingEnd); // 바로 최종 업데이트
    pendingStart = null;
    pendingEnd = null;
  }
 
  viewportStartOverride.value = null;
  isViewportHandleDragging.value = false;
  document.body.style.cursor = '';
  detachHandleListeners(onMove, onUp);
};

드래그 끝나면:

  • 대기 중인 RAF가 있으면 취소
  • pending에 값이 남아있으면 바로 setViewportRange 호출

이렇게 해야 마지막 상태가 정확하게 반영됨

5. 변경 전후 비교

5-1. 최적화 전

pointermove 이벤트 (초당 100회)

setViewportRange 호출 (초당 100회)

ws.zoom() + setScroll() (초당 100회)

파형 리렌더링 (초당 100회) ❌ 버벅버벅

5-2. 최적화 후

pointermove 이벤트 (초당 100회)

viewportStartOverride 즉시 업데이트 (초당 100회) UI 반응 빠름

pendingStart/pendingEnd 저장 (초당 100회)

requestAnimationFrame 예약 (최대 60회/초) 스로틀링됨

setViewportRange 호출 (최대 60회/초)

파형 리렌더링 (최대 60회/초) 부드럽게!

5-3. 개선 효과

  • 호출 횟수 감소: 초당 100회 → 최대 60회 (약 40% 감소)
  • 반응성은 그대로: UI 오버레이는 바로바로 업데이트되니까 사용자는 느끼지 못함
  • 자동 최적화: 탭 바꾸면 RAF 자동으로 멈춰서 배터리도 절약

6. 핵심 정리

6-1. 두 단계로 업데이트

  • UI 오버레이: 즉시 업데이트로 반응성 확보
  • 무거운 파형: RAF로 스로틀링해서 성능 확보

6-2. 중복 호출 방지

  • rafId === null 체크로 RAF 하나만 실행
  • 불필요한 렌더링 작업 제거

6-3. 최신 값 보장

  • pendingStart/pendingEnd에 항상 최신 값 저장
  • 드래그 끝나면 최종 값 바로 반영

7. 마무리

RAF 스로틀링 진짜 간단한데 효과가 좋다. 특히:

  • 브라우저랑 싱크 맞춤: 프레임 단위로 자동으로 최적화됨
  • 코드 심플: setTimeout이나 debounce보다 깔끔
  • 자동 최적화: 탭 안 보고 있으면 알아서 멈춤

오디오 에디터처럼 실시간으로 반응해야 하는 앱 만들 때 RAF 스로틀링 진짜 유용하더라.

다음에 비슷한 문제 생기면 또 써야지 ㅎㅎ

참고

    관련 글

    댓글 0

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

    0/1000

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