자바스크립트는 싱글 스레드다. 하지만 우리는 setTimeout(), Promise 등을 통해 멀티 스레드와 유사한 경험을 한다. 이것이 어떻게 가능한 것일까?

1. 콜스택, 이벤트루프, 메시지큐

궁금증을 해결하기에 앞서 일단 자바스크립트의 가장 기본적인 실행 단위인 함수 실행 방식에 대해 알아보자.

1.1. 콜스택

콜스택CallStack은 현재 실행 중인 함수들을 관리하는 스택이다. 간단히 설명하면 아래와 같다.

  1. 자바스크립트는 함수가 호출되면 콜스택에 해당 함수를 추가push한 뒤, 함수의 내용을 실행한다.
  2. 해당 함수를 실행하던 중 다른 함수를 호출하면 마찬가지로 그 함수도 스택에 추가push한다. 자바스크립트는 콜스택의 가장 위에 있는 함수를 실행한다.
  3. 함수의 실행이 끝나면 해당 함수를 콜스택으로부터 제거pop한다. 콜스택에 아직 함수가 남아있다면, 스택의 가장 위에 있는 함수를 계속 실행한다.
  4. 콜스택이 빌 때까지 2,3번을 반복한다.
function c() {
  console.log("call c");
}
function b() {
  console.log("call b");
}
function a() {
  console.log("call a");
  b();
  c();
}
a();

위 코드로 예를 들어 설명하면 아래와 같다.

  1. a() 가 실행되었다. 콜스택에 a() 가 추가된다. 콜스택: [a()]
  2. b() 가 실행되었다. 콜스캑에 b() 도 추가된다. 콜스택: [a(), b()]
  3. b() 의 실행이 끝난다. 콜스택에서 b() 를 제거한다. 콜스택: [a()]
  4. c() 가 실행되었다. 콜스택에 c() 도 추가된다. 콜스택: [a(), c()]
  5. 함수 c() 의 실행이 끝난다. 콜스택에서 c() 를 제거한다. 콜스택: [a()]
  6. 함수 a() 의 실행이 끝난다. 콜스택에서 a() 를 제거한다. 콜스택: []

1.2. 메시지큐, 이벤트루프

코드에 의해 직접 실행된 함수들은 위처럼 실행된다. 하지만 자바스크립트는 코드 상에서 직접 호출하는 함수 이외에도 호출되는 함수들이 있다. 바로 setTimeout 이나 DOM 이벤트에 의해 실행되는 콜백 함수들이다. 이 콜백 함수들은 해당 이벤트가 발생할 때마다 메시지큐MessageQueue라는 큐에 추가된다.

한편 자바스크립트에는 이벤트루프EventLoop라는 것이 존재하는데, 이벤트루프는 콜스택과 메시지큐를 계속 확인한다. 콜스택이 비어있으면서 메시지큐에 함수가 있다면, 해당 함수를 메시지큐로부터 콜스택으로 옮겨넣는다.

2. 자바스크립트는 싱글스레드라며?

그런데 여기서 의문점이 생긴다.

"자바스크립트는 싱글 스레드라서 한 번에 한 가지 일밖에 처리를 못하는데, 어떻게 1. 콜스택에서 함수가 실행되고 있는 와중에 2. 메시지큐에 콜백 함수를 추가할 수 있는 거지? 이게 가능하려면 메시지큐에 콜백 함수를 추가하는 코드는 별도의 스레드에서 돌고 있어야 하는 거 아닌가? 그럼 자바스크립트는 멀티 스레드여야 하는데?"

이 의문은 반은 맞고 반은 틀렸다.

자바스크립트는 싱글 스레드가 맞다. 하지만 자바스크립트가 실행되는 런타임 환경은 싱글 스레드가 아니다. 멀티 스레드이다. 여기서 말하는 *"자바스크립트가 실행되는 런타임 환경"*은 Node.js, 웹브라우저 등이 있다.

프론트엔드 엔지니어 관점에서 웹브라우저에만 집중하자면, 웹브라우저는 "자바스크립트 실행 컨텍스트"라는 스레드 이외에 브라우저의 스레드가 별도로 존재한다. 해당 스레드에서는 Web API 가 실행되는데, Web API 의 대표적인 기능은 아래와 같다.

  • DOM
  • ajax
  • setTimeout 등 타이머 처리

