[모던JS: 브라우저] 이벤트 루프와 매크로 · 마이크로태스크

KG·2021년 6월 24일
0

모던JS

목록 보기
39/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

이벤트 루프와 매크로·마이크로태스크

브라우저 측 자바스크립트의 실행 흐름은 Node.js와 마찬가지로 이벤트 루프에 기반하고 있다. 따라서 이벤트 루프가 무엇인지 이해하고, 어떻게 동작하는지 잘 파악하고 있어야 최적나 올바른 아키텍쳐 설계가 가능하다.

자바스크립트 자체 환경에서는 사실 이벤트 루프라는 개념이 없다. 실제로 ECMAScript 스펙에는 이벤트 루프에 대한 내용, 정확히는 동시성이나 비동기와 관련된 언급이 없다고 할 수 있다 (ES6에서 async/await 관련 Promise 개념이 등장하면서 조금 달라지긴 했다).

실제로 크롬에서 사용하는 V8 자바스크립트 엔진은 단일 호출 스택(Call Stack)을 사용하며, 요청이 들어올 때마다 해당 요청을 순차적으로 담아 LIFO(Last In First Out) 순서로 처리할 뿐이다. 그 외 비동기 요청이나 동시성에 대한 처리는 모두 자바스크립트 엔진을 구동하는 환경인 브라우저 또는 Node.js가 담당한다.

위 그림은 브라우저 환경을 그림으로 나타낸 것이다. 이처럼 자바스크립트 엔진은 이벤트 루프, 그리고 Web API와 태스크 큐와 분리되어 있다. 즉 브라우저에서 비동기 호출을 위해 사용하는 setTimeout이나 fetch와 같은 함수들은 자바스크립트 엔진에서가 아닌 Web API 영역에 따로 정의되어 있다.

해당 이미지는 Node.js의 환경을 나타내는 그림인데 브라우저의 환경과 비슷한 구조를 띄고 있는 것을 알 수 있다. 노드는 비동기 IO까지 지원하기 위해 C++로 제작된libuv 라이브러리를 사용하며, 해당 라이브러리가 이벤트 루프를 제공한다. 자바스크립트 엔진은 비동기 작업을 위해 노드의 API를 호출하며, 이때 넘겨진 콜백은 libuv의 이벤트 루프를 통해 스케쥴되고 실행된다.

흔히 자바스크립트를 단일 스레드 기반의 언어라고 설명하곤 한다. 이는 자바스크립트 엔진이 단일 호출 스택을 사용하는 관점에서는 사실이다. 그렇지만 실제 자바스크립트가 구동되는 환경까지 고려한다면 내부적으로 여러 개의 스레드가 사용되고 있으며, 이러한 구동 환경에서 단일 호출 스택을 사용하는 엔진과 상호 연동하기 위해 사용하는 장치가 바로 이벤트 루프라고 할 수 있다.

이벤트 루프

이벤트 루프의 정의는 아주 간단하다. 이벤트 루프는 태스크가 들어오길 기다렸다가 태스크가 들어오면 이를 처리하고, 처리할 태스크가 없는 경우에는 잠드는, 끊임없이 돌아가는 자바스크립트 엔진과 상호 연동하기 위한 장치이다. MDN 문서에서는 이벤트 루프를 다음의 가상 코드로 설명하고 있다.

while(queue.waitForMessage()) {
  queue.processNextMessage();
}

waitForMessage 메서드는 현재 실행중인 태스크가 없을 때, 다음 태스크가 큐에 추가될 때까지 대기하는 역할을 수행한다. 즉 이벤트 루프는 이와 같은 방식으로 현재 실행중인 태스크가 있는지 그리고 태스크 큐에 태스크가 있는지 반복적으로 확인하며 동작을 처리하는 순서에 기여한다.

이를 통해 자바스크립트 엔진이 돌아가는 알고리즘을 일반화 하면 다음과 같다.

  1. 처리해야 할 태스크가 있는 경우 : 먼저 들어온 태스크부터 순차적으로 처리
  2. 처리해야 할 태스크가 없는 경우 : 잠들어 있다가 새로운 태스크가 추가되면 1로 돌아감

이처럼 자바스크립트 엔진은 대부분의 시간 동안 아무런 일을 하지 않고 있다가 스크립트나 핸들러, 이벤트, 네트워크 요청, 비동기 작업 등이 발생할 때 활성화된다. 이때 자바스크립트 엔진을 활성화 하는 태스크에는 다음과 같은 것들이 있다.

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

