
간단하게 많이 사용하는 ssh 명령어와 vim 단축키를 정리합니다
[소소한 개발 일지] 자바스크립트 딥 다이브 #1 이벤트 루프
2025-02-13
Explanation
오늘은 자바스크립트 딥 다이브 시리즈! 첫 번째로 자바스크립트 이벤트 루프에 대해서 조금 더 깊게 생각해 본 내용을 정리해 보려고 합니다.
위 이미지는 간단하게 만들어 본 자바스크립트 이벤트 루프에 대해 간략히 정리한 이미지 입니다.
이벤트 루프를 설명을 하려는 글은 아니기 때문에 가볍게 리마인드용으로만 봐주세요.
사실은, 이게 오늘의 포스팅에 주제랍니다.
자바스크립트를 공부할 때 보면 이벤트 루프는 중요하게 다루어지는 데, 그 이유가 무엇일까요?
미리 말씀드리자면, 저도 정확한 정답을 알지 못합니다.. 이 글은 개인적으로 그 이유에 대해 생각해 본 글이기 때문에, 혹시 다른 이유를 알고 계시다면 댓글을 통해 공유해 주세요!
가장 기본적인 이유는 자바스크립트의 싱글 스레드와 그로 인한 비동기 코드의 동작을 이해하기 위해서 일 것 같아요. 자바스크립트는 비동기 코드를 브라우저(또는 Node.js)가 메인 스레드 밖에서 관리하고, 비동기로 실행이 완료된 결과가 Microtask Queue나 Task Queue에 등록되고 이벤트 루프에 의해 다시 메인 스레드로 동작하게 됩니다.
1 2 3 4 5 6 7 8 9 |
console.log("A") setTimeout(() => { console.log("B") }, 0) console.log("C") // A // C // B |
아주 간단한 예로, setTimeout은 지연시간을 0으로 설정하더라도 이벤트 루프에서 Task Queue에서 대기하고 있다가 Call Stack이 비면 실행되기 때문에 “A”, “C”, “B” 순으로 출력이 된답니다.
그리고 다음으로 생각해 볼 수 있는 부분은, 똑같이 Web API의 비동기 메서드를 사용하더라도 그 차이가 있는데요. 예를 들면 setTimeout은 Task Queue에서 대기하고 Promise.then의 경우에는 Microtask Queue에서 대기하며 Call Stack이 비었을 때, Microtask Queue가 먼저 실행되고 그 이후에 setTimeout이 실행됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
console.log("A") setTimeout(() => { console.log("B") }, 0) Promise.resolve().then(() => { console.log("C") }) console.log("D") // A // D // C // B |
이 또한 아주 간단한 예로, Microtask Queue가 Task Queue보다 먼저 실행됨을 알 수 있습니다.
이 특징을 프로젝트 상황에서 생각해보면, HTTP 통신에서 예전에는 XMLHttpRequest를 사용했었고, 요즘은 fetch를 사용하는데요. 기본적으로 XMLHttpRequest는 Callback을 기반으로 동작하기 때문에 응답이 Task Queue에서 대기하고 fetch는 Promise를 기반으로 동작하기 때문에 응답이 Microtask Queue에서 대기합니다. 그리고 이로 인해 Call Stack이 비었을 때 fetch의 응답이 더 빠르게 처리됩니다.
정확하게 이야기하면 XMLHttpRequest가 기본적으로 Callback을 기반으로 하기 때문에 Task Queue에 대기한다는 것이고 XMLHttpRequest도 아래와 같이 응답을 Promise를 사용해서 처리하면 Microtask Queue에서 대기하게 됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function promiseXHR(url) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open("GET", url, true) xhr.onreadystatechange = function () { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { resolve(xhr.responseText) } else { reject(new Error(`Error`)) } } } xhr.onerror = () => reject(new Error("Error")) xhr.send() }) } |
여기까지의 내용은 기본적인 것들이고, 이제 본격적으로! (숨 참고 ..)
(주의! 딥 다이브라고 했지만.. 수심이 앝을 수 있습니다.)
앞서 이야기 했던 것처럼 비동기 함수는 Microtask Queue와 Task Queue에서 Call Stack이 빌때까지 기다리게 되는데요. 그말인즉 비동기 요청한 후 많은 시간동안 Call Stack이 비지 않는다면(동기 로직이 오랫동안 실행된다면) 비동기의 결과가 과도하게 지연되어 문제가 생길 수 있습니다.
예를 들자면, setTimeout을 사용해서 애니메이션을 구현했을 때, 만약 무거운 동기 코드가 실행되거나 계속해서 Call Stack이 쌓이게 되면 SetTimeout의 Callback이 실행되지 않아서 애니메이션이 버벅이게 되는 문제가 생길 수 있습니다.
만약, 위처럼 애니메이션 문제라면 ‘requestAnimationFrame’를 사용하는 방법이 있을 것 같습니다.
(‘requestAnimationFrame’는 Call Stack과 무관하게 브라우저가 다음 프레임을 그리기 직전에 실행되며, 자연스럽게 렌더링과 동기화됩니다.)
그리고 다른 방법으로 동기 코드가 무거운 연산을 필요로 한다면, ‘Web Worker’를 사용해서 무거운 연산을 비동기로 백그라운드로 실행하거나 또는 무거운 연산을 수행하는 함수를 나뉘고 그 사이에 Microtask Queue나 Task Queue가 실행될 수 있는 틈을 주는 방법이 있는데요. 간단한 예로,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function heavyFnc1() { const start = Date.now() while (Date.now() - start < 1000) {} } function heavyFnc2() { const start = Date.now() while (Date.now() - start < 1000) {} } function heavyFnc() { heavyFnc1() setTimeout(() => { heavyFnc2() }, 0) } heavyFnc() |
위와 같이 무거운 연산을 수행하는 함수 사이에 setTimeout을 사용해서 태스크 큐에 실행되지 못하고 대기 중인 태스크들을 수행할 수 있는 틈을 만들어줄 수 있습니다.
실제로 사용한다면 재귀 함수를 사용해서 구현하겠죠?
만약에 그리고 해당 연산이 급하지 않은 작업이라면, ‘requestIdleCallback’를 활용해서 브라우저가 유휴 상태일 때 실행하도록 설정할 수도 있습니다. (여기서 브라우저의 유휴 상태는 Call Stack, Microtask Queue, Task Queue 모두 비어 있는 상태입니다.)
‘requestIdleCallback’는 아직 실험적 기능으로 모든 주요 브라우저가 지원하고 있지는 않습니다.
https://developer.mozilla.org/ko/docs/Web/API/Window/requestIdleCallback
그리고 Microtask Queue, Task Queue에 대해 알아보다가 새롭게 알게 된 내용이 있는데요.
기본적으로는 브라우저의 렌더링 엔진과 자바스크립트 엔진은 별개로 동작하지만, 이벤트 루프 주기에서 Call Stack이 비어있을 때, Microtask Queue를 실행한 후 브라우저의 렌더링을 수행해야 하는 타이밍(프레임 업데이트 시점)이라면 렌더링을 먼저 수행하고, 그 후 Task Queue에 있는 Task가 실행된다는 것 입니다.
간단하게 이야기하면, Microtask Queue는 Call Stack이 비어 있는 즉시 실행되며, Task Queue의 작업은 브라우저가 렌더링할 필요가 있다면 렌더링 후에 실행됩니다. (Task Queue는 렌더링보다 우선 순위가 낮다.)
이 차이를 이해하기 좋은 유사한 예시가, React에서 useEffect와 useLayoutEffect의 차이를 생각하면 조금 이해하기 좋은데요. useLayoutEffect가 DOM이 갱신된 직후 그리고 useEffect가 화면이 그려진 뒤 동작으로 나뉘는 것처럼, Microtask Queue는 Call Stack이 빈 즉시, Task Queue는 일반적인 이벤트 루프 다음 사이클에 동작하는 것으로 나뉩니다.
예를 들어 비동기로 수행하는 함수가 아주 많은 숫자로 연속해서 실행된다고 가정했을 때, Microtask Queue에서 수행할 경우 메인 스레드를 독점해서 UI 렌더링이 멈추거나 버벅일 수 있습니다. 반면 Task Queue는 이벤트 루프 다음 사이클에 실행하기 때문에 중간중간 렌더링이나 이벤트 처리를 할 수 있는 틈을 만들어 줄 수 있습니다.
이렇듯, 일반적으로 비동기 코드를 빨리 실행해야 하는 상황이라면 Promise.then과 같이 Microtask Queue가 유리하지만 브라우저가 렌더링이나 이벤트를 수행할 틈을 주거나 큰 연산을 위해 UI가 멈추지 않도록 나누어 실행해야 할 때는 setTimeout과 같은 Task Queue를 사용할 수도 있습니다.
여기까지 짧게나마 자바스크립트의 이벤트 루프에 대해서 알아봤는데요. 시간이 늦은 관계로..
다음에 또 생각나거나 알게 되는 내용이 있다면 추가하도록 하겠습니다!