[JavaScript/Node.js] 이벤트 루프

seungjun.dev·2025년 8월 26일

JavaScript

목록 보기
7/8

🔁 이벤트 루프

JS는 싱글 스레드 언어다.
그렇다면 어떻게 비동기 처리를 할 수 있을까?

이는 이벤트 루프를 활용한다.

오래 걸리는 작업은 일단 다른 곳(웹 브라우저나 Node.js의 별도 스레드)에 맡겨두고, 자바스크립트는 바로 다음 코드를 실행한다.
그리고 맡겨뒀던 작업이 끝나면, 그 결과를 받아서 "이제 실행해도 돼!" 하고 다시 자바스크립트에게 알려주는 역할을 바로 이벤트 루프가 담당한다.

핵심 역할

  1. 호출 스택 (Call Stack)이 비어있는지 계속 확인

  2. 호출 스택이 비었다면, 태스크 큐 (Task Queue)에 처리할 작업(콜백 함수)이 있는지 확인

  3. 큐에 작업이 있다면, 가장 오래된 작업을 꺼내 호출 스택으로 옮겨 실행

  4. 이 과정을 끊임없이 반복 (그래서 '루프'라고 불린다)

  • Call Stack: 현재 실행 중인 코드를 기록
  • Web API: setTimeout, fetch, 파일 시스템 접근 등 시간이 걸리는 비동기 작업을 처리하는 별도의 공간
  • Callback Queue (Task Queue): Web API에서 완료된 비동기 작업의 콜백 함수들이 대기하는 줄
  • Event Loop: Call Stack이 비어있는지 계속 확인하고, 비어있다면 Callback Queue에서 가장 오래된 작업을 Call Stack으로 옮겨 실행

태스크 큐는 두 종류

이벤트 루프가 확인하는 태스크 큐는 두 가지 종류로 나뉜다.
이는 우선 순위에 따라 구분된다.

  • Microtask Queue: 현재 실행 중인 작업이 끝난 직후, 즉시 처리해야 하는 작업들을 모아두는 곳, 다른 어떤 작업보다도 높은 우선순위를 가진다
    • e.g. Promise.then(), .catch(), .finally() 콜백 함수 & async/await
    • 특징: 이벤트 루프는 하나의 매크로태스크를 처리한 후, 마이크로태스크 큐가 완전히 빌 때까지 모든 마이크로태스크를 순서대로 실행한다. 마이크로태스크가 실행되는 동안 새로운 마이크로태스크가 추가되면, 그것까지 모두 실행하고 나서야 다음으로 넘어간다.
  • Macrotask Queue: 일반적인 비동기 작업들을 모아두는 곳이다. 우리가 보통 '태스크 큐'라고 부르는 것이 바로 이것!
    • e.g. setTimeout(), setInterval() 콜백 함수 & 이벤트 핸들러 & fetch 등 네트워크 요청

처리 순서 요약

지금까지의 개념을 정리해서 이벤트 루프의 처리 순서를 정리해보면 다음과 같다.

1. 초기 코드 실행 (콜 스택)

  • 처음 스크립트가 실행되면 모든 동기적 코드가 순서대로 콜 스택에 쌓였다가 실행되고 즉시 제거된다.
  • 이 과정에서 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. 콜 스택에 추가 -> 실행 -> 제거

2. 매크로태스크 큐에서 태스크 가져오기

  • 콜 스택이 완전히 비워지면, 이벤트 루프는 매크로태스크 큐를 확인한다.
  • 만약 큐에 대기 중인 작업(콜백 함수)이 있다면, 그중 가장 오래된 작업 하나를 꺼내 콜 스택으로 보낸다.
  • 위 예시 코드에서 setTimeout의 콜백 함수가 매크로태스크 큐에 들어와 대기하고 있다.

3. 콜 스택 실행

  • 콜 스택으로 옮겨진 콜백 함수가 실행된다.
  • 이 콜백 함수가 실행되는 동안 발생하는 모든 동기 코드는 이 단계에서 처리된다.

4. (중요) 마이크로태스크 큐 비우기

  • 하나의 매크로태스크가 완료된 직후, 이벤트 루프는 마이크로태스크 큐를 확인한다.
  • 만약 마이크로태스크 큐에 작업이 있다면, 큐가 완전히 빌 때까지 모든 작업을 순서대로 꺼내 콜 스택에서 실행한다.
  • 핵심: 매크로태스크는 한 번의 루프에서 하나만 실행되지만, 마이크로태스크는 한 사이클에서 큐에 있는 모든 작업이 실행된다. (우선순위가 더 높다)

