자바스크립트 이벤트 루프: 마이크로태스크(Microtasks)와 매크로태스크(Macrotasks)

Duckuism
podo_official
Published in
12 min readFeb 26, 2020

자바스크립트의 이벤트 루프에 대한 깊은 이해

안녕하세요. 🍇포도 스터디의 한유덕입니다. 이번 달 포도 스터디 월간 포스팅의 주제는 자바스크립트의 이벤트 루프입니다.

이 포스팅은 아래 포스팅을 국문으로 번역한 내용입니다. 가독성을 위해 의역이 된 부분이 다소 존재하므로, 원문과 차이가 있거나 오해의 소지가 있는 부분은 지적해주시면 수정하도록 하겠습니다.

아래 내용으로 보아 위 포스팅은 CC-BY-NC-SA 라이센스로 저작자를 밝히고 비영리로 이용이 가능하며, 저작물의 변경도 가능하므로 원 저작자의 동의를 구하지 않고 번역하였음을 밝힙니다. CC-BY-NC-SA 저작권의 2차적 저작물은 원 저작물의 저작권과 동일한 CC-BY-NC-SA 라이센스가 적용됩니다.

이벤트 루프: 마이크로태스크(Microtask)와 매크로태스크(Macrotask)

Node.js 뿐만 아니라 브라우저의 자바스크립트 실행 흐름은 이벤트 루프에 기반을 두고 있습니다.

이벤트 루프가 어떻게 동작하는지 이해하는 것은 최적화와 올바른 아키텍쳐를 위해 굉장히 중요합니다.

이 글에서는 먼저 이론적으로 이벤트 루프가 어떻게 동작하는 지에 대해 살펴보고, 이해를 위해 실제 애플리케이션을 살펴보겠습니다.

이벤트 루프

이벤트 루프의 개념은 매우 단순합니다. 엔진은 작업(task)을 기다리고 실행한 후, 더 많은 작업들을 기다리며 다시 잠들고, 새로운 작업이 나타나면 잠에서 깨어나 해당 작업을 실행합니다. 이러한 과정이 끝없이 반복(endless loop)되게 되는데, 이것이 바로 이벤트 루프입니다.

일반적인 엔진의 알고리즘은 다음과 같습니다.

  1. 작업이 있다면 해당 작업을 실행한 후, 남은 작업 중 가장오래된 작업부터 실행합니다.
  2. 엔진은 추가 작업이 생길 때까지 잠들어있다가 추가 작업이 생기면 1번 과정으로 돌아갑니다.

위 과정은 우리가 웹 페이지를 눈으로 보기 위해 필수적으로 일어나야 하는 과정입니다. 엔진은 대부분의 시간에 아무것도 하지 않고 오직 스크립트, 핸들러, 이벤트를 활성화할 때만 동작합니다.

작업의 구체적인 예시는 아래와 같습니다.

  • 외부 스크립트 <script src="..."> 를 불러올 때, 작업은 스크립트를 실행하는 것입니다.
  • 유저가 마우스를 움직일 때, 작업은 mousemove 이벤트를 실행하고 핸들러를 실행하는 것입니다.
  • 예약된 setTimeout 시간이 만료될 때, 작업은 setTimeout의 콜백 함수를 실행시키는 것입니다.
  • 등등…

작업들이 생기고 엔진이 작업들을 처리한 후, 엔진은 또 다른 작업을 기다립니다. (엔진이 잠들었을 때는 CPU의 메모리 소비가 0에 가깝습니다.)

만약 엔진이 바쁠 때 새로운 작업이 들어온다면, 그 작업은 대기열에 들어갑니다.

이렇게 엔진이 바쁠 때 미뤄진 작업들로 형성된 대기열을 매크로테스크 대기열(Macrotask queue, v8 용어)이라고 부릅니다.

이런 상황의 예를 들어볼까요? 엔진이 스크립트를 실행하느라 바쁠 때, 사용자가 마우스를 움직여서mousemove 이벤트를 일으킬 수도 있고, setTimeout 함수에서 설정한 시간이 만료될 수도 있습니다. 엔진이 바쁠 때 생기는 이러한 작업들은 위 사진처럼 즉시 실행되지 못하고 매크로태스크 대기열을 형성하게 됩니다.

