🔗 이전 내용
DOM 렌더링 방식에 따른 성능 비교해보기 - 1
DOM 렌더링 방식에 따른 성능 비교해보기 - 2
리플로우 줄이기 - 1
📢 들어가며
이전 포스트에서는 offsetWidth 등과 같은 Dom의 레이아웃 정보를 조회를 효율적으로 함으로써 리플로우를 줄이는 방법에 대해서 알아보았다.
이번 포스트에서는 requestAnimationFrame을 사용해서 리플로우를 줄이는 방법에 대해서 알아보자.
🛒 requestAnimationFrame
requestAnimationFrame(이하 rAF)은 리플로우 바로 전에 콜백 함수를 호출하도록 예약하는 API다.
아래 이미지 처럼 새로운 프레임을 만들기 위한 렌더 사이클이 시작되면, rAF의 콜백 함수가 호출된 후 리플로우가 발생하고 최종적으로 화면에 출력되게 된다.
사용방법은 다음과 같다.
requestAnimationFrame((timestamp) => {
});
setTimeout 등과 같이 함수 호출 시 전달한 콜백함수가 즉시 호출되는 것이 아닌 별도의 큐에 등록되게 된다. 매크로 태스크 큐나 마이크로 태스크 큐와는 별개로 rAF을 위한 별도의 큐가 존재한다.
렌더링 단계가 되면 큐에 존재하는 모든 함수들을 호출하게 된다. setInterval과 달리 콜백함수는 오직 한 번만 호출되고 큐에서 제거된다.
즉, 지속적으로 호출이 되게 하려면 재귀를 사용해야 한다.
function fn() {
requestAnimationFrame(fn);
}
requestAnimationFrame(fn);
rAF는 어느 타이밍에 호출되는 가?
자바스크립트 코드에 의해 메인 스레드가 실행중이라면 렌더 사이클은 실행되지 않는다. 현재 태스크가 종료된 후 마이크로 태스크 큐의 작업까지 모두 비어지게 되면 이벤트 루프는 rAF 큐에서 함수를 하나씩 호출하게 된다.
정리하면 다음과 같다.
- 태스크(매크로 태스크)가 콜 스택에 추가
- 콜 스택에 존재하는 모든 작업을 실행 후 종료
- 콜 스택이 비면 마이크로 태스크 큐에서 하나씩 호출
- 마이크로 태스크 큐가 비었고, VSync 신호를 받으면 렌더 사이클이 시작(변경 사항이 존재한다고 가정)
- rAF 큐가 존재한다면 하나씩 호출
- 렌더링 파이프라인 수행
실제로 위의 순서대로 호출이 되는 지 코드를 통해 확인해 보자.
function fn() {
console.log(5);
}
requestAnimationFrame(() => {
console.log(1);
});
console.log(2);
Promise.resolve().then(() => {
console.log("3");
});
console.log(4);
fn();
위의 코드를 실행해 보면 결과는 2 → 4 → 5 → 3 → 1 순으로 실행되는 것을 확인할 수 있다.
rAF 콜백 함수 안에서 rAF 호출하기
rAF 콜백 함수 안에서 rAF를 호출할 수 있다. rAF 콜백 함수 안에서 rAF를 호출하더라도 이번 프레임에서는 호출되지 않는다. 다음 프레임에 호출 되도록 예약만 가능하다.
아래의 코드를 실행해서 확인해보자. 대략 16ms의 시간차가 나는 것을 확인할 수 있다.
requestAnimationFrame(() => {
console.log(performance.now());
requestAnimationFrame(() => {
console.log(performance.now());
});
});
Dom에 대한 쓰기 작업을 수행한 후 레이아웃 정보를 읽는 경우 강제 리플로우가 발생하게 된다. 이를 방지하기 위해 다음 프레임으로 처리를 넘기고 싶을 때 사용할 수 있다.
rAF는 16ms 마다 호출되는 것이 아니다.
rAF를 재귀적으로 호출하게 되면 1분에 60번 호출이 된다고 보통 알려져있다. 이는 대개 모니터의 주사율이 60Hz 이기 때문이다.
주사율이란 모니터 또는 디스플레이가 1분에 몇번의 화면을 갱신하는지를 수치로 나타낸 것이다. 60Hz 라는 건 1분에 60번 갱신된다는 걸 의미한다. 갱신 되는 타이밍에서만 화면이 다시 그려진다.
갱신 주기에 맞추어 업데이트 하는 경우 프레임 간 간격이 일정해지고 자연스럽게 애니메이션 효과를 처리할 수 있기 때문에 브라우저도 이 주기에 맞추어 화면에 갱신 될 이미지를 디스플레이에 전달한다.
이제 위의 주제 중 하나인 'rAF는 어느 타이밍에 호출되는 가?' 에서 잠깐 언급했던 VSync에 대해서 알아보자.
디스플레이 화면에 출력되기까지의 순서는 다음과 같다.
- 디스플레이가 현재 프런트 버퍼를 읽어 화면에 뿌리는 동안, 브라우저/GPU는 백 버퍼에 다음 프레임을 준비한다.
- 수직 블랭크(VBlank) 구간에, VSync에 맞춰 프런트와 백 버퍼를 스왑 한다.
- 이 VSync에 동기화된 프레임 틱이 시작되면 브라우저는 rAF 큐의 콜백을 모두 실행하는 걸 시작으로 하여 렌더 파이프라인을 모두 실행 후 백 버퍼를 완성한다.
- 다음 수직 블랭크 구간에서 다시 스왑이 발생하고 3번에서 만든 프레임이 화면에 출력되게 된다.
수직 블랭크는 현재 프레임을 모두 그린 뒤 다음 프레임을 시작하기 전까지의 빈 구간이다.
VSync는 수직 블랭크가 시작되었음을 나타내는 신호(또는 그 신호에 맞춰 버퍼를 바꾸는 동기화 방식) 이다.
그래서 디스플레이가 60Hz가 아니라 144Hz 이거나 다른 주사율을 가지고 있다면 해당 1분에 해당 주사율 만큼 화면을 갱신을 하게 된다. (144Hz 라면 1분에 144번을 갱신)
단순히 rAF는 1분에 60번 호출된다라고만 알고 애니메이션을 구현한다면 144Hz 모니터에서는 예상치 못하게 더 빠른 속도로 진행될 수 있다.
See the Pen Untitled by suld2495 (@suld2495) on CodePen.
const p = document.querySelector('p');
let count = 1;
const loop = () => {
p.style.left = `${count++}px`;
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
위 코드는 1프레임마다 1px씩 이동하도록 코드를 작성하였다. 60Hz 디스플레이에서는 60px만큼 이동하지만, 144Hz 디스플레이에서는 144px만큼 이동하게 된다.
이 문제를 해결하시 위해서는 rAF 콜백함수로 전달 받은 timestamp 값과 특정 시간 당 변화 값으로 계산해야 디스플레이와 무관하게 동일하게 처리할 수 있다.
아래와 같이 코드를 수정해보았다.
- 1초당 이동하고 싶은 거리를 통해, 1ms 마다 이동거리를 계산한다.
- 첫 rAF 콜백 함수가 호출되는 시점으로부터 현재 시간까지 지난 시간을 계산한다.
- 1ms 마다 이동거리와 지난 시간을 곱해서 최종 이동 거리를 계산한다.
const p = document.querySelector('p');
const 초당_이동_거리 = 60;
const 나노초당_이동_거리 = 초당_이동_거리 / 1000;
let start;
function loop(timestamp) {
if (!start) {
start = timestamp;
}
const left = (now - start) * 나노초당_이동_거리
p.style.left = `${left}px`;
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
🎯 정리
- rAF 콜백 함수는 VSync 신호에 맞추어 가장 먼저 실행되고 rAF 큐가 비어질때까지 모두 실행된다.
- rAF 콜백 함수에서 rAF를 호출하면 다음 프레임에 호출을 예약한다.
- rAF를 재귀적으로 호출했을 때 디스플레이의 주사율만큼 호출된다.
'Javascript > 성능' 카테고리의 다른 글
[Javascript - 성능] 리플로우 줄이기 - 3 (requestAnimationFrame - 2) (0) | 2025.07.27 |
---|---|
[Javascript - 성능] 리플로우 줄이기 - 1 (0) | 2025.07.20 |
[Javascript - 성능] DOM 렌더링 방식에 따른 성능 비교해보기 - 2 (0) | 2025.06.29 |
[Javascript - 성능] DOM 렌더링 방식에 따른 성능 비교해보기 - 1 (0) | 2025.06.22 |