hhjeee.log
GitHub Icon

이벤트 루프와 비동기 통신의 이해

2025년 04월 08일


자바스크립트는 싱글 스레드에서 작동한다.

그러나 이러한 싱글 스레드 기반의 자바스크립트에서도 많은 양의 비동기 작업이 이루어지고 있다. 예를 들어 사용자는 검색어를 입력해 검색을 위한 네트워크 요청이 발생하는 순간에도 사용자는 다른 작업을 처리할 수 있다. 자바스크립트는 싱글 스레드의 동기식으로 작동하므로 검색 결과를 받기 전까지 아무 작업도 하지 못하는 것이 자연스러워 보이지만, 우리는 다양한 비동기 작업을 수행하고 있다.

싱글 스레드 자바스크립트

먼저, 자바스크립트는 왜 싱글 스레드로 설계됐을까 ?

스레드는 하나의 프로세스에서 동시에 서로 같은 자원에 접근할 수 있는데, 동시에 여러 작업을 수행하다 보면 같은 자원에 대해 여러번 수정하는 등의 동시성 문제가 발생할 수 있어 이에 대한 처리가 필요하다. 또한 하나의 스레드가 문제가 생기면 같은 자원을 공유하는 다른 스레드에도 문제가 발생할 수 있다.

최초의 자바스크립트는 브라우저에서 HTML을 그리는 데 한정적인 도움을 주는 보조 역할로 만들어져 버튼 위에 이미지를 띄우거나 폼을 처리하는 등 아주 기초적인 수준에서만 제한적으로 사용했다. 만약 자바스크립트가 멀티 스레딩을 지원해 동시에 여러 스레드가 DOM을 조작할 수 있게 된다면 동시성 문제로 브라우저의 DOM 표시에 큰 문제를 야기할 수도 있을 것이다.

당시 상황을 고려해보면, 자바스크립트는 단순한 작업 처리를 위해 만들어졌기 때문에 싱글 스레드로 설계되었을 것이다.

자바스크립트가 싱글 스레드라는 것은 자바스크립트 코드의 실행이 하나의 스레드에서 순차적으로 이루어진다는 것을 의미한다. 즉, 자바스크립트의 모든 코드는 동기적으로 한 번에 하나씩 순차적으로 처리된다.

그렇다면 동기식으로 작동하는 자바스크립트에서 어떻게 비동기 코드를 처리할 수 있는 것일까 ?

이벤트 루프란 ?

이벤트 루프란 자바스크립트 런타임 외부에서 자바스크립트의 비동기 실행을 돕기 위해 만들어진 장치이다.

호출 스택과 이벤트 루프

호출 스택(콜스택, call stack)은 자바스크립트에서 수행해야 할 코드나 함수를 순차적으로 담아두는 스택이다.

이 코드는 다음과 같은 순서로 호출 스택에 쌓이고 비워지게 된다.

function bar() {
  console.log('bar');
}
function baz() {
  console.log('baz');
}
function foo() {
  console.log('foo');
  bar();
  baz();
}
 
foo();
  1. foo() 함수 푸시
  2. foo() 내부의 console.log 푸시
  3. 2의 실행 완료 후 팝
  4. bar() 함수 푸시
  5. bar() 내부의 console.log() 푸시
  6. 5의 실행 완료 후 팝
  7. bar()에 남은 것이 없으므로 팝
  8. baz() 함수 푸시
  9. baz() 내부의 console.log() 푸시
  10. 9의 실행 완료 후 팝
  11. baz()에 남은 것이 없으므로 팝
  12. foo()에 남은 것이 없으므로 팝
  13. 스택 비워짐

위 과정에서 호출 스택이 비어 있는지 여부를 확인하는 것이 이벤트 루프이다. 이벤트 루프는 단일 스레드 내부에서 호출 스택 내부에 수행할 작업이 있는지 확인하고, 있다면 자바스크립트 엔진을 통해 수행한다. '코드를 실행하는 것'과 '호출 스택이 비어있는지 확인하는 것' 모두 단일 스레드에서 일어나며 두 작업은 순차적으로 이루어진다.