태스크는 하나의 집합을 이루는데, 자바스크립트 엔진에서 호출 스택은 집합을 이루는 태스크들을 차례로 처리하고, 새로운 태스크가 추가될 때까지 대기한다. 이 대기시간은 CPU idle 타임으로 자원 소비는 0에 가까우며 엔진은 잠들어 있는 상태와 같다. 자바스크립트의 호출 스택에 들어오는 작업은 모두 LIFO 순서로 처리된다. 그리고 작업이 어떤 값을 반환하는 경우에 해당 값은 스택에서 제거된다.

자바스크립트의 함수가 실행되는 방식을 보통 Run to Completion이라고 부른다. 이는 하나의 함수가 실행되면 이 함수의 실행이 끝날 때까지는 다른 어떤 작업도 중간에 끼어들 수 없음을 의미한다. 때문에 자바스크립트 엔진은 하나의 호출 스택을 사용하며, 현재 스택에 쌓여있는 모든 함수들이 실행을 마지고 스택에서 제거되기 전까지는 다른 어떤 함수도 실행될 수 없음을 말한다.

어떤 코드가 처음 실행될 때라고 하더라도, 전역 환경에서 실행되는 한 단위의 코드블록으로써 가상의 익명함수가 이를 감싸고 있는 실행 컨텍스트로 간주한다. 크롬의 경우엔 해당 전역 컨텍스트가 anonymous라는 이름을 가지고 있다. 만약 다음의 코드를 실행시킨다면 아래와 같은 순서로 호출 스택에 함수가 기록되며 사라질 것이다.

function foo() {
  bar();
  console.log('foo');
}

function bar() {
  console.log('bar');
}

function baz() {
  console.log('baz');
}

setTimeout(baz, 0);
foo();

이때 스케쥴링 함수인 setTimeout 부분을 주목하자. 딜레이를 0으로 설정함은 바로 호출한다는 의미와 다름이 없지만 호출 스택을 보면 가장 마지막에 호출되고 있음을 볼 수 있다. 이는 스케쥴링 함수는 자바스크립트 엔진에서 자체적으로 처리하지 못하고 Web API에게 넘겨주기 때문이다. Web API는 자바스크립트 엔진에서 자체적으로 처리하지 못하는 작업을 백그라운드에서 지원한다. 이를 통해 비동기적이며 non-blocking한 동작을 수행할 수 있다. 일부 DOM API, setTimeout과 같은 타이머, 네크워크 요청 관련은 모두 Web API에서 제공한다. 위의 코드 말고, 다시 처음 gif 이미지에서 보여주는 코드로 돌아가보자.

Web API에서 타이머는 전달된 인수 값인 1000ms 후에 실행된다. 그러나 크롬 브라우저의 경우엔 기본적으로 이 딜레이 값을 0으로 설정해도 내부적으로 4ms의 딜레이를 가진다. 이에 대해선 앞서 함수챕터에서 설명한 바 있다. 이후 지정된 딜레이가 지나고 실행된 콜백은 즉시 호출 스택에 추가되지 않고 태스크 큐로 이동하게 된다.

하지만 새로운 태스크는 엔진이 활성화 되어있을 때도 언제든지 추가될 수 있다. 이때 추가되는 태스크는 호출 스택이 아닌 큐에 추가 되는데, 크롬의 V8엔진에서는 해당 큐를 매크로태스크 큐(Macrotask queue)라고 부른다.

좀 더 구체적인 사례를 가지고 매크로태스크 큐에 대해 알아보자. 엔진이 script를 처리하느라 바쁜데, 사용자가 마우스를 움직여 mousemove 이벤트를 활성화하고 바로 이어서 setTimeout에서 설정한 시간이 지났다고 가정해보자. 이때 세 태스크는 큐에 하나씩 추가되는데, 위 그림은 이러한 상황을 묘사한 것이다. 큐에 있는 태스크들은 들어간 순서대로, 즉 FIFO(First In First Out) 순서로 처리된다. 따라서 엔진은 script를 먼저 처리하고, mousemove 이벤트와 핸들러, setTimeout 핸들러를 순차적으로 처리한다.

이때 엔진이 특정 태스크를 처리하는 동안엔 렌더링이 절대 발생하지 않는다. 태스크를 처리하는데 걸리는 시간이 길지 않으면 이는 보통 큰 문제가 되지 않는다.

