Event Loop

soom·2021년 1월 31일
1
post-thumbnail

JavaScript Process (Sync?)

자바스크립트의 큰 특징 중 하나는 '단일 스레드' 기반의 언어라는 점이다. 스레드가 하나라는 말은 곧, 동시에 하나의 작업만을 처리할 수 있다라는 말이다. 하지만 실제로 자바스크립트가 사용되는 환경을 생각해보면 많은 작업이 동시에 처리되고 있는 걸 볼 수 있다.

예를 들면, 웹브라우저는 애니메이션 효과를 보여주면서 마우스 입력을 받아서 처리하고, Node.js기반의 웹서버에서는 동시에 여러 개의 HTTP 요청을 처리하기도 한다. 어떻게 스레드가 하나인데 이런 일이 가능할까? 질문을 바꿔보면 '자바스크립트는 어떻게 동시성(Concurrency)을 지원하는 걸까'?

이때 등장하는 개념이 바로 '이벤트 루프'이다. Node.js를 소개할 때 '이벤트 루프 기반의 비동기 방식으로 Non-Blocking IO를 지원하고..' 와 같은 문구를 본 적이 있을 것이다. 즉, 자바스크립트는 이벤트 루프를 이용해서 비동기 방식으로 동시성을 지원한다.

동기 방식의(Java 같은) 다른 언어를 사용하다가 Node.js 등을 통해 자바스크립트를 처음 접하게 되는 사람들은 이 '이벤트 루프'의 개념이 익숙하지 않아서 애를 먹는다. 뿐만 아니라 자바스크립트를 오랫동안 사용해서 비동기 방식의 프로그래밍에 익숙한 사람들조차 이벤트 루프가 실제로 어떻게 동작하는지에 대해서는 자세히 모르는 경우가 많다.

ECMAScript에는 이벤트 루프가 없다

웬만큼 두꺼운 자바스크립트 관련 서적들을 뒤져봐도 이벤트 루프에 대한 설명은 의외로 쉽게 찾아보기가 힘들다. 그 이유는 아마, 실제로 ECMAScript 스펙에 이벤트 루프에 대한 내용이 없기 때문일 것이다.

좀더 구체적으로 표현하면 'ECMAScript 에는 동시성이나 비동기와 관련된 언급이 없다'고 할 수 있겠다(사실 ES6부터는 조금 달라졌지만, 나중에 좀더 설명하겠다). 실제로 V8과 같은 자바스크립트 엔진은 단일 호출 스택(Call Stack)을 사용하며, 요청이 들어올 때마다 해당 요청을 순차적으로 호출 스택에 담아 처리할 뿐이다.

그렇다면 비동기 요청은 어떻게 이루어지며, 동시성에 대한 처리는 누가 하는 걸까? 바로 이 자바스크립트 엔진을 구동하는 환경, 즉 브라우저나 Node.js가 담당한다. 먼저 브라우저 환경을 간단하게 그림으로 표현하면 다음과 같다.

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

이 그림에서도 브라우저의 환경과 비슷한 구조를 볼 수 있다. 잘 알려진 대로 Node.js는 비동기 IO를 지원하기 위해 libuv 라이브러리를 사용하며, 이 libuv가 이벤트 루프를 제공한다. 자바스크립트 엔진은 비동기 작업을 위해 Node.jsAPI를 호출하며, 이때 넘겨진 콜백은 libuv의 이벤트 루프를 통해 스케쥴되고 실행된다.

이제 어느 정도 감이 잡힐 것이다. 각각에 대해 좀더 자세히 알아보기 전에 한가지만 확실히 짚고 넘어가자. 자바스크립트가 '단일 스레드' 기반의 언어라는 말은 '자바스크립트 엔진이 단일 호출 스택을 사용한다'는 관점에서만 사실이다. 실제 자바스크립트가 구동되는 환경(브라우저, Node.js등)에서는 주로 여러 개의 스레드가 사용되며, 이러한 구동 환경이 단일 호출 스택을 사용하는 자바 스크립트 엔진과 상호 연동하기 위해 사용하는 장치가 바로 '이벤트 루프'인 것이다.

