
JS는 싱글 스레드 언어다.
그렇다면 어떻게 비동기 처리를 할 수 있을까?
이는 이벤트 루프를 활용한다.
오래 걸리는 작업은 일단 다른 곳(웹 브라우저나 Node.js의 별도 스레드)에 맡겨두고, 자바스크립트는 바로 다음 코드를 실행한다.
그리고 맡겨뒀던 작업이 끝나면, 그 결과를 받아서 "이제 실행해도 돼!" 하고 다시 자바스크립트에게 알려주는 역할을 바로 이벤트 루프가 담당한다.
호출 스택 (Call Stack)이 비어있는지 계속 확인
호출 스택이 비었다면, 태스크 큐 (Task Queue)에 처리할 작업(콜백 함수)이 있는지 확인
큐에 작업이 있다면, 가장 오래된 작업을 꺼내 호출 스택으로 옮겨 실행
이 과정을 끊임없이 반복 (그래서 '루프'라고 불린다)
setTimeout, fetch, 파일 시스템 접근 등 시간이 걸리는 비동기 작업을 처리하는 별도의 공간이벤트 루프가 확인하는 태스크 큐는 두 가지 종류로 나뉜다.
이는 우선 순위에 따라 구분된다.
Promise의 .then(), .catch(), .finally() 콜백 함수 & async/awaitsetTimeout(), setInterval() 콜백 함수 & 이벤트 핸들러 & fetch 등 네트워크 요청
지금까지의 개념을 정리해서 이벤트 루프의 처리 순서를 정리해보면 다음과 같다.
setTimeout이나 Promise 같은 비동기 코드를 만나면, JS 엔진은 해당 작업을 Web API (또는 Node.js API)로 보낸 뒤 바로 다음 동기 코드를 실행한다. (기다리지 않는다)console.log('Start!'); // 1. 콜 스택에 추가 -> 실행 -> 제거
setTimeout(() => { // 2. Web API로 타이머 작업을 보냄
console.log('Timeout!');
}, 0);
Promise.resolve().then(() => { // 3. Promise의 then 콜백을 마이크로태스크 큐로 보냄
console.log('Promise!');
});
console.log('End!'); // 4. 콜 스택에 추가 -> 실행 -> 제거
setTimeout의 콜백 함수가 매크로태스크 큐에 들어와 대기하고 있다.위 2단계로 돌아가 다음 매크로태스크가 있는지 확인하며 전체 과정을 계속 반복한다. 이게 '이벤트 루프'다.
console.log('Start!'); // 1. 콜 스택에 추가 -> 실행 -> 제거
setTimeout(() => { // 2. Web API로 타이머 작업을 보냄
console.log('Timeout!');
}, 0);
Promise.resolve().then(() => { // 3. Promise의 then 콜백을 마이크로태스크 큐로 보냄
console.log('Promise!');
});
console.log('End!'); // 4. 콜 스택에 추가 -> 실행 -> 제거
console.log('Start!') 실행. 출력: "Start!"setTimeout 만남 -> 타이머 콜백 함수는 Web API로 전달.Promise.then 만남 -> then의 콜백 함수는 마이크로태스크 큐로 전달.console.log('End!') 실행. 출력: "End!"() => console.log('Promise!') 가 있다.setTimeout의 콜백 함수인 () => console.log('Timeout!') 이 있다.최종 출력 결과:
Start!
End!
Promise!
Timeout!
function plus() {
let a = 1;
setTimeout(() => console.log(++a), 1000);
return a;
}
const result = plus();
console.log("result :", result); //?
result: 1
// (1초 딜레이 후)
2
plus() 호출, Call Stack에 쌓임, 변수 a 가 1로 선언됨setTimeout를 만나면 타이머 설정(1초)과 () => console.log(++a) 라는 콜백 함수를 Web API에 위임a 반환 -> plus()는 Call Stack에서 제거됨 -> result에 1 할당 ->result: 1 출력setTimeout의 콜백 함수는 Callback Queue로 이동함() => console.log(++a)가 실행됨plus 함수의 환경을 기억하고 있음plus() 의 변수 a에 접근할 수 있다!!++a가 실행돼 a는 2가 됨console.log(2)가 실행되어 콘솔에 2 출력7번이 특히 중요한데 이 콜백 함수의 실행은
result변수에 아무런 영향을 주지 않는다.
result변수는 4번 단계에서plus()가 반환한1을 할당받고 그거로 끝남- 나중에 실행되는 콜백 함수는
result를 수정하는 코드가 아니라, 단지 자신이 기억하고 있던a의 값을 2로 바꿔서 콘솔에2를 출력할 뿐이다.
const baseData = [1, 2, 3, 4, 5, 6, 100];
function sync() {
baseData.forEach((v, i) => {
console.log("sync ", i);
});
}
const asyncRun = (arr, fn) => {
// value, index
arr.forEach((v, i) => {
setTimeout(() => fn(i), 1000);
});
};
function sync2() {
baseData.forEach((v, i) => {
console.log("sync 2 ", i);
});
}
asyncRun(baseData, (idx) => console.log(idx));
sync();
sync2();
// --- 동기 코드 즉시 실행 ---
sync 0
sync 1
sync 2
sync 3
sync 4
sync 5
sync 6
sync 2 0
sync 2 1
sync 2 2
sync 2 3
sync 2 4
sync 2 5
sync 2 6
// --- 약 1초 후 비동기 코드 실행 ---
0
1
2
3
4
5
6
asyncRun()이 호출되며 Call Stack에 들어감forEach는 동기적으로 실행됨baseData의 7개 요소를 멈춤 없이 순회함forEach 루프가 돌아가며 setTimeout이 총 7번 호출됨asyncRun()은 여기서 실행이 끝나 Call Stack에서 제거됨sync()가 호출되며 Call Stack에 들어가고, forEach 루프가 동기적으로 실행sync 0부터 sync 6까지 순차적으로 바로 출력됨sync()가 Call Stack에서 제거됨sync2() 함수가 호출되며 Call Stack에 들어가고, forEach 루프가 동기적으로 실행sync 2 0부터 sync 2 6까지 순차적으로 바로 출력됨sync2()가 Call Stack에서 제거됨Q.
sync()가 끝나고sync2()가 실행되려던 찰나에 1초가 끝나서 비동기 작업이 먼저 실행된다는 보장은 없을까?
A. 그런 건 없다.
sync()와 sync2()는 현재 실행 흐름에 있는 동기 코드이기 때문에, 1초가 그 사이에 지나가더라도 무조건 sync2()까지 모두 실행된 후에 비동기 작업이 시작된다.
이벤트 루프의 가장 중요하고 절대적인 규칙은 다음과 같다.
"Call Stack이 완전히 비워질 때까지, 절대로 Callback Queue에서 작업을 가져오지 않는다."
즉, 이벤트 루프는 현재 실행 중인 동기 코드를 절대로 방해하거나 중단시키지 않는다.
그렇다면 만약 sync() 실행 중에 1초가 지났다면?
sync() 실행 중 (Call Stack에 sync 있음)setTimeout의 콜백 함수들을 Callback Queue로 보냄sync()가 실행 중이므로 Call Stack은 비어있지 않음sync2() 실행sync()의 실행이 끝나면, 바로 다음 동기 코드인 sync2()가 Call Stack에 들어와 실행됨sync2()까지 실행이 끝나고 Call Stack이 완전히 비워짐이처럼 JS는 현재 실행 중인 동기 코드 블록의 실행을 최우선으로 보장한다.
비동기 콜백은 아무리 먼저 준비가 되더라도, 현재 진행 중인 동기 작업들이 모두 끝날 때까지 얌전히 줄을 서서 기다려야 한다.
const baseData = [1, 2, 3, 4, 5, 6, 100];
const asyncRun = (arr, fn) => {
arr.forEach((v, i) => {
setTimeout(() => {
setTimeout(() => {
console.log("cb 2");
fn(i);
}, 1000);
console.log("cb 1");
}, 1000);
});
};
asyncRun(baseData, (idx) => console.log(idx));
// --- 약 1초 후 ---
cb 1
cb 1
cb 1
cb 1
cb 1
cb 1
cb 1
// --- 다시 약 1초 후 (총 2초 후) ---
cb 2
0
cb 2
1
cb 2
2
cb 2
3
cb 2
4
cb 2
5
cb 2
6
setTimeout이 중첩되어있는데, 여기서 첫 번째를 바깥쪽, 두 번째를 안쪽으로 지칭한다.
최초 동기 코드 실행 (0초)
asyncRun()이 호출되며 Call Stack으로 들어감forEach 루프가 동기적으로 7번 실행됨setTimeout이 총 7번 호출됨asyncRun()의 모든 동기적 코드가 실행 완료되어 Call Stack에서 제거됨setTimeout은 아직 존재조차 하지 않는다!약 1초 후 (첫 번째 콜백들의 실행)
setTimeout을 만나게 되고, 이때 새로운 1초 타이머와 안쪽 콜백 함수가 Web API에 등록됨cb 1이 출력됨약 2초 후 (두 번째 콜백들의 실행)
cb 2가 출력됨fn(i), 즉 console.log(0)이 실행되어 0이 출력됨