Pandaman Blog

[JS] Javascript의 Event Loop (이벤트 루프) 본문

Front end/Javascript

[JS] Javascript의 Event Loop (이벤트 루프)

oyg0420 2020. 2. 22. 21:08

Javascript의 Event Loop (이벤트 루프)

자바스크립트의 코드 실행, 이벤트 수집과 처리, 큐에 놓인 하위 작업들을 담당하는 이벤트 루프에 기반한 동시성(currency) 모델을 가지고 있습니다. 이 모델은 C 또는 Java와 같은 언어와 완전히 다릅니다.

MDN문서에서는 자바스크립트의 이벤트 루프를 위와 같이 말한다. 그렇다면 이벤트 루프가 이와 같은 작업들을 어떠한 방식으로 처리하는지 알아보도록 하자.

자바스크립트의 런타임

런타임(영어: runtime→실행시간)은 컴퓨터 과학에서 컴퓨터 프로그램이 실행되고 있는 동안의 동작을 말한다.

자바스크립트에서의 런타임이란 브라우저에서의 자바스크립트 작동 방식이라고 말할 수 있다. 우리는 이전 주제인 실행 컨텍스트을 통해 일부 작동 방식에 대해 배웠다고 말할 수 있다.
자바스크립트의 런타임은 크게 Stack, Heap, Queue로 나눌 수 있다.

Stack

브라우저의 자바스크립트 엔진에는 요청이 들어올 때 요청을 처리하는 스택이 존재한다. 함수가 호출되면 호출된 함수의 실행 컨텍스트가 스택에 푸시되고 실행이 종료되면 스택에서 반환된다.
아래는 함수의 요청이 들어올 때 스택이 그 요청을 처리하는 과정의 예제이다.

function fn1() {
  function fn2() {
    console.log('i am second function');
  }
  fn2();
}

fn1();

함수 fn1가 실행될 때 함수 fn1의 실행 컨텍스트가 생성되고 스택에 푸시된다. 함수 fn1()의 내부 함수 fn2()가 호출되면 실행컨텍스트가 생성되고 fn1의 실행 컨텍스트 위에 푸시된다. fn2()가 종료되면 fn2() 실행 컨텍스트는 fn1()의 실행 컨텍스트를 남기고 스택에서 반환된다. fn1()이 종료되면 fn1()의 실행 컨텍스트는 스택에서 반환되고 스택은 비워진다.

우리는 이미 이 부분은 실행컨텍스트에서 확인했다.
앞으로 우리가 확인할 사항은 하나의 작업만을 처리할 수 있는 단일의 스택에서 어떻게 동시성을 갖게 되는가이다. 이 부분은 이벤트 루프에서 자세히 알아보도록 하자.

Heap

객체들은 힙 안에 할당된다. 힙은 구조화되지 않은 메모리 영역을 말한다.

Queue

Queue에 대해 알아보기 위해 아래 예제를 살펴보자.
참고로 setTimeout 함수의 첫 번째 인수는 콜백 함수이고, 두 번째 인수는 타이머로서 만료되면 콜백 함수가 실행된다.

function fn1() {
  function fn2() {
      console.log('i will go to Queue first');
  }
  setTimeout(fn2, 0)
  function fn3() {
    console.log('i will go to Stack first');
  }
  fn3();
}

fn1();

함수 setTimeout의 콜백 함수 fn2는 0(ms)이 지나고 실행된다. 따라서, 함수 fn2가 실행되어 결과는 'i will go to Queue first'가 먼저 나타나고, 이후에 함수 fn3가 실행되어 'i will go to Stack first'가 나타난다고 생각할 수 있다. 하지만 결과는 반대이다.
함수 setTimeout가 호출되면 브라우저의 Web APIs로 타이머 이벤트를 요청한 후 바로 스택에서 제거된다. 그리고 fn3의 함수가 실행되면 함수 fn3가 스택에 추가되고 종료되면 제거된다. 이미 생성된 함수 fn1()가 스택에서 제거되고 스택이 비워지면 마지막에 콜백 함수 fn2가 호출되어 스택에 추가된다. 따라서 결과적으로 함수 fn2은 0ms보다 더 늦게 실행이 되는 것이다.

위 예제를 통해 고민해야 할 부분이 생겼다.
1. 함수 fn2는 어디에서 머물고 있었을까?
2. 스택이 비워졌다는 걸 어떻게 알까?

함수 fn2는 어디에서 머물고 있었을까?

위에서 setTimeout함수가 호출되면 브라우저의 Web APIs로 타이머 이벤트를 요청하고 스택에서 제거된다고 말했다. 이 Web APIs는 타이머 이벤트를 통해서 설정된 시간이 만료되면 콜백 함수를 Queue라는 공간에 추가한다.
이처럼 Queue는 처리해야 할 작업이 임시로 저장되는 장소이다. 따라서, 추가된 콜백 함수는 Queue에 대기하고 스택이 비워지면 콜백 함수를 실행하여 스택에 푸시한다.

