Published on

이벤트 루프

Authors
  • avatar
    Name
    Na Hyunwoo
    Twitter

자바스크립트의 큰 특징 중 하나는 ‘싱글 스레드’ 기반의 언어라는 점입니다. 스레드가 하나라는 말은 곧 동시에 하나의 작업만을 처리할 수 있다라는 말입니다. 하지만 실제로 자바스크립트가 사용되는 환경을 생각해보면 많은 작업이 동시에 처리되고 있는 것을 볼 수 있습니다. 어떻게 스레드가 하나인데 이런 일이 가능할까요 ? 질문을 다시 정의해보면 ‘자바스크립트는 어떻게 동시성(concurrency)을 지원하는 걸까요 ?’

이때 등장하는 개념이 바로 ‘이벤트 루프’입니다. 자바스크립트는 이벤트 루프를 이용해서 비동기 방식을 통해 동시성을 지원합니다.

자바스크립트 엔진은 단일 콜 스택(Call Stack)을 사용하며, 요청이 들어올 때마다 해당 요청을 순차적으로 콜 스택에 담아 처리할 뿐입니다. 그렇다면 비동기 요청은 어떻게 이루어지고, 동시성에 대한 처리는 누가 하는 것일까요? 바로 자바스크립트 엔진을 구동하는 환경, 즉 브라우저와 Node.js가 담당합니다. 먼저 브라우저 환경을 그림으로 표현하면 다음과 같습니다.

브라우저는 HTML, CSS, 자바스크립트를 실행하여. 웹페이지를 화면에 렌더링하는 것이 주된 목적이지만, Node.js는. 서버 개발 환경을 제공하는 것이 주된 목적입니다.

event-loop-browser.png

위 그림을 보면 우리가 비동기 호출을 할 때 사용하는 setTimeout이나 XMLHttpRequest와 같은 함수들은 자바스크립트 엔진이 아닌 Web API영역에 따로 정의되어 있습니다. 또한 이벤트 루프와 태스크 큐와 같은 장치도 자바스크립트 엔진 외부에 구현되어있습니다. 아래는 Node.js 환경입니다.

event-loop-nodejs.png

이 그림은 브라우저의 환경과 비슷한 구조입니다. Node.js는 비동기 IO를 지원하기 위해 libuv 라이브러리를 사용하며, 이 libuv가 이벤트 루프를 제공합니다. 자바스크립트 엔진은 비동기 작업을 위해 Node.js의 API를 호출하며, 이때 넘겨진 콜백은 libuv의 이벤트 루프를 통해 스케쥴되고 실행됩니다.

여기서 확실히 짚고 넘어가야 할 것은 자바스크립트가 ‘단일 스레드’ 기반의 언어라는 말은 **‘자바스크립트 엔진이 단일 콜 스택을 사용한다’**는 관점에서만 사실입니다. 실제 자바스크립트가 구동되는 환경(브라우저, Node.js 등)에서는 여러 개의 스레드가 사용됩니다. 이런 구동 환경이 단일 콜 스택을 사용하는 자바스크립트 엔진과 상호 연동하기 위해 사용하는 장치가 바로 이벤트 루프입니다.

Run-to-Completion

자바스크립트의 함수가 실행되는 방식을 보통 Run to Completion이라고 합니다. 즉, 하나의 함수가 실행되면 이 함수의 실행이 끝날 때가지는 다른 어떤 작업도 중간에 끼어들지 못한다는 의미입니다. 자바스크립트 엔진은 하나의 콜 스택을 사용하고, 현재 스택에 쌓여있는 모든 함수들이 실행을 마치고 스택에서 제거되기 전까지는 다른 어떤 함수도 실행될 수 없습니다. 다음의 예제를 봅시다.