대기열의 작업들은 “먼저 들어간 것이 먼저 실행되는” 원리로 처리됩니다. 엔진은 스크립트 처리가 끝나면mousemove 이벤트를 처리하고, 대기열 다음에 놓인setTimeout 핸들러 등을 처리합니다.

지금까지는 아주 간단하죠?

두 가지 사실 더!

  1. 렌더링은 엔진이 작업을 처리하는 동안에는 절대로 일어나지 않습니다. 처리가 오래 걸리더라도 상관 없이 말이죠. DOM에 대한 변경사항은 작업에 대한 처리가 모두 끝난 다음 적용됩니다.
  2. 작업 처리가 너무 오래걸리면, 브라우저는 다른 작업을 하거나 사용자 이벤트를 처리할 수 없으므로 전체 페이지 작업을 종료할 것을 제안하는 “페이지 응답 없음”과 같은 경고가 나타납니다. 이러한 현상은 복잡한 계산이나 무한루프로 귀결되는 프로그래밍 에러가 있을 때 생깁니다.

지금까지가 이론입니다. 이제 어떻게 우리가 이 지식을 적용할 수 있는지 보도록 하죠.

예시 1: CPU를 많이 차지하는 작업 나눠서 처리하기

CPU를 많이 차지하는 작업을 가지고 있다고 가정해보겠습니다.

예를 들어, 페이지에서 코드 예제에 색상을 입힐 때 사용되는 문법 강조(syntax highlighting)는 CPU에게 매우 무거운 작업입니다. 코드를 강조하기 위해 코드를 분석하고 색상이 입혀진 요소들을 생성산 후, 문서에 이 요소들을 더하기 때문이죠. 텍스트가 많을수록 많은 시간이 걸립니다.

엔진이 문법 강조를 처리하느라 바쁘다면, DOM과 관련된 다른 일이나 사용자 이벤트 처리는 할 수 없습니다. 이런 상황은 잠시 동안 브라우저가 ‘간헐적’으로 멈추거나 아얘 ‘멈추게 ’만들 수도 있는데, 사용자 경험 관점에서 이런 일은 절대 일어나면 안 됩니다.

우리는 이러한 큰 작업을 작은 작업으로 나눔으로써 문제를 해결 수 있습니다. 처음 100줄에 대해서만 문법 강조 처리를 먼저하고, 다음 100줄은 setTimeout으로 예약해서 문법 강조 처리를 하는거죠.

이 접근 방법을 단순하게 증명하기 위해 위에서 예시로 든 문법 강조 대신 1부터 1000000000까지 세는 함수를 살펴보도록 하겠습니다.

아래 코드를 실행하면 엔진은 때때로 멈춥니다. 서버 사이드 JS에서는 명확히 인지하기 힘들지만, 브라우저에서 실행한다면 페이지에서 다른 버튼을 클릭했을 때 셈이 끝날 때까지 아무런 반응도 일어나지 않는 것을 볼 수 있습니다.

브라우저는 아마도 “스크립트가 너무 오래걸린다.”는 경고를 보여줄 것입니다.

이제 setTimeout으로 감싸서 기존의 작업을 나눠보죠.

이제 브라우저 인터페이스는 숫자를 ‘세는’ 동안에도 완벽하게 동작합니다.

숫자를 세는 과정을 작게 나누고, 이 과정을 필요한 만큼 반복합니다.

  1. 처음 세는 범위 i=1…1000000
  2. 두 번째 세는 범위 i=1000001..2000000
  3. 계속 반복…

이제 엔진이 1번 과정을 실행하느라 바쁠 때 onclick 이벤트와 같은 새로운 작업이 나타난다면, 엔진은 이 작업을 매크로태스크 대기열에 넣어놓고 1번 과정이 끝난 후 2번 과정이 실행되기 전에 실행합니다. 숫자를 세는 이벤트 루프들의 주기적인 결과 반환은 사용자 행동에 반응하기 위해 다른 것들을 해야하는 자바스크립트 엔진에게 충분한 “공기(Air)”를 제공합니다.

