

해당 내용은 신입으로 취업하기 전인 2023년에 포스팅하였으나, 연차가 쌓인 2025년에 다시 해당 포스팅을 읽어보니 부정확한 부분을 많이 발견했습니다.
옛날 글에서 부정확했던 부분을 바로잡아, 이번 포스팅에서 다시 정리합니다.
ℹ️ 다음 내용을 알아야 이번 포스팅을 이해할 수 있어요.
- 스택(stack) & 큐(queue)
- 동기 vs 비동기
- 콜백/Promise/async–await 기초
- 타이머 API: `setTimeout`, `setInterval` 기본 동작
자바스크립트는 메인 스레드에서 한 번에 하나의 작업만 실행합니다.
그러나 브라우저의 Web API → 태스크 큐(태스크/마이크로태스크) → 이벤트 루프 덕분에 비동기처럼 동작합니다.
흔히 자바스크립트는 싱글 스레드 언어라고 합니다.
이게 무슨 의미일까요?
자바스크립트는 브라우저의 메인 스레드에서 한 번에 하나의 작업만 실행됩니다.
즉, 한 시점에서 활성된 콜 스택(Call Stack)은 1개이며, 콜 스택이 비어야만 다음 작업을 시작할 수 있습니다. 그래서 자바스크립트는 싱글 스래드 언어라고 불립니다.
그런데 실제로는 여러 일이 “동시에” 굴러가는 듯 보입니다.
버튼 클릭을 처리하면서도 네트워크 요청을 보내고, 여러 개의 HTTP 요청을 동시에 날려도 화면이 멈추지 않습니다.
자바스크립트는 싱글 스레드 언어라면서 어떻게 이런 일이 가능할까요?
비밀은 런타임(브라우저)에 있습니다.
런타임은 자바스크립트가 공연을 펼치는 ‘무대’와 같습니다.
무대에는 조명, 배경, 소품, 등장 인물들, 스태프들, 감독이 있어야 제대로 진행되듯,
런타임에도 JS 코드가 실행되는 메인 스레드 뿐만 아니라, Web API, 콜백 큐, 이벤트 루프 등이 있죠.
ℹ️ 참고
Web Worker를 쓰면 메인 스레드 외에 **추가 스레드/콜 스택**을 만들 수 있습니다만,
이 포스팅에선 생략합니다.
갑자기 처음 보는 용어가 많이 나왔군요!
이제 하나씩 용어 정리를 해봅시다.
콜 스택(call stack)
Web API
setTimeout, fetch , DOM 이벤트 등)태스크 큐(= 콜백 큐)
이벤트 루프(Event loop)
아래 예시 코드를 같이 봅시다.
console.log(1);
setTimeout(() => {
console.log(2);
}, 0);
console.log(3);
//코드 출처: https://talkwithcode.tistory.com/89
위 코드 블록을 실행하면 콘솔에 1 -> 3 -> 2 순서대로 출력됩니다.
여기서 console.log(1)과 console.log(3)은 ‘동기(synchronous)’ 코드이기 때문에, 현재 태스크(스크립트 실행)에서 즉시 실행됩니다. 반면 setTimeout 콜백(console.log(2))은 비동기로 다음 태스크 턴에 실행되죠.
동작 과정
console.log(1)이 콜스택에 들어가 실행된 뒤 스택에서 사라집니다. (동기)setTimeout 호출 자체는 콜 스택에서 금방 끝나지만, 타이머의 실제 대기/만료 관리는 브라우저 Web API(타이머)가 담당합니다. (비동기)console.log(3) 이 콜 스택에 올라 실행된 뒤 스택에서 사라집니다. (동기)setTimeout의 콜백이 태스크 큐(= 콜백 큐) 에 들어가 대기합니다.console.log(2) 출력.이 예시를 통해 알 수 있는 점
자바스크립트의 태스크 큐는 매크로태스크 큐와 마이크로태스크 큐로 나뉩니다.
이들 큐는 비동기 작업의 우선순위를 관리하고, 이벤트 루프가 적절한 시점에 콜백을 실행하기 위해 사용됩니다.
<script> 실행click, mousemove 등..)setTimeout에서 설정한 시간이 다 된 경우, 콜백 함수를 실행하는 것Promise.then/catch/finally, queueMicrotask, MutationObserver, await 후속 처리.📌 마이크로태스크 체크포인트(microtask checkpoint)란?
마이크로태스크 큐를 모두 비워 주는 절차입니다.
이벤트 루프는 다음 사이클을 반복합니다.
이미지 출처: JavaScript Visualized: Promises & Async/Await
아래 이미지를 보면 더 쉽게 이해가 됩니다.
위의 이미지에서 script, mousemove, setTimeout은 매크로 태스크입니다.
브라우저의 이벤트 루프는 매크로 태스크 1개의 실행이 끝날 때마다
마이크로태스크 체크포인트를 돌려 마이크로태스크 큐를 빌 때까지 모두 처리한 뒤,
render 기회를 주고 다음 태스크(매크로)로 넘어갑니다.
실제 기술 면접에서는 JS 코드를 보여주면서 실행 결과를 설명하라는 질문이 자주 나옵니다. 출력 결과도 맞혀야하며, 이에 관한 설명도 반드시 할 수 있어야 하므로 연습을 같이 해봅시다.
아래 코드에서 버튼을 클릭하게 되면 콘솔 출력 결과가 어떻게 될까요?
import { useEffect } from "react";
export default function ClickOrderDemo() {
const handleClick = () => {
console.log("handler start");
setTimeout(() => {
console.log("timeout 0 (next task)");
}, 0);
Promise.resolve().then(() => {
console.log("microtask (.then)");
});
console.log("handler end (sync)");
};
return (
<button onClick={handleClick}>
Click me
</button>
);
}
버튼을 클릭 후 콘솔 출력 결과:
handler start
handler end (sync)
microtask (.then)
timeout 0 (next task)
동작 과정 설명
handleClick 실행 시작handler start가 찍힙니다.setTimeout(..., 0) 등록setTimeout(fn, delay)가 호출됩니다.setTimeout 호출 자체는 동기지만, 브라우저 타이머(Web API)가 “fn을 delay 뒤에 실행할 수 있도록 예약(알람 설정)”을 받아갑니다.fn은 지금이 아니라 “다음 태스크 턴” 에 실행될 후보가 됩니다.Promise.resolve().then(...) 호출 및 등록.then의 콜백은 마이크로태스크 큐에 들어갈 예약입니다.console.log("handler end (sync)")microtask (.then) 이 즉시 다음으로 찍히죠.setTimeout 콜백 실행timeout 0 (next task) 출력됩니다.정리
Promise.then은 마이크로태스크 → 현재 태스크가 끝난 직후에 먼저.setTimeout 콜백은 다음 태스크에서 실행 → 가장 마지막.setTimeout(() => {
console.log('1')
setTimeout(() => {console.log('2')} )
Promise.resolve().then(()=> console.log('3'))
console.log('4')
})
Promise.resolve().then(() => {
console.log('5')
setTimeout(() => {console.log('6')} )
Promise.resolve().then(()=> console.log('7'))
console.log('8')
})
console.log('9')
//코드 출처: https://www.maeil-mail.kr/question/214
출력 순서는 9 → 5 → 8 → 7 → 1 → 4 → 3 → 6 → 2입니다.
이 코드는 초기 스크립트(태스크 1개) → 마이크로태스크들 → 타이머(다음 태스크들) 순서로 진행돼요.
동작 과정 설명
setTimeout(() => { ... }) 등록 → 타이머 T1 예약 (0ms)Promise.resolve().then(() => { ... }) 등록 → 마이크로태스크 M_out 예약console.log('9') → 출력: 9⇒ 현재 태스크(스크립트)가 끝났으므로 마이크로태스크 체크포인트로 이동
마이크로태스크 단계
1) 실행: M_out
M_out 콜백 본문
console.log('5')
setTimeout(() => {console.log('6')} ) //**타이머 T2** (0ms)
Promise.resolve().then(()=> console.log('7')) //**마이크로태스크 M_in**
console.log('8')
console.log('5') → 출력: 5setTimeout(() => console.log('6'))Promise.resolve().then(() => console.log('7'))console.log('8') → 출력: 82) 이어서 실행: M_in
console.log('7') → 출력: 7⇒ 마이크로태스크가 모두 비었으니 다음 태스크로 이동
(이 시점에 0ms 타이머: T1, T2가 준비됨. 먼저 예약된 T1이 선행)
타이머 T1 실행
T1 콜백 본문:
console.log('1');
setTimeout(() => console.log('2')); // 타이머 T3 (0ms)
Promise.resolve().then(() => console.log('3')); // 마이크로태스크 M3
console.log('4');
console.log('1') → 출력: 1console.log('4') → 출력: 4⇒ 현재 태스크(T1)가 끝났으므로 마이크로태스크 체크포인트로 이동
마이크로태스크 체크포인트
console.log('3') → 출력: 3⇒ 마이크로태스크가 모두 비었으니 다음 태스크로 이동
(이 시점에 0ms 타이머: T2, T3가 매크로태스크 큐에서 대기 중)
console.log('6') → 출력: 6console.log('2') → 출력: 2setTimeout의 순서지금까지 여러 setTimeout이 등록되었으나, delay가 0ms로 모두 같았습니다. delay가 같으면 등록한 순서대로 실행되죠(FIFO).
그런데 만약 delay가 다르다면 어떻게 될까요?
다음 코드의 출력 결과를 맞혀보세요.
setTimeout(() => console.log('T50'), 50);
setTimeout(() => console.log('T10'), 10);
Promise.resolve().then(() => console.log('micro'));
console.log('sync');
console.log('sync')는 동기 코드이니 sync 가 제일 먼저 출력되겠고,
Promies.then()의 콜백은 마이크로태스크이니, sync 출력 이후 micro가 출력되는 것을 우리는 압니다.
그런데 setTimeout의 경우는 어떻게 될까요?
T50이 T10의 윗줄에서 먼저 호출되었으니, T50이 T10보다 먼저 출력될까요?
정답
sync
micro
T10
T50
T10이 T50보다 먼저 출력되었습니다!
어떻게 된 일일까요?
T50이 Web API에 먼저 위임되는 것은 맞지만 fn(여기에선 console.log(’T50’))은 delay (여기에선 50ms)가 지난 뒤에야 Web API가 fn을 태스크 큐에 넣습니다!
즉, T10이 T50 보다 먼저 태스크 큐에 들어가게 되는 거죠!
ℹ️ 참고: setTimeout의 delay
setTimeout의 delay는 정확히 delay ‘정각’에 실행된다는 의미가 아닙니다.
delay는 “**최소 지연**(at least)”을 의미합니다.
정리하자면, 여러 타이머가 있을 때 순서:
1. 만료 시각이 이른 것부터,
2. 만료 시각이 같다면 등록 순서(FIFO) 로 실행됩니다.
이번 포스팅에서는 다음 내용을 공부했습니다.
벨로그에 본격적으로 포스팅하는 건 오랜만이네요!
앞으로도 이번 포스팅처럼 옛날 포스팅의 업그레이드 버전을 가져오도록 하겠습니다.
감사합니다!
