이벤트 루프
이벤트 루프. JS의 한계를 보완하다.

머릿말
자바스크립트는 싱글 스레드 언어입니다. 당시에는 이 설계가 좋은 선택이었는데요. 그 시절 멀티 프로세서 컴퓨터는 보편적이지 않았고 처리할 코드도 적었으니까요.
자바스크립트가 개발된 1995년 즈음은 자바의 시대였습니다. 자바스크립트는 자바와 호환 잘 되고 보조하기만 하면 되는 접착 언어(glue language) 취급이었습니다.
싱글 스레드는 한 스레드에서만 작업을 담당하기 때문에 많은 작업이 몰려와도 중요성을 따지지 않고 순서대로 일일이 처리하느라 시간이 오래 걸립니다.
이는 이벤트 루프가 탄생한 계기이기도 합니다. 이벤트 루프는 작업의 중요성, 특성 등에 따라 뭐부터 실행할지 순서를 나눕니다.
이벤트 루프를 이루는 유명한 요소는 아래와 같습니다.
- call stack
- task queue
- microtask queue
- requestAnimationFrame(rAF)
- Web API
rAF는 조금 특이하므로 다른 것들을 먼저 소개한 다음에 적겠습니다.
콜 스택
ECMAScript에서 부르는 정식 명칭은 실행 문맥 스택입니다. (이후부턴 스택이라고 부르겠습니다.) 모든 작업은 최종적으로 스택에서 처리합니다. 즉, 이벤트 루프는 작업이 언제 스택에 들어갈지 분류합니다.
console.log(1);
setTimeout(() => {
console.log(2);
},0);
console.log(3);
setTimeout은 바로 스택에 가지 않기 때문에 실행 순서가 뒤로 밀려납니다.
작업 큐
setTimeout은 스택에서 바로 처리하지 않고 태스크 큐(task queue)로 넘겨집니다. 태스크 큐는 스택이 처리중일 땐 기다리고 스택이 비워지면 밀린 작업을 하나씩 스택에게 넘깁니다. 따라서 위 코드는 아래와 같이 진행됩니다.

마이크로 태스크 큐
마이크로 태스크 큐(microtask queue)는 스택보단 나중, task보단 우선되는 작업입니다. 대표 예시는 then() 콜백 함수가 있습니다.
// click 1
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 1'));
console.log('Listener 1');
});
// click 2
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 2'));
console.log('Listener 2');
});

주의: 브라우저의 마우스 클릭은 task queue로 처리됩니다.
click 1의 실행 차례가 되면 Listener 1을 출력하고 microtask 1을 출력 작업을 마이크로 태스크 큐로 보냅니다.

Listener 1 → microtask 1 → Listener 2 → microtask 2
// 클릭 이벤트 1
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 1'));
console.log('Listener 1');
});
// 클릭 이벤트 2
button.addEventListener('click', () => {
Promise.resolve().then(() => console.log('microtask 2'));
console.log('Listener 2');
});
button.click();
주의: button.click()는 자바스크립트로 실행하는 것이라서 스택에서 처리됩니다. 즉, 마이크로 태스크 큐는 button.click 함수가 끝날 때까지 기다립니다.