주목할 만한 것은 두 가지 변형(setTimeout으로 작업을 나눈 것과 나누지 않은 것)들의 속도가 비슷하다는 것입니다. 숫자를 세는데에 소요되는 전체 시간도 거의 차이가 없습니다.

이것을 좀 더 개선해보겠습니다.

scheduling을 count() 의 시작으로 옮기겠습니다.

우리가 count() 를 시작하고 count() 가 더 필요하다는 것을 알았다면, counting 작업을 시작하기 전에 setTimeout을 통해 필요한 만큼 예약합니다.

이제 위 코드를 실행하면 훨씬 더 적은 시간이 걸리는 것을 쉽게 알 수 있습니다.

왜 그럴까요?

간단합니다. 중첩된 많은 setTimeout 호출들 사이에는 브라우저 내의 최소지연 시간인 4ms가 존재하기 때문입니다. 심지어 우리가 0까지 숫자를 세더라도, 함수호출을 한 번이라도 하게 되면 4ms가 걸립니다. (혹은 좀 더 걸릴 수도 있습니다.) 그래서 우리가 함수 호출을 setTimeout을 통해 예약하면 할 수록, 실행 속도는 더욱 빨라집니다. (함수 호출이 미리 예약되어 대기열에 들어가 있으므로 호출에 걸리는 4ms 시간이 제거되는거죠.)

마침내, 우리는 CPU를 많이 차지하는 작업을 작게 나누었습니다. 이제 사용자 인터페이스는 막히지 않게 되었으며, 전체적인 실행 시간은 훨씬 적어졌습니다.

예시 2: 진행 상태 표시

브라우저 스크립트의 무거운 작업들을 나누는 것의 또 다른 이점은 진행 상태를 표시하여 볼 수 있다는 것입니다.

보통 브라우저는 현재 실행중인 코드의 실행이 모두 완료된 후에 렌더링을 시작합니다. 그 작업이 오래걸리던 그렇지 않던 상관없이 말이죠. 따라서 DOM의 변화는 현재 실행중인 코드의 실행이 모두 끝나야만 적용됩니다.

우리가 만든 코드의 함수는 많은 요소들을 만들고, 문서에 만들어진 요소들을 하나하나 붙이고, 이 요소들의 스타일도 변경합니다. 사용자는 이 모든 과정이 끝날 때까지 어떠한 중간과정도 볼 수 없습니다. 이 부분이 아주 중요한 문제입니다.

아래에 데모를 위한 코드가 있습니다. i에 대한 변화는 함수가 모두 끝날때까지 볼 수 없고, 우리는 마지막 i의 값만 확인할 수 있습니다.

하지만 작업이 처리되는 동안, 진행 상태를 표시하는 상태 바를 사용자에게 보여줘야 할 때도 있습니다.

만일 무거운 작업을 setTimeout을 사용하여 나누면, DOM의 변화는 나눠진 작업들 사이사이에 일어나게 됩니다.

아래 코드처럼, 무거운 작업을 setTimeout으로 나눌 수 있습니다.

이제 <div> 태그는 i의 값의 증가량을 보여줌으로써 진행 상태를 표시합니다.

예시 3: 이벤트 후에 특정 액션을 처리할 때

이벤트 핸들러에서 발생한 이벤트가 모든 레벨을 거쳐 우리가 원하는 지점으로 올라올(bubble up) 때까지, 우리는 어떤 액션을 연기하고 싶을 때가 있습니다. 이럴 때 코드를 setTimeout으로 감싸줌으로써 delay를 0으로 만들 수 있습니다.

아래에 예시가 있습니다. 커스텀 이벤트 menu-opensetTimeout안에서 실행되고 이 이벤트는 "클릭" 이벤트가 완전히 다뤄진 후에 일어납니다.

매크로태스크(Macrotask)와 마이크로태스크(Microtask)

위에서 설명한 매크로태스크 말고도, 마이크로태스크라는 것이 존재합니다.

마이크로태스크는 렌더링과 같은 과정을 포함하고 있는 매크로태스크와 달리 우리의 코드로 인해서만 생성됩니다. 보통 promise에 의해 생성되는데, .then/catch/finally 핸들러의 실행 명령이 마이크로 태스크가 됩니다.