5. 렌더링

  • 마이크로태스크 큐까지 모두 비워지면, 브라우저는 필요한 경우 UI를 다시 그리는 렌더링 작업을 수행한다.

6. 반복

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. 콜 스택에 추가 -> 실행 -> 제거
  1. 초기 실행
  • console.log('Start!') 실행. 출력: "Start!"
  • setTimeout 만남 -> 타이머 콜백 함수는 Web API로 전달.
  • Promise.then 만남 -> then의 콜백 함수는 마이크로태스크 큐로 전달.
  • console.log('End!') 실행. 출력: "End!"
  • 이제 모든 동기 코드가 실행되어 콜 스택은 비었다.
  1. 이벤트 루프 시작
  • 매크로태스크 큐를 확인하기 전에, 마이크로태스크 큐부터 확인.
  • 마이크로태스크 큐() => console.log('Promise!') 가 있다.
  • 이것을 콜 스택으로 가져와 실행. 출력: "Promise!"
  • 마이크로태스크 큐가 모두 비워졌다.
  1. 다음 루프
  • 이벤트 루프매크로태스크 큐를 확인한다.
  • setTimeout의 콜백 함수인 () => console.log('Timeout!') 이 있다.
  • 이것을 콜 스택으로 가져와 실행. 출력: "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
  1. plus() 호출, Call Stack에 쌓임, 변수 a1로 선언됨
  2. setTimeout를 만나면 타이머 설정(1초)과 () => console.log(++a) 라는 콜백 함수를 Web API에 위임
  3. JS는 기다리지 않고 바로 다음줄을 실행
  4. a 반환 -> plus()Call Stack에서 제거됨 -> result1 할당 ->result: 1 출력
  5. Web API에서 1초 타이머 작업이 끝나면, setTimeout의 콜백 함수는 Callback Queue로 이동함
  6. Event LoopCall Stack이 비어있는 것을 확인하고, Callback Queue에서 콜백 함수를 Call Stack으로 가져와 실행
  7. 콜백 함수 () => console.log(++a)가 실행됨
    • 이때 콜백 함수는 클로저에 의해 자신이 선언됐던 plus 함수의 환경을 기억하고 있음
      • 그래서 이미 사라진 plus() 의 변수 a에 접근할 수 있다!!
    • ++a가 실행돼 a2가 됨
    • 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
  1. asyncRun()이 호출되며 Call Stack에 들어감
    • 이때 함수 내부의 forEach동기적으로 실행됨
    • 즉, baseData의 7개 요소를 멈춤 없이 순회함
  2. forEach 루프가 돌아가며 setTimeout이 총 7번 호출됨
    • 이때 거의 동시에 7개의 1초 타이머와 콜백 함수가 Web API에 위임됨
    • asyncRun()은 여기서 실행이 끝나 Call Stack에서 제거됨
  3. 다음줄인 sync()가 호출되며 Call Stack에 들어가고, forEach 루프가 동기적으로 실행
    • 콘솔에 sync 0부터 sync 6까지 순차적으로 바로 출력됨
    • 함수 실행이 끝나면 sync()Call Stack에서 제거됨
  4. 다음줄인 sync2() 함수가 호출되며 Call Stack에 들어가고, forEach 루프가 동기적으로 실행
    • 콘솔에 sync 2 0부터 sync 2 6까지 순차적으로 바로 출력됨
    • 함수 실행이 끝나면 sync2()Call Stack에서 제거됨
    • 이 시점에서 메인 스크립트의 모든 동기 코드는 실행 완료됐고 Call Stack은 비어있다.
  5. 약 1초 후, Web API에 있던 7개의 타이머가 거의 동시에 만료된다.
  6. Web API는 만료된 순서대로 7개의 콜백 함수를 Callback Queue로 이동시킨다.
    • 콜백 함수들이 줄을 서게 된다.
  7. Event LoopCall Stack이 비어있는 것을 확인하면, Callback Queue의 맨 앞에 있는 작업을 Call Stack으로 가져와 실행한다. (7번 반복됨)

Q. sync()가 끝나고 sync2()가 실행되려던 찰나에 1초가 끝나서 비동기 작업이 먼저 실행된다는 보장은 없을까?

A. 그런 건 없다.

sync()sync2()는 현재 실행 흐름에 있는 동기 코드이기 때문에, 1초가 그 사이에 지나가더라도 무조건 sync2()까지 모두 실행된 후에 비동기 작업이 시작된다.