단일 호출 스택과 Run-to-Completion

Run-to-completion이란, 하나의 메시지 처리가 시작되면 이 메시지의 처리가 끝날 때까지는 다른 어떤 작업도 중간에 끼어들지 못한다는 의미다. 아래는 run-to-completion의 예제다.

위 왼쪽 예제 코드를 실행하면 오른쪽과 같은 결과를 확인할 수 있다(브라우저 프로세스가 먹통이 되어 어쩔 수 없이 강제 종료시켜야 할 수도 있습니다). 그럼 이러한 run-to-completion 방식의 동작 원리는 무엇일까?

Call Stack

JavaScript 엔진에는 코드가 실행될 때 그 위치를 나타내는 커서(cursor) 역할을 하는 콜 스택이라는 곳이 있다. 요청이 들어올 때마다 해당 요청을 순차적으로 콜 스택에 담아 처리한다.

예를 들어, 현재 어떤 함수가 호출되어서 동작하고 있는지, 다음에 어떤 함수가 호출되어야 하는지 등을 제어한다. 아래 예제 코드를 보면 JavaScript 코드가 수행될 때 콜 스택에서 어떤 일이 일어나는지 확인할 수 있다.

‘hello’라는 메시지를 출력하는 코드를 갖고 있는 hello 함수와, 그 hello 함수를 호출하고 ‘JSConfKorea’라는 메시지를 출력하는 코드를 갖고 있는 helloJsConf 함수를 정의한 뒤, 마지막에 helloJsConf 함수를 호출하는 코드이다.

이 코드를 실행하는 동안 콜 스택에선 아래와 같은 동작이 진행됩니다.

  1. 전체 main 코드 블록이 스택에 쌓인다.
  2. helloJsConf 함수가 호출되어 스택에 쌓인다.
  3. hello 함수가 호출되어 스택에 쌓인다.
  4. console.log('hello')가 스택에 쌓인다.
  5. ‘hello’를 콘솔에 출력함으로써 console.log('hello')는 스택에서 제거된다.
  6. hello 함수가 스택에서 제거된다.
  7. console.log('JSConfKorea')가 스택에 쌓인다.
  8. ‘JSConfKorea’를 콘솔에 출력함으로써 console.log('JSConfKorea')는 스택에서 제거된다.
  9. helloJSConf 함수도 일을 모두 마쳤으니 스택에서 제거된다.
  10. main 코드 블록이 스택에서 제거된다.
  11. 이와 같이 JavaScript는 콜 스택 구조와 함께 run-to-completion 방식으로 동작합니다.

그렇다면 만약 같은 상황에서 요청을 차례로 처리하다가 시간이 다소 오래 걸리는 작업을 만나면 어떻게 될까요? 아래 예제를 살펴보겠습니다.

이전 예제와 같이 동작을 하다가 someExpensive 함수와 같이 처리하는 데 오래 걸리는 요청을 만나면 ‘hello’ 나 ‘jsConfKorea’ 메시지를 출력하는 일에 지연이 발생할 것이다.

그렇다면 여기서 한 가지 의문이 생긴다. JavaScript가 단일 콜 스택 구조로 작업을 처리한다고 했었다. 우리가 웹 서비스를 이용할 때를 생각해 봅시다. 클릭하고 스크롤하고 타이핑하는 와중에 데이터를 호출하여 화면에 보여주고… 이러한 작업들이 정말 순차적으로 차례차례 기다리면서 처리되고 있는 걸까? 실제로는 그렇지 않다. 브라우저와 JavaScript 엔진은 이러한 동시성 문제를 해결해주는 웹 API(setTimeout, Promise 등..)와 이벤트 루프를 제공하고 있다.

Event Loop

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

