JS는 어떻게 비동기를 지원하는가

박민우·2023년 9월 30일
1

JavaScript

목록 보기
7/14
post-thumbnail

📌 호출 스택이 하나인 JS 엔진

JS 엔진은 하나의 호출 스택(Call Stack)을 가지고 있으며, Single Thread 기반의 언어이다. 이는 곧, 하나의 함수가 실행되면 이 함수의 실행이 끝날 때까지 다른 작업이 중간에 끼어들지 못한다는 의미이다. 함수가 실행되면 해당 함수는 호출 스택의 가장 상단에 위치하고, 함수의 실행이 끝날 때 해당 함수는 호출 스택에서 제거된다.

이러한 단일 호출 스택의 단점은, 브라우저에서 호출 스택에 실행할 함수가 쌓여있는 동안은 다른 일을 할 수 없다는 것이다. 이 상태를 blocked라 한다. 이 상태에서 브라우저는 렌더링을 할 수도 없고, 다른 코드를 실행할 수도 없다.

브라우저를 렌더링 하는 렌더링 엔진과 js를 실행하는 js 엔진은 서로 다르고, 직렬적으로 실행되기 때문에 서로 동시에 실행될 수 없다.


📌 이벤트 루프를 통한 동시성 지원

그렇다면 JS는 어떻게 동시성, 비동기 방식을 지원할까?

=> 브라우저에는 이벤트 루프가 있고, 이벤트 루프 기반의 비동기 방식으로 Non-blocking IO를 지원하고, 이를 통해 동시성을 지원한다.

Non-blocking IO

입출력 처리는 시작만 해둔 채 완료되지 않은 상태에서 다른 처리 작업을 계속 진행할 수 있도록 멈추지 않고 입출력 처리를 기다리는 방법을 말한다.

JS 자체에는 이벤트 루프가 없다. V8 같은 JS 엔진은 단일 호출 스택을 사용하고, 요청이 들어오면 순서에 따라 처리할 뿐이다.

=> JS 엔진을 구동하는 런타임 환경(브라우저나 NodeJs)이 비동기 요청을 담당한다.

런타임 환경이 제공하는 것은 다음과 같다.

  • Web APIs => 브라우저에서
    • DOM(document)
    • AJAX(XMLHttpRequest)
    • Timeout(setTimeout)
  • Event Loop
  • Task Queue

📌 이벤트 루프의 역할

위에서 이벤트 루프를 통해 동시성, 비동기를 지원한다고 했는데 정확히 이벤트 루프가 어떤 역할을 하는지 알아보자.

Event loop가 하는 일

호출 스택이 비어있을 경우, Task queue에서 함수를 꺼내 호출 스택에 추가한다.

이벤트 루프는 하나의 단순한 동작만을 수행한다. 호출 스택과 Task Queue를 감시하면서, 만약 호출 스택이 비어있다면 이벤트 루프는 큐에서 첫 번째 Task를 호출 스택에 넣고 해당 Task를 수행시킨다.

=> 이러한 반복을 이벤트 루프에서는 tick이라고 한다.

즉, 이벤트 루프는 호출 스택을 보고 있다가 만약 비어있으면 task queue에서 task를 꺼내 호출 스택에 넣어줌으로써 비동기 작업을 지원하고 동시성을 확보하는 것이다.

이벤트 루프는 다발적으로 발생한 메세지들을 큐에 쌓고 실행을 해주면서 동시성을 확보하는 것이지 실제로 동시에 동작이 수행되는 것은 아니다. 실제 실행 자체는 호출 스택에 올라가서 수행이 된다. 이로 인해 하나의 함수가 실행이 완료되기 전에는 다른 함수가 실행될 수 없는 "Run to Completion" 특성을 가지게 된다.


📌 Web API의 역할

위의 설명을 통해 이벤트 루프가 하는 일을 살펴보았다. 하지만, 사실 이벤트 루프는 task queue에 있는 task를 호출 스택에 넣어주기만 하는 것이지, 실제로 특정 작업들을 동시에 실행해주는 것은 이벤트 루프가 아니다.

그렇다면 우리가 JS 코드를 실행할 때 작업들을 비동기적으로 동시에 처리해주는 주체는 무엇일까?

Web API란?

Ajax 요청, setTimeout(), 이벤트 핸들러의 등록과 같이 웹 브라우저에서 제공하는 기능들을 말한다. 여기서 중요한 점은 이러한 요청들의 처리가 JavaScript 엔진의 쓰레드와는 다른 쓰레드들에서 이뤄진다는 점이다.

JavaScript 엔진의 호출 스택에서 실행된 비동기 함수가 요청하는 비동기 작업에 대한 정보와 콜백 함수를 Web API를 통해 브라우저에게 넘기면, 브라우저는 이러한 요청들을 별도의 쓰레드에 위임하게 되는 것이다. 그러면 그 쓰레드는 해당 요청이 완료되는 순간 전달받았던 콜백 함수를 JavaScript 엔진의 태스크 큐라는 곳에 집어넣는다.

setTimeout 예시

setTimeout(function exec() {
  console.log('second')
}, 1000);

위 코드를 실행했을 때,

  1. setTimeout() 함수가 Call Stack 맨 위로 들어와 실행된다.

  2. setTimeout() 함수가 실행되면 JavaScript 엔진은 웹 API를 통해 브라우저에게 setTimeout() 작업을 요청하면서 콜백 함수를 전달하고, 브라우저는 이러한 타이머 작업을 별도의 쓰레드에게 위임한다.

  3. setTimeout()가 Call stack에서 제거된다.

  4. 브라우저는 timer에 할당된 시간이 지난 후, 콜백으로 전달된 exec() 함수를 Callback queue에 푸시한다.

  5. Event Loop는 Call Stack이 비어있으면 Call back queue에 있는 exec()를 Call stack에 추가한다.

  6. exec()이 실행되고, 종료 시 Call stack에서 제거된다.

setTimeout의 delay인자는 콜백함수가 delay ms 후에 실행 되는 것을 보장하지 않는다. 정확히는 콜백함수가 delay ms 후에 Callback Queue에 들어가는 것을 보장한다.


📌 결론

JavaScript 엔진의 스택에서 실행된 비동기 함수가 요청하는 비동기 작업은 Web API를 통해 브라우저에게 넘어가고, 브라우저는 별도의 스레드에서 이를 작업하게 된다. 작업이 끝나면 전달받은 콜백함수를 Task queue에 넣고, 이벤트 루프가 이를 호출 스택으로 옮겨주는 것이다.

=> JS는 Web API이벤트 루프를 통해 비동기 작업을 가능하게 하고, 동시성을 확보한다.

즉, 자바스크립트 엔진 자체는 싱글 쓰레드가 맞지만 자바스크립트 런타임은 싱글 쓰레드가 아니다. 만약 모든 자바스크립트 코드가 자바스크립트 엔진에서 싱글 스레드 방식으로 동작한다면 자바스크립트는 비동기로 동작할 수 없다. 즉, 자바스크립트 엔진은 싱글 스레드로 동작하지만, 브라우저는 멀티 스레드로 동작한다.

💡 시각화된 자바스크립트: 이벤트 루프 글을 통해 실제로 setTimeOut 메서드가 실행될 때 JS엔진, 콜스택, 이벤트루프, 큐가 어떻게 동작하는지 애니메이션을 통해 확인할 수 있다.


🙇🏻‍♂️ 참고

모던 자바스크립트 Deep Dive
자바스크립트의 호출 스택과 이벤트 루프
JavaScript 비동기 작업의 원리

profile
꾸준히, 깊게

0개의 댓글