그러나 하나의 태스크를 처리하는데 매우 긴 시간이 소요된다면 브라우저는 태스크를 처리하는 동안 발생한 사용자 이벤트 등 새로운 태스크들을 처리하지 못한다. 이 경우 응답없는 페이지 등과 같은 오류창을만나볼 수 있는데, 이는 보통 아주 복잡한 계산이 필요하거나 프로그래밍 에러로 인해 무한 루프에 빠져 새로운 태스크를 처리하지 못한 상황에서 브라우저가 중단을 유도하도록 하는 작업이다.

이벤트 루프와 자바스크립트의 호출 스택에 대해 이론적인 부분을 살펴보았다. 몇 가지 예시를 통해 이들의 상호 작용에 대해 조금 더 면밀히 살펴보자.

1) 유스케이스1 : CPU 소모가 많은 태스크 쪼개기

CPU 소모가 매우 큰 태스크가 있다고 가정해보자. 사실 브라우저 환경에서 CPU 소모가 큰 작업은 거의 필요하지 않거나, 있다고 하더라도 브라우저 본연의 임무를 과도하게 벗어난 오버 스펙인 경우가 많다. 그렇지만 웹 애플리케이션이 계속 발전함에 따라 이전에는 시도조차 못 할 고스펙 작업들을 브라우저 자체에서 많이 처리하고 있기도 하다.

다시 본론으로 돌아와 이처럼 CPU 소모가 큰 작업을 처리하려면 오랜 시간이 걸릴 것이고, 자바스크립트 엔진은 단일 호출 스택을 사용하기 때문에, 해당 작업이 수행되는 동안에는 다른 작업이 개입할 수 없어 이후 작업들이 모두 블록킹(blocking)되는 현상이 발생할 것이다. 따라서 이런 작업은 태스크를 쪼개어 스케쥴링 하는 최적화가 필요하다. 먼저 리팩토링 전의 코드를 살펴보자. 해당 작업은 실제로는 CPU를 과도하게 소모하는 작업은 아니지만, 다소 오랜 시간을 잡아먹는 작업이다. 1부터 1000000000까지 세는 동안 브라우저는 다른 작업을 처리하지 못하기 때문에 이 동안 지연이 발생한다. 실제로 해당 작업 처리 시간 동안 브라우저의 버튼이나 기본 마우스 이벤트 등은 발생하지 않는다.

let i = 0;
let start = Date.now();

function count() {
  // CPU 소모가 많은 무거운 작업을 수행
  for (let j = 0; j < 1e9; j++) {
    i++;
  }

  alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
}

count();

이러한 태스크는 setTimeout 스케줄링 함수를 이용해 쉽게 쪼갤 수 있다. 다음과 같이 중첩 setTimeout 호출을 사용해 태스크를 쪼개보자.

let i = 0;
let start = Date.now();

function count() {
  // 무거운 작업을 쪼갠 후 이를 수행
  do {
    i++;
  } while (i % 1e6 !== 0);
  
  if (i === 1e9) {
    alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
  } else {
    setTimeout(count);
  }
}

count();

이제는 숫자를 세는 도중에도 브라우저가 온전히 제 기능을 하는 것을 확인할 수 있다. 이러한 동작이 가능한 이유는 count 태스크를 일부 쪼개 수행하고, 만약 카운팅이 온전히 끝나지 않은 경우엔 다시 setTimeout 스케줄링 함수를 이용해 이를 태스크 큐로 보내기 때문이다.

  1. 첫 번째 부분 카운팅: i=1 ... 1000000
  2. 두 번째 부분 카운팅: i=1000001 ... 2000000
  3. 원하는 숫자를 다 셀 때까지 부분 카운팅이 이어짐

엔진이 첫 번째 부분 카운팅을 처리하고 있는 도중에 onclick 이벤트와 같은 새로운 태스크가 생기면 이는 태스크 큐에 들어간다. 이 태스크는 첫 번째 부분 카운팅이 끝나고서 두 번째 부분 카운팅이 실행되기 전에 실행된다. 즉 부분 카운팅 중간 중간 환기를 통해 이벤트 루프가 돌아갈 수 있게 설정한 것이다.

그런데 위와 같은 방식으로 구현했을 경우, 태스크를 쪼개기 전의 완료시간과 어느 정도 격차가 존재하는 것을 볼 수 있다. 중간 중간 환기를 시키기 때문에 당연히 실행 시간의 차이가 있지만 그 격차가 생각보다 꽤 크다고 생각할 수 있다. 이는 중첩 setTimeout 호출이 많아지는 경우, 브라우저 최소 대기 시간이 4밀리초가 되기 때문에 해당 대기 시간이 매번 추가되어 격차가 발생한 것이다.

