자바스크립트와 이벤트루프

seungchan.dev·2022년 8월 14일
8

Javascript

목록 보기
2/3
post-thumbnail

👀 tl;dr


  • 자바스크립트 엔진 자체는 싱글 스레드로 동작하고 자바스크립트를 구동하는 환경이 멀티스레드 환경에서 동작한다.
  • Task Queue는 두가지로 분류되어 Call stack이 비어있을 때 우선순위 따라 다르게 처리된다.

🪡 자바스크립트와 비동기 처리


자바스크립트는 단일 쓰레드 기반의 언어로 기본적으로 스레드가 하나이기에 동시에 하나의 작업만을 처리할 수 있다는 것을 시사한다. 하지만 실제로 자바스크립트가 사용되는 환경을 생각해보면 하나의 작업만이 아닌 더 많은 작업들이 동시에 처리되는 것을 확인할 수 있다. 예를 들어, 웹브라우저는 애니메이션 효과를 보여주면서 마우스 입력을 받아 처리한다든지, Node.js 기반의 웹서버에서는 동시에 여러 개의 HTTP 요청을 처리하기도 한다. 이처럼 단일 스레드인 자바스크립트에서도 동시성을 지원할 수 있는 비결에는 이벤트 루프 가 존재하여 비동기적인 일 처리를 가능케 하기 때문이다.


👍 자바스크립트 자체는 단일 스레드이다.


위에서 자바스크립트가 단일 스레드 기반임에도 동시성을 지원한다고 서술하였지만, 자바스크립트 엔진에는 이벤트 루프를 취급하지 않는다. 자바스크립트의 엔진인 V8의 경우 단일 호출 스택(Call stack)을 사용하며 요청이 들어올 때 마다 해당 요청을 순차적으로 호출 스택에 담아 처리할 뿐이다.

비동기 요청은 자바스크립트 엔진을 구동하는 환경, 즉 브라우저NodeJS가 담당한다. 브라우저 환경은 다음과 같다.

비동기 호출을 위해 사용하는 setTimeout 이나 XMLHttpRequest 와 같은 함수들은 자바스크립트 엔진이 아닌 Web API 영역에 따로 정의되어 있다. 또한 이벤트 루프와 태스크 큐와 같은 장치들도 자바스크립트 엔진 밖에 구현되어 있는 것을 확인할 수 있다.

NodeJS 환경에서도 브라우저와 거의 비슷한 구조를 볼 수 있는데, 차이점이 있다면 비동기 IO를 지원하기 위하여 libuv 라이브러리를 사용하며 이 libuv 가 이벤트 루프를 제공한다. 자바스크립트 엔진은 비동기 작업을 위해서 NodeJS의 API를 호출하며, 이때 넘겨진 콜백은 libuv 의 이벤트 루프를 통해 스케쥴되고 실행된다.

위에서 확인해 보았듯이 자바스크립트가 단일 스레드 기반의 언어라는 말은 자바스크립트 엔진이 단일 호출 스택을 사용한다는 관점에서만 사실이다. 실제 자바스크립트가 구동되는 환경(브라우저, NodeJS 등)에서는 주로 여러 개의 스레드가 사용되며, 이러한 구동 환경이 단일 호출 스택을 사용하는 자바스크립트 엔진과 상호 연동하기 위해 사용하는 장치가 바로 이벤트 루프 인 것이다.


♻️ 이벤트 루프와 Task Queue


앞서 자바스크립트에서는 비동기 이벤트들을 처리하기 위해서 Task Queue 와 이벤트 루프를 활용한다고 하였다.

위의 그림에서 볼 수 있듯이 자바스크립트에서 Web API에 정의 되어있는 비동기 이벤트들을 호출하게 되면 Callback(Task) Queue와 이벤트 루프가 연계하여 비동기 이벤트를 처리하게 된다. 이를 자세하게 알아보기 위해 아래의 코드를 실행해본다고 하자.

const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 500);
const baz = () => console.log("Third");

