[JS Study #06] 자바스크립트는 왜 순서대로 실행되지 않을까
🚀 시작하며
프론트엔드 개발 신입으로서 업무를 보면서 직접 코드를 작성하는 시간보다 다른 사람이 작성한 코드를 보면서 흐름을 정리하거나 보는 경우가 많았습니다. 이때 들었던 생각이 있습니다.
“코드를 위에서부터 썼는데 왜 아래가 먼저 실행되지?”
이는 자바스크립트의 이벤트 루프(event loop)와 비동기 처리 방식 때문인데요,
오늘은 그 핵심 원리를 시각적으로, 그리고 실전 예제 중심으로 정리해보려고 합니다.
JavaScript는 왜 싱글 스레드일까?
자바스크립트(JavaScript)는 싱글 스레드 언어입니다.
즉, 한 번에 한 줄의 코드만 처리할 수 있다는 뜻입니다.
멀티 스레드가 아니라면 브라우저에서 여러 작업을 동시에 처리할 수 없을 텐데… 어떻게 가능할까요?
💡 정답은? 브라우저(Web API)가 비동기 작업을 대신 처리해주기 때문!
- JavaScript는 코어 로직만 처리 (싱글 스레드)
- 브라우저는 타이머, 이벤트 리스너 등 비동기 작업을 백그라운드에서 처리 후 다시 JS에게 넘김
비유하자면?
혼자 일하는 셰프가 요리만 하고, 손님 응대나 배달은 다른 직원이 맡는 구조인 것입니다.
동기 vs 비동기: 무슨 차이일까?
✅ 동기(Synchronous)
-
코드를 한 줄씩 순차적으로 실행
-
이전 작업이 끝나야 다음 줄 실행 가능
console.log('1');
console.log('2');
결과: 1, 2
✅ 비동기(Asynchronous)
- 기다리지 않고 다음 코드를 먼저 실행함
- 완료되면 나중에 “콜백(Callback)” 형태로 실행됨
console.log('1');
setTimeout(() => {
console.log('2');
}, 1000);
console.log('3');
결과: 1, 3, 2
콜백(Callback)
콜백(Callback)
콜백함수는 다른 함수에 인자로 전달되어, 특정 이벤트나 조건이 발생했을 때 호출되는 함수를 말합니다. 👉 나중에 실행될 함수를 미리 넘겨주는 것
🔍 왜 사용하는가?
JavaScript
는 싱글 스레드라서 서버에 파일을 요청하거나, 파일을 읽는 등의 시간이 오래걸리는 작업은 비동기로 처리하고, 그 결과가 나왔을 때 실행될 함수를 미리 정해줘야 합니다. 이때 사용되는 것이 바로 콜백입니다.
function greet(name, callback) {
console.log("안녕하세요, " + name + "님!");
callback(); // 나중에 실행됨
}
function finishGreeting() {
console.log("인사 완료!");
}
greet("언", finishGreeting);
>>>
안녕하세요, 언님!
인사 완료!
✅ 비동기 상황에서 콜백
setTimeout(() => {
console.log("1초 후에 실행!");
}, 1000);
위의 코드에서 ()=>{console.log("1초 후에 실행")}
이것이 바로 콜백 함수 입니다. setTimeout
은 1초 후에 이 함수를 실행해 달라는 의미이고, 그 함수를 콜백으로 전달한 것입니다.
콜백 큐와 마이크로태스크 큐
자바스크립트에는 두 가지 큐(Queue)가 존재합니다.
📦 콜백 큐 (Task Queue)
브라우저의 Web API에서 비동기 작업이 완료되었을 때, 해당 작업의 콜백 함수가 등록되는 큐를 의미합니다.
setTimeout
, setInterval
, DOM 이벤트
, XHR(onload)
등이 여기에 해당됩니다.
Call Stack이 비었을 때, 이벤트 루프가 이 큐에서 하나씩 꺼내서 실행합니다.
setTimeout(() => {
console.log('Callback Queue');
}, 0);
📦 마이크로태스크 큐 (Microtask Queue)
비동기 작업 중에서도 더 높은 우선순위를 가진 작업들이 들어가는 큐입니다.
Promise.then
, async/await
의 await 이후, queueMicrotask()
후등이 여기에 해당됩니다.
Call Stack이 비자마다 가장 먼저 처리됩니다.
하나라도 있으면, 모두 처리한 후에 콜백 큐로 넘어갑니다.
Promise.resolve().then(() => {
console.log('Microtask Queue');
});
💥 차이점 정리
항목 | 마이크로태스크 큐 | 콜백 큐 |
---|---|---|
우선순위 | 더 높음 | 낮음 |
실행 시점 | Call Stack이 비자마자 바로 | 마이크로태스크가 모두 끝난 후 |
대표 작업 | Promise.then , await , queueMicrotask |
setTimeout , setInterval , DOM 이벤트 등 |
이벤트 루프가 처리하는 순서
✅ 이벤트 루프 동작 순서
- JavaScript 코드 실행 (Call Stack)
- Stack이 비면
- Microtask Queue 처리
- 그 후에 Callback Queue 처리
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve().then(() => {
console.log('C');
});
console.log('D');
실행 결과는?
A
D
C
B
❗ 왜 이렇게 실행될까?
A
→console.log
동기 처리setTimeout
: Callback Queue로 이동 (0초지만 최소 지연 있음)Promise.then
: Microtask Queue → Stack이 비면 바로 실행D
: 마지막 동기 코드 → 바로 실행됨- Stack이 비면 → Microtask(C) → Callback(B)
setTimeout, Promise, async/await 비교 실험
🔬 실험 1: setTimeout vs Promise
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
결과:
promise
→timeout
➡️ Microtask Queue가 항상 우선!
실험 2: async/await 안의 흐름
async function test() {
console.log('1');
await Promise.resolve();
console.log('2');
}
test();
console.log('3');
결과:
1
,3
,2
→ await
는 함수 내부를 마이크로태스크로 분리해서, 먼저 바깥 코드(3)가 실행됨
실무에서 겪는 대표 이슈와 해결법
❌ Spinner가 안 뜨는 문제
setLoading(true);
fetchData();
setLoading(false);
이렇게 하면 fetch가 끝나기 전에
setLoading(false)
가 실행됨 ➡️ 해결:await
또는.then
으로 분리 필요
❌ Promise 내부 비동기 로직 누락
function getData() {
fetch('...')
.then(res => res.json());
}
console.log(getData()); // undefined
Promise
를 반환하지 않아서 await도 못 쓰고, 결과도 못 받음
➡️ 해결: return fetch(...)
❌ setTimeout 안의 state 갱신이 늦게 반영
setTimeout(() => {
setState(value + 1); // value가 최신값이 아닐 수 있음
}, 1000);
➡️ 해결: setState(prev => prev + 1)
형태 사용
댓글남기기