본문 바로가기

Study/웹기초

이벤트루프와 태스크 큐, 마이크로태스크 큐

JS는 싱글 스레드 기반의 언어이며, 하나의 호출 스택만을 사용한다.

이는 요청이 동기적으로 처리되어, 한 번에 한 가지 일만 처리할 수 있음을 의미한다.

 

하지만 자바스크립트가 구동되는 환경(Node.js, 브라우저)은 여러 스레드가 사용된다. 

만약, 네트워크 요청과 같은 비동기 함수가 동기적으로 이루어지는 함수로 만들어졌다면, 어떤 일이 일어날까?

 

네트워크 요청이 다른 서버로 보내지고, 컴퓨터는 응답 받기를 기다리며 느려질 것이다. 그 사이에 클릭이나, 다른 요소가 렌더링이 되어져야 하는게 있더라도, 스택은 네트워크 요청 함수에 블락킹 되어있으므로, 아무 일도 일어나지 않게 된다.

이러한 문제는 비동기 콜백을 사용함으로써 해결된다. 

 

여러 스레드가 사용되는 구동 환경이 자바스크립트 엔진과 연동하기 위해 사용되는 장치가 '이벤트 루프' 이다.

웹 사이트나 애플리케이션의 코드는 메인 스레드에서 실행되며, 같은 이벤트 루프를 공유한다.

 

- 브라우저 환경의 구조

  • 자바스크립트 엔진
    - Heap : 객체들은 힙 메모리에 할당된다. 크기가 동적으로 변하는 값들의 참조 값을 갖고 있다.
    - Call Stack : 함수 호출 시, 실행 컨텍스트가 생성되며, 이러한 실행 컨텍스트들이 콜 스택을 구성한다.
  • Web API or Browser API
    - 웹 브라우저에 구현된 API이다.
    - DOM event, AJAX, Timer 등이 있다.
  • 이벤트 루프
    - 콜 스택이 비었다면, 태스크 큐에 있는 콜백 함수를 처리한다.
  • 태스크 큐
    - 이벤트 루프는 하나 이상의 태스크 큐를 갖는다.
    - 태스크 큐는 태스크의 Set이다.
    - 이벤트 루프가 큐의 첫 번째 태스크를 가져오는 것이 아니라, 태스크 큐에서 실행 가능한(runnable) 첫 번째 태스크를 가져오는 것이다. 태스크 중에서 가장 오래된 태스크를 가져온다.

 

- 이벤트 루프

이벤트 루프란 구동하는 환경(브라우저, 노드)에서 가지고 있는 장치이다.

이벤트 루프는 태스크가 들어오면 이를 처리하고, 처리할 태스크가 없는 경우엔 잠드는, 끊임없이 돌아가는 자바스크립트 내 루프이다.


 

태스크의 종류

  • 외부 스크립트 <script src="...">가 로드될 때, 이 스크립트를 실행하는 것
  • 사용자가 마우스를 움직일 때 mousemove 이벤트와 이벤트 핸들러를 실행하는 것
  • setTimeout에서 설정한 시간이 다 된 경우, 콜백 함수를 실행하는 것
  • 기타 등등

JS는 집합을 이루고 있는 태스크들을 차례대로 처리하고, 새로운 태스크가 추가될 때까지 기다린다.
태스크를 기다리는 동안엔 CPU 자원 소비는 0에 가까워지고 엔진은 잠들게 된다.

새로운 태스크는 엔진이 바쁠 때 추가될 수도 있습니다.

이때 이 태스크는 큐에 추가되는데, V8 용어로 '매크로태스크 큐(macrotask queue)'라고 부른다.

 

- 마이크로태스크큐

function myFunc1() {
  setTimeout(() => {
    console.log("time");
  }, 0);
  myFunc2();
  Promise.resolve().then(() => {
    console.log("promise");
  });
  console.log("bye");
}

function myFunc2() {
  console.log("hello");
}

