안녕하세요 :)
JavaScript는 싱글 스레드로 동작하는 언어입니다. 하지만 비동기 작업으로 인해 여러 작업을 동시에 처리하는 것처럼 보이게 하는데요. 이 비동기 작업의 핵심 원리는 이벤트 루프와 태스크 큐를 이용한 작업 관리에서 비롯됩니다. 이번 글에서는 비동기 작업의 구조와 실행 순서, 이벤트 루프와 태스크 큐의 역할을 단계별로 알아보도록 하겠습니다.
우선 태스크 큐(Task Queue or Callback Queue)가 무엇인지, 어떤 역할을 하는지 먼저 살펴보겠습니다.
Task Queue란?
태스크 큐는 JS에서 비동기 작업을 대기시키고 관리하는 공간입니다. JS가 싱글 스레드 기반으로 동작하는 언어라서 한 번에 한 가지 작업만 처리할 수 있지만, 웹 브라우저나 Node.js 환경에서 비동기 작업을 통해 멀티 스레드가 동작하는 것처럼 한 번에 여러 작업을 처리하는 것처럼 보일 수 있습니다. 이 비동기 작업들이 제때 실행되고 완료될 수 있도록 순서와 타이밍을 관리하는 역할을 태스크 큐가 담당합니다.

태스크 큐의 역할을 조금 더 구체적으로 살펴볼까요?
태스크 큐는 비동기 작업이 완료된 후에 비동기 함수를 따라오는 콜백 함수를 대기시켜놓는 곳입니다. 위에서 말했든 JS는 싱글 스레드로 동작하기 때문에, 비동기 작업이 완료되어도 이를 즉시 콜 스택에서 실행할 수 없습니다. 대신, 완료된 작업을 콜백을 Task Queue에 쌓아두고, 콜 스택이 비었을 때 이벤트 루프가 태스크 큐에서 대기 중인 작업을 꺼내 콜 스택으로 전달합니다.
예를 들어보자면, setTimeout과 같은 비동기 작업은 타이머가 끝난 후 태스크 큐에 콜백함수를 쌓아두고 대기하게 됩니다. 이후, 이벤트 루프가 태스크 큐에서 해당 콜백 함수를 꺼내어 콜 스텍으로 올려서 실행하도록 하는 구조입니다.
이벤트 루프와 콜 스텍은 어떤 관계일까요?
이벤트 루프(Event Loop)는 태스크 큐와 콜 스택 사이를 조율하는 중재자 입니다. 끊임 없이 콜 스택과 태스크 큐를 확인하며, 콜 스택이 비었을 때(동기 작업이 전부 완료됐을 때) 태스크 큐에서 콜백 작업을 가져와 실행할 수 있도록 합니다.
정리하자면,
- 콜 스택이 비어 있는지 확인
- 비어 있다면 태스크 큐에서 대기 중인 콜백 작업을 꺼내와 콜 스택에 올림
- 작업이 완료되면 콜 스택에서 제거하고, 다시 태스크 큐를 확인
이러한 상호작용을 하며 둘 사이의 관계가 유지되며, 이벤트 루프와 태스크 큐 덕분에 JavaScript는 비동기 작업의 결과를 순차적으로 관리할 수 있습니다.
태스크 큐의 종류와 차이점
태스크 큐에도 종류가 있다는 거 알고 계셨나요? JS에서는 비동기 작업들이 다양한 종류의 작업을 수행할 수 있도록 두 가지 태스크 큐를 구분하여 사용합니다. 매크로태스크 큐와 마이크로태스크 큐인데요. 두 종류의 큐는 실행 순서와 우선 순위가 다르며, 특정 작업들이 어떤 큐에 쌓이는지에 따라 처리되는 방식과 타이밍이 달라집니다.