위 코드를 해석해 보자. waitForMessage 함수가 동기적으로 동작한다고 가정했을 때, 무한 루프를 실행하면서 메시지를 기다리고, 메시지가 있다면 다음 메시지를 처리한다는 의미. 즉 이벤트 루프는 JavaScript의 엔진의 구성 요소는 아니지만, 구동되는 환경(브라우저나 Node.js와 같은 런타임 환경)에서 콜 스택에 어떤 작업을 쌓을지 관장하는 역할을 한다.

이벤트 루프가 어떻게 동작하는지 간단하게 살펴보면 다음과 같다.

  1. 처리할 작업이 있다면 그중 가장 오래된 작업을 실행한다.
  2. 처리할 작업이 없다면 다음 작업을 기다린다.
  3. 다시 처리할 작업이 있다면 1번으로 돌아가 반복한다.

아래 예제를 통해 좀 더 자세히 살펴보자.

setTimeout은 타이머 이벤트를 생성해 인자로 넘겨준 시간만큼 기다렸다가 수행하는 기능을 한다. 예제 코드와 같이 인자가 없는 경우에는 기본값인 0을 넘겨준다. 타이머 시간을 0으로 주었기 때문에 바로 실행되어야 할 것 같지만 실제론 그렇지 않다. 왜 그런지는 이후에 코드가 어떻게 동작하는지 하나하나 따라가면서 알아보도록 하자.

Promise는 비동기 작업이 처리되었을 미래 시점의 완료 또는 실패의 상황을 다루는데 사용하는 API다. 위 코드에서는 resolve 메서드를 통해 빈 값으로 이행하는 Promise를 반환하고 then 메서드를 통해 이행 완료하였을 때의 콜백을 넘겨준다.

코드는 아래 순서로 동작한다.

  1. setTimeout을 호출한다.
  2. 콜백을 태스크 대기 열에 담아둔다.
  3. Promise를 호출한다.
  4. then에 콜백으로 넘어온 부분을 ‘마이크로 태스크’ 대기 열에 담아둔다.

Micro Task

여기서 ‘마이크로 태스크(micro task)’에 대해 잠깐 짚고 넘어가자. ES2015에서는 동시성을 다루기 위한 Promise와 같은 API들이 추가되었다. 이들은 일반 태스크와는 조금 다른, 마이크로 태스크를 다루게 된다.

태스크는 브라우저 혹은 그 외 구동 환경에서 순차적으로 실행되어야 하는 작업을 의미한다. 단순히 스크립트를 실행하거나, setTimeout이나 UI 이벤트 발생으로 인한 콜백 등이 그 대상이 된다. 마이크로 태스크는 현재 실행되고 있는 작업 바로 다음으로 실행되어야 할 비동기 작업을 뜻한다. 즉 마이크로 태스크는 일반 태스크보다 높은 우선순위를 갖는다고 볼 수 있다. 예제에 사용된 PromiseObserver API, NodeJSprocess.nextTick 등이 그 대상이 된다.

앞서 설명한 이벤트 루프의 동작 순서에 마이크로 태스크 개념을 포함하면 다음과 같다.

  1. 마이크로 태스크가 있는지 먼저 확인하고, 있다면 모든 마이크로 태스크를 먼저 수행한다.
  2. 처리할 태스크가 있다면 가장 오래된 태스크를 실행한다.
  3. 처리할 태스크가 없다면 다음 태스크를 기다린다.
  4. 다시 처리할 작업이 있다면 1번으로 돌아가 반복한다.
  5. 태스크를 기다리기 전에 마이크로 태스크가 있는지를 먼저 확인하고, 마이크로 태스크가 있다면 먼저 모두 수행하고 나서 태스크를 수행한다.

그럼 아까 예제 코드로 다시 돌아오면, 드디어 이벤트 루프가 하는 일을 확인할 수 있다. Promisethen 메서드로 넘겨준 콜백이 마이크로 태스크로써 이벤트 루프를 통해 콜 스택으로 투입된 뒤 실행된다. 그다음엔 ‘hello’를 출력하는 태스크를 수행한다.