myFunc1();

 

지금까지 봤던 개념대로 예상해보면 "time" 후에 "promise"가 찍힐 것처럼 느껴진다.

하지만 놀랍게도 순서는 "hello" -> "bye" -> "promise" -> "time" 이다.

태스크는 실제로는 마이크로 태스크(micro task)와 매크로 태스크(macro task)를 분리하여 처리하기 때문이다.

※ 매크로 태스크는 일반 태스크로 불리기도 한다.

 

이벤트 루프 알고리즘 요약

  1. 매크로 태스크 큐에서 가장 오래된 태스크 를 꺼내서 실행시킨다.(예: 스크립트를 실행)
  2. 모든 마이크로태스크를 실행한다.
  3. 렌더링 작업을 실행한다.
  4. 매크로 태스크 큐에 새로운 매크로 태스크가 나타날 때까지 대기한다.
  5. 1번으로 돌아간다.

 마이크로 태스크 큐는 매크로 태스크 큐에 비해 우선순위가 높아서, 콜 스택이 비워지면 마이크로 태스크 큐를 먼저 확인하게 된다.

이처럼 마이크로태스크는 다른 이벤트 핸들러나 렌더링 작업, 혹은 다른 매크로태스크가 실행되기 전에 처리되는데,

이런 순서가 중요한 이유는 (마우스 좌표 변경이나 네트워크 통신에 의한 데이터 변경 같이 애플리케이션 환경에 변화를 주는 작업에 영향을 받지 않고) 모든 마이크로태스크를 동일한 환경에서 처리할 수 있기 때문이다.

 

 

마이크로 태스크들은 실행하면서 새로운 마이크로 태스크를 큐에 추가할 수도 있다. 새롭게 추가된 마이크로 태스크도 큐가 빌 때까지 계속해서 실행된다.

반대로, 이벤트 루프는 매크로 태스크 큐에 있는 것을 실행시키기 시작할 때 있는 매크로 태스크만 실행시킨다. 

매크로 태스크가 추가한 매크로 태스크는 다음 이벤트 루프가 실행될 때까지 실행되지 않는다.

예시

다음 코드를 보고 결과를 예상해보자.

console.log('script start'); // A

setTimeout(function () { // B
  console.log('setTimeout');
}, 0);

Promise.resolve() 
  .then(function () { // C
    console.log('promise1');
  })
  .then(function () { // D
    console.log('promise2');
  });

console.log('script end'); // E
script start
script end
promise1
promise2
setTimeout

코드는 위에서부터 차례로 실행된다. 
1. 콜 스택에는 전역 실행 객체가 있고, '스크립트 실행'이라는 태스크가 매크로 태스크 큐에 들어있다.
2. 이벤트 루프는 매크로 태스크 큐에 있는 '스크립트 실행' 태스크를 실행한다.
3. A에 도달하면, 'script start'가 출력된다.
4. B에 도달하면, setTimeout web api가 타이머를 실행시키고, 타이머가 종료되면 콜백 함수가 매크로 태스크 큐에 들어간다.
5. C에 도달하면, 콜백 함수가 마이크로 태스크 큐에 들어간다.
6. E에 도달하면, 'script end'가 출력된다.
7. 콜 스택이 비었으므로, 이벤트 루프는 마이크로 태스크 큐에 있는 프라미스 콜백 함수를 실행시킨다.
8. 'promise 1'이 출력된다.
9. Promise.then 메서드는 D 콜백 함수를 마이크로 태스크 큐에 등록한다.
10. 이벤트 루프는 다음 마이크로 태스크인 D 콜백 함수가 실행시킨다.
11. 'promise 2'가 출력된다.
12. 렌더링할 것이 있으면, 브라우저는 렌더링을 한다.
13. 매크로 태스크 큐에 있는 setTimeout 콜백함수를 실행시킨다.
14. 'setTimeout'이 출력된다.

 