하나씩 자세히 살펴보겠습니다.
- 매크로태스크 큐(Macrotask Queue)
- 매크로태스크 큐는 일반적인, 비교적 큰 규모의 비동기 작업들이 대기하는 공간입니다. 여기에는 보통 브라우저 API에서 제공하는 비동기 작업들이 쌓이며, 상대적으로 긴 시간이 걸릴 수 있는 작업들을 포함합니다.
- 매크로태스크 큐에 쌓이는 작업들은 비교적 즉각적이거나 신속한 처리가 필요하지 않기 때문에, 이벤트 루프가 매크로태스크 큐에 도달할 때 처리됩니다. 예시를 보도록 하겠습니다.
- 예시 1: setTimeout
- setTimeout은 지정된 시간이 지난 후 콜백 함수를 실행하는 타이머 함수입니다. 이 비동기 함수는 매크로태스크 큐에 콜백을 등록하고, 지정된 시간이 지나면 콜백이 매크로태스크 큐에 쌓여서 대기하게 됩니다.
- 예시 2: setInterval
- setInterval은 일정한 간격으로 반복적으로 실행될 콜백을 매크로태스크 큐에 등록합니다. 이 역시 이벤트 루프가 매크로태스크 큐에 도달할 때 실행됩니다.
- 예시 3: DOM 이벤트 핸들러
- 클릭, 스크롤, 키 입력 등의 DOM 이벤트 핸들러도 매크로태스크 큐에 등록됩니다. 사용자가 발생시키는 이벤트가 언제 일어날지 모르는 특성상, 이벤트 루프가 매크로태스크 큐의 순서대로 이벤트 콜백을 실행하게 됩니다.
- 예시 4: I/O 작업 및 네트워크 요청
- fetch와 같은 네트워크 요청 작업은 요청 후 응답이 완료되면 매크로태스크 큐에 콜백을 등록하여 응답 결과를 처리합니다. 외부 리소스 요청은 대기 시간이 길 수 있어 매크로태스크 큐에 배치되는 게 적합합니다.
- 마이크로태스크 큐(Microtask Queue)
- 마이크로태스크 큐는 작고 빠르게 실행되어야 하는 비동기 작업들이 대기하는 공간입니다. 예시를 보도록 하겠습니다.
- 예시 1: Promise.then()
- Promise는 비동기 작업이 완료되었을 때 후속 작업을 정의하는 함수입니다. Promise.then()은 해당 비동기 작업의 결과가 준비되면 즉시 후속 작업(콜백 작업)을 실행하여 연속적인 작업이 가능하도록 합니다.
- 예시 2: async/await
- async/await는 비동기 작업이 완료되기까지 기다린 후 결과를 가져오는 구문입니다. await 뒤에 오는 비동기 작업이 완료되면 후속 작업이 마이크로태스크 큐에 등록되어 즉시 처리됩니다.
자 지금까지 마이크로태스크 큐와 매크로태스크 큐에 대해서 알아보았습니다. 두 종류의 큐는 실행되는 우선순위 또한 다른데, 이크로태스크 큐가 매크로태스크 큐보다 더 먼저 실행되게 됩니다. 왜 그럴까요?
왜 마이크로태스크 큐의 우선순위가 더 높을까?