이벤트 루프의 가장 중요하고 절대적인 규칙은 다음과 같다.

"Call Stack이 완전히 비워질 때까지, 절대로 Callback Queue에서 작업을 가져오지 않는다."

즉, 이벤트 루프는 현재 실행 중인 동기 코드를 절대로 방해하거나 중단시키지 않는다.

그렇다면 만약 sync() 실행 중에 1초가 지났다면?

  1. sync() 실행 중 (Call Stack에 sync 있음)
    • 이때 Web API에 있던 타이머가 끝남
    • Web API는 setTimeout의 콜백 함수들을 Callback Queue로 보냄
    • 이제 Callback Queue에는 7개의 콜백 함수가 실행되기를 기다리고 있음
  2. 이벤트 루프의 상태 확인
    • 이벤트 루프는 계속해서 Call Stack이 비었는지 확인
    • 하지만 아직 sync()가 실행 중이므로 Call Stack은 비어있지 않음
    • 따라서 이벤트 루프는 Callback Queue에 작업이 있다는 것을 알지만, 아무것도 하지 않고 기다림
  3. sync2() 실행
    • sync()의 실행이 끝나면, 바로 다음 동기 코드인 sync2()가 Call Stack에 들어와 실행됨
    • Call Stack은 여전히 비어있지 않으므로, 이벤트 루프는 계속 대기
  4. 모든 동기 코드 종료
    • 마침내 sync2()까지 실행이 끝나고 Call Stack이 완전히 비워짐
  5. 비동기 코드 실행 시작
    • 이제서야 이벤트 루프는 Callback Queue에서 기다리던 첫 번째 콜백 함수를 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이 중첩되어있는데, 여기서 첫 번째를 바깥쪽, 두 번째를 안쪽으로 지칭한다.

  1. 최초 동기 코드 실행 (0초)

    1. asyncRun()이 호출되며 Call Stack으로 들어감
    2. 내부의 forEach 루프가 동기적으로 7번 실행됨
    3. 루프가 돌 때마다 바깥쪽 setTimeout이 총 7번 호출됨
    4. 결과적으로 7개의 1초 타이머와 바깥쪽 콜백 함수들이 Web API에 등록됨
    5. asyncRun()의 모든 동기적 코드가 실행 완료되어 Call Stack에서 제거됨
      • 이 시점: Call Stack은 비어있고, Web API에서는 7개의 타이머가 1초를 세고 있음
      • 안쪽의 setTimeout은 아직 존재조차 하지 않는다!
  2. 약 1초 후 (첫 번째 콜백들의 실행)

    1. Web API에 있던 7개의 타이머가 거의 동시에 만료됨
    2. 7개의 바깥쪽 콜백 함수들이 Callback Queue로 순서대로 이동함
    3. Event LoopCall Stack이 비어있는 것을 확인하고, Callback Queue에 있던 바깥쪽 콜백을 하나씩 Call Stack으로 옮겨 실행시킴
    4. 첫 번째 바깥쪽 콜백이 실행됨
      • 안쪽 setTimeout을 만나게 되고, 이때 새로운 1초 타이머와 안쪽 콜백 함수가 Web API에 등록됨
      • cb 1이 출력됨
    5. 첫 번째 바깥쪽 콜백의 실행이 끝나면 Call Stack에서 제거됨
    6. Event Loop는 다음 바깥쪽 콜백을 가져와 4~5번 과정을 반복
      • 이 과정이 7번 빠르게 반복됨
      • 이 시점: 콘솔에는 "cb 1"이 7번 출력되고, Web API에는 새로운 7개의 1초 타이머가 다시 1초를 세기 시작함
  3. 약 2초 후 (두 번째 콜백들의 실행)

    1. Web API에서 새로 등록되었던 7개의 안쪽 타이머들이 거의 동시에 만료됨
    2. 7개의 안쪽 콜백 함수들이 Callback Queue로 이동
    3. Event LoopCall Stack이 비어있는 것을 확인하고, 안쪽 콜백들을 하나씩 Call Stack으로 옮겨 실행시킴
    4. 첫 번째 안쪽 콜백이 실행됨
      • cb 2가 출력됨
      • fn(i), 즉 console.log(0)이 실행되어 0이 출력됨
    5. 이 과정이 7번 반복되어 "cb 2"와 인덱스(0~6)가 순서대로 출력됨
profile
Web FE Dev | Microsoft Student Ambassadors Alumni

0개의 댓글