태스크 큐 vs 마이크로태스크 큐

  • 콜백함수를 태스크 큐에 넣는 함수들
    • setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, UI 렌더링
  • 콜백함수를 마이크로태스크 큐에 넣는 함수들
    • process.nextTick, Promise, Object.observe, MutationObserver
console.log('콜 스택!');
setTimeout(() => console.log('태스크 큐!'), 0);
Promise.resolve().then(() => console.log('마이크로태스크 큐!'));

결과는 다음과 같다.

콜 스택!
마이크로태스크 큐!
태스크 큐!

볼 수 있다시피, 마이크로태스크 큐의 콜백함수가 먼저 처리된다. 그렇다면 여기서 처음 나오는 console.log() 는 언제 처리되는 것일까? 처음 스크립트가 로드될 때 “스크립트 실행” 이라는 태스크가 먼저 태스크 큐에 들어간다. 그리고 나서 이벤트 루프가 태스크 큐에서 해당 태스크를 가져와 콜 스택을 실행하는 것이다. 즉, 콜 스택에는 이미 GEC(Global Execution Context)가 생성되어 있는 상태에서 “스크립트 실행” 이라는 태스크를 실행하게 되면 그제서야 GEC에 속한 코드가 실행되는 방식이다.

그럼 하나하나 어떻게 동작하는지 그림으로 살펴보자.

제일 먼저, “스크립트 실행” 태스크가 태스크 큐에 들어가게 된다.

이후, 이벤트 루프가 그 태스크를 가져와서 로드된 스크립트를 실행시킨다. 따라서 맨 처음에 console.log 가 실행된다.

그 다음, setTimeout() 이 콜 스택으로 가고 브라우저가 이를 받아서 타이머를 동작시킨다.

타이머가 끝나면 setTimeout() 의 콜백함수를 태스크 큐에 넣는다.

Promise 가 콜 스택으로 가고 콜백함수를 마이크로태스크 큐에 넣는다.

이벤트 루프는 마이크로태스크 큐에서 제일 오래된 태스크인 Promise 의 콜백함수를 가져와 콜 스택에 넣는다.

Promise 의 콜백함수가 끝나고 태스크 큐에서 제일 오래된 태스크인 setTimeout() 의 콜백함수를 가져와 콜 스택에 넣는다.

setTimeout() 의 콜백함수가 끝나면 콜 스택이 비게 되고 프로그램이 종료된다.

 

이벤트 루프에 대한 자세한 내용은 아래의 영상들을 참고하여 더 공부해보자.

https://www.youtube.com/watch?v=8aGhZQkoFbQ 

https://www.youtube.com/watch?v=cCOL7MC4Pl0&t=1s 

https://www.youtube.com/watch?v=wcxWlyps4Vg&t=1s 

https://vimeo.com/96425312

 

 

참조 

https://velog.io/@yejineee/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%A3%A8%ED%94%84%EC%99%80-%ED%83%9C%EC%8A%A4%ED%81%AC-%ED%81%90-%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C-%ED%83%9C%EC%8A%A4%ED%81%AC-%EB%A7%A4%ED%81%AC%EB%A1%9C-%ED%83%9C%EC%8A%A4%ED%81%AC-g6f0joxx

 

 https://ko.javascript.info/event-loop
 https://baeharam.netlify.app/posts/javascript/JS-Task%EC%99%80-Microtask%EC%9D%98-%EB%8F%99%EC%9E%91%EB%B0%A9%EC%8B%9D

 

 

 

 

 

 

 

 

 

 

'Study > 웹기초' 카테고리의 다른 글

캡슐화(encapsulation)  (0) 2022.09.22
관심사의 분리(SoC)  (0) 2022.09.22
Restful API  (0) 2022.09.15
브라우저 저장소 차이점 (LocalStorage, SessionStorage, Cookie)  (0) 2022.09.15
this란?  (0) 2022.09.15