자바스크립트는 어떻게 동작하나요?

미소·2023년 2월 26일
1
post-thumbnail

How Does JavaScript Even Work? Things Which 90% of JavaScript Developers Don't Know! (Part 1),
How Does JavaScript Even Work? Things Which 90% of JavaScript Developers Don't Know! (Part 2) 글을 참고하여 작성되었으나, 오역이 있을 수 있습니다.

JavaScript와 싱글 스레드

자바스크립트가 싱글 스레드라는 것은 엔진마다 단 하나의 호출 스택(call stack)이 존재한다는 것입니다.

그렇기에 모든 실행은 하나씩 순차적으로 발생합니다.
즉, 코드들은 현재의 코드가 실행되길 기다려야 합니다.

하나의 스레드 == 하나의 호출 스택 == 한 번에 하나씩

🤔 그럼 자바스크립트는 다중 스레드가 불가능한가요?
아닙니다! Worker Thread 를 이용하여 다중 스레드와 비슷하게 만들 수 있습니다.

그렇다면 왜 자바스크립트를 싱글 스레드 언어라고 부를까요?
브라우저의 V8 같은 엔진에는 하나의 호출 스택만 존재하기에 JS를 싱글 스레드라고 부릅니다.

간단한 개념 잡기

자바스크립트 엔진

JS 엔진의 가장 대표적인 예시는 Google의 V8 엔진입니다. V8 엔진은 크롬과 Node.js에서 사용됩니다.
엔진을 간단하게 표현하면 아래와 같습니다.

엔진은 두 가지의 대표 요소를 가지고 있습니다.

  • Memory Heap: 메모리 할당이 일어나는 곳
  • Call Stack: 코드 실행에 따라 호출 스택이 쌓이는 곳

런타임(Runtime)

거의 모든 JavaScript 개발자들은 setTimeout 같은 브라우저 내장 API를 사용합니다.
해당 API는 JS 엔진에서 제공하는 것도 아닌데 어떻게 사용할 수 있는 걸까요?

현실은 조금 복잡합니다.

브라우저에는 JS 엔진 외에도 다양한 요소들이 많습니다.

DOM, Ajax, setTimeout과 같이 브라우저에서 제공하는 API들을 Web API라고 합니다.

그림 아래 쪽엔 이벤트 루프(event loop)와 콜백 큐(callback queue)도 있습니다.

호출 스택(Call Stack)

자바스크립트는 싱글 스레드 프로그래밍 언어입니다.

이 말은 하나의 호출 스택을 가지고 있다는 의미입니다.
따라서 한 번에 한 작업만 처리할 수 있습니다.

호출 스택은 기본적으로 우리가 프로그램 상에서 어디에 있는지를 기록하는 자료구조입니다.

만약 함수를 실행하면, 해당 함수는 호출 스택의 최상단에 존재하게 됩니다.
만약 함수를 반환하면(리턴 값을 돌려준다면), 스택의 최상단에 존재하던 것이 사라지게 됩니다.

그것이 스택의 역할입니다.

예제 코드를 살펴봅시다.

function multiply(x, y) {
  return x * y;
}
function printSquare(x) {
  var s = multiply(x, x);
  console.log(s);
}
printSquare(5);

엔진이 위 코드를 실행할 때, 호출 스택은 비워집니다.
그리고 아래처럼 변하게 됩니다.

호출 스택의 각 항목을 스택 프레임(Stack Frame)이라고 부릅니다.

동시성 & 이벤트 루프(Event Loop)

호출 스택에 굉장히 시간이 오래 걸리는 함수 호출이 있다면 어떻게 될까요?
예를 들면 브라우저에서 JavaScript를 사용하여 복잡한 이미지 변환을 하는 것 같은 상황에서요.

이게 문제가 있나요? 라고 생각하실 수 있습니다.