이는 숫자를 세기 전에 스케줄링을 먼저 하도록 하면, 숫자를 세면서 어느 정도 대기 시간을 소모하기 때문에 보다 빠른 실행 속도를 보장할 수 있다.

function count() {
  // 스케줄링 코드를 함수 앞부분으로 옮김
  if (i < 1e9 - 1e6) {
    setTimeout(count);
  }
  
  do {
    i++;
  } while(i % ie6 !== 0);
  
  if (i === 1e9) {
    alert("처리에 걸린 시간: " + (Date.now() - start) + "ms");
  }
}

count();

2) 유스케이스2 : 프로그레스 바

태스크를 여러 개로 쪼갤 때의 장점은 진행 상태를 나타내주는 프로그레스 바를 만들 때도 드러난다. 브라우저는 스크립트 실행 시간이 오래 걸리든 아니든간 상관없이 대개 실행 중인 코드의 처리가 끝난 이후 렌더링 작업을 수행한다.

함수를 사용해 원하는 만큼의 요소를 동적으로 생성하고 추가한 뒤, 각 요소의 스타일을 변경할 수 있다는 점에서 이러한 브라우저 동작 방식은 유용하다. 모든 작업이 이뤄지는 동안 사용자는 완성되지 않은 중간 상태의 화면을 보지 않기 때문이다.

아래 코드를 실행하면 사용자는 i가 실시간으로 변하는 것은 볼 수 없고, 그저 마지막 상태의 i값이 출력되는 것만 확인할 수 있다. 이는 함수가 끝나기 전 렌더링이 되지 않기 때문이다.

<div id="progress"></div>

<script>
  function count() {
    for (let i = 0; i < 1e6; i++) {
      i++;
      progress.innerHTML = i;
    }
  }
                            
  count();
</script>

그러나 UX를 위해서는 중간 진척 상황을 프로그레스 바와 같은 형태로 보여주는 인디케이터를 만들어야 하는 경우가 많다. 이럴 때도 setTimeout을 이용해 태스크를 여러개로 쪼개면 상태 변화를 서브 태스크 중간마다 보여줄 수 있다. 그렇지 않으면 중간 상태를 렌더링하고 싶어도, 메인 태스크가 끝나기 전까지는 브라우저의 렌더링이 멈추기 때문에 보여줄 방법이 없다.

let i = 0;

function count() {
  // 무거운 작업을 쪼갠 후 이를 수행
  do {
    i++;
    progress.innerHTML = i;
  } while (i % 1e3 !== 0);
  
  if (i < 1e7) {
    setTimeout(count);
  }
}

count();

해당 코드를 실행하면 아까와는 달리 <div> 태그에 실시간으로 변하는 i 값을 렌더링 할 수 있다. 이때 변하는 단위는 1000 단위이다.

3) 유스케이스3 : 이벤트 처리가 끝난 이후 작업하기

이벤트 핸들러를 만들다 보면 이벤트 버블링이 모두 끝난 이후 모든 DOM 트리 레벨에서 이벤트가 핸들링 될 때까지 특정 액션을 연기시켜야 하는 경우가 있다. 이럴 때 연기 시킬 액션 관련 코드를 지연 시간이 0인 setTimeout으로 감싸면 해당 동작을 구현할 수 있다.

앞서 커스텀 이벤트 디스패치를 다룬 챕터에서 이미 이 같은 동작을 살펴본 바 있다. 아래 예시는 클릭 이벤트가 완전히 핸들링 되고 난 후 menu-open 이벤트를 디스패칭하는 예시이다.

menu.onclick = function() {
  // ...
  
  let customEvent = new CustomEvent('menu-open', {
    bubbles: true
  });
  
  setTimeout(() => menu.dispatchEvent(customEvent));
};

매크로태스크와 마이크로태스크

태스크 큐는 다시 매크로태스크와 마이크로태스크 큐로 구분할 수 있다. 매크로태스크 큐는 이벤트 큐 또는 콜백 큐로 불리기도 하며, 마이크로태스크 큐는 잡 큐라고 부르기도 한다. 마이크로태스크 큐의 경우에는 이미 비동기-프라미스 챕터에서 다룬 바 있다. 앞서 살펴본 대부분의 일반 태스크는 모두 매크로태스크 큐에 들어간다.