아래는 위에서 설명한 모던 자바스크립트 엔진에 대한 그림이다.

모던 자바스크립트 엔진 - 출처: MDN

Event Loop

위에서 남은 질문이 있다. 스택이 비워졌다는 걸 어떻게 알까?이다. 바로 이벤트 루프라는 녀석 때문이다. 이벤트 루프는 스택이 비워질 때마다 큐에 있는 콜백 함수를 꺼내온다. 이러한 이벤트 루프 작동방식으로 인해 단일 스택인 자바스크립트 엔진에서 동시성(concurrency)이 제공되는 것이다.
이벤트 루프는 그 구현 방식 때문에 붙은 이름이며, 구현 방식은 다음과 유사하다.

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

queue.waitForMessage 메소드는 스택이 비워졌는지(실행 중인 Task가 없는지), 큐에 Task가 존재하는지를 확인한다. 두 가지 조건에 만족하면 큐의 첫 번째 Task를 실행시켜 스택에 푸시한다.

아래는 브라우저의 이벤트 루프의 동작 방식을 표현한 사진이다.

이벤트 루프 동작 방식

예제를 통해서 정확히 이해해보자.

function callFirstFn() {
 console.log('Call First Function');
}

function callSecondFn() {
  console.log('Call Second Function');
}

function callThirdFn() {
  console.log('Call Third Function');
}

function callMainFn() {
   console.log('Call Main Function');
}
setTimeout(callFirstFn, 100); // 1
callMainFn(); // 2
setTimeout(callSecondFn, 100); // 3
setTimeout(callThirdFn, 100); // 4

1번 setTimeout함수는 스택에 푸시되고 브라우저의 Web APIs로 타이머 이벤트를 요청한다.
2번 함수 callMainFn가 호출되면서 스택에 푸시되고 실행되고, 종료되면 스택에서 반환된다.
아래의 3, 4번 setTimeout함수는 1번과 동일한 작동을 한다.
100ms가 지나면 해당 콜백 함수들(callFirstFn, callSecondFn, callThirdFn)을 순차적으로 큐에 넣는다.
이벤트 루프는 현재 스택이 비워져 있다면 큐의 첫 번째 콜백 함수인 callFirstFn를 먼저 실행시켜 스택에 푸시한다.
실행이 종료되고 callFirstFn가 스택에서 반환되면 이벤트 루프는 다시 callSecondFn, callThirdFn 순으로 동일한 작업을 반복한다. 더 이상 큐에 Task가 존재하지 않는다면 이벤트 루프는 큐에 Task가 추가될 때까지 기다리게 된다.

Javascript의 비동기 함수 작동방식

비동기 함수란 무엇인가? 그리고 자바스크립트에서는 왜 비동기 함수를 필요로 하는가에 대해서 알아야 한다. 위에서 설명한 것처럼 브라우저의 자바스크립트 엔진은 단일 스택을 갖고 있다. 그 의미는 현재 Task가 종료되기 전까지는 다음 Task를 진행을 할 수 없는 것을 의미한다.
그렇다면 무엇이 필요한가? 바로 비동기 함수이다. 비동기 함수는 호출을 한 후 결과를 기다리지 않아도 되는 함수라고 할 수 있다. 예를 들어위에서 사용한 setTimeout은 비동기 함수이다. 우리가 setTimeout에 만료시간을 1분으로 설정해도 1분 동안 결과를 기다리지 않아도 Task들은 동작한다.

아래 간단한 예제를 살펴보자.

const callTick = () => alert('tick');
const clearTick = () => {
  clearInterval(timer); 
  alert('stop');
}
const timer = setInterval(callTick, 2000);
setTimeout(clearTick, 5000);
  • setInterval와 setTimeout가 작동하면 순차적으로 스택에 푸시되었다가 Web API에 이벤트가 전송되고 스택에서 제거된다.
  • Web API의 이벤트 설정된 이벤트에 의해 2초가 지나면 콜백 함수 callTick은 큐로 이동한다. 이때 이벤트 루프는 스택에 실행 중인 Task 있는지 확인 후 없다면 큐에 있는 callTick 함수를 실행시키고 스택에 푸시한다.
  • Web API에서는 다시 2초가 지나면 위와 동일한 작동을 한다.
  • 5초가 되었을 때 clearTick 콜백 함수를 큐에 푸시한다.
  • 이벤트 루프는 스택에 실행 중인 Task 있는지 확인 후 없다면 큐에 있는 clearTick을 스택에 푸시한다.
  • clearTick 함수 내부의 clearInterval가 실행되고 스택에 푸시되었다가 Web API에 이벤트가 전송되고 스택에서 제거된다. Alert 함수가 실행된다. clearInterval가 Web Api에 보낸 이벤트로 설정된 타이머 이벤트를 제거한다.

