[Javascript] 코드로 보는 비동기 작업 이해하기
자바스크립트에서 비동기 작업을 다루는 것은 매우 중요한 일이나 개인적으로 헷갈리는 부분이 많았습니다.
해당 게시물에서는 예시 코드와 함께 코드가 어떤 순서대로 작동되는지 나타내는 모식도와 함께 정리해 보았습니다.
node.js 와 브라우저에서의 비동기 작업은 다소 차이가 있기에 브라우저를 기준으로 설명했습니다.
호출 스택 (Call Stack)
자바스크립트 코드에서 호출된 함수가 쌓이는 공간입니다.
이벤트 루프 (Event loop)
호출 스택, 콜백 큐를 감시하며 실행될 작업을 조정하는 역할을 합니다.
동기적으로 작업하는 함수는 호출 스택에 쌓아가져 실행시키고, 호출 스택이 비어있을 때만 콜백 큐에 있는 작업을 순차적으로 호출 스택에 올리는 역할을 합니다.
콜백 큐 (Callback Queue)
비동기 작업의 처리 결과로 실행될 콜백 함수들을 보관하는 장소입니다.
MicroTaskQueue, MacroTaskQueue, Animation Frames로 구성되어 있습니다.
Web APIs
브라우저에서 제공하는 API로 ajax, setTimeout 등을 포함합니다. 동기적인 API와 비동기적인 API들이 모두 존재하며 ajax, setTimeout 같은 경우는 비동기적으로 동작합니다.
아래 코드를 보며 먼저 실행 순서를 예측해 보시기 바랍니다.
async function a() {
console.log(1);
c();
new Promise((resolve) => {
console.log(2);
resolve();
}).then(() => {
console.log(3);
});
b();
}
function b() {
console.log(4);
}
function c() {
setTimeout(() => {
console.log(5);
}, 0);
}
function d() {
setTimeout(() => {
console.log(6);
}, 0);
}
function init() {
d();
a();
}
init();
위 코드가 실행되는 순서를 모식도와 함께 살펴보겠습니다.
먼저, 함수의 선언부를 지나 가장 먼저 호출된 init() 함수가 호출 스택에 쌓입니다.
그다음으로 호출된 init() 함수 내 d() 함수를 호출 스택에 올려 실행시킵니다.
d() 함수 내부에 있는 setTimeout을 호출 스택에 올립니다. setTimeout은 비동기로 동작하는 Web API이며 해당 작업(0 ms간 대기하는 작업)을 Web API에게 위임하고, 그 작업 결과로 나온 콜백 함수(console.log(6))을 콜백 큐에 올립니다.
추가 작업이 없으므로 d() 함수의 호출은 종료돼 호출 스택에서 사라집니다.
다음으로 a() 함수를 호출 스택에 올립니다.
그 후 곧바로 a 함수 내 최상단에 위치한 console.log(1)을 호출 스택에 올립니다.
동기적으로 동작하는 console.log 함수는 곧바로 실행돼 console에 1이 찍히고 호출 스택에서 사라집니다.
그 후 c() 함수를 호출 스택에 올립니다.
c 함수도 setTimeout 작업을 Web API에게 위임한 후 콜백 큐에 콜백 함수를 올립니다.
Web API에게 작업을 위임한 후 곧바로 콜백 큐에 콜백 함수를 올리는 이유는 지연시간을 0ms로 지정해 뒀기 때문입니다.
c 함수의 호출도 종료돼 호출 스택에서 사라집니다.
그 후 Promise 생성자 함수 내 console.log(2)를 호출 스택에 올립니다.
Promise 생성자 함수 내 코드는 동기적으로 실행됩니다.
동기적으로 실행되는 console.log(2), resolve()는 순차적으로 진행되어 콘솔에 2가 찍힙니다.
resolve()를 호출해 프로미스를 이행시켰으니 then() 내부의 콜백 함수를 콜백 큐에 저장합니다.
then은 프로미스의 객체 상태가 이행 상태가 되었을 때 연쇄적으로 이어지는 작업을 진행할 수 있도록 합니다.
Promise 작업의 일부로써 콜백 큐에 들어간 console.log(3)은 특이하게 작동되므로 Promise라고 임의로 표시를 해뒀습니다.
다음으로 b 함수를 호출 스택에 올립니다.
b 함수는 내부에 동기적으로 동작하는 console.log(4)만 존재합니다. 해당 함수를 호출 스택에 올려 실행시킵니다.
console.log(4)가 실행되어 콘솔에 4가 찍혔습니다.
또한, 이로써 b 함수가 종료되고, a 함수까지 종료되 모두 호출 스택에서 사라졌습니다.
또한, init 함수 내 더 이상 동기적으로 작동하는 코드가 남아있지 않아 init 함수도 종료되어 호출 스택에서 사라집니다.
이벤트 루프는 호출 스택, 콜백 큐를 확인하며 호출 스택이 비었을 때 콜백 큐의 작업을 호출 스택으로 이동시킵니다.
호출 스택이 마침내 비어졌으므로 콜백 큐의 작업을 호출 스택으로 옮겨야 합니다.
여기서 추가적으로 이해해야 할 부분이 생깁니다. 콜백 큐는 Micro Task Queue, Macro Task Queue, Animation Frames로 구성되어 있으며 해당 큐의 우선순위는 일반적으로 Micro > Animation > Macro 순입니다.
Micro Task Queue에 적재되는 작업으로는
- process.nextTick
- Promise
- Object.observe
- MutationObserver
와 같은 작업이 있으며
Macro Task Queue에 적재되는 작업으로는
- setTimeout
- setInterval
- setImmediate
- I/O 작업
등이 있습니다.
따라서 콜백 큐에는 가장 늦게 적재된 console.log(3)의 작업은 Promise에 의한 작업이었기에 Micro task queue에 적재됐으므로 가장 먼저 호출 스택으로 옮겨집니다.
위 규칙에 따라 log(3)이 호출 스택에 올라갑니다.
콘솔에 3이 찍히며 호출 스택은 다시 빈 상태가 됩니다.
다음 작업인 console.log(6)이 호출 스택에 올라갑니다.
콘솔에 6이 찍히고 호출 스택이 빈 상태가 됩니다.
빈 호출 스택에 곧바로 console.log(5)가 올라갑니다.
콘솔에 5가 찍히며 모든 작업이 종료됩니다.