function delay() {
  for (var i = 0; i < 100000; 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()

이 예제를 실행하면 콘솔에는 ‘bar!’ → ‘foo!’ → ‘baz!’의 순서로 찍힙니다. 위의 코드가 코드내 주석으로 숫자가 적힌 각 시점의 콜 스택을 그림으로 그려보면 다음과 같습니다.

event-loop-call-stack.png

setTimeout 함수는 브라우저에게 타이머 이벤트를 요청한 후에 바로 스택에서 제거됩니다. 그 후에 foo 함수가 스택에 추가되고, foo 함수가 내부적으로 실행하는 함수들이 차례로 스택에 추가되었다가 제거됩니다. 마지막으로 foo 함수가 실행을 마치면서 콜 스택이 비워지게 되고, 그 이후에 baz함수가 스택에 추가되어 콘솔에 ‘baz!’가 찍히게 됩니다.

태스크 큐와 이벤트 루프

여기서 우리가 오늘 알고자 했던 이벤트 루프의 역할이 나옵니다. 위의 예제에서 setTimeout 함수를 통해 넘긴 baz함수를 foo 함수가 끝나자마자 실행될 수 있게 하는 녀석이 바로 이벤트 루프입니다. 태스크 는 이름처럼 콜백 함수들이 대기하는 큐 형태의 배열이라 할 수 있고, 이벤트 루프는 콜 스택이 비워질 때마다 큐에서 콜백 함수를 꺼내와서 실행하는 역할을 합니다.

앞의 예제에서 코드를 실행하는 중에 10ms가 지났을 때, 브라우저의 타이머는 baz를 바로 실행하지 않고 태스크 큐에 추가합니다. 이벤트 루프는 콜 스택이 비워지면 그 때 태스크 큐에 대기중인 baz를 실행해서 콜 스택에 추가합니다.

간단하게 정리하면 다음과 같습니다.

모든 비동기 API들은 작업이 완료되면 콜백 함수를 태스크 큐에 추가합니다.

이벤트 루프‘콜 스택이 비워졌을 때’ 태스크 큐의 첫 번째 태스크를 꺼내와 실행합니다.

좀 더 명확하게 이해하기 위해 앞의 예제를 바꿔보겠습니다.

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

setTimeout(foo, 10)
setTimeout(bar, 10)
setTimeout(baz, 10)

이 코드를 실행하면 setTimeout 함수가 세 번 호출된 이후에 실행을 마치고 콜 스택이 비워집니다. 그리고 10ms가 지나는 순간 foo, bar, baz 함수가 순차적으로 태스크 큐에 추가됩니다. 이벤트 루프는 foo가 태스크 큐에 들어오자 마자, 콜 스택이 비어있기 때문에 foo를 실행해서 콜 스택에 추가합니다. foo함수의 실행이 끝나면 콜 스택이 비워지고 이벤트 루프는 다시 태스크 큐에서 다음 콜백인 bar를 가져와 실행합니다. 다음 baz도 마찬가지입니다. baz까지 실행외 완료되면 콜 스택과 태스크 큐 모두가 비어있기 때문에 이벤트 루프는 새로운 태스크가 태스크 큐에 추가될때까지 기다립니다.

비동기 API와 try-catch

setTimeout뿐 아니라 addEventListener, XMLHttpRequest와 같은 브라우저의 함수들과 같은 모든 비동기 API들은 이벤트 루프를 통해 콜백 함수를 실행합니다. 우리는 이제 아래와 같은 코드가 왜 에러를 잡을 수 없는지 확실히 알 수 있습니다.

$('.btn').click(function () {
  // (A)
  try {
    $.getJSON('/api/members', function (res) {
      // (B)
      // 에러 발생 코드
    })
  } catch (e) {
    console.log('Error : ' + e.message)
  }
})

위의 코드에서 버튼이 클릭되어 콜백 A가 실행될 때 $.getJSON 함수는 브라우저의 XMLHttpRequest API를 통해 서버로 비동기 요청을 보낸 후에 바로 실행을 마치고 콜 스택에서 제거됩니다. 이후에 서버에서 응답을 받은 브라우저는 콜백 B를 태스크 큐에 추가하고 B는 이벤트 루프에 의해 실행되어 콜 스택에 추가됩니다. 하지만 이 때 A는 이미 콜 스택에서 비워진 상태이기 때문에 콜 스택에는 B만 존재합니다. 즉 B는 A가 실행될 때와는 전혀 다른 독립적인 컨텍스트에서 실행이 되고, 따라서 A 내부의 try-catch문의 영향을 받지 않습니다.

이를 해결하기 위해서는 콜백 B의 내부에 try-catch를 실행해야 합니다.

$('.btn').click(function () {
  // (A)
  $.getJSON('/api/members', function (res) {
    // (B)
    try {
      // 에러 발생 코드
    } catch (e) {
      console.log('Error : ' + e.message)
    }
  })
})

setTimeout(fn, 0)

이 코드는 0초 후에 fn을 실행하라는 의미를 가지고 있습니다. 그러나 우리는 지금까지 살펴봤듯이 setTimeout(fn, 0)은 그냥 fn코드와는 많은 차이를 가지고 있습니다. setTimeout 함수는 콜 스택에 추가 된 후 API를 통해 태스크 큐에 콜백 함수가 추가됩니다. 그 후에 콜 스택에 있는 setTimeout 함수는 제거되고 콜 스택이 비워지면 이벤트 루프는 태스크 큐에 가장 첫 번째에 있는 콜백 함수를 콜 스택에 추가합니다. 아래와 같은 예제가 있습니다.

setTimeout(function () {
  console.log('Na')
}, 0)
console.log('Hyunwoo')

그러면 이는 Hyunwoo → Na로 출력됩니다.

위의 방법을 통해 너무 오래 걸리는 코드를 setTimeout을 사용하여 적절하게 다른 태스크로 나누어 주면 애플리케이션이 더 빠르게 동작하게 할 수 있습니다.

여기서 한가지 짚고 넘어갈 사실은 ‘0’이라는 숫자가 실제로 ‘즉시’를 의미하지 않는다는 점입니다. 브라우저는 내부적으로 타이머의 최소단위를 정하여 관리하기 때문에 실제로는 그 최소단위만큼 지난 후에 태스크 큐에 추가됩니다. 최소단위는 브라우저별로 차이가 있습니다. 크롬의 경우 최소 단위로 4ms를 사용하기 때문에 크롬에서 setTimeout(fn, 0)은 setTimeout(fn, 4)와 동일한 의미입니다.

Promise와 이벤트 루프

console.log('script start')

setTimeout(function () {
  // (A)
  console.log('setTimeout')
}, 0)

Promise.resolve()
  .then(function () {
    // (B)
    console.log('promise1')
  })
  .then(function () {
    // (C)
    console.log('promise2')
  })

console.log('script end')

다음과 같은 코드가 있을 때, 콘솔창에 찍히는 순서는 다음과 같습니다.

script start
script end
promise1
promise2
setTimeout

띠용 ? 우리가 위에서 배운 태스크 큐와 이벤트 루프의 개념을 적용해보면 script start, setTimeout, Promise, script end가 콜스택에서 실행된 이후에 태스크 큐에는 비동기로 동작하는 setTimeout, Promise 순으로 들어가고 태스크 큐는 순서대로 console.log(’setTimeout’) → console.log(’promise1’) → console.log(’promise2’)가 실행되어야 하는 것이 아닐까 ?

그러나, 여기서 마이크로 태스크 큐(Microtask Queue)가 등장합니다.

마이크로 태스크일반 태스크보다 더 높은 우선순위를 갖는 태스크입니다. 즉, 태스크 큐에 대기중인 태스크가 있더라도 마이크로 태스크가 먼저 실행됩니다. 위의 예제를 다시 살펴봅시다. setTimeout() 함수는 콜백 A를 태스크 큐에 추가하고, Promise의 then()메소드는 콜백 B를 마이크로 태스크 큐에 추가합니다. 위의 코드를 통해 콜 스택이 실행되고 비워지면 이벤트 루프는 태스크 큐 대신 마이크로 태스크 큐를 먼저 확인합니다. 현재 마이크로 태스크 큐에는 콜백 B가 있으므로 콜백 B를 실행합니다. 그 뒤에 콜백 B가 실행된 후 두 번째 then() 메소드가 콜백 C를 마이크로 태스크 큐에 추가합니다. 이벤트 루프는 다시 마이크로 태스크를 확인하고 콜백 C를 실행합니다. 그 다음 다시 이벤트 루프는 마이크로 태스크 큐가 빈 것을 확인한 뒤 태스크 큐를 확인하게 됩니다.

해결된 의문들

자바스크립트가 동시성을 갖는다고 말하는 이유가 무엇일까 ? 결국 이벤트 루프라는 녀석은 콜 스택이 비워지면 → 태스크 큐를 실행한다는 하나의 싱글 스레드처럼 동작하는 것이 아닌가 ?

다음과 같은 예를 생각해보자.

setTimeout(() => {
  console.log('hyunwoo')
}, 100)
console.log('Na')

이 코드가 어떻게 동작하는지 살펴보자. 콜 스택에 setTimeout 함수가 호출이 된다. 이후에 setTimeout 의 콜백함수는 브라우저를 통해 태스크 큐에 추가된다. 태스크 큐에 추가되면 setTimeout 콜백함수의 타이머가 작동한다. 이와 동시에 콜 스택은 console.log(”Na”)라는 코드를 실행한다. 그니까 타이머 시간 계산 + 콘솔로그 출력이 동시에 이루어지고 있으므로 동시성을 가진다고 말할 수 있는 것이다.

결론

자바스크립트 엔진은 싱글 스레드이기 때문에 한번에 하나의 태스크만 처리할 수 있다. 그러나 Task Queue와 Event Loop는 자바스크립트가 멀티 스레드처럼 동작하게 해준다 !

참고

  • 스택(Stack): 자료구조 중 하나이다. 선입후출(LIFO, Last In First Out)의 룰을 따른다.
  • 큐(Queue): 자료구조 중 하나이다. 선입선출(FIFO, First In First Out)의 룰을 따른다.

레퍼런스

이벤트 루프 - JavaScript | MDN

자바스크립트와 이벤트 루프 : NHN Cloud Meetup

이벤트 루프란?

이벤트 루프, 넌 누구냐

[JavaScript] 이벤트 루프란?

Event Loop (이벤트 루프)