하지만 이 문제를 브라우저 관점으로 보자면 호출 스택의 함수를 실행하는 동안 브라우저는 아무 작업도 하지 못하고 기다려야 합니다.

브라우저는 렌더링도 못하고 다른 코드를 실행할 수도 없습니다. 그저 갇힌 상태가 되는 것입니다.
만약 매끄럽고 자연스러운 화면 UI를 원한다면 위 경우는 문제가 됩니다.

이 뿐만이 아닙니다.

브라우저가 호출 스택의 많은 일들을 처리하면 화면이 오랫동안 응답하지 않게 됩니다.
이 경우에는 대부분의 브라우저가 아래와 같은 에러를 띄우며 페이지를 종료할 것인지 묻습니다.

이 상황은 절대 이상적인 사용자 경험을 가질 수 없습니다.

그렇다면 어떻게 페이지 렌더링 동작을 방해하지 않고 브라우저의 응답도 끊지 않으며 코드를 실행할 수 있을까요?

정답은 비동기 콜백입니다.

이제는 자세하게 이야기해봅시다!

메인 스레드와 호출 스택 자세히 이해하기

몇 가지 프로그램을 통해 메인 스레드와 호출 스택의 실행 흐름을 이해해봅시다!

호출 스택 이해하기

function bar() {
  console.log('bar');
}
function jazz() {
  console.log('jazz');
  bar();
}
function foo() {
  console.log('foo');
  jazz();
}
const btn = document.getElementById('foo');
btn.addEventListener('click', foo, false);

// Output (버튼 클릭 시)
// foo
// jazz
// bar
  1. Global Execution Context(GEC)가 생성되어 호출 스택에 푸시됩니다.
  2. foo 버튼을 클릭합니다.
  3. foo()에 대한 실행 컨텍스트가 생성되고 호출 스택에 푸시된 뒤 한 줄씩 실행합니다.
  4. foo() 함수에선 “foo”를 콘솔에 출력하고 jazz() 함수를 호출합니다.
  5. jazz()에 대한 실행 컨텍스트가 새로 생성되고 호출 스택의 상단에 푸시됩니다.
  6. jazz()는 “jazz”를 콘솔에 출력하고 bar() 함수를 호출합니다.
  7. bar()에 대한 실행 컨텍스트가 새로 생성되고 호출 스택의 상단에 푸시됩니다.
    그 이후 “bar”가 콘솔에 출력됩니다.
  8. bar()가 리턴되면 스택에서 제거(pop)됩니다.
  9. jazz()와 foo()모두 제거되어 스택의 마지막에 도달하게 됩니다.
  10. 프로그램이 종료되면 GEC도 제거되고 호출 스택이 비워집니다.

위 과정을 통해 함수가 호출 스택에 추가(push)되고, 제거(pop)되는 방식을 확인할 수 있습니다.

전역 실행 컨텍스트(Global Execution Context): GEC는 JS 코드가 실행을 시작할 때 생기는 기본 실행 컨텍스트입니다. 이것을 통해 window 객체와 this 키워드를 만들고 바인딩합니다.

GEC는 실행이 종료되면 호출 스택에서 제거되고 ECMAScript 코드가 실행되기 전 다시 생성됩니다.
(ECMA 명세에서 실행 컨텍스트에 대해 확인해보세요!)

싱글 스레드로 인해 발생하는 문제

호출 스택 폭파시키기

예외가 발생했을 때 콘솔에 나타나는 스택 트레이스(Stack Trace)는 오류가 발생하기 전까지의 스택들을 꼬리를 물며 스택을 표시하는 것입니다.

// foo.js

function foo() {
  throw new Error('SessionStack will help you resolve crashes :)');
}
function bar() {
  foo();
}
function start() {
  bar();
}
start();

위 코드를 실행하면 아래와 같이 스택의 꼬리를 물며 에러가 발생됩니다.