사실 위에서 Event Loop에 대해 설명하면서 브라우저가 어떻게 비동기 함수를 처리하는지에 대해 알 수 있었다. 이처럼 브라우저는 setTimeout, setInterval 함수와 같은 비동기 방식의 API(XMLHttpRequest, addEventListener,...)들은 이벤트 루프를 통해 콜백 함수를 실행한다.

Promise와 Micro Task

Promise는 Micro task의 선언이다. 생성자에 인자로 준 함수는 Micro Task Queue에 추가된다.
Micro task는 무엇이고 Micro Task Queue는 무엇일까? Task Queue과 무엇이 다를까?

일단 아래 예제를 통해 Promise의 Micro task에 대해 알아보자.

console.log('I will go to stack directly'); // A
setTimeout(() => { // B
 console.log('I will go to task queue'); 
}, 0)
new Promise(() => { // C
 console.log('i will go to micro task queue');
})

A는 호출되면 바로 콜 스택으로 푸시되어 실행된다.
B는 호출되면 콜 스택에 푸시되고 Web APIs에 이벤트를 요청하고 스택에서 제거된다. 이때 Task인 콜백 함수도 전달되며, Task는 설정된 시간이 만료되면 Task Queue로 이동된다.
C는 Promise생성자로 인자로 받은 콜백 함수는 Micro Task Queue로 이동한다. 이때 Micro Task Queue로 이동된 Task를 우리는 Micro Task라고 부른다.
실행 결과는 어떻게 될까? 결과는 'I will go to stack directly' -> 'i will go to micro task queue' -> 'I will go to task queue' 순으로 로그가 남는다.
A와 B의 순서는 위에서 설명했기 때문에 짐작할 수 있지만, C의 Promise생성자 인자로 받은 콜백 함수는 어떻게 setTimeout 비동기 함수의 콜백 함수보다 먼저 실행될까?
Micro Task는 Task보다 우선순위를 갖는다. Event Loop는 Task가 쌓인 Queue, Micro Task가 쌓인 Micro Task Queue를 확인하고 Micro Task Queue에 Micro Task 있다면 Micro Task를 콜 스택에 푸시하여 실행시킨다. 다시 Micro Task Queue를 확인하고 Micro Task가 없다면 Queue에 있는 Task를 스택에 푸시하여 실행시킨다.
따라서 setTimeout의 콜백 함수(Task)보다, Promise생성자로 인자로 받은 콜백 함수(Micro Task)가 먼저 실행된다.

 

아래의 경우는 어떻게 될까?

setTimeout(() => {
 console.log('I will go to task queue'); 
}, 0)
Promise.resolve().then(() => {
 console.log('I will go to Micro Task Queue!')
}).then(() => {
 console.log('I will go to Micro Task Queue!!')
})

실행 결과는 'I will go to Micro Task Queue!' -> 'I will go to Micro Task Queue!!' -> 'I will go to task queue'이다. Promise의 resolve함수를 호출하면 then 메서드에 넘겨진 함수를 Micro Task에 추가한다. 그리고 두 번째 then 메서드의 넘겨진 함수를 Micro Task에 추가한다. 이벤트 루프는 Micro Task Queue에 있는 첫 번째 Micro Task를 스택에 푸시하고 첫 번째 Micro Task가 스택에서 반환되면 다시 두 번째 Micro Task를 스택에 푸시하여 실행시킨다. Micro Task Queue가 비워졌다면 Queue에 있는 Task를 스택에 푸시하여 실행시킨다.

결론은 Micro Task는 이벤트 루프에 의해 Queue의 Task 보다 우선권을 갖고 있다는 점이다.
Micro Task Queue가 비워 진후에 Queue의 Task가 실행된다.

마치며

이번 주제는 실행 콘텍스트의 명확한 이해가 있었다면 큰 문제가 없다고 생각한다. 오늘 배운 이벤트 루프는 위의 그림(이벤트 루프 동작 방식)만 명확하게 이해했다면 오늘 공부는 성공했다고 생각한다. 참고로 What the heck is the event loop anyway?는 이벤트 루프를 공부를 하면서 참고한 영상이다.

설명이 부족하거나, 오류가 있다면 피드백 남겨주시기 바랍니다. 감사합니다.

참고: https://narusas.github.io/2018/03/28/Promise.html

 

파트너스 활동을 통해 일정액의 수수료를 제공받을 수 있음

Comments