그렇다면 이벤트 루프에 대한 이해를 기반으로 비동기를 다루는 웹 API를 활용하면 모든 문제를 다 해결할 수 있는 걸까? 아쉽게도 그렇진 않다.

여전히 앞선 태스크 때문에 다음 태스크 실행이 가로막힐 수 있는 가능성이 남아 있다. 아래 예제를 보면, 코드가 차례로 수행되다가 고비용 연산 작업으로 가정한 someExpensive 함수를 먼저 콜 스택으로 밀어 넣었다. 이 때문에 ‘hello’를 출력하는 태스크는 이벤트 루프에 막혀 버린다. 해당 작업이 완료되고 나서야 실행될 수 있다.

정리하자면, 태스크는 항상 이벤트 루프를 통해 순차적으로 실행되기 때문에 임의의 태스크가 완료되기 전까지는 다른 태스크가 실행될 수 없고, 마이크로 태스크 대기 열은 일반 태스크 대기 열보다 우선순위가 높기 때문에 마이크로 태스크 대기 열이 모두 비워지기 전까진 UI 이벤트가 실행될 수 없다.

즉 CPU에서 고 비용 연산을 포함한 태스크나 마이크로 태스크가 실행되고 있다면, UI와 직결된 클릭, 텍스트 입력, 렌더링과 같은 이벤트가 가로막힐 수 있고, 이것은 곧 사용자 경험을 해치는 요소가 될 수 있다는 것이다.

JavaScript Async Process

  • 자바스크립트는 동기적으로 실행되는 네트워크 요청이 콜스택을 블로킹하기 때문에 브라우저는 다른 일을 동시에 할 수 없음
    이를 극복하기 위한 해결책으로 비동기 콜백을 이용
  • SetTimeout() 함수의 비동기 처리는 자바스크립트의 콜 스택에서 사라졌다가 콜 스택이 비어지면 다시 스택에 쌓이는 형태이며, 이는 Event Loop와 동시성의 역할을 함
  • 이와 같이 자바스크립트는 한번에 하나씩만 처리하지만, 브라우저가 WepAPIs 같은 것들을 제공하여(DOM, Ajax, SetTimeout) 자바스크립트에서 호출할 수 있는 스레드를 효과적으로 지원하는 역할을 함

SetTimeout 함수는 자바스크립트나 브라우저의 V8 소스코드에 존재하지 않고, 자바스크립트의 런타임 환경에 존재하는 별도의 API

  • SetTimeout의 딜레이 시간은 최소의 시간이며, 더 늘어날 수도 있음

Event Loop

  • Event Loop의 역할은 콜 스택과 태스크 큐를 주시하다가 스택이 비워지면 큐의 첫 번째 콜백을 스택에 쌓는 역할을 함
  • Fetch APIRequest도 비슷한 처리 방식임(요청하고 스택 밖에서 기다리다가 스택이 비워지고 처리되는 방식)
  • addEventListner도 마찬가지로 이벤트가 발생하면 Wep API에서 콜백 큐로 보내지고, 차례대로 콜 스택에 쌓여서 실행되는 구조임

Call Stack & Rendering

  • 콜 스택이 차 있는 상태에서는 렌더링을 할 수 없음
  • 렌더도 하나의 콜백처럼 행동하며 스택이 비워질 때까지 기다려야 하며, 보통의 콜백보다는 더 높은 우선순위를 가짐.
    하지만 위에서도 언급하였지만 콜 스택에 동기식 루프가 진행되는 동안은 렌더도 막히게 되어 다른 행동(화면의 텍스트를 선택하는 등)이 불가능함
  • 그렇기 때문에 비동기 루프를 통해 큐에 쌓고, 렌더가 끼어들 기회를 주는 방식으로 처리함

다음의 글을 참고하였습니다.

profile
yeeaasss rules!!!!

0개의 댓글