내가 보려고 만든 개발 공부 일지

자바스크립트 - 매크로태스크(Macrotask) 와 마이크로태스크(Microtask) 그리고 작업 스케줄링 본문

Javascript

자바스크립트 - 매크로태스크(Macrotask) 와 마이크로태스크(Microtask) 그리고 작업 스케줄링

kwangsunny 2022. 3. 19. 02:07

이전에 실행 컨택스트에 대한 내용을 포스팅 한 적이 있다.

복습겸 짧게 요약하면, 실행 컨택스트는 현재 실행중인 코드의 환경 정보를 가진 데이터 구조이고,

함수가 호출될 때 만들어져서 호출스택에 push 된다.

함수가 끝나면 (return 되면) 호출스택에서 pop 되고 메모리에서 제거된다.

 

실행 컨택스트 관리는 자바스크립트 엔진이 수행하는 가장 중요하면서도 기본적인 역할이고,

이 개념만 제대로 이해해도 대단한 일이다.

하지만 이것만 가지고는 브라우저의 작동 매커니즘을 모두 이해했다고 할 수 없다.

 

실제 브라우저에서는 DOM 이벤트, 네트워크 요청, 렌더링, 비동기 처리 등 수 많은 일들이 동시 다발적으로 발생한다.

무거운 작업을 수행할때도 브라우저가 버벅이거나 먹통이 되지 않도록 앱을 잘 설계하기 위해선

브라우저가 어떻게 이 모든 것들을 처리하는지를 이해할 필요가 있다.

 

 

자바스크립트 실행 환경 - 호스트

자바스크립트는 원래 브라우저 환경에서 사용하려고 만든 언어이다.

시간이 흘러 자바스크립트는 점점 발전했고, 자바스크립트는 브라우저뿐 아니라 다른 환경에서도 사용되기 시작했는데

(대표적인 예로 node.js) 이렇게 자바스크립트가 돌아가는 환경을 호스트(host) 라 부르고, 호스트마다 해당 환경에

특화된 기능을 제공한다. 

브라우저는 웹페이지를 제어하는데 필요한 여러 기능들을 제공하는데 그 기능의 최상단이 바로 window 객체이다.

window객체는 전역 객체로써 setTimeout, XMLHttpRequest, alert ... 등 페이지를 제어하기 위한 다양한 메서드를 가지고 있다. 

이런 기능들은 자바스크립트 코어 기능이 아니라 브라우저라는 호스트에서 제공하는 기능으로 Web APIs 라고 부른다.

 

아래 그림을 통해 브라우저 환경을 살펴보자.

브라우저 구성 요소

먼저 자바스크립트 엔진 부분을 보면, 힙과 호출스택으로 구성되어있다.

힙에는 코드에서 선언된 객체가 실제로 할당되는 메모리 공간이다. 호출스택에서 코드를 실행하다 객체를 

만나면 참조값을 통해 힙에 저장되어 있는 객체에 접근한다. 호출스택은 실행 컨택스트들이 쌓이고 실행되는 공간이다.

 

흔히 자바스크립트는 싱글 스레드 언어라고 말하는데, 이건 호출스택이 한 개 존재한다는 뜻과 같다.

호출스택에서는 한 번에 하나씩 코드를 실행한다.  

한 번에 하나씩...? 그런데 실제 우리는 브라우저에서 웹서핑을 할때 화면이 그려지는 동시에 목록 조회,

스크롤, 클릭, 키입력 등등 다양한 행위들을 하는데도 이 모든것들이 동시에 처리되는것 처럼 보인다. 

 

자바스크립트 엔진이 관리하는 호출스택은 하나인데

네트워크 요청, 사용자 이벤트, 렌더링, 등 비동기적으로 발생하는 작업들은 대체 어떻게 처리되는 걸까?

 

 

매크로태스크, 마이크로태스크

위에서 언급했던 비동기 작업들은 태스크큐 라는 저장 공간에 들어간다.

태스크큐는 발생한 순서대로 큐에 쌓이고 이벤트 루프에 의해 처리된다.

태스크큐는 매크로태스크큐 와 마이크로태스크 로 구분할 수 있는데, 이 둘의 차이는 처리할 작업의 우선순위 이다.

 

매크로와 마이크로에 속하는 작업들은 아래와 같다. 

매크로태스크 - DOM 이벤트 콜백, 타이머(setTimeout, setInterval), 스크립트 로딩 등

마이크로태스크 - 프로미스 핸들러 (then / catch / finally) + await,  옵저버 (MutationObserver 등)   

 

마이크로태스크는 매크로태스크 보다 우선순위가 높다. 그래서 항상 마이크로태스크의 작업이 더 먼저 처리된다.

 

 

이벤트 루프 (Event Loop)

위 그림을 보면 이벤트 루프가 존재한다.

이벤트 루프는 자바스크립트 엔진 바깥에서 자바스크립트 엔진을 도와 비동기 작업을 가능하게 해주는 녀석이다.

처리해야 할 태스크는 Web API 에서 잠깐 대기 하다가, 매크로태스크 또는 마이크로태스크로 들어간다. 

 

이벤트 루프는 작업할 태스크가 없으면 대기 상태가 되고, 이때는 CPU를 소모하지 않는다.

그러다 태스크가 큐에 들어오면 이벤트 루프는 아래와 같은 순서로 일을한다.

 

1. 호출스택이 비었는지 지속적으로 확인한다.

2. 호출스택이 비게 되면 제일먼저 마이크로태스크 큐를 확인하고 가장 오래된 태스크부터 꺼내서

   호출스택으로 전달해 주는데, 이걸 마이크로태스크 큐가 빌 때까지 수행한다.

3. 모든 마이크로태스크가 처리된 직후, 렌더링 작업이 필요하면 렌더링을 수행한다. 

4. 매크로태스크 큐를 확인한다.

5. 매크로태스크 큐에서 가장 오래된 태스크 하나를 꺼내 호출스택에 전달해준다.

6. 다시 1번 으로 돌아간다.

 

위 절차를 아래 예시를 통해 더 자세히 살펴보자.

처리되길 기다리는 태스크들

현재 호출스택에 작업들이 많아서 자바스크립트 엔진이 바쁜 와중에 여러 비동기 작업들이 큐에 쌓여있는 상황이다. 

이벤트 루프는 태스크들을 처리하기 위해 호출 스택이 비었는지 계속 확인한다. 

호출스택이 비었다면, 이벤트 루프는 가장 먼저 마이크로태스크 큐에 쌓여있는 태스크들을  

Promise then -> Promise then -> Observer callback 순서로 모두 처리할 것이다.

그리고 매크로태스크 큐를 처리하기 전에 UI 렌더링 작업이 필요하면 렌더링을 이때 수행한다.

이제 매크로태스크 큐의 click callback 을 처리하고 다시 마이크로태스크 큐를 확인한다.

만약 마이크로태스크 큐에 처리할 태스크들이 또 쌓여있다면 그것들을 모두 처리한 후 다시 렌더링 작업을 수행하고

매크로태스크의 setTimeout callback 을 처리한다.

 

위 예시의 작업 순서

여기서 주목할 부분은, 브라우저는 매크로태스크 하나를 처리할 때마다 마이크로태스크 전부를 다 처리하고 렌더링을 수행한다는 것이다.

그래서 마이크로태스크가 모두 처리되기 전까지는 UI 렌더링이나 네트워크 요청은 절대 일어나지 않는다.

 

이제 코드를 통해 정말 위의 순서대로 실행되는지 확인해 보자.

<script>
    console.log('시작');

    setTimeout(()=> console.log('타이머')); // (A)

    Promise.resolve()
    .then(()=> console.log('프로미스 1')) // (B)
    .then(()=> console.log('프로미스 2')); // (C)

    console.log('끝'); // (D)
</script>

코드 실행 결과

console.log 는 일반적인 동기 코드이므로 바로 호출스택에서 실행되므로 '시작' 이 가장 먼저 출력된다.

(참고로 console.log 도 Web API 인데, Web API 라고 해서 모두 태스크큐로 가는것은 아니다. 비동기적으로 실행되는

Web API 들만 큐로 이동)

(A) 에서 setTimeout을 만나면 자바스크립트 엔진은 호출스택에서 setTimeout을 바로 실행하지 않고 Web API 로 보낸다. Web API는 setTimeout의 지연시간 만큼 지난 후 (예제에선 0ms 이므로 0ms 만큼 대기) 

매크로태스크 큐에 콜백을 넣어준다.

그 다음에 (B)에서 이행된 상태의 Promise then 을 만나게 되고, 이 then은 마이크로태스크 큐에 들어가게 된다.

마지막으로 (D) 에서 console.log 를 만나 '끝' 을 출력한다.

 

현재 상태를 체크해보면 아래와 같다.

호출스택  -->  x

매크로태스크 큐  -->  setTimeout callback

마이크로태스크 큐  -->  (첫번째) Promise then

콘솔  -->  '시작' - '끝'  

 

호출스택이 비어있으므로, 이벤트 루프는 매크로태스크 큐에서 then 을 꺼내 호출스택에 전달한다.

호출스택에서 then 이 수행되고 나면 두 번째 then 이 매크로태스크 큐에 들어가게된다.

이벤트 루프는 두 번째 then 을 호출스택으로 전달하고 두 번째 then 까지 모두 실행된다.

 

호출스택  -->  x

매크로태스크 큐  -->  setTimeout callback

마이크로태스크 큐  -->  x

콘솔  -->  '시작' - '끝' - '프로미스1' - '프로미스2' 

 

이제 마이크로태스크가 모두 처리되었으니 매크로태스크의 setTimeout 을 꺼내 처리하면서 

모든 처리가 완료된다. 

 

이 코드의 예시는 아래 링크를 참고하였다.

이 링크에는 코드를 한 단계씩 실행해볼 수 있는 데모도 있어서 태스크 처리 과정을 이해하는데 많은 도움이 되었다.

 

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

 

Tasks, microtasks, queues and schedules

 

jakearchibald.com

 

 

 

렌더링 발생 시점

지금까지 호출스택과 매크로, 마이크로 태스크가 이벤트 루프에 의해 어떤 순서로 처리되는지 알아보았다.

만약 작업들 중에 DOM을 조작하는 코드가 수행되었다면, 이 수정 사항을 렌더링해서 화면에 보여주는건 어느 시점에 발생할까? 

 

브라우저는 호출스택에 작업이 모두 수행되고 비게 되면 렌더링을 수행한다.

그런데 앞선 설명에서도 언급했듯이 큐에 태스크들이 대기중 이라면,

하나의 매크로태스크가 처리되고 마이크로태스크를 모두 처리 후 렌더링을 수행한다.

 

그럼 진짜 이런식으로 렌더링이 발생하는지 코드로 확인해보자.

<style>
    .animation {
        width: 30px;
        height: 30px;
        margin: 25px;
        border: 5px solid;
        border-color: yellow transparent yellow transparent;
        border-radius: 50%;
        animation: spinner 1s linear infinite;
    }

    @keyframes spinner {
        from {transform: rotate(0);}
        to {transform: rotate(360deg);}
    }
</style>

<div>
    <div class="animation"></div>
    <div>
        일반코드 : <span id="normal" class="test">0</span>
    </div>
    <div>
        매크로 : <span id="macro" class="test">0</span>
    </div>
    <div>
        마이크로 : <span id="micro" class="test">0</span>
    </div>

    <div class="btn-box">
        <button onclick="reset()">reset</button>
        <button onclick="count()">count</button>
    </div>
</div>

<script defer>
    let normal = document.querySelector('#normal');
    let macro = document.querySelector('#macro');
    let micro = document.querySelector('#micro');

    function count(){
    	// 매크로태스크
        setTimeout(()=>{
            longTask(macro, 3000000);
        });

        // 마이크로태스크
        Promise.resolve().then(()=>{
            longTask(micro, 2000000);
        });
        
        // 일반코드
        longTask(normal, 1000000);
    }

    function longTask(elem, num){
        for(let i=1; i<=num; i++){
            elem.textContent = i;
        }
    }

    function reset(){
        normal.textContent = 0;
        macro.textContent = 0;
        micro.textContent = 0;
    }
</script>

위 코드를 실행한 모습

우선 코드를 간단히 설명하자면,

animation 클래스는 360도 무한 회전하는 애니메이션 css다.

이 애니메이션을 준 이유는 과연 css 애니메이션 효과도 호출스택이 바쁠때 렌더링되지 않고 멈추게될까..? 하는 생각에 그냥 한 번 넣어봤다.

 

그리고 화면엔 reset 과 count 버튼 두 개가 존재하는데 reset 을 클릭하면 모든 숫자가 0으로 초기화 되고,

count 버튼을 클릭하면 동기 코드 / 매크로태스크(setTimeout) / 마이크로태스크(Promise)

경우로 나눠서 각 span 의 textContent 를 수정하는 DOM 조작을 발생시킬 것이다.

longTask 함수는 조작할 엘리먼트와 조작 횟수를 인수로 받는다.

(참고로 for문 1000000번 실행은 약 1초 정도 소요됨)

 

이제 케이스별로 렌더링이 언제 발생하는지 알아보자. 

 

케이스1 - 일반코드와 매크로태스크

일반코드와 매크로태스크인 경우만 먼저 실행해보겠다. (Promise 부분은 주석 처리) 

일반 코드와 매크로태스크의 렌더링 시점

count 버튼을 클릭하면 일반코드의 숫자가 먼저 바뀌고 약 3초 정도 후에 매크로의 숫자가 바뀐다.

여기서 매크로태스크는 지연시간이 0 인 setTimeout 이므로 매크로태스크 큐에 들어갈 것이다.

그리고 일반코드 longTask 가 호출 스택에 들어가고 1000000번의 반복 작업을 수행할 것이다.

그런데 for 문의 i 가 변하면서 일반코드 span 의 숫자도 똑같이 값이 더해져가는 모습은 볼 수 없다.

게다가 count 를 클릭한 순간부터 마우스를 다시 버튼에 올려놓아도 버튼의 색이 변하지 않고 어떤 클릭이나

스크롤도 먹지 않게된다.

 

그 이유는 for문의 1000000번 작업이 호출스택 에서 계속 실행중이라 렌더링을 실행하지 않기 때문이다.

1000000번 작업이 다끝나고 스택이 비워졌을때 비로소 렌더링이 일어나 span의 숫자가 1000000 으로 바뀌는 모습을 볼 수 있다.

그리고 호출스택이 비워졌으니 대기중이던 setTimeout 콜백이 실행되고, 이번엔 3000000번 작업을 수행한다.

마찬가지로 호출스택으로 들어간 setTimeout 의 콜백 작업도 3000000번이 모두 완료되고 스택이 비워진 후

그제서야 화면에 3000000 이라는 숫자가 렌더링 되는걸 볼 수 있다.

 

(아, 그리고 css 애니메이션 효과는 렌더링 시점과 상관없이 계속 움직이는 걸로 보아

렌더링은 DOM 속성값을 수정했을때만 발생하는걸로)  

 

 

케이스2 - 일반코드와 마이크로태스크

이번엔 setTimeout 부분을 주석하고 일반코드와 마이크로태스크의 렌더링 시점을 보자.

일반 코드와 마이크로태스크의 렌더링 시점

오, 케이스1 과는 조금 다른 결과다.

케이스1 에서는 일반코드가 모두 수행되고 매크로태스크를 처리하기 전에 렌더링이 한 번 발생했는데,

이번엔 일반코드와 마이크로태스크의 렌더링이 같이 일어났다.

 

이 결과로 알 수 있는것은, 케이스1 에서는 호출스택이 비워진 후 대기중인 마이크로태스크가 없었기 때문에

바로 렌더링을 수행해서 렌더링이 일반코드 --> 매크로 순으로 발생했지만,

케이스2 에서는 일반코드가 끝나고 마이크로태스크가 존재했기때문에 먼저 마이크로태스크를 처리하고

렌더링을 수행해서 일반코드 와 마이크로의 숫자가 동시에 바뀌는 것이다.  

 

 

케이스3 - 일반코드와 매크로, 마이크로 태스크

마지막은 3가지를 한 번에 실행해서 렌더링이 언제 되는지 보자.

일반 코드와 매크로, 마이크로 태스크의 렌더링 시점

위의 두 케이스들을 보고 대충 결과가 예상됐을 것이다.

작업의 우선순위는 일반코드 --> 마이크로태스크 --> 매크로태스크 이다.

그래서 케이스2 에서 봤듯이 일반코드와 마이크로태스크의 렌더링이 먼저 일어나고 마지막에 매크로태스크의 렌더링이 발생하고 있다.  

 

 

무거운 작업 스케줄링하기 - setTimeout

이제 자바스크립트 엔진이 이벤트 루프와 함께 어떻게 비동기 작업들을 처리하는지, 또 렌더링은 언제 발생하는지

알게 되었다.

그런데 앞선 렌더링 시점 예시에서 for문이 수행되고 있는 동안은 렌더링이 발생하지 않아서 숫자가 실시간으로 증가하는 모습도 볼 수 없었고, 클릭이나 스크롤같은 DOM 이벤트들도 큐에서 호출스택이 비워지기만 계속 기다리는 상태여서

브라우저가 아무 반응이 없는 상태가 되버리고 말았다.  

이 문제를 해결할 방법은 없는것일까?

 

이 문제의 원인은 바로 오래걸리는 무거운 작업때문에 호출스택이 비워지지 않기 때문이다.

마치 화장실칸 안에 있는 사람이 배탈이나서 오랫동안 나오지 못해 화장실 줄이 점점 길어지고 있는 상황인 것이다.

우리는 이 문제를 setTimeout으로 해결할 수 있다.

 

위에서 사용했던 카운트 예시의 longTask 함수를 setTimeout 으로 스케줄링 하도록 리팩토링 해보자.

// 기존 코드
function longTask(elem){
    for(let i=1; i<=1000000; i++){
        elem.textContent = i;
    }
}

// 스케줄링한 코드
let i = 0;
function longTask(elem){
    do {
        i++;
        elem.textContent = i;
    }while(i % 10000 != 0);

    if(i < 1000000){
        setTimeout(()=> longTask(elem));
    }
}

좌 : 기존코드  /  우 :  setTimeout으로 작업 스케줄링 해준 코드

자, 어떤가?

왼쪽의 기존 코드는 작업을 한번에 처리하고 마지막에 한 번 렌더링이 일어나기 때문에 i 가 변하는 모습을 볼 수 없다.

반면 리팩토링된 코드는 10000개씩 일을 나눠서 setTimeout으로 작업을 스케줄링 해주고있다.

이렇게 되면 setTimeout 한 세트가 끝나고 호출스택이 비워졌을때 다음 세트를 처리하기 전에 렌더링이 발생한다.

 

즉, 10000개 단위로 중간중간 렌더링이 일어나서 i 가 변하는 모습을 볼 수 있는것이다.

게다가, 한 세트가 실행되는 도중에 마우스나 키보드 같은 이벤트가 큐에 쌓여도 그 이벤트들이 다음 setTimeout 보다

먼저 태스크큐에 쌓였으므로, 다음 세트가 실행되기 전에 모든 이벤트들이 처리될 수 있게 되서 

브라우저가 먹통이되는 현상도 일어나지 않는다.

 

이렇게 모든게 의도한대로 잘 해결된거 같은데, 한가지 주목할 점이 있다.

사용자 이벤트가 막히지 않고, DOM이 변하는 모습도 실시간으로 볼 수 있게됐지만 작업이 완료되는 총 시간이

개선전 코드보다 더 오래 걸리고 있다.

 

그 이유는 setTimeout이 연달아 많이 호출될 경우 최소 4ms만큼 대기 하도록 브라우저가 강제하기 때문이다.

그래서 지연시간이 0 초라도 이는 최소 지연시간을 의미하는거지 실제론 0초보다 더 많은 시간이 지난후

setTimeout 의 콜백이 실행될 수 있다는 점을 꼭 기억해야 한다.

 

 

 

 잘못된 내용은 댓글로 알려주세요 :)

Comments