마이크로태스크는 코드를 사용해서만 만들 수 있는데, 주로 프라미스를 이용해 만든다. 프라미스와 함께 쓰이는 .then/catch/finally 핸들러가 마이크로태스크 큐로 들어가는 작업이 된다. 여기에 더해 async/await 같은 키워드를 사용해도 해당 작업은 마이크로태스크 큐가 된다. 또 잘 쓰이지는 않지만 표준 API 중 queueMicrotask(func)를 사용하는 경우 func를 마이크로태스크 큐에 넣어 처리할 수 있다.

자바스크립트 엔진은 매크로태스크 하나를 처리하고 난 직후엔, 다른 매크로태스크나 렌더링 작업을 하기 전 마이크로태스크 큐에 있는 모든 작업을 처리한다. 즉 마이크로태스크는 일반 매크로태스크보다 더 높은 우선순위를 가지고 있다고 할 수 있다.

setTimeout(() => alert('timeout'));

Promise.resolve()
  .then(() => alert('promise'));

alert('code');

다음 코드를 실행하면 아래 순서대로 문자열이 출력된다.

  1. code : 일반적인 동기 호출
  2. promise : 마이크로태스크 이므로 현재 코드 실행 후 가장 높은 우선순위
  3. timeout : 매크로태스크

매크로태스크와 마이크로태스크 처리 로직을 첨가하면, 위에서 살펴본 그림을 아래와 같이 조금 더 고도화 할 수 있다. 스크립트는 매크로태스크 큐에 가장 먼저 들어온 태스크이고, 호출 스택이 비워지는 즉시 실행된다. 실행 이후 마이크로태스크 큐에 작업이 있다면, 들어있는 모든 마이크로태스크를 수행하고 렌더링이 일어난다. 그리고 다시 매크로태스큐 큐에서 호출 스택이 비워져있다면 태스크를 들고와서 실행하는 구조이다.

이처럼 마이크로태스크는 다른 이벤트 핸들러나 렌더링 작업, 혹은 다른 매크로태스크가 실행되기 전에 처리된다. 이런 처리순서가 중요한 이유는 마이크로태스크 간에 동일한 애플리케이션 환경이 보장되기 때문이다. 이렇게 해야 마우스 좌표 변경이나 통신에 의한 데이터 변경 없이 모든 마이크로태스크를 동일한 환경에서 처리할 수 있다.

그런데 개발을 하다보면 직접 만든 함수를 현재 코드 실행이 끝난 후, 새로운 이벤트 핸들러가 처리되기 전이면서 렌더링이 실행되기 전에 비동기적으로 실행해야 하는 경우가 생길 수 있다. 이런 경우엔 위에서 언급한 queueMicrotask(func) API를 이용해 커스텀 함수를 스케쥴링 할 수 있다.

앞서 살펴본 프로그레스 바 예시에서 setTimeout 대신 queueMicrotask를 사용해 함수 count를 스케줄링 해보자. 이 경우 예시를 실행하면 동기 코드처럼 카운팅이 다 끝났을 때 숫자가 렌더링 되는 것을 확인할 수 있다.

let i = 0;

function count() {
  do {
    i++;
    progress.innerHTML = i;
  } while(i % ie3 !== 0);
  
  if (i < 1e6) {
    queueMicrotask(count);
  }
}

count();

처음 count 함수를 실행하면 나머지 태스크는 모두 마이크로태스크 큐로 들어가기 때문에 처음의 함수 실행이 끝나고서 계속 마이크로태스크 큐에서 나머지 작업을 가져온다. 이는 항상 렌더링보다 앞서기 때문에 카운팅이 끝나야 렌더링이 일어나는 것이다.

이벤트 루프 알고리즘을 요약하면 다음과 같다.

  1. 매크로태스크 큐에서 가장 오래된 태스크를 꺼내 실행 (FIFO 순서)
  2. 모든 마이크로태스크를 실행
    • 이 작업은 마이크로태스크 큐가 빌 때까지 이어지고, FIFO 순서로 처리
  3. 렌더링 할 것이 있다면 처리
  4. 매크로태스크 큐가 비어있으면 새로운 매크로태스크가 나타날 때까지 대기
  5. 1번으로 복귀

References

  1. https://ko.javascript.info/event-loop
  2. https://chanmi-lee.github.io/articles/2020-06/JavaScript-Visualized-Event-Loop
  3. https://meetup.toast.com/posts/89
  4. https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop
profile
개발잘하고싶다

0개의 댓글