본문 바로가기
Javascript/성능

[Javascript - 성능] 리플로우 줄이기 - 3 (requestAnimationFrame - 2)

by 맨날개발 2025. 7. 27.

🔗 이전 내용

DOM 렌더링 방식에 따른 성능 비교해보기 - 1
DOM 렌더링 방식에 따른 성능 비교해보기 - 2
리플로우 줄이기 - 1
리플로우 줄이기 - 2 (requestAnimationFrame - 1)

 

📢 들어가며

requestAnimationFrame(이하 rAF)을 활용해서 리플로우를 줄이는 방법에 대해서 알아보자. 본래 목적은 애니메이션을 부드럽게 만드는 것에 있다. 하지만 이 타이밍을 활용하면 불필요한 레이아웃 계산을 줄이고, 백그라운드 탭에서 자동으로 멈추는 등 결과적으로 성능을 높일 수 있다.

 

 

DOM 읽기/쓰기 작업 분리

이전 포스트에서 확인했듯, 쓰기 직후 곧바로 레이아웃 값을 읽으면 강제 리플로우 가 일어난다.

그래서 이를 방지하기 위해 쓰기 작업과 읽기 작업은 분리해서 강제 리플로우를 줄일 수 있었다.

 

하지만 코드가 복잡해질수록 아래의 문제 때문에 관리하는 데 어려움을 겪을 수 밖에 없다. 

  1. 일반 태스크)는 렌더 사이클과 맞물려 있지 않아, 렌더링 전 읽기를 수행하여 강제 리플로우가 발생할 수 있다.
  2. 각 함수에서 읽기과 쓰기 순서를 지키더라도 여러 함수가 호출 되는 경우 읽기 → 쓰기 →  읽기 → 쓰기로 처리될 수 있다.

 

rAF 콜백을 활용하면 한 프레임 안에서 읽기와 쓰기를 배치를 통해 위 문제를 단순화할 수 있다.

 

그 이유는 다음과 같다.

  1. 일반 함수에서는 캐싱 된 읽기 작업만을 수행한다. rAF 콜백 함수에서 필요한 값은 변수에 저장한다.
  2. rAF 콜백 함수내에서는 쓰기 작업 전 캐싱 된 읽기 결과에만 접근해서 강제 리플로우가 발생하는 것을 막을 수 있다.
  3. 이후 일반 함수에서 조회하는 시점에서는 이미 렌더링이 완료되었기 때문에 읽기 작업 시 캐싱된 결과를 사용하게 된다.

 

아래는 네모 박스의 사이즈를 프레임 당 1px씩 증가시키는 애니메이션이다. rAF에서 쓰기가 완료 된 후 읽기를 통해 캐싱된 결과를 저장한다. 

See the Pen Untitled by suld2495 (@suld2495) on CodePen.

ResizeObserver 내부에서 entry.contentRect.height를 통해 높이를 구할 수 있지만, 이 예제용 코드이기 때문에 offsetHeight를 활용하였다.

 

 

rAF 한곳에서 모두 관리

브라우저는 VSync 신호에 맞춰, 해당 프레임에 예약된 rAF 콜백들을 한꺼번에 실행한다. 이때 각 콜백이 DOM 읽기와 쓰기를 섞어 수행하면, 전체 실행 순서가 읽기 → 쓰기 → 읽기 → 쓰기처럼 뒤섞이면서 강제 리플로우 가 발생할 수 있다. (위 처럼 읽기와 쓰기를 분리하는 경우에는 무관).

 

또한 rAF를 여러 곳에서 남발하면, 콜백을 등록 및 해제하고 실행 순서를 조율하는 데에 불필요한 오버헤드가 생긴다. 오버헤드 자체는 리플로우 비용에 비해 작지만, 관리 포인트가 늘고 중복 호출/중복 측정이 생길 가능성이 커진다.

 

그래서 rAF 호출 지점을 한 곳(또는 소수)으로 모아 중앙에서 읽기/쓰기 순서를 통제하는 편이 더 안전하고 효율적이다.

 

See the Pen Untitled by suld2495 (@suld2495) on CodePen.

 

결과를 확인해보면 대략 2배 정도 차이가 나는 것을 확인할 수 있다. 물론 rAF의 개수가 1000개 인 것에 비해 대략 3ms 차이 밖에 나진 않긴 하지만, 오버헤드 절감 뿐 아니라 유지보수, 테스트, 확장 측면에서 편하기 때문에 하나 또는 소수만 사용하는 것이 좋다.

 

 

큐 활용

