
자바스크립트는 싱글 스레드 언어이기에, 동기 방식으로 한번에 단 하나의 작업만을 처리한다. 이러한 처리는 직렬 방식이라고 하기도 하는데,
특정 요청이 시작된 이후에는 무조건 응답을 받은 이후에야 비로소 다음 작업을 처리할 수 있다.
과거에는, 실행 단위가 오직 프로세스 뿐이었다.
(프로그램을 구동해, 프로그램 상태가 메모리상에 실행되는 작업 단위 )
이때, 하나의 프로그램에는 - 오직 하나의 프로세스만이 할당되므로, 동시에 여러개의 복잡한 작업을 수행하기 어려웠다.
이에 따라, 더 작은 실행 단위인 스레드가 만들어졌고, 하나의 프로세스에서 여러개의 스레드를 만들어 스레드끼리 메모리를 공유하며 여러 작업을 동시에 수행할 수 있게 되었다.
근데 자바스크립트는 왜 하필 싱글 스레드일까?
자바스크립트의 시작은 비교적 간단한 작동 범위에서 '간단한 경고창을 띄우거나, 버튼 위에 이미지를 띄우는 것'과 같은 수행에 초점을 두었다.
한편, 여러 스레드에서 DOM 을 조작한다면, 메모리가 공유된채 동시에 같은 자원에 접근하게 되어 타이밍 이슈가 발생되었을 수도 있을 것이다.
이러한 상황에 빗어대 보면, 자바스크립트를 싱글 스레드로 구현한 것은 굉장히 합리적인 결정이라 볼 수 있다.
asynchronous 비동기성 이라는 뜻은, 동시에 일어나지 않는것을 의미하는데
동기식과 다르게 즉시 결과가 주어지지 않을수도 있고, 따라서 응답이 언제 올지도 알 수 없다.
그렇기 때문에 자바스크립트는 싱글 스레드 기반으로 동작하지만, 비동기 처리를 위해 브라우저(Web API)와 함께 이벤트 루프를 활용한다.
이벤트 루프는 브라우저의 동작 타이밍을 관리하는 시스템이다.
🔹 주요 개념 정리
- 콜 스택
- 자바스크립트에서 수행해야 할 코드나 함수를 순차적으로 담아둔 스택
- 호출 되면 순차적으로 코드를 넣고, 실행이 끝나면 호출 스택에서 제거된다.- 이벤트 루프
- 콜 스택이 비었는지 확인하고, 실행할 작업(태스크 큐 또는 마이크로태스크 큐)이 있으면 가져오는 역할- 태스크 큐
- setTimeout, setInterval 같은 비동기 작업이 실행을 기다리는 공간
- 테스크 큐란, 실행해야 할 테스크의 집합을 의미한다.
- 이때, 호출 스택이 비었다면 선택된 큐 중에서 실행 가능한 가장 오래된 테스크를 "이벤트 루프"가 꺼내와 호출 스택에 넣는다
- 마이크로태스크 큐
- Promise.then(), MutationObserver 같은 높은 우선순위 작업이 대기하는 공간
- 테스크 큐보다 더 높은 우선권을 갖는다! (마이크로 테스크 큐가 빌 때까지 기존 테스크 큐는 대기)
자바스크립트가 구동되는 환경인, Node.js와 브라우저에서는 여러 스레드가 사용되기 때문에, 이벤트 루프로 여러 스레드가 사용되는 환경과 - 자바스크립트 엔진을 연동한다
근데 싱글 스레드라 순차적으로 코드를 처리한다면, 비동기 함수는 누가 수행하는데?
이는 자바스크립트 코드가 동기식으로 실행되는 메인 스레드가 아닌
테스크 큐가 할당하는 별도의 스레드에서 처리된다.
그래서 Web API || Browser API 에서 비동기 로직이 실행되고, 그 콜백이 테스크 큐에 들어가는 것이다.
예제 코드로 본 동작 과정을 아래와 같다.
function bar() {
console.log("bar");
}
function baz() {
console.log("baz");
}
function foo() {
console.log("foo");
setTimeout(bar, 0)
baz();
}
foo();
🔁 동작순서
1️⃣ foo()가 호출 스택에 들어간다.
- 현재 스택: [ foo ]
2️⃣ console.log("foo")가 호출 스택에 들어간다.
- 현재 스택: [ foo, console.log ]
- "foo" 출력 후 제거됨
- 현재 스택: [ foo ]
3️⃣ setTimeout(bar, 0) 실행
- setTimeout 자체는 브라우저(Web API)에게 "bar를 0ms 후 실행해줘"라고 요청하는 역할만 함
- setTimeout은 즉시 실행되고 호출 스택에서 제거됨
- 현재 스택: [ foo ]
(이때 bar는 태스크 큐(Task Queue)로 이동하지 않고, 브라우저 타이머가 먼저 담당)
4️⃣ baz()가 호출 스택에 들어간다.
- 현재 스택: [ foo, baz ]
5️⃣ console.log("baz")가 호출 스택에 들어간다.
- 현재 스택: [ foo, baz, console.log ]
- "baz" 출력 후 제거
- 현재 스택: [ foo, baz ]
6️⃣ baz() 실행이 끝났으므로 호출 스택에서 제거된다.
- 현재 스택: [ foo ]
7️⃣ foo() 실행이 끝났으므로 호출 스택에서 제거된다.
- 현재 스택: [] (완전히 비어 있음)
📌 이벤트 루프 시작
8️⃣ 이벤트 루프가 호출 스택이 비어 있는지 확인한다.
- 현재 스택: [] (비어 있음)
태스크 큐를 확인했을 때, bar() 실행 대기 중
9️⃣ bar()가 호출 스택에 들어간다.
- 현재 스택: [ bar ]
🔟 console.log("bar")가 호출 스택에 들어간다.
- 현재 스택: [ bar, console.log ]
- "bar" 출력 후 제거됨
- 현재 스택: [ bar ]
1️⃣1️⃣ bar() 실행이 끝났으므로 호출 스택에서 제거된다.
- 현재 스택: [] (완전히 비어 있음)
태스크 큐는 setTimeout 같은 작업들이 들어가며, 각 콜백이 실행될 때마다 DOM을 렌더링하므로 하나씩 화면에 보인다.
마이크로태스크 큐는 queueMicrotask, Promise 등에서 작업을 처리하며, **모든 작업을 끝낸 후 한 번만 렌더링이 이루어진다.
[1] 태스크 큐
// setTimeout으로 태스크 큐에 작업을 넣어서 1부터 렌더링 macrotask.addEventListenerCclick', function () { for (let i = 0; i <= 100000; i++) { setTimeout(() => { macrotask.innerHTML = i }, 0) } })
각 setTimeout 콜백은 태스크 큐에 추가되어 이벤트 루프에 의해 순차적으로 처리되고, 각 콜백이 실행될 때마다 렌더링이 발생합니다.
[2] 마이크로태스크 큐
// queueMicrotask로 마이크로 태스크 큐에 넣어서 1부터 렌더링 microtask.addEventListener('click’, function () { for (let i = 0; i <= 100000; i++) { queueMicrotask(() => { microtask.innerHTML = i }) })
마이크로태스크 큐 코드는, 100,000까지 숫자가 올라가기 전까지는 렌더링이 일어나지 않다가 for 문이 끝나야 비로소 렌더링 기회를 얻으며 100,000이라는 숫자가 한 번에 나타난다.
[3] 동기코드
// 동기 코드로 버튼에 1 부터 렌더링 sync.addEventListener('click', function () { for (let i = 0; i <= 100000; i++) { sync.innerHTML = i } })
마이크로태스크 큐 코드와 마찬가지로 렌더링이 전혀 일어나지 않다가 100,000까지 다 끝난 이후에야 한 번에 렌더링이 일어난다.