호출 스택의 최대치가 되면 “스택 폭파시키기(Blowing the stack)”가 일어납니다.
특히 코드를 광범위하게 테스트하지 않고 재귀를 사용하는 경우에 매우 쉽게 발생할 수 있습니다.

function foo() {
  foo();
}
foo();

엔진이 이 코드를 실행할 때, 함수인 foo를 호출합니다.

여기서는 foo 함수가 반복적으로 자기 자신을 호출하는 재귀 호출을 수행합니다.
그러면 호출 스택에 똑같은 함수가 계속 쌓이게 됩니다.

이렇게 실행을 하게 되면 어떤 시점에 호출 스택의 최대치를 넘어 함수 호출을 하게 됩니다.
그러면 브라우저가 이런 에러를 발생시킵니다.

싱글 스레드 기반은 멀티 스레드 환경에서 일어나는 데드락(deadlocks) 같은 복잡한 문제들을 처리할 필요가 없기에 굉장히 쉽습니다.

출처: https://m.blog.naver.com/smc503/221920305642

그러나 싱글 스레드에서 실행하는 것은 굉장히 제한적입니다.
한 개의 호출 스택이 가지고 있는 JavaScript에서는 작업이 느려지면 어떻게 될까요?

메인 스레드 차단하기

만약 버튼 클릭 이벤트에 무한 루프가 발생하면 어떻게 되는지 확인해봅시다!

// 이 함수가 버튼 클릭 시 무한 루프를 도는 함수입니다.
function blockMainThread() {
  while (true);
}

//메인 스레드 버튼에 이벤트를 주입합니다.
const btn = document.getElementById('block');
btn.addEventListener('click', blockEventLoop);

버튼을 클릭하면 blockMainThread() 함수의 실행 컨텍스트가 호출 스택으로 푸시되고 무한 루프로 인해 계속 실행하게 됩니다. (GEC는 항상 존재합니다)

이로 인해 싱글 스레드는 blockMainThread() 함수를 계속 실행하게 되며 아무 것도 처리할 수 없기에 모든 UI 응답이 차단됩니다.

따라서 버튼이 클릭되면 탭은 완전히 응답을 받을 수 없는 상태가 되며 탭에서 아무것도 할 수 없습니다.
’스레드를 강제로 중지하라’는 중지 프롬프트 창을 보기 전까지 말이죠..!

이제 저희는 문제에 대해 인지하였습니다.

우리가 실행한 코드는 극단적으로 작성한 코드지만 어떤 함수에서 오랜 시간이 소요되고 다른 모든 코드를 차단하여 오랜 시간동안 페이지가 응답하지 않는 경우가 생길 수 있습니다.

개발자가 정한 시간 이후에야 무언가를 실행하기 원하는 경우도 있습니다.

하지만 싱글 스레드를 이용하는 것은 사용자의 경험에 굉장히 좋지 않을 것입니다.

이것을 Web APIs, 이벤트 루프(event loop), 여러 개의 Queue를 사용하여 자바스크립트 단일 스레드 문제를 해결하였습니다.

문제 해결

Web APIs

기본적으로 브라우저에 의해 우리에게 노출되는 수많은 강력한 기능과 인터페이스를 이야기합니다.
Web APIs는 더 빠른 언어(ex: 크롬의 C++)로 구현되지만 일반적으로 자바스크립트와 함께 사용됩니다.

  1. 네트워크 요청
  2. DOM 조작
  3. Local Storage
  4. 블루투스
  5. 화면 캡처
  6. 위치
  7. setTimeout, 타이머

등등… 저희가 자주 사용하는 console.log도 콘솔 Web API의 일부입니다.

자세한 내용은 Web APIs 문서를 확인해보세요!

처음 읽는 개발자에겐 마음이 아플 수 있는 내용일 수 있습니다…(?)
모든 fetch, setTimeout, console.log는 자바스크립트가 아니며 브라우저에서 지원하는 기능입니다.