rAF를 하나만 사용하는 경우 문제가 존재한다. 서로 다른 기능까지 모두 한 파일(또는 한 루프)에 몰아넣게 된다. 결과적으로 파일이 비대해지고, 기능별로 코드를 분리하더라도 쓰기 전에 끝난 읽기 결과를 다시 각 함수로 전달해야 하므로 의존 관계와 인자 전달이 복잡해진다. 그 결과 유지보수 시 변경 영향 범위를 파악하기 어려워지는 문제가 발생한다.

 

각 기능들은 자체 파일에서 읽기와 쓰기 작업을 큐에 등록하고 rAF에서 큐에 등록 된 모듈들을 호출하는 방식으로 문제를 해결해볼 수 있다.

 

먼저 읽기와 쓰기를 위한 큐를 준비한다. 큐에 등록 된 함수들은 rAF 콜백함수가 호출될 때 한번에 처리된다.

const readQueue  = [];
const writeQueue = [];

const measure = (fn) => {
  readQueue.push(fn);
}

const mutate = (fn) => {
  writeQueue.push(fn);
}

 

rAF 콜백함수를 등록한다. 콜백함수 내부에서는 큐에 등록된 함수들을 호출한다. 이때 readQueue를 먼저 호출하여 강제 리플로우가 발생하지 않도록한다.

const flush = () => {
  readQueue.splice(0).forEach(fn => fn());
  writeQueue.splice(0).forEach(fn => fn());
}

requestAnimationFrame(flush);

 

그리고 아래와 같이 큐에 등록해준다. rAF 콜백 함수에서 readQueue가 호출되면 쓰기작업에 대한 함수를 writeQueue에 추가하게 된다.

 

읽기 작업의 결과를 클로저를 활용해서 사용할 수 있다.

measure(() => {
  const height = box.offsetHeight;
  mutate(() => box.style.height = `${height + 10}px`);
});

 

위의 코드를 클로저를 활용하기 위해 measure 함수내에서 mutate 함수를 호출해주어야 하는 단점이 있다. 또한 클로저를 활용하기 때문에 메모리문제를 발생시킬 수 있다.

 

 

동일한 패턴이지만 쓰기 작업에서 클로저를 통해 값을 가져오지 않고 함수 호출 시 readQueue 반환 값을 인자로 전달받도록 처리할 수도 있다.

 

큐에 등록하는 코드까지는 동일하다.

const readQueue  = [];
const writeQueue = [];

const measure = (fn) => {
  readQueue.push(fn);
}

const mutate = (fn) => {
  writeQueue.push(fn);
}

 

읽기 작업을 하는 함수는 쓰기 작업에 사용될 값을 반환하도록 작성한다. 그래서 아래와 같이 읽기 큐에 담긴 함수 호출의 결과를 저장했다가 쓰기 큐에 전달해준다.

const flush = () => {
  const results = readQueue.splice(0).map(fn => fn());
  writeQueue.splice(0).forEach((fn, index) => fn(...results[index]));
}

requestAnimationFrame(flush);

 

읽기 작업 시 호출될 함수의 반환값은 아래와 같이 배열로 반환한다. 그리고 쓰기 작업시 호출될 함수의 인자로 전달받게 된다.

measure(() => {
  return [box.offsetHeight];
});

mutate((height) => box.style.height = `${height + 10}px`);

 

위와 같이 읽기와 쓰기 큐에 등록할 함수를 분리할 수 있다. 단점은, 쓰기 작업시 호출 될 함수에 전달할 인자를 배열에 담긴 순서대로 전달하기 때문에 순서가 꼬이는 경우 잘못된 값을 전달하게 될 수 있다. 그리고 분리되었기 때문에 누락될 가능성도 좀 더 커지게 되었다.

 

위와 같은 문제 때문에 관리의 어려움 및 디버깅을 어렵게 만든다.

각 장단이 존재하니 실제 사용하는 환경에 맞추어 적절히 선택하면 될 것 같다.

 

만약 이번 프레임의 쓰기 작업 결과를 토대로 다음 프레임에 읽기와 쓰기를 예약하고 싶은 경우 다음과 같이 작성하면 된다.

measure(() => {
  const height = box.offsetHeight;
  
  mutate(() => {
    box.style.height = `${height + 10}px`

    schedule();

    measure(() => {
      const height = box.offsetHeight;

      mutate(() => {
        box.style.height = `${height + 10}px`
      });
    })
  });
});

 

 

🎯 정리

  • rAF를 활용하면 읽기와 쓰기 작업 분리를 단순화할 수 있다.
  • rAF 하나만 사용할때 큐를 활용하면 기능별로 함수를 분리할 때 용이하다.