또한 마이크로태스크 대기열 안에서 실행할 콜백 함수를 대기열에 넣어주는 queueMicrotask(func) 라는 특별한 함수도 존재합니다.

“모든 매크로태스크가 종료되는 즉시, 엔진은 남아있는 매크로태스크, 렌더링 등 다른 것들보다 우선적으로 마이크로태스크 큐 안의 모든 작업을 실행합니다.”

예를 들어 보겠습니다.

위 코드의 실행 순서가 어떻게 될까요?

  1. 일반적인 동기 호출인 code 가 첫 번째로 보여집니다.
  2. .then 은 마이크로태스크 대기열로 넘어가고, 1번 코드의 실행 이후에 바로 실행되므로 promise 는 두 번째로 출력됩니다.
  3. 또 다른 매크로 태스크인 timeout 은 마지막으로 보여집니다.

이벤트 루프를 사진으로 표현하자면 아래과 같습니다.

모든 마이크로태스크는 다른 이벤트 핸들링이나 렌더링 혹은 또 다른 매크로태스크가 실행되기 전에 완료됩니다.

이것은 애플리케이션 환경이 기본적으로 마이크로태스크들 사이에서 변화하지 않는다(마우스 좌표의 변경이 없음, 새 네트워크 데이터 없음 등)는 것을 보증하기 때문에 굉장히 중요한 사실입니다.

만일 우리가 현재 코드의 실행 후 렌더링이 일어나거나 새로운 이벤트들이 처리되기 전에 비동기적으로 특정 함수를 실행하고 싶다면,queueMicrotask를 통해 마이크로태크스 대기열에 해당 함수를 넣어놓고 실행하면 되는 것이죠.

대표적인 예가 위에서 보았던 숫자를 세는 진행 상태 표시 바이고, 아래에서는 위에서 사용했던setTimeout 대신 queueMicrotask를 사용했습니다. 이제 마치 동기 코드처럼 렌더링이 맨 마지막에 실행되는 것을 볼 수 있습니다.

요약

이벤트 루프의 전체 알고리즘:

  1. 매크로태스크 대기열에서 가장 오래된 작업을 꺼내어 실행합니다. (예: 스크립트 실행)
  2. 모든 마이크로태스크를 실행합니다. (마이크로태스크 대기열이 비어있지 않다면 가장 오래된 마이크로태스크를 대기열에서 꺼내어 실행합니다.)
  3. DOM 변경사항이 있는 경우 렌더링합니다.
  4. 매크로태스크 대기열이 비었다면, 다른 매크로태스크가 생길 때까지 기다립니다.
  5. 처리할 매크로태스크가 생기면 1번으로 돌아갑니다.

매크로태스크의 특징:

  • 딜레이가 0인 setTimeout(func) 을 사용합니다.

setTimeout은 큰 계산이 요구되는 무거운 작업을 작게 쪼개어 주고 브라우저가 사용자 이벤트에 반응하고 나눠진 작업들 사이에서 진행 상태를 표시할 수 있게 해줍니다.

또한, 이벤트가 완전히 처리된 후에 특정 액션에서 이벤트 핸들러가 실행되도록 예약하는 데도 사용될 수 있습니다.

마이크로태스크의 특징:

  • promise 객체의 핸들러들이 마이크로 태스크에 들어갑니다.

마이크로태스크 사이에서는 어떠한 UI 혹은 네트워크 변화가 없습니다. 마이크로태스크는 즉시 다음 마이크로 태스크를 실행하기 때문입니다.

따라서 사용자는 함수를 비동기식으로 실행하기 위해 queueMicrotask() 를 사용할 수 있지만, 올바른 환경에서 사용해야합니다.

길고 긴 번역 글이 드디어 끝났습니다. 긴 글을 끝까지 읽어주셔서 감사합니다. 번역을 진행하며 자바스크립트의 이벤트 루프에 대해 좀 더 깊게 살펴 볼 수 있는 좋은 기회가 되었습니다. 다음에는 더 흥미롭고 재밌는 주제로 찾아오도록 하겠습니다. 😁

--

--