브라우저 스레드는 Web API 에 의해 발생하는 이벤트의 콜백 함수들을 메시지큐에 밀어넣는다.

(브라우저 환경은 이외에도 웹워커WebWorker라는 별도의 스레드가 존재하며, 런타임 환경에 따라서 더 많은 스레드가 존재할 수 있다.)

3. Promise.all()

좋다. 자바스크립트는 싱글 스레드지만 자바스크립트 런타임 환경은 멀티 스레드라는 것을 알았다. Web API 실행을 위한 스레드가 하나 더 존재하며, 따라서 setTimeout 이나 DOM 이벤트, Promise 의 콜백들이 문제 없이 실행된다는 것을 알았다.

그렇다면 여기서 한 가지 의문이 든다.

"Promise.all()은? 이 함수는 여러 Promise 들을 동시에 실행시켜주는 거 아니었어? 그런데 브라우저 스레드가 싱글 스레드면 말이 안 되잖아? 멀티 스레드인 거 아냐?"

아래 코드를 보자.

function func1() {
  console.log("func1 called");
  return new Promise((resolve) => {
    for (let i = 0; i < 50000; i++) {
      if (i % 10000 === 0) {
        console.log("func1", i);
      }
    }
    resolve();
    console.log("func1 end");
  });
}
function func2() {
  console.log("func2 called");
  return new Promise((resolve) => {
    for (let i = 0; i < 50000; i++) {
      if (i % 10000 === 0) {
        console.log("func2", i);
      }
    }
    resolve();
    console.log("func2 end");
  });
}
Promise.all([func1(), func2()]);

// 실행 결과
// func1 called
// func1 0
// func1 10000
// func1 20000
// func1 30000
// func1 40000
// func1 end
// func2 called
// func2 0
// func2 10000
// func2 20000
// func2 30000
// func2 40000
// func2 end

멀티 스레드에서 실행되지 않는 것을 알 수 있다. Promise.all 도 기존 Promise 와 동일한 방법으로 실행된다. 인자로 받은 함수들은 동기적으로 차례로 실행되며, 실행이 완료되었을 때 콜백을 메시지큐에 넣는다.

4. 결론

따라서 우리가 얻은 결론은 아주 간단하다.

자바스크립트는 싱글 스레드다. 하지만 자바스크립트의 런타임 환경은 멀티 스레드다.

5. 더 알아봐야 할 것들..

명쾌하게 해결된 궁금증도 있지만 아직도 모호하거나 오히려 새로 생긴 궁금증들이 있다. 잘 기록해두었다가 나중에 다시 조사해보자.

  • 자바스크립트가 싱글 스레드라면, 그리고 메시지큐가 자바스크립트의 스레드에 포함된 녀석이라면, 어떻게 콜스택이 실행되고 있는 와중에 메시지큐는 메시지(함수)를 받을 수 있는 것일까?
    • (10월 10일 덧붙임): 자바스크립트는 스크립트가 실행되는 단일 스레드로만 이루어진 게 맞다. 이벤트루프와 메시지큐는 이 스레드에 속하지 않은, 별도의 스레드에서 실행되는 녀석들이다 (브라우저, nodejs 등이 지원). 따라서 자바스크립트가 싱글 스레드에서 실행되는 동안, 이벤트루프와 메시지큐는 개별적으로 실행될 수 있다. 참고
  • 어떤 글에서는 Promise 가 Web API 스레드에서 실행된다고 하고, 어떤 글에서는 자바스크립트 스레드에서 실행된다고 한다. 뭐가 맞는 것인가?
    • 자바스크립트 스레드일 가능성이 높아 보인다.
    • 이건 마이크로태스크큐MicrotaskQueue와 같이 정리하면 될 것 같다.
    • (10월 10일 덧붙임): Promise 는 자바스크립트 스레드에서 실행되는 게 맞다.
  • ajax 콜은 여러 콜이 동시에 비동기적으로 실행된다는데, 이게 사실인가? 사실이라면 원리는 무엇인가?
    • "겉보기에는 비동기적이지만 내부적으로는 동기적으로 실행된다."가 아니라 실제로 비동기적으로 실행된다고 하는 글이 있는데, 확인이 필요하다.
    • (10월 10일 덧붙임): ajax 콜은 브라우저에 의해 멀티 스레드에서 실행된다.

6. References