setTimeout만 추가된 이 코드는 다음과 같은 순서로 호출 스택에 쌓이고 비워지게 된다.

function bar() {
  console.log('bar');
}
function baz() {
  console.log('baz');
}
function foo() {
  console.log('foo');
  setTimeout(bar, 0);
  baz();
}
 
foo();
  1. foo() 함수 푸시
  2. foo() 내부의 console.log 푸시
  3. 2의 실행 완료 후 팝
  4. setTimeout(bar, 0) 푸시
  5. 4번에 대해 타이머 이벤트 실행되며 태스크 큐로 들어가고, 스택에서 제거
  6. baz() 함수 푸시
  7. baz() 내부의 console.log() 푸시
  8. 7의 실행 완료 후 팝
  9. baz()에 남은 것이 없으므로 팝
  10. foo()에 남은 것이 없으므로 팝
  11. 스택 비워짐
  12. 이벤트 루프가 스택 비워져 있다는 것 확인 -> 태스크 큐의 bar()를 스택에 푸시
  13. bar() 내부의 console.log() 푸시
  14. 13의 실행 완료 후 팝
  15. bar()에 남은 것이 없으므로 팝
  16. 스택 비워짐

태스크 큐라는 개념이 등장하는데, 여기서 태스크 큐는 실행해야 할 태스크의 집합을 의미한다. 여기서 실행해야 할 태스크는 비동기 함수의 콜백 함수나 이벤트 핸들러 등을 의미한다.

이벤트 루프는 태스크 큐를 한 개 이상 가지고 있다. 이벤트 루프는 호출 스택에 실행 중인 코드가 있는지, 그리고 태스크 큐에 대기 중인 함수가 있는지 반복해서 확인한다. 만약 호출 스택이 비었다면 태스크 큐에 대기 중인 작업이 있는지 확인하고, 이 작업을 실행 가능한 오래된 것부터 순차적으로 꺼내와 실행한다. 이 작업 또한 태스크 큐가 빌 때까지 이루어진다.

그렇다면 비동기 함수는 누가 수행하는 것일까 ? n초 뒤에 setTimeout을 요청하는 작업은 누가 처리할까 ?
이러한 작업들은 모두 태스크 큐가 할당되는 별도의 스레드에서 수행되며, 이 별도의 스레드에서 태스크 큐에 작업을 할당해 처리하는 것은 모두 브라우저나 Node.js의 역할이다.

자바스크립트의 코드 실행은 싱글 스레드에서 이루어지지만 이러한 외부 Web API 등은 모두 자바스크립트 코드 외부에서 실행되고 콜백이 태스크 큐로 들어가는 것이다.

태스크 큐와 마이크로 태스크 큐

태스크 큐와 다르게 마이크로 태스크 큐도 존재한다. 여기에 들어가는 마이크로 태스크에는 대표적으로 Promise가 있다. 마이크로 태스크 큐는 태스크 큐보다 우선권을 갖는데. 마이크로 태스크 큐가 빌 때까지 기존 태스크 큐의 실행은 뒤로 미루어진다.

function foo() {
  console.log('foo');
}
function bar() {
  console.log('bar');
}
function baz() {
  console.log('baz');
}
 
setTimeout(foo, 0);
 
Promise.resolve().then(bar).then(baz);

위 코드를 실행하면 bar, baz, foo 순서대로 출력되며, Promise가 우선됨을 알 수 있다.

각 태스크에 들어가는 대표적인 작업은 다음과 같다.

태스크 큐마이크로 태스크 큐
setTimeout, setInterval, setImmediatePromise, queueMicroTask, MutationObserver

참고자료

모던 리액트 Deep Dive 1.5장 이벤트 루프와 비동기 통신의 이해 참조