bar();
foo();
baz();

실행 과정은 다음의 순서로 진행된다.

  1. bar 함수가 호출된다. 이때 Call stack에 들어온 setTimeout 이라는 비동기 API를 처리해야 되기에 WebAPI 에서 500 밀리 초 동안 대기하게 된다
  2. 대기 하는 동안 foo 함수가 호출되고 이로 인해 출력창에 “First” 가 나온다.
  3. setTimeout에서 명시한 대기시간이 만료되어 Task Queue 로 전달된다. Task Queue 에 쌓이는 내용들은 이벤트 루프에 의해서 처리되는데, 이는 Call stack 이 비어있는 경우에만 이벤트루프에 의해 처리된다.
  4. baz 함수가 호출되고 출력창에 “Third” 가 나온다.
  5. Call stack 이 비어있는 상태인것을 파악한 이벤트 루프가 Task Queue 에 저장되어 있던 setTimeout 의 콜백함수를 콜스택에 넘기고 실행이 이루어지면서 출력창에는 “Second” 가 나오게 된다.

즉 정리해보자면, 동기적인 Task들은 Call stack 에 의하여 순차적으로 처리되는 반면 setTimeout 과 같은 비동기 이벤트들은 Call stack에 들어오더라도 Web API와 Task Queue를 거친 뒤 Call stack 이 비어있을때에만 이벤트 루프에 의해 Call stack 으로 옮겨져 다시 처리됨을 알 수 있다.


⚙️ Micro Task , Macro Task


Task Queue 에 의해서 비동기적으로 처리되는 Task들은 크게 두 가지 종류로 나뉘게 된다. 우선순위 다소 높은 비동기 API들을 Macro Task로 분류하며 대표적인 예로 위에서 다뤘던 setTimeout , setInterval , setImmediate 등이 있다. 한편, 이보다 더 높은 우선순위를 가지는 비동기 API들은 Micro Task 로 분류하며, process.nextTick, Promise객체의 callback, queueMicrotask 등이 있다. 그럼 이들이 어떤 방식으로 처리되게 될까?


앞에서 말했듯이 우선순위가 더 높은 Task들이 Micro Task 들로 분류되기에 위 그림 처럼 여기에 저장되어있던 비동기 Task들이 우선적으로 처리된 이후에, Macro Task 에 저장된 Task들이 처리되는 모습이다. 물론 Micro Task 들 역시도 Call stack 에서 더 이상 처리할게 없어야 비로소 실행된다.

그러면 실제 예시에서 setTimeoutPromise 객체의 코드가 동시에 주어졌을때 어떻게 처리되는지 확인해보자. 예시로 다음의 코드를 살펴보자.

console.log('Start!');

setTimeout(() => {
	console.log('Timeout!');
}, 0);

Promise.resolve('Promise!').then(res => console.log(res));

console.log('End!');

코드 내용을 살펴보면, 첫번째 비동기 부분에선 setTimeout 을 통해서 0초동안 대기 하였다가 Timeout! 을 출력하는 콜백함수를 실행한다. 두번째 비동기 부분에선 Promise 객체에 의해 비동기 적으로 Promise! 라는 텍스트를 출력하는 부분이다. 이 코드를 실행해보면 아래와 같다.

Call Stack에 첫번째 코드 부분이 쌓인 뒤 실행 되어 콘솔창에 Start! 가 출력된다.

setTimeout 부분이 콜스택에 적재된 이후에 WebAPI 에 의해 타이머가 작동하게 된다. 0초라서 사실상 바로 타이머는 종료된다.


타이머가 종료됨에 따라 setTimeout 의 콜백함수 부분은 MacroTask 로 분류된다. 세번째 부분인 Promise 부분에선 콜스택에 적재 된다. 이때 Promise.resolve 는 인자로 받은 값을 .then 키워드 부분에 전달하는 역할을 함과 동시에 비동기 처리를 해제하는 역할을 한다. 이에 따라 .then 이후 부분은 MircroTask 에 분류시킨다.