두 종류의 큐를 이해했다면 이제 두 큐의 우선순위 차이에 대해 알아볼 차례입니다. 마이크로태스크 큐가 매크로태스크 큐보다 우선순위가 높은 이유가 무었일까요?
간단하게 이유를 말해보자면, 데이터 일관성과 효율적인 UX를 보장하기 위해서 입니다.
1. 마이크로태스크의 우선 처리로 얻는 이점
- 마이크로태스크 큐에는 Promise.then()이나 async/await와 같은 연속적인 데이터 처리가 필요한 비동기 작업이 주로 쌓이게 됩니다.
- 이러한 작업들은 이전 작업이 완료된 후 곧바로 다음 작업이 이어져야 데이터 일관성이 보장됩니다. 왜 그럴까요?
async function fetchUserData(userId) {
const response = await fetch(`https://api.JinukExample.com/user/${userId}`);
const userData = await response.json();
return userData;
}
async function updateProfile(userData) {
await fetch(`https://api.JinukExample.com/user/update`, {
method: 'POST',
body: JSON.stringify(userData)
});
}
async function notifyUser(message) {
await fetch(`https://api.JinukExample.com/notify`, {
method: 'POST',
body: JSON.stringify({ message })
});
fetchUserData(userId)
.then(userData => updateProfile(userData))
.then(() => notifyUser('프로필 업데이트'))
- 위 예시 코드는 사용자 프로필 데이터를 가져온 후, 가져온 데이터를 기반으로 프로필을 업데이트 하고, 마지막으로 알림을 표시하는 Promise.then() 코드입니다.
- 각 then에서 실행되는 작업들은 이전 작업의 결과에 의존하므로, 이 실행 순서가 어긋나면 데이터가 올바르게 반영되지 않을 수 있습니다.
- 그렇기 때문에 이런 의존성이 심한 작업을 먼저 처리함으로써 연속적인 코드 흐름을 보장하려고 하는 것입니다.
2. 매크로태스크 실행 지연의 구조적 이유
- 매크로태스크 큐에는 setTimeout과 같은 비교적 긴 대기 시간이 필요한 작업들이 쌓이게 됩니다.
- 만약 setTimeout의 콜백 함수가 매크로태스크 큐에 대기 중일 때, 마이크로태스크 큐의 작업이 많아 끝나지 않았다면, 매크로태스크 큐의 작업은 마이크로태스크 큐가 비워질 때까지 대기 상태가 됩니다.
- 대기 상태가 되는 이유는 JS의 이벤트 루프가 마이크로태스크 큐의 모든 작업을 우선적으로 처리한 후에 매크로태스크 큐의 작업을 처리하도록 설계되어 있기 때문입니다.
- 예를 들어, async/await에서 대기하는 작업들이 지속적으로 마이크로태스크 큐에 쌓이면, 매크로태스크 큐의 setTimeout 콜백은 그동안 계속 대기해야 합니다.
- 그렇기 때문에 무한히 쌓이는 마이크로태스크 작업은 피하고 두 종류의 큐의 균형을 유지시키는 게 중요합니다.
이벤트 루프를 통한 작업의 순서 보장
태스크 큐의 우선순위가 '마이크로태스크 큐 > 매크로태스크 큐' 이기 때문에, 이벤트 루프는 콜 스택 -> 마이크로태스크 큐 -> 매크로태스크 큐 순서로 관리하면서 작업을 처리하게 됩니다.

이벤트 루프는 계속해서 콜 스택이 비어있는지 확인하고, 비어있을 때 태스크 큐에 대기 중인 작업을 순서대로 처리합니다. 이때 이벤트 루프는 마이크로태스크 큐의 모든 작업을 우선적으로 처리한 후 매크로태스크 큐로 이동합니다. 이 구조 덕분에 Promise.then()이나 async/await 구문 같은 작업들이 우선적으로 처리되고, 데이터 흐름이 연속적으로 이루어질 수 있는 것입니다.
즉, 마이크로태스크 큐에 쌓이 작업들은 주로 빠르게 반영해야 하는 후속 작업이므로, 이벤트 루프는 이를 최우선으로 처리해 연속성을 보장하고, 매크로태스크 큐의 독립적인 작업들이 UX에 영향을 미치지 않도록 해줍니다.
이벤트 루프가 어떻게 작업을 처리하는지 코드를 보면서 이해 해볼까요?
console.log("start"); // 1번 실행(동기 작업)
setTimout(() => {
console.log("Timeout") // 4번 실행(매크로태스크 큐)
}, 0);
Promise.resolve().then(() => {
console.log("promise"); // 3번 실행(마이크로태스크 큐)
});
console.log("end") // 2번 실행(동기 작업)
- 동기 작업 실행
- 첫 번째 줄의 console.log()는 동기 작업이기에 즉시 콜 스택으로 올라가 실행됩니다.
- setTimeout 설정
- 다음으로, setTimeout 함수가 호출이 됩니다. 이 비동기 함수는 매크로태스크 큐에 콜백을 등록하여 지정된 시간이 지나면 실행되도록 설정합니다.
- 코드에는 시간이 0으로 설정되어 있지만, 매크로태스크 큐에 등록되는 작업이므로 즉시 실행되지 않고 대기 상태에 들어갑니다.
- Promise.then 등록
- 이후 Promise.resolve().then()이 호출됩니다. Promise는 마이크로태스크 큐에 작업을 등록하므로, 작업이 완료되면 마이크로태스크 큐에 쌓여 대기하게 됩니다.
- 동기 작업 실행
- 또 다시 console.log() 동기 작업이 즉시 콜 스택으로 올라가 실행됩니다.
- 이벤트 루프 실행 - 마이크로태스크 큐 우선 처리
- 두 개의 동기 작업 처리가 완료된 후, 콜 스택이 비었을 때 이벤트 루프가 동작하여 마이크로태스크 큐의 작업을 먼저 처리합니다. 여기서 Promise.then()에 등록된 콜백이 실행되므로 console.log("promise")가 실행됩니다.
- 매크로태스크 큐 실행
- 마이크로태스크 큐도 비워진다면 이벤트 루프는 매크로태스크 큐로 눈길을 돌립니다. 등록된 setTimeout의 콜백을 처리하게 되므로 console.log("Timeout")이 실행됩니다.
최종 실행 순서 및 결과는 다음과 같습니다.
- 출력 결과: "start", "end", "promise", "Timeout"
- 실행 순서 요약:
- 1번: console.log("start") (동기 작업)
- 2번: console.log("end") (동기 작업)
- 3번: console.log("promise") (Promise.then, 마이크로태스크 큐)
- 4번: console.log("Timeout") (setTimeout, 매크로태스크 큐)
끝내는 말
오늘은 JS의 비동기 작업 처리의 원리, 특히 이벤트 루프와 태스크 큐에 대해서 알아보았습니다. 마이크로태스크 큐와 매크로태스크 큐의 우선순위 차이가 어떻게 되는지, 어떤 이로운 점이 있는지, 순서 관리가 어떻게 되는지 이해하는 데 초점을 맞춰봤습니다..!
이벤트 루프와 태스크 큐의 원리를 알면 비동기 흐름을 조금 더 안정적으로 만들 수 있지 않을까요? 앞으로 비동기 작업을 다룰 때는 저런 구조를 염두에 두고 개발에 활용해 보면 도움이 될 거 같습니다!
읽어주셔서 감사합니다 :)
'JavaScript' 카테고리의 다른 글
함수 스코프와 블록 스코프 (3) | 2024.11.12 |
---|---|
console.log? console.error? 무슨 차이일까? (1) | 2024.11.10 |
npm은 뭘까? (1) | 2024.11.09 |
비동기 Promise 이해하기 (1) | 2024.11.07 |
JavaScript가 싱글 스레드인 이유 (0) | 2024.10.17 |