출력 순서: Listener 1 → Listener 2 → Microtask 1 → Microtask 2
마이크로 태스크와 실행 순서
queueMicrotask는 콜백 함수를 마이크로 태스크 큐로 보냅니다. MDN에서는 아래와 같은 예제로 활용법을 제시합니다.
customElement.prototype.getData = url => {
if (this.cache[url]) {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
element.addEventListener("load", () => console.log("데이터를 씁니다."));
console.log("데이터 받는 중...");
element.getData();
console.log("데이터를 받았습니다.");
- fetch.then으로 새 요청을 할 경우: 마이크로 태스크
"데이터 받는 중..."
"데이터를 받았습니다."
"데이터를 씁니다." - 예전에도 url 요청을 해서 캐시가 있을 경우: 스택
"데이터 받는 중..."
"데이터를 씁니다."
"데이터를 받았습니다."
클라이언트 입장에선 똑같은 요청이지만 캐시가 있느냐 없느냐에 따라 실행 순서가 달라지면 이상합니다. 위와 같은 문제를 queueMicrotask로 처리해서 해결할 수 있다고 제시합니다.
customElement.prototype.getData = url => {
if (this.cache[url]) {
queueMicrotask(() => {
this.data = this.cache[url];
this.dispatchEvent(new Event("load"));
});
} else {
fetch(url).then(result => result.arrayBuffer()).then(data => {
this.cache[url] = data;
this.data = data;
this.dispatchEvent(new Event("load"));
});
}
};
element.addEventListener("load", () => console.log("데이터를 씁니다."));
console.log("데이터 받는 중...");
element.getData();
console.log("데이터를 받았습니다.");
if, else 둘 다 마이크로 태스크로 처리되기 때문에 실행 순서가 보장됩니다.
"데이터 받는 중..."
"데이터를 받았습니다."
"데이터를 씁니다."
마이크로 태스크와 호출 횟수
const messageQueue = [];
let sendMessage = (message) => {
messageQueue.push(message);
if (messageQueue.length === 1) {
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
queueMicrotask(() => console.log(json));
}
};
for (let i = 0; i < 4; i++) {
sendMessage("testMessage");
}
// "testMessage"
// "testMessage"
// "testMessage"
// "testMessage"
위 코드를 스택으로 처리하면 "testMessage"가 네 번 출력됩니다.
const messageQueue = [];
let sendMessage = (message) => {
messageQueue.push(message);
if (messageQueue.length === 1) {
queueMicrotask(() => {
const json = JSON.stringify(messageQueue);
messageQueue.length = 0;
queueMicrotask(() => console.log(json));
}
}
};
for (let i = 0; i < 4; i++) {
sendMessage("testMessage");
}
// ["testMessage","testMessage","testMessage","testMessage"]
- for문 코드는 스택으로 돌아간다.
- sendMessage의 콜백 함수에서 push는 바로 스택으로 실행한다
2-1. mesaggeQueue의 길이가 1일 때에만 microtask queue로 작업을 보낸다. - for문이 끝나면 microtask queue에 있는 작업을 가져와 처리한다.
최종 처리 코드를 최초에 한 번만 마이크로 태스크에 등록하는 트릭을 통해 스택에 부하가 걸리는 걸 방지할 수 있다고 제시합니다.
Web API
처음 예제로 돌아가보겠습니다. setTimeout이 태스크 큐로 보내진다고 했는데, 정확히 말하면 이런 Web API들은 중간 과정을 하나 더 가집니다.
fetch(link)
.then(res => res.json())
.then(result => console.log(result));

then의 콜백 함수는 마이크로 태스크로 처리하지만 데이터를 가져오는 중간 과정은 그 외의 영역입니다. 예를 들어 서버가 응답하는 데에 시간이 오래 걸리면, 그동안 클라이언트는 데이터가 올 때까지 모든 작업을 멈추고 기다리는 게 아니라 다른 작업을 합니다.
Web API는 런타임 환경에서 작업하다가 상황에 맞춰서 태스크 큐나 스택으로 작업을 보냅니다. (런타임 환경은 브라우저일 수도 있고 Node일 수도 있고 상황마다 다릅니다.) setTimeout도 런타임 환경에서 시간을 센 다음에 콜백 함수를 태스크 큐로 보냅니다.

예제 그림에서 보듯이, 사실 V8은 코드 실행에 집중하기 위해 스택만 다룹니다. 이벤트 루프는 런타임 환경에서 Web API, 태스크 큐, 마이크로 태스크 큐 등을 제공함으로서 동작합니다.
requestAnimationFrame
크롬 기준으로 브라우저 렌더링은 rAF → styling → Layout → Painting 순서로 작동하며 렌더링 주기는 매 프레임입니다. 즉, rAF의 실행 주기 또한 매 프레임입니다. 다른 이벤트 루프 요소들에 비해 실행 시점이 고정적입니다.
function animate() {
ctx.fillStyle = "#2d2d2d";
ctx.globalAlpha = 0.05;
ctx.fillRect(0, 0, canvas.width, canvas.height);
particlesArray.map((particle) => {
particle.update();
ctx.globalAlpha = particle.bright;
particle.draw();
});
// setInterval로 할 경우
setInterval(animate, 1000 / 60);
// rAF로 할 경우
requestAnimationFrame(animate);
}
init(MAX_PARTICLES_LENGTH);
animate();


setInterval은 태스크 큐로 처리하기 때문에 프레임마다 실행하라고 시켜도 실행 시점이 조금씩 바뀝니다. 한 프레임을 건너뛰거나, 한 프레임에 두 번 실행할 수도 있기 때문에 애니메이션이 거칩니다. 그러나 rAF는 매 프레임마다 실행하기 때문에 애니메이션이 부드럽습니다.
rAF 또한 다른 이벤트 루프와 실행 순서가 꼬일 수 있습니다. 아래 예시를 봅시다.
// 애니메이션 시작을 제어하는 상위 함수
const handleAnimation = () => {
stopAnimation();
setPhase(-phaseSign);
};
// 애니메이션 정지
const stopAnimation = () => {
onAnimate = false;
particles = [];
const img = phaseMap.get(-phaseSign)[0];
drawImg(img);
};
// 모드 phase 변경
const setPhase = (newPhaseSign) => {
phaseSign = newPhaseSign;
phase = phaseMap.get(phaseSign)[1];
particles = init();
play();
};
// animate 신호를 켜고 실행
const play = () => {
onAnimate = true;
animate();
};
// rAF로 애니메이션 반복
const animate = () => {
if (!onAnimate) return;
particles.map(setParticle);
requestAnimationFrame(animate);
};


의도는 애니메이션 중에 클릭하면 실행중인 애니메이션을 멈추고 새로운 애니메이션을 실행하는 것입니다.

문제는 onAnimate입니다. 모든 작업이 스택에서 처리되기 때문에 애니메이션 신호(정지)가 rAF로 전달되기 전에 애니메이션 신호(재생)으로 바뀝니다. 스택은 항상 최우선 작업이기 때문에 rAF보다도 우선됩니다.


즉, rAF는 정지되지 않고 새로운 재생 신호만 중복으로 전달됐습니다.

setTimeout을 사용하면 작업이 태스크 큐로 처리되므로 rAF보다 늦게 실행될 가능성이 있습니다. 다만 태스크 큐와 rAF의 실행 순서는 절대적 우열이 없습니다.
const play = () => {
setTimeout(() => {
onAnimate = true;
animate();
}, 0);
};


rAF는 프레임마다 실행될 뿐 태스크 큐보다 우선된다는 법칙은 없습니다. 따라서 태스크 큐 작업을 안정적으로 실행하려면 한 프레임 이상으로 넉넉하게 잡아야 합니다.
const play = () => {
setTimeout(() => {
onAnimate = true;
animate();
}, 60);
};


참조
- JavaScript의 queueMicrotask()와 함께 마이크로태스크 사용하기
- 브라우져의 이벤트 루프
- Jake Archibald: In The Loop - JSConf.Asia
- What the heck is the event loop anyway? | JSConf EU
- JavaScript Visualized: Promises & Async/Await
- will the micro-task queue or the macro-task queue run first?
- 이벤트 루프를 직접 실행하며 시각적으로 확인하는 사이트
- How js run on browser
- Introduction to Microtask queue
- Understanding Event Loop, Call Stack, Event & Job Queue in Javascript
- Web Worker를 사용한 이미지 로딩