네번째 부분이 실행됨에 따라 End! 라는 글자가 콘솔창에 출력된다. 이때 Task Queue에 적재된 Task들은 아직 Call Stack 내부가 아직 비워지지 않음에 따라 아직 Queue에서 빠져나가지 않는다.


Call stack 이 비워진 것을 파악한 이벤트 루프는 순차적으로 MicroTask Queue 에 있는 Task들을 비워낸 후 MacroTask Queue 에 있는 Task들을 비워낸다. 비워낼때에는 당연히 Call Stack에 적재 시킨뒤에 순차적으로 실행시켜 처리한다.


🚧 Async/Await 와 이벤트 루프


Async/Await 키워드는 비동기 처리를 위해 ES7 부터 새롭게 도입된 것으로 기존의 Promise 와 잘 호환되게 설계 되었다. 이전에는 비동기 이벤트 처리를 위해 Promise 객체를 직접적으로 명시하는 수고가 필요했는데 이는 잘 작동하겠지만, 여전히 직관성이 떨어지는 코드에 해당된다.


Async/await 키워드를 이용해 이제 Promise 객체를 반환하는 부분을 직관적으로 표현할 수 있게 된다.

이를 자세하게 파헤쳐 보고자 아래의 코드를 예시로 알아보고자 한다.

const one = () => Promise.resolve('One!');

async function myFunc(){
	console.log('In function!');
	const res = await One();
	console.log(res);
}

console.log('Before Function!');
myFunc();
console.log('After Function!');

위의 코드를 실행해보면 다음과 같은 결과를 얻게 된다. 어떻게 되는건지 한줄한줄 파헤쳐 보자.


당연하게도 첫번째에서는 Call stack에 console.log 부분이 적재된 이후에 실행되어 ‘Before function!’ 이라는 문장이 출력되게 된다.


두번째 부분에서 myFunc 함수가 호출되고 내부에 console.log 부분이 호출됨에 따라 콘솔 창에는 In Function! 이 추가로 출력된다.


세번째에서는 myFunc 내부함수 one 이 호출되고 이는 Promise 객체를 반환한다. 이때 중요한 것은 await 키워드가 있다는 점인데, 이로 인해 myFunc 함수의 내부 실행은 잠시 중단되고 Call stack 에서 빠져나와 나머지 부분은 Microtask Queue 에 의해 처리된다. 이는 자바스크립트 엔진이 await 키워드를 인식하면 async 함수의 실행은 지연되는 것으로 처리하기 때문이다.


“After function!” 이 출력하는 부분을 실행 한 뒤에 Microstask Queue 에 저장된 myFunc 실행 부분이 Call Stack 에 적재되어 실행된다. 그렇기에 마지막으로 one 이 반환하는 “One!” 이 출력된다.

참고로, 아래의 코드 처럼 await 키워드를 myFunc 실행부분 앞에도 추가하게 되면 위와 다른 결과가 나올것이다.

const one = () => Promise.resolve('One!');

async function myFunc(){
	console.log('In function!');
	const res = await one();
	console.log(res);
}

console.log('Before Function!');
await myFunc();
console.log('After Function!');

이는 myFunc을 호출한 최상단부 자체를 Microtask Queue 로 넘기고 res를 출력하는 부분부터 After Function 을 출력하는 부분까지 순차적으로 처리되게 되기 때문이다.

📌 출처와 참고자료

자바스크립트와 이벤트 루프 : NHN Cloud Meetup

[JavaScript] Task Queue말고 다른 큐가 더 있다고? (MicroTask Queue, Animation Frames)

JavaScript 비동기 핵심 Event Loop 정리

✨♻️ JavaScript Visualized: Event Loop

⭐️🎀 JavaScript Visualized: Promises & Async/Await

profile
For the enjoyful FE development

1개의 댓글

comment-user-thumbnail
2023년 9월 17일

너무 깔끔하게 정리해주셔서 쉽게 이해하며 읽을 수 있었습니다 👍

답글 달기