Web API의 특징은 브라우저에 의해 효과적으로 다른 스레드로 실행되고 탭의 기본 자바스크립트 스레드를 방해하지 않는다는 것입니다.

그렇다면 Web API인 console.log도 비동기적으로 실행될까요?

네! 다른 주요 브라우저에서도 console.log는 비동기적으로 실행됩니다.

그러나 console.log는 콜백을 포함하지 않기에 브라우저에게 할 일을 넘긴 후 브라우저가 콘솔을 출력한 뒤 다시 JS 엔진으로 돌아오진 않습니다.

따라서 비동기 특성을 관찰할 수 없습니다.

결론적으로 모든 Web API는 메인 스레드를 차단하지 않고 다른 스레드에서 효과적으로 실행되며 JS 엔진에서 모든 느린 작업들을 넘깁니다.

출처: https://lightmap.dev/how-does-javascript-even-work-1

브라우저는 GEC에 의해 생성된 전역 객체인 window 객체를 통해 JS 엔진이 모든 Web API에 접근할 수 있도록 권한을 부여합니다.
실제로 window.setTimeout 으로 Web API에 접근해야 하지만 JS에서 이런 접근 권한을 가지고 있기에 접근하기 용이하도록 해줍니다.

그러나 Web API에서 네트워크 호출, 디스크 접근 등의 콜백을 전달하는 것 같은 경우에 메인 스레드와 어떻게 소통할 수 있을까요?

여기에서 다양한 큐와 이벤트 루프가 사용됩니다.

Queues

  1. Task Queue (= Callback Queue)
  2. Render Queue
  3. Micro-Task Queue (= Job Queue)

먼저 Task Queue에 대해 이해하고 Task Queue를 이용하여 이벤트 루프를 설명하겠습니다.
그 이후 전체 시스템이 어떻게 작동하는지 알아볼 예정입니다.

Task Queue

Callback Queue, Message Queue라고도 불리는 Task Queue는 기본적으로 선입선출(FIFO) 데이터 구조입니다. (자세한 Queue 데이터 구조는 여기서 확인해보세요)

따라서 Web API가 무거운 작업을 수행하고 백그라운드에서 시간이 걸리는 작업을 실행하면 이런 작업의 콜백을 Callback Queue라고 불리는 Task Queue에게 전달합니다.

출처: https://lightmap.dev/how-does-javascript-even-work-1

Event Loop

출처: http://latentflip.com/loupe

모든 JS 코드가 실행되고 호출 스택이 빈다면 이벤트 루프가 실행됩니다.
이후 이벤트 루프는 Task Queue에 진행할 작업이 있는지 확인하고 호출 스택에 푸시하여 실행하게 됩니다.

자바스크립트 프로그램은 아래와 같은 순서로 진행이 되는 것이죠!

  1. 호출 스택이 Web API 관련된 작업을 만나면 Web API에게 도움을 요청합니다.
  2. Web API는 호출 스택에 완료로 표시한 뒤 Web API로 해당 작업을 넘깁니다.
  3. 브라우저는 백그라운드에서 작업을 실행하고 콜백을 Task Queue(Callback Queue)로 푸시합니다.
  4. 일반 코드 실행이 완료된 뒤 호출 스택이 비어 있으며 GEC도 제거되면 이벤트 루프가 일을 시작합니다.
  5. 이벤트 루프는 Task Queue의 작업을 확인하고 작업을 하나씩 Call Stack에 푸시한 뒤 실행합니다.
  6. GEC는 항상 자바스크립트 코드가 실행되기 전에 생성되므로 이벤트 루프가 작업을 푸시할 때도 GEC가 생성됩니다.

이벤트 루프는 일반적인 CS 용어이며 프로그램에서 이벤트나 메세지를 기다리고 디스패치하는 프로그래밍 구조 또는 디자인 패턴을 나타냅니다.
이벤트 루프는 Windows OS, GLib 등 다양한 곳에서 사용됩니다. (Wikipedia)
이것은 JS에만 있는 개념은 아니며 구현은 차이가 있겠지만 같은 개념을 사용합니다.

