CPU 소모가 많은 task가 화면을 멈추는 문제를 setTimeout으로 쪼개서 해결하는 방법을 살펴봅니다.
앞선 글에서 자바스크립트 엔진이 task를 처리하는 동안은 렌더링이 일어나지 않고, task가 길어지면 그 사이에 들어온 새 task도 처리하지 못한다는 점을 확인했습니다. 이번 글에서는 이 특성 때문에 실제로 화면이 멈추는 상황을 코드로 재현하고, setTimeout으로 작업을 쪼개 화면을 살려두는 방법을 정리합니다.
CPU 소모가 아주 많은 task 하나가 있다고 가정해보겠습니다.
let i = 0;
let start = Date.now();
function count() {
// CPU 소모가 많은 무거운 작업을 수행
for (let j = 0; j < 1e9; j++) {
i++;
}
alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
}
count();
1e9(0이 9개)를 센 다음 alert를 하는 코드인데, count()가 실행되는 동안 브라우저는 사용자 이벤트 처리나 DOM 관련 작업을 완전히 멈추게 됩니다. 실행시키고 페이지에 마우스 우클릭을 하면 alert가 나올 때까지 아무것도 안 보이다가, alert가 나오고 나서야 우클릭 메뉴가 나타납니다.
코드를 쪼개서 이를 개선해보겠습니다.
let i = 0;
let start = Date.now();
function count() {
// 무거운 작업을 쪼갠 후 이를 수행 (*)
do {
i++;
// 2 * 1e6, 3 * 1e6 등 1e6의 배수에서 do...while문을 나가게 됩니다.
} while (i % 1e6 != 0);
if (i == 1e9) {
// 위의 do...while문을 1e3번 반복하면 도착
alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
} else {
// 1e6의 배수이지만 1e9가 아닐 경우
setTimeout(count); // 새로운 호출을 스케줄링 (**)
}
}
count();
do...while문을 1e3번 반복해서 alert가 나오는데, 그 이전까지는 setTimeout으로 count를 넘기기 때문에 동기적인 코드들이 그 사이사이에 돌아갈 수 있습니다. 덕분에 이벤트 핸들러나 DOM 관련 동작들을 처리할 수 있게 됩니다.
위 코드는 사실 약간의 시간차가 존재합니다. 다음처럼 setTimeout 호출을 앞으로 끌어오면 이를 해결할 수 있습니다.
let i = 0;
let start = Date.now();
function count() {
// 스케줄링 코드를 함수 앞부분으로 옮김
if (i < 1e9 - 1e6) {
setTimeout(count); // 새로운 호출을 스케줄링함
}
do {
i++;
} while (i % 1e6 != 0);
if (i == 1e9) {
alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
}
}
count();
이제 부분 카운팅(do...while)이 일어나기 전에 부분 재스케줄링(setTimeout)이 먼저 일어납니다.
setTimeout 호출이 많은 경우 브라우저의 최소 대기 시간이 4밀리초이기 때문에, 숫자를 세기 전에 스케줄링을 해두면 숫자를 세는 동안 그 대기 시간을 함께 소모하게 되어 실행이 더 빨라집니다. (setTimeout 로직이 뒤쪽에 있으면 setTimeout(count)가 실행되고 바로 다음 macrotask로 count 함수가 실행돼야 하는데, 그러지 못하고 4ms를 기다렸다가 실행된다는 뜻입니다.)
task를 여러 개로 쪼갤 때의 장점은 진행 상태를 나타내는 progress bar를 만들 때도 드러납니다.
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
브라우저는 시간이 오래 걸리든 아니든 상관없이, 현재 작업 중인 task가 끝나야 DOM 변경분을 화면에 렌더링합니다. 그래서 위 코드는 progress bar의 기능을 완수하지 못합니다. i가 전부 지나가야, 즉 현재 작업 중인 task가 끝나야 DOM을 렌더링하기 때문입니다.
<div id="progress"></div>
<script>
let i = 0;
function count() {
// 무거운 작업을 쪼갠 후 이를 수행
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 != 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
위처럼 코드를 분할하면 i가 변화하는 과정을 화면에 출력해줄 수 있습니다. setTimeout으로 count를 넘기기 때문에 count 호출과 호출 사이에 동기적인 동작들을 수행할 수 있게 되고, 그 결과 progress.innerHTML이 변경되었다는 DOM 변경 동작이 실제로 화면에 반영됩니다.
이벤트 핸들러를 만들 때, 특정 액션을 모든 이벤트 버블링이 끝난 후에 실행시키고 싶다면 ms가 0인 setTimeout을 사용하면 됩니다.
menu.onclick = function () {
// ...
// 클릭한 메뉴 내 항목 정보가 담긴 커스텀 이벤트 생성
let customEvent = new CustomEvent("menu-open", {
bubbles: true,
});
// 비동기로 커스텀 이벤트를 디스패칭
setTimeout(() => menu.dispatchEvent(customEvent));
};
"menu-open"이라는 커스텀 이벤트를 만든 후, 해당 이벤트 디스패치를 setTimeout으로 넘겼습니다. 이렇게 하면 menu의 click 이벤트가 완전히 핸들링된 후에야 menu-open 이벤트가 디스패칭됩니다.
setTimeout으로 작업을 쪼개는 방법은 화면을 살려두는 데는 효과적이지만, 그 사이에 정확히 어떤 순서로 작업들이 처리되는지는 아직 설명하지 않았습니다. 다음 글에서는 매크로태스크와 마이크로태스크를 구분해서 그 처리 순서를 자세히 들여다보겠습니다.
Promise 핸들러가 항상 비동기로 실행되는 이유인 마이크로태스크 큐를 짚고, 이를 편하게 다루는 async/await 문법과 실행 순서 종합 문제로 시리즈를 마무리합니다.
Promise.all/allSettled/race 같은 정적 메서드, AbortController로 요청을 취소하는 방법, 그리고 콜백을 Promise로 바꾸는 Promisification을 정리합니다.
then을 이어 붙여 비동기 작업을 순서대로 처리하는 Promise 체이닝과, fetch 예제, 그리고 체인에서 에러를 다루는 방법을 정리합니다.
메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.