“자바스크립트는 단일 스레드인데, 어떻게 논블로킹처럼 동작할까요?”
핵심은 역할 분담입니다. 자바스크립트 엔진은 코드 실행기(콜스택) 역할만 하고, 브라우저/Node 런타임(Web APIs/Node APIs) 이 I/O·타이머·네트워크 같은 오래 걸리는 일들을 백그라운드에서 처리합니다.
Call stack
- 자바스크립트 엔진은 함수를 호출할 때 콜스택에 push하고, 끝나면 pop합니다.
- 싱글 스레드이기 때문에, 스택이 바쁜 동안에는 다른 자바스크립트 코드를 실행할 수 없습니다.
- 그래서 무거운 동기 연산(예: 큰 루프, 복잡한 계산)이 스택을 오래 점유하면 블로킹이 발생합니다.
논블로킹 환경: 오래 걸리는 일은 “맡기고 결과만 받기”
- I/O, 타이머, 네트워크 같은 작업은 엔진이 직접 하지 않습니다. 브라우저의 Web APIs(혹은 Node의 런타임)가 백그라운드 스레드에서 처리합니다.
- 예를 들어 setTimeout을 호출하면, 엔진은 콜백 + 타이머 정보를 타이머 시스템에 넘기고 자기 할 일을 계속합니다. 타이머가 끝나면 콜백이 큐에 등록됩니다.
- 콜백 함수 자체의 실행은 언제나 메인 스레드(콜스택) 에서 이루어집니다. 백그라운드는 “준비만” 하고, 실행권은 이벤트 루프를 통해 다시 JS에게 돌아옵니다.
이벤트 루프와 두 개의 큐
이벤트 루프는 “지금 콜스택이 비었는지”를 계속 확인합니다. 비어 있으면 큐에서 작업을 하나 꺼내 스택으로 올립니다. 여기에는 두 종류의 큐가 있습니다.
매크로태스크 큐 (Macrotask Queue)
- 큰 단위 작업들이 대기합니다. 예: setTimeout, 사용자 입력 이벤트, MessageChannel 등
- 매 틱(tick) 마다 매크로태스크는 1개씩만 처리합니다. (여러 개를 한 번에 처리하면 프레임을 쉽게 다 써버리기 때문입니다.)
마이크로태스크 큐 (Microtask Queue)
- 즉각 처리해야 하는 작은 후처리가 대기합니다. 예: Promise.then/catch/finally, queueMicrotask, MutationObserver
- 한 틱에서 매크로태스크 1개를 끝낸 뒤, 마이크로태스크는 전부 비웁니다. 그래서 Promise.then 체인이 많으면 렌더링이 잠시 미뤄질 수도 있습니다. (마이크로태스크 “기아” 현상)
- 적절하게 쓰면 좋지만, 과하면 렌더링이 뒤로 밀릴 수 있습니다. 아래처럼 재귀로 마이크로태스크를 무한 생성하면 화면이 굶습니다.
(function starve() { Promise.resolve().then(starve); })();
(참고) 한 틱의 순서(브라우저 기준)
- 매크로태스크 1개 실행 → 2) 마이크로태스크 전부 비우기 → 3) 렌더 단계(스타일/레이아웃/페인트, requestAnimationFrame) → 4) 다음 틱
예시
console.log('A');
setTimeout(() => console.log('macro: setTimeout(0)'), 0);
Promise.resolve()
.then(() => console.log('micro: then 1'))
.then(() => console.log('micro: then 2'));
queueMicrotask(() => console.log('micro: queueMicrotask'));
console.log('B');
A
B
micro: then 1
micro: then 2
micro: queueMicrotask
macro: setTimeout(0)
- 동기 코드(
A
,B
)가 먼저 실행되고, - 마이크로태스크가 모두 처리된 뒤,
- 다음 틱에 매크로태스크가 실행됩니다.