Chrome의 이벤트 루프

이벤트 루프는 V8의 일부일까요?
ㄴ 아닙니다!!!!!!!!!!!!!!

출처: https://lightmap.dev/how-does-javascript-even-work-1

이벤트 루프가 V8의 일부가 아니라면 어떻게 이벤트 루프가 호출 스택이 비었는지 판단할까요?

Chrome 브라우저에서 V8은 매우 인기있는 오픈 소스인 이벤트 알림 라이브러리 libevent와 함께 제공되어 Chrome에 이벤트 루프 기능을 제공합니다.

반면 Node에서는 이벤트 루프 기능을 위해 V8과 함께 libUV를 사용합니다.

서버 측 실행을 용이하게 하기 위해 노드에서 약간 다른 이벤트 루프 구현이 필요하기에 Chrome 브라우저의 이벤트 루프와는 다른 라이브러리를 사용합니다.

이벤트 루프 이해하기

무한 재귀 상황

호출 스택을 폭파했던 프로그램을 기억하시나요?
이제 아무 문제 없이 작동하는 프로그램을 약간의 변경을 통해 작성해보겠습니다.

function foo(i) {
  console.log(i);
  setTimeout(() => foo(i + 1), 0);
}

const btn = document.getElementById('foo');
btn.addEventListener('click', () => foo(0), false);

setTimeout에 작성된 지연 시간이 0이라고 명시되어 있습니다.
그 외는 폭발했던 프로그램과 동일한 프로그램입니다. 작동방식을 이해해봅시다!

  1. GEC가 생성되어 호출 스택에 추가됩니다.
  2. 버튼을 클릭합니다.
  3. foo() 함수 컨텍스트가 호출 스택에 추가됩니다.
  4. foo()가 실행되고 첫 번째 호출에서 i값을 0으로 출력합니다.
  5. Web API인 setTimeout을 만나면 호출 스택에서 Web API 쪽인 브라우저에게 작업을 넘깁니다.
  6. foo() 함수의 마지막에 도달하여 foo() 실행 컨텍스트가 사라지지만 여전히 setTimeout이 실행되지 않아 프로그램이 종료되지 않습니다.
  7. 지연 시간이 0이기에 setTimeout의 콜백이 Task Queue에게 전달됩니다.
  8. foo(i+1)인 Task Queue의 함수는 이벤트 루프에 의해 빈 호출 스택으로 푸시되고 실행합니다.
  9. 3단계로 다시 돌아가 실행합니다.

요점은 계속 실행하며 호출 스택을 채우지 않는다는 것입니다.
이것은 모두 브라우저에서 제공하는 setTimeout() API 덕분입니다…!

타임아웃 정렬(Timeout Sort!!!)

버블 정렬, 삽입 정렬, 선택 정렬에 대해 들어보셨겠지만 타임아웃 정렬에 대해 알고 계신가요?

다른 언어에선 Sleep Sort라고 많이 이야기하는 듯 합니다!

setTimeout() API를 이용하여 숫자를 정렬해보겠습니다.

let arr = [10, 100, 500, 20, 35];

arr.forEach((item) => {
  setTimeout(() => console.log(item), item);
});
  1. 항상 GEC가 생성됩니다.
  2. 모든 배열 요소는 setTimeout를 호출되어 스택에 푸시된 후 브라우저로 오프로드 됩니다.(넘깁니다.)
  3. setTimeout은 배열에 정의된 숫자만큼 지연됩니다.
  4. 가장 짧은 10ms가 끝나면 Task Queue로 푸시되고 이벤트 루프로 인해 콜 스택으로 푸시되고 10이 기록됩니다.
  5. 이후 20, 35, 100, 500 순으로 출력됩니다.

