본문 바로가기

Javascript

JavaScript - 이벤트 루프 (Event Loop)

비동기와 동기에 대해서 더 깊게 알아보자. 먼저, 자바스크립트의 언어의 특징 중 하나는 자바스크립트의 함수가 실행되는 방식인데, 이를 보통 "Run to Completion" 이라고 한다. 이는, 하나의 함수가 실행되면 이 함수가 실행이 끝나기 전 까진 다른 어떤 작업도 중간에 못 끼어든다는 이야기이다. 자바스크립트 엔진은 하나의 호출 스택만을 사용한다. 따라서, 현재 스택에 쌓여있는 모든 함수들이 실행을 마치고, 스택에서 제거되기 전 까진 다른 어떠한 함수도 실행될 수 없다. 아래 코드를 보자.

 

function delay() {
    for (let i = 0; i < 1000000000; i++);
}

function foo() {
    delay();
    bar();
    console.log('foo!'); // (3)
}

function bar() {
    delay();
    console.log('bar!'); // (2)
}

function baz() {
    console.log('baz!'); // (4)
}

setTimeout(baz, 10); // (1)
foo();

 

delay() 라는 함수는 어림짐작으로 내 컴퓨터에서 작업하는 데에 2초 가량 걸리는 함수였다. 이를 실행시켜보면,

 

PS C:\Users\bumsu\nodejs-projects\web1_html_internet\web1_html_internet> node sync_and_async.js
bar!
foo!
baz!

 

위와 같은 결과가 나온다. 보면, baz는 실행하는 데에 10ms만 기다리라고 했고, delay() 함수는 실행 시킬 때 약 0.5초 (500ms) 정도가 걸리는데, 왜 baz가 가장 마지막에 찍히는 걸까? 위의 코드가 전역 환경에서 실행된다고 가정하고, 위 코드의 주석으로 숫자가 적힌 각 시점의 호출 스택을 그림으로 그려보면 다음과 같다.

 

set

setTimeout은 브라우저에게 타이머 이벤트를 요청한 후에 바로 스택에서 제거된다. 그 후에 foo가 추가되고, foo의 요청에 따라 bar가 추가된다. bar 함수는 console.log('bar!')을 찍고, 함께 스택에서 제거되며, foo 함수는 그제서야 console.log('foo!')를 찍고, 함께 스택에서 제거된다. 그 후, 콜백함수로써 남아있던 baz 함수가 스택에 추가된 후에야 마지막 console.log('baz!')가 실행된다.

 

그렇다면, setTimeout 함수를 통해 넘긴 baz 함수는 어떻게 foo가 끝난 후에야 실행될 수 있을까? 분명 자바스크립트는 싱글 스레드 언어라고 했는데, 어디에 저장될 수 있는 것이며, 어떻게 나중에야 콜백이 되게 되는 것일까? 이 역할을 하는 것이 바로 태스크 큐 (task queue) 와 이벤트 루프 (event loop) 이다. 태스크 큐는 콜백 함수들이 대기하는 큐 (FIFO) 형태의 배열이라 할 수 있고, 이벤트 루프는 호출 스택이 비워질 때마다 큐에서 콜백 함수를 꺼내와서 실행하는 역할을 해준다.

 

앞의 예제에서, 코드가 처음 실행되면 이는 '현재 실행중인 태스크'가 된다. 코드를 실행하는 도중, 10ms이 지나면 브라우저의 타이머가 baz를 바로 실행하지 않고 태스크 큐에 추가한다. 이벤트 루프는 '현재 실행중인 태스크'가 종료되자 마자 태스크 큐에서 대기중인 첫 번째 태스크를 실행한다. foo가 실행을 마치고 호출 스택이 비워지면 현재 실행 중인 태스크는 종료되며, 그 때 이벤트 루프가 태스크 큐에서 대기중인 첫 번째 태스크 baz를 실행하여 호출 스택에 추가한다.

 

자 이제 여러 콜백을 만들어 이를 실행시켜 보자.

 

function delay() {
    for (var i = 0; i < 1000000000; i++);
}
function foo() {
    delay();
    console.log('foo!');
}
function bar() {
    delay();
    console.log('bar!');
}

setTimeout(foo, 10000);
setTimeout(bar, 10000);
delay();
console.log('hello')

 

결과

 

PS C:\Users\bumsu\nodejs-projects\web1_html_internet\web1_html_internet> node sync_and_async.js
hello
foo!
bar!

 

위 코드를 다시 보자. 각 setTimeout 또는 console.log 함수가 실행되는 속도를 1ms라고 가정하고, delay 함수가 실행되는 속도를 50ms라고 가정하자. 그럼 순서는 다음과 같아진다.

 

  1. 첫 번째 setTimeout (1ms 후 완료)

  2. 두 번째 setTimeout (2ms 후 완료)

  3. delay 작업 처리 (52ms 후 완료)

  4. console.log 작업 처리 (53ms 후 완료) 후 스택 비움

  5. 프로미스 콜백 함수 호출하여 큐에 추가 (1001ms 후 완료), 이벤트 루프가 스택이 비어 있는 것을 확인, 스택에 콜백 추가 후 실행, 동시에 두 번째 콜백 함수 큐에 추가 (1002ms 후 완료)

  6. 콜백 함수 1 delay 함수 실행 후 console.log 함수 실행 (1053ms 후 완료)

  7. 이벤트 루프가 스택이 비어있는 것을 다시 확인, 콜백 함수 2 와 delay 함수 실행 (1103ms 후 완료).

  8. console.log 함수 실행 후 (1104ms 후 완료) 프로그램 종료.

 

이벤트 루프

이벤트 루프는 왜 루프일까? MDN의 이벤트 루프 설명에서는 왜 '루프'라는 이름이 붙었는지 아주 간단한 가상코드로 설명한다.

 

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

 

위 코드의 waitForMessage() 메서드는 현재 실행중인 태스크가 없을 때 다음 태스크가 큐에 추가될 때 까지 대기하는 역할을 한다. 즉 이벤트 루프는 현재 실행중인 태스크가 없는지태스크 큐에 태스크가 있는지를 반복적으로 확인하는 것이다. 즉,

  • 모든 비동기 API들은 작업이 완료되면 콜백 함수를 태스크 큐에 추가한다.
  • 이벤트 루프는 '현재 실행중인 태스크가 없을 때' (주로 호출 스택이 비워졌을 때) 태스크 큐의 첫 번째 태스크를 꺼내와 실행한다.

 

출처

https://meetup.toast.com/posts/89

https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop