useEffect(() => {
const handleTimer = () => {
const now = Date.now();
const delay = now - startTimestamp.current;
setTime((prev) => prev - delay);
addDelayToList(delay);
startTimestamp.current = now;
requestAnimationFrameId.current = requestAnimationFrame(handleTimer);
};
requestAnimationFrameId.current = requestAnimationFrame(handleTimer);
return () => {
if (requestAnimationFrameId.current)
cancelAnimationFrame(requestAnimationFrameId.current);
};
}, []);
requestAnimationFrame()
은 다음 리페인트 직전에 실행될 콜백 함수를 브라우저에 등록하는 메서드다. 매 프레임마다 콜백함수를 실행하려면 연쇄적으로 호출하면 된다.
그렇다면 언제마다 리페인트를 진행할까? 이것은 모니터의 주사율마다 다르다.
주사율이란, 모니터가 1초당 보여줄 수 있는 이미지 수이다. 일반적인 디스플레이는 60Hz(=60fps)이고 이는 1초에 프레임을 60개 보여주는 것을 의미한다. 물론 디스플레이 사양에 따라 60Hz뿐 아니라 144Hz, 240Hz 등 다양한 주사율이 있다. 60Hz 디스플레이 기준으로 브라우저는 16.7ms(1000/60)마다 화면을 리페인트하려 한다.
이벤트 루프에서 requestAnimationFrame()
의 콜백 함수는 'Animation Frames'라는 특별한 큐에 저장된다. 이 큐에 있는 콜백 함수들은 항상 브라우저의 렌더링 단계(rendering step) 직전에 실행된다.
성능 탭에서 requestAnimationFrame()
로 만든 타이머가 동작하는 과정을 살펴보자.
16.7ms(매 프레임)마다 규칙적으로 작업이 실행되는 것을 확인할 수 있다.
이 작업 내역을 좀 더 자세히 살펴보면,
콜백이 실행되어 타이머를 업데이트하고, 화면을 리페인트 하는 것이 하나의 작업으로 묶여있는 것을 확인할 수 있다! 3ms만에 빠르게 샤샤샥 실행된다!
requestAnimationFrame
의 장점첫 번째! 화면 갱신과 동기화된 부드러운 애니메이션을 구현할 수 있다.
이게 무슨 말이냐면, 아래 그림을 보자.
setInterval
로 애니메이션을 구현하는 경우, 화면 갱신 주기와는 무관하게 정해진 시간마다 javascript 코드를 실행한다. 만약 javascript 실행 시간이 모호해 한 프레임을 초과하게 되면, 브라우저는 해당 프레임을 버리고 다음 프레임을 준비한다.
부드러운 애니메이션으로 보이려면 프레임 60개(60fps)가 연속적으로 보여야 한다. 하지만 setInterval
로 애니메이션을 구현하려 하면 위와 같은 일이 발생하고, 이는 프레임 일부가 유실되는 것이므로 애니메이션이 부자연스럽게 보일 것이다. 이렇게 중간에 프레임이 누락되는 현상을 프레임 드롭이라고 한다.
반대로 requestAnimationFrame
은 화면 갱신 주기에 최적화돼있기 때문에 프레임이 버려지지 않고 부드러운 애니메이션을 표현할 수 있게 된다. (물론 콜백 함수 수행 시간이 프레임 갱신 주기보다 길면 프레임 드롭 현상이 일어난다.)
장점 두 번째는, 백그라운드 탭에서는 실행을 중지하여 불필요한 리소스 사용을 방지한다.
왼쪽은 백그라운드 탭일 때 아무 작업도 실행하지 않는 모습, 오른쪽은 다시 탭을 활성화했을 때 화면 갱신 주기에 맞춰 작업을 실행하는 모습이다.
// WebWorkerTimer.tsx
useEffect(() => {
const timerWorker = new Worker("src/worker/timerWorker.ts", {
type: "module",
});
timerWorker.postMessage({ state: "start", time: milliseconds });
timerWorker.onmessage = function (e) {
setTime(e.data.time);
addDelayToList(e.data.delay);
};
return () => {
timerWorker.postMessage({ state: "stop" });
timerWorker.terminate();
};
}, [milliseconds]);
// timerWorker.ts
let intervalId: ReturnType<typeof setInterval> | number;
let startTimestamp: number;
let time: number;
const handleTimer = () => {
if (time <= 0) {
clearInterval(intervalId);
return;
}
const now = Date.now();
const delay = now - startTimestamp;
const delayedSec = Math.max(1, Math.floor(delay / 1000));
startTimestamp = now;
time -= delayedSec * 1000;
postMessage({ time, delay });
};
self.onmessage = function (e: MessageEvent) {
if (e.data.state === "start") {
time = e.data.time;
startTimestamp = Date.now();
intervalId = setInterval(handleTimer, MILLISECOND);
}
if (e.data.state === "stop") {
clearInterval(intervalId);
}
};
웹 워커(Web worker)는 스크립트 연산을 주 실행 스레드와 분리된 별도의 백그라운드 스레드에서 실행할 수 있는 기술이다. 웹워커로 생성한 스레드는 브라우저 렌더링 같은 메인 스레드의 작업을 방해하지 않는다.
브라우저는 CPU 사용량을 최적화하기 위해 탭이 숨겨지면 자바스크립트 타이머(setInterval
, setTImeout
)를 크게 제한한다.
그래서 실행 주기가 1000ms인 setInterval 타이머가 백그라운드로 전환되면 delay값이 약 60000ms(=1분)으로 실행 주기가 제한된 것을 확인할 수 있다.
반면 웹 워커는 메인 스레드와 독립된 환경에서 동작하기 때문에 탭이 숨겨져도 제한되지 않는다.
웹 워커가 메인 스레드와 독립적으로 동작하는 것을 확인하기 위해 간단한 테스트를 진행해보았다.
웹 워커 타이머에서 타이머 지연
버튼을 눌러 메인 스레드를 의도적으로 3초 정도 지연시켜 보았는데, delay list를 보면 메인 스레드의 지연과 관계 없이 delay값이 계속 1000ms로 유지된 것을 확인할 수 있다.
딜레이 시간이 화면에 표시되는 게 느려서 의문일 수 있는데, 딜레이 시간을 표기하는 addDelayToList
은 worker가 아닌 메인 스레드에서 동작한다. 따라서 delay가 3초 정도 늦게 표기된다.
addDelayToList
를 worker에서 제외한 이유는 해당 함수는 직접 document에 접근해 DOM tree를 조작하기 때문이다.
function addDelayToList(delay: number) {
const ulElement = document.querySelector(".delay-list")!;
const li = document.createElement("li");
if (ulElement.children.length > 50) {
ulElement.removeChild(ulElement.firstChild!);
}
li.innerText = `[delay] ${delay}`;
ulElement.appendChild(li);
ulElement.scrollTop = ulElement.scrollHeight;
}
export default addDelayToList;
메인 스레드는 window가 GlobalScope이다. 하지만 웹 워커는 별도 WorkerGlobalScope를 가진다. 때문에 window의 메서드(requestAnimationFrame 등..
)나 DOM 조작이 불가능하다.
따라서 딜레이를 표시하는 로직은 DOM조작을 하기 때문에 메인 스레드에서 처리하도록 분리했다.
이렇게 타이머 만드는 4가지 방법에 대해 알아보았다.
처음에는 타이머를 만드려면 setInterval
를 이용하는 방법만 있지 않나 생각했는데, 생각보다 다양한 방법이 있었고 덕분에 자바스크립트의 동작 원리까지 깊게 배울 수 있었다.
다음에도 타이머를 만들게 된다면,
- 브라우저 탭이 비활성화됐을 때도 정확한 시간 측정이 필요한지?
- 배터리 소모를 고려해야 하는지?
- 다른 애니메이션이나 무거운 작업과 함께 실행되는지?
이런 상황들을 고려하여 선택할 것 같다.
setInterval
requestAnimationFrame
web worker
출처