멋지지 않나요?

Render Queue

Render Queue(이하 렌더 큐)는 모든 화면 업데이트 혹은 repaint(이하 리페인트) 전에 수행해야 할 모든 작업을 처리하는 이벤트 루프 시스템의 또 다른 큐 입니다.

브라우저가 정기적으로 이런 리페인트를 수행하기 때문에 모든 효과와 애니메이션이 발생합니다.

브라우저 작동 과정을 이해하신 분들이라면 리페인트 메커니즘이 어떤 순서로 동작하는지 아실겁니다.

위의 다이어그램은 자바스크립트 작업(리페인트 전 작업)과 리페인트와 관련된 4가지 주요 단계를 보여줍니다.
더 자세한 내용은 브라우저 동작 과정 혹은 “Browser Rendering Queue in-depth” 글을 확인해보세요!

브라우저는 리페인트를 할 때마다 모든 단계를 수행하는 것은 아니며 변경된 사항이 있을 때만 수정합니다.

브라우저는 어떤 주기로 리페인트 할까요?
주요 브라우저는 일반적으로 초당 60회로(= 60FPS) 리페인트 작업을 진행합니다.
눈이 더 빠른 변화를 인식할 수 없기에 일반적으로 60FPS을 사용합니다.

그러나 메인 스레드(main thread)가 유휴 상태이거나 호출 스택이 비어 있을 때만 이 속도로 리페인트할 수 있을 것입니다.

따라서 메인 스레드에서 실행 중인 작업이 있는 동안에는 리페인트가 발생할 수 없습니다.

그러기에 작업이 대기 중인 경우 브라우저는 교묘하게 몇 가지 작업을 진행한 후 렌더링 작업을 진행하고 빠르게 다시 돌아오는 방식으로 진행됩니다.

위에서 이야기 했던 무한 루프를 실행하여 호출 스택을 폭파시켰던 예제와 setTimeout을 통해 문제를 해결했던 예제를 기억하시나요?

setTimeout을 이용하면 해당 부분은 호출 스택이 비어있는 상황이 있기에 렌더 작업이 들어올 기회가 생겨 문제가 해결이 되었습니다.

latentflip.com/loupe 사이트를 사용해 렌더 큐를 직접 화면으로 확인할 수 있습니다.

자바스크립트로 돌아가서 리페인트 전에 작업을 어떻게 Queue(이하 큐)에 넣을 수 있을까요?

자바스크립트에서는 렌더링 큐에 작업을 정렬할 수 있습니다.
또한 브라우저의 메서드(Web API) requestAnimationFrame를 사용하여 이런 작업을 정렬할 수 있습니다.

requestAnimationFrame은 주요 브라우저에선 페인트 전에 작업을 정렬하지만 모든 렌더링 단계 이후 작업을 정렬하는 사파리는 다릅니다.

즉, 다음 렌더링 중에 작업이 실행되게 됩니다.

렌더 큐에는 실행 중에 무언가를 추가할 경우 다음 페인트 주기에 처리되도록 하는 속성이 있습니다.

지금까지는 Microtask Queue(이하 마이크로태스크 큐)에 들어가기 전 렌더링 큐와 이벤트 루프와 호출 스택과 함께 작동하는 큐에 대해 이야기하였습니다.

마이크로태스크 큐를 이해하기 위해선 ES6에서 새롭게 생긴 개념, Promise에 대해 알아야 합니다.

Promises

이전 글에서 시간을 지연하는 작업이 성공적으로 실행되면 콜백으로 전달했습니다.
그러나 콜백 안에 콜백이 있고 그 안에 다시 콜백이 있다면 콜백 지옥(callback hell)에 빠질 수 있습니다.

출처: https://dev.to/jerrycode06/callback-hell-and-how-to-rescue-it-ggj

이를 방지하기 위해 Promise가 ES6에서 등장하였습니다.

Promise에는 3가지 상태가 있습니다.

  1. Resolved, 작업이 완료된 상태를 의미합니다.
  2. Rejected, 작업에 문제가 발생한 상태를 의미합니다.
  3. Pending, 작업이 진행되고 있는 상태를 의미합니다.
const p = new Promise((resolve, reject) => {
  const a = 1;
  if (!a) {
    reject(new Error('This Promise is Rejected'));
  } else {
    resolve('This Promise is resolved');
  }
});

p.then(() => {
  console.log('Successfully Resolved');
}).catch(() => {
  console.log('Rejected');
});

코드를 통해 Promise는 Reject와 Resolve라는 상태로 분기 처리를 하는 것을 확인할 수 있습니다.
작업이 성공적으로 실행되면 Resolve를, 그렇지 않으면 Reject를 호출합니다.

Promise에 .then() 메서드를 사용하면 promise가 resolve 상태일 때 해당 메서드 안에 있는 콜백을 실행하게 됩니다. 반면 .catch() 메서드를 사용하면 promise가 reject 상태일 때 거부 콜백이 실행됩니다.

.finally() 는 Promise의 상태 값이 확정된 뒤 실행되는 메서드입니다.

Promise는 Promise Chanining이라는 것을 이용해 콜백 지옥을 해결하고 async/await를 이용하여 비동기 함수에서 Promise를 처리할 수 있습니다.

지금은 JS 동작 방식에 집중하여 Promise와 .then(), .catch() 콜백을 처리하는 Queue에 대해 이해해봅시다!

MicroTask Queue

Microtask Queue(= Job Queue, 이하 마이크로태스크 큐)는 기본적으로 Promise를 처리하는 흥미로운 Queue입니다.

마이크로태스크 큐는 태스크 큐와 렌더링 큐보다 우선 순위가 높습니다.
즉, 마이크로태스크 큐의 작업이 먼저 실행됩니다.

호출 스택이 비게되면 이벤트 루프는 마이크로태스크 큐에 작업이 있는지 확인하고 작업이 있다면 해당 작업을 완료합니다.

마이크로태스크 큐의 우선 순위가 가장 높기에 모든 작업을 완료할 때까지 이벤트 루프는 마이크로태스크 큐의 작업을 진행하게 됩니다.

이 동작으로 인해 무한 루프와 유사한 코드가 작성될 수 있습니다.

function blockEventLoop() {
  Promise.resolve().then(blockEventLoop);
}

const btn = document.getElementById('kill');
btn.addEventListener('click', blockEventLoop, false);

id가 kill인 버튼을 누르면 모든 작업이 얼게 됩니다.
작동 순서를 통해 이유를 알아봅시다.

  1. 스크립트가 실행되고 btn에 click 이벤트가 추가됩니다.
  2. id가 kill인 버튼을 누릅니다.
  3. 이벤트 리스너가 호출되고 큐에 blockEventLoop 함수가 추가됩니다.
  4. 호출 스택은 비어있는 상태이므로 이벤트 루프가 promise를 호출 스택으로 가져오고 실행하는 동안 다른 promise가 큐에 추가되는 작업이 반복됩니다.
  5. 그렇기에 메인 스레드는 쉬지 않고 일하며 이로 인해 정지된 화면으로 보이게 됩니다.

다른 예시를 하나 더 확인해봅시다! (출처: JSConf)

const button = document.getElementById('run');

button.addEventListener('click', () => {
  Promise.resolve().then(() => {
    console.log('MicroTask 1');
  });
  console.log('Listener 1');
});
button.addEventListener('click', () => {
  Promise.resolve().then(() => {
    console.log('MicroTask 2');
  });
  console.log('Listener 2');
});
  • 버튼을 클릭할 경우 콘솔 창에는 어떤 결과가 출력될까요?
    Listener 1
    MicroTask 1
    Listener 2
    MicroTask 2

출력이 직관적이지 않으므로 실행 순서를 확인해봅시다!

  1. 스크립트가 실행되고 이벤트 리스너가 버튼에 추가됩니다.
  2. 버튼을 클릭하면 첫 번째 이벤트 리스너가 호출 스택에 추가됩니다.
  3. Promise가 resolve되고 콜백이 마이크로태스크 큐로 넘어갑니다. 그 이후 Listener 1 이 출력됩니다.
  4. 첫 번째 이벤트 리스너가 호출 스택에서 제거되고 호출 스택이 비게 됩니다.
    그 이후 마이크로태스크의 작업이 호출 스택으로 넘어와 MicroTask 1 이 출력됩니다.
  5. 두 번째 이벤트 리스너도 동일합니다.
  6. 실행이 종료됩니다.

그래서 JS의 마이크로태스크는 무엇일까요?

  1. Promises
  2. Mutation Observers
  3. queueMicrotask()

Queue 정리하기

  • Task Queue
    • 작업이 한 번에 하나씩 실행됩니다.
    • 이동 전 모든 작업이 완료되어야 한다는 규칙은 없습니다.
  • Render Queue
    • 이벤트 루프가 렌더 큐에 오면 존재하는 모든 작업을 완료하고 이벤트 루프가 시작된 후 작업이 추가되면 해당 작업은 다음 차례로 넘기게 됩니다.
  • Microtask Queue
    • 해당 큐는 모든 작업이 없어질 때까지 실행됩니다.
      그렇기에 Promise가 무한 재귀를 실행하면 메인 스레드가 차단됩니다.

모든 Queue가 사용되는 예시

function log(arg) {
  console.log(arg);
}

// Function which will add a callback to task queue using setTimeout
function taskQueue(number, callback) {
  setTimeout(function () {
    callback(`Timeout ${number}`);
  }, 0);
}

// Function which will return a promise
function microTaskQueue(number) {
  return Promise.resolve(`Promise ${number}`);
}

// Pushing to microtask queue
microTaskQueue(1).then(log);
microTaskQueue(2).then(log);

// Pushing to task queue
taskQueue(1, log);

// Pushing to render queue
requestAnimationFrame(function () {
  log('RequestAnimationFrame 1');
});

//Normal code to show that the script will run till completion first
for (let i = 0; i < 100000000; i++) {}
console.log('The end');

순서를 이해해봅시다!

  1. 먼저 microTaskQueue(1).then(log) / microTaskQueue(2).then(log) 를 호출합니다.
    두 log 함수는 마이크로태스크 큐에 들어갑니다.
  2. setTimeout API를 통해 태스크 큐에 log 함수를 추가하는 taskQueue(1, log) 를 호출합니다.
  3. 렌더 큐에 log 함수를 추가하는 requestAnimationFrame() 함수를 호출합니다.
  4. for문이 실행되고 The end 가 출력됩니다.
  5. 이제 호출 스택이 비었으므로 이벤트 루프가 나타납니다.
  6. 첫 번째는 마이크로태스크 큐가 가장 우선 순위가 높으므로 마이크로태스크 큐의 log 함수를 통해 Promise 1 , Promise 2 가 출력됩니다.
  7. 그 다음 브라우저가 렌더 큐로 이동하여 RequestAnimationFrame 1이 출력됩니다.
  8. 마지막으로 태스크 큐로 이동하여 Timeout 1 이 출력됩니다.

마지막으로 이벤트 루프를 코드로 표현해보자면 아래와 비슷할 것 입니다.

출처: JSConf

이 이야기를 듣고 더 궁금한 점이 생겼다면…

https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/

위 블로그를 확인하여 큐의 동작이 브라우저마다 어떻게 차이가 있는지 애니메이션을 통해 확인하실 수 있습니다!

참고 자료

profile
https://blog.areumsheep.vercel.app/ 으로 이동 중 🏃‍♀️

0개의 댓글