자바스크립트의 큰 특징 중 하나는 '단일 스레드' 기반의 언어라는 점이다. 스레드가 하나라는 말은 곧, 동시에 하나의 작업만을 처리할 수 있다라는 말이다. 하지만 실제로 자바스크립트가 사용되는 환경을 생각해보면 많은 작업이 동시에 처리되고 있는 걸 볼 수 있다.
예를 들면, 웹브라우저는 애니메이션 효과를 보여주면서 마우스 입력을 받아서 처리하고, Node.js
기반의 웹서버에서는 동시에 여러 개의 HTTP 요청을 처리하기도 한다. 어떻게 스레드가 하나인데 이런 일이 가능할까? 질문을 바꿔보면 '자바스크립트는 어떻게 동시성(Concurrency
)을 지원하는 걸까'?
이때 등장하는 개념이 바로 '이벤트 루프'이다. Node.js
를 소개할 때 '이벤트 루프 기반의 비동기 방식으로 Non-Blocking IO
를 지원하고..' 와 같은 문구를 본 적이 있을 것이다. 즉, 자바스크립트는 이벤트 루프를 이용해서 비동기 방식으로 동시성을 지원한다.
동기 방식의(Java
같은) 다른 언어를 사용하다가 Node.js
등을 통해 자바스크립트를 처음 접하게 되는 사람들은 이 '이벤트 루프'의 개념이 익숙하지 않아서 애를 먹는다. 뿐만 아니라 자바스크립트를 오랫동안 사용해서 비동기 방식의 프로그래밍에 익숙한 사람들조차 이벤트 루프가 실제로 어떻게 동작하는지에 대해서는 자세히 모르는 경우가 많다.
웬만큼 두꺼운 자바스크립트 관련 서적들을 뒤져봐도 이벤트 루프에 대한 설명은 의외로 쉽게 찾아보기가 힘들다. 그 이유는 아마, 실제로 ECMAScript
스펙에 이벤트 루프에 대한 내용이 없기 때문일 것이다.
좀더 구체적으로 표현하면 'ECMAScript
에는 동시성이나 비동기와 관련된 언급이 없다'고 할 수 있겠다(사실 ES6
부터는 조금 달라졌지만, 나중에 좀더 설명하겠다). 실제로 V8과 같은 자바스크립트 엔진은 단일 호출 스택(Call Stack
)을 사용하며, 요청이 들어올 때마다 해당 요청을 순차적으로 호출 스택에 담아 처리할 뿐이다.
그렇다면 비동기 요청은 어떻게 이루어지며, 동시성에 대한 처리는 누가 하는 걸까? 바로 이 자바스크립트 엔진을 구동하는 환경, 즉 브라우저나 Node.js
가 담당한다. 먼저 브라우저 환경을 간단하게 그림으로 표현하면 다음과 같다.
위 그림에서 볼 수 있듯이 실제로 우리가 비동기 호출을 위해 사용하는 setTimeout
이나 XMLHttpRequest
와 같은 함수들은 자바스크립트 엔진이 아닌 Web API
영역에 따로 정의되어 있다. 또한 이벤트 루프와 태스크 큐와 같은 장치도 자바스크립트 엔진 외부에 구현되어 있는 것을 볼 수 있다. 다음은 Node.js
환경이다.
이 그림에서도 브라우저의 환경과 비슷한 구조를 볼 수 있다. 잘 알려진 대로 Node.js
는 비동기 IO
를 지원하기 위해 libuv
라이브러리를 사용하며, 이 libuv
가 이벤트 루프를 제공한다. 자바스크립트 엔진은 비동기 작업을 위해 Node.js
의 API
를 호출하며, 이때 넘겨진 콜백은 libuv
의 이벤트 루프를 통해 스케쥴되고 실행된다.
이제 어느 정도 감이 잡힐 것이다. 각각에 대해 좀더 자세히 알아보기 전에 한가지만 확실히 짚고 넘어가자. 자바스크립트가 '단일 스레드' 기반의 언어라는 말은 '자바스크립트 엔진이 단일 호출 스택을 사용한다'는 관점에서만 사실이다. 실제 자바스크립트가 구동되는 환경(브라우저, Node.js
등)에서는 주로 여러 개의 스레드가 사용되며, 이러한 구동 환경이 단일 호출 스택을 사용하는 자바 스크립트 엔진과 상호 연동하기 위해 사용하는 장치가 바로 '이벤트 루프'인 것이다.
Run-to-completion이란, 하나의 메시지 처리가 시작되면 이 메시지의 처리가 끝날 때까지는 다른 어떤 작업도 중간에 끼어들지 못한다는 의미다. 아래는 run-to-completion의 예제다.
위 왼쪽 예제 코드를 실행하면 오른쪽과 같은 결과를 확인할 수 있다(브라우저 프로세스가 먹통이 되어 어쩔 수 없이 강제 종료시켜야 할 수도 있습니다). 그럼 이러한 run-to-completion 방식의 동작 원리는 무엇일까?
JavaScript
엔진에는 코드가 실행될 때 그 위치를 나타내는 커서(cursor
) 역할을 하는 콜 스택이라는 곳이 있다. 요청이 들어올 때마다 해당 요청을 순차적으로 콜 스택에 담아 처리한다.
예를 들어, 현재 어떤 함수가 호출되어서 동작하고 있는지, 다음에 어떤 함수가 호출되어야 하는지 등을 제어한다. 아래 예제 코드를 보면 JavaScript
코드가 수행될 때 콜 스택에서 어떤 일이 일어나는지 확인할 수 있다.
‘hello’
라는 메시지를 출력하는 코드를 갖고 있는 hello
함수와, 그 hello
함수를 호출하고 ‘JSConfKorea’
라는 메시지를 출력하는 코드를 갖고 있는 helloJsConf
함수를 정의한 뒤, 마지막에 helloJsConf
함수를 호출하는 코드이다.
이 코드를 실행하는 동안 콜 스택에선 아래와 같은 동작이 진행됩니다.
main
코드 블록이 스택에 쌓인다.helloJsConf
함수가 호출되어 스택에 쌓인다.hello
함수가 호출되어 스택에 쌓인다.console.log('hello')
가 스택에 쌓인다.‘hello’
를 콘솔에 출력함으로써 console.log('hello')
는 스택에서 제거된다.hello
함수가 스택에서 제거된다.console.log('JSConfKorea')
가 스택에 쌓인다.‘JSConfKorea’
를 콘솔에 출력함으로써 console.log('JSConfKorea')
는 스택에서 제거된다.helloJSConf
함수도 일을 모두 마쳤으니 스택에서 제거된다.main
코드 블록이 스택에서 제거된다.JavaScript
는 콜 스택 구조와 함께 run-to-completion
방식으로 동작합니다.그렇다면 만약 같은 상황에서 요청을 차례로 처리하다가 시간이 다소 오래 걸리는 작업을 만나면 어떻게 될까요? 아래 예제를 살펴보겠습니다.
이전 예제와 같이 동작을 하다가 someExpensive
함수와 같이 처리하는 데 오래 걸리는 요청을 만나면 ‘hello
’ 나 ‘jsConfKorea
’ 메시지를 출력하는 일에 지연이 발생할 것이다.
그렇다면 여기서 한 가지 의문이 생긴다. JavaScript
가 단일 콜 스택 구조로 작업을 처리한다고 했었다. 우리가 웹 서비스를 이용할 때를 생각해 봅시다. 클릭하고 스크롤하고 타이핑하는 와중에 데이터를 호출하여 화면에 보여주고… 이러한 작업들이 정말 순차적으로 차례차례 기다리면서 처리되고 있는 걸까? 실제로는 그렇지 않다. 브라우저와 JavaScript
엔진은 이러한 동시성 문제를 해결해주는 웹 API(setTimeout, Promise 등..)
와 이벤트 루프를 제공하고 있다.
while(queue.waitForMessage()) {
queue.processNextMessage()
}
위 코드를 해석해 보자. waitForMessage
함수가 동기적으로 동작한다고 가정했을 때, 무한 루프를 실행하면서 메시지를 기다리고, 메시지가 있다면 다음 메시지를 처리한다는 의미. 즉 이벤트 루프는 JavaScript
의 엔진의 구성 요소는 아니지만, 구동되는 환경(브라우저나 Node.js
와 같은 런타임 환경)에서 콜 스택에 어떤 작업을 쌓을지 관장하는 역할을 한다.
이벤트 루프가 어떻게 동작하는지 간단하게 살펴보면 다음과 같다.
아래 예제를 통해 좀 더 자세히 살펴보자.
setTimeout
은 타이머 이벤트를 생성해 인자로 넘겨준 시간만큼 기다렸다가 수행하는 기능을 한다. 예제 코드와 같이 인자가 없는 경우에는 기본값인 0을 넘겨준다. 타이머 시간을 0으로 주었기 때문에 바로 실행되어야 할 것 같지만 실제론 그렇지 않다. 왜 그런지는 이후에 코드가 어떻게 동작하는지 하나하나 따라가면서 알아보도록 하자.
Promise
는 비동기 작업이 처리되었을 미래 시점의 완료 또는 실패의 상황을 다루는데 사용하는 API
다. 위 코드에서는 resolve
메서드를 통해 빈 값으로 이행하는 Promise
를 반환하고 then
메서드를 통해 이행 완료하였을 때의 콜백을 넘겨준다.
코드는 아래 순서로 동작한다.
setTimeout
을 호출한다.Promise
를 호출한다.then
에 콜백으로 넘어온 부분을 ‘마이크로 태스크’ 대기 열에 담아둔다.여기서 ‘마이크로 태스크(micro task)’에 대해 잠깐 짚고 넘어가자. ES2015
에서는 동시성을 다루기 위한 Promise
와 같은 API
들이 추가되었다. 이들은 일반 태스크와는 조금 다른, 마이크로 태스크를 다루게 된다.
태스크는 브라우저 혹은 그 외 구동 환경에서 순차적으로 실행되어야 하는 작업을 의미한다. 단순히 스크립트를 실행하거나, setTimeout
이나 UI
이벤트 발생으로 인한 콜백 등이 그 대상이 된다. 마이크로 태스크는 현재 실행되고 있는 작업 바로 다음으로 실행되어야 할 비동기 작업을 뜻한다. 즉 마이크로 태스크는 일반 태스크보다 높은 우선순위를 갖는다고 볼 수 있다. 예제에 사용된 Promise
나 Observer API
, NodeJS
의 process.nextTick
등이 그 대상이 된다.
앞서 설명한 이벤트 루프의 동작 순서에 마이크로 태스크 개념을 포함하면 다음과 같다.
그럼 아까 예제 코드로 다시 돌아오면, 드디어 이벤트 루프가 하는 일을 확인할 수 있다. Promise
의 then
메서드로 넘겨준 콜백이 마이크로 태스크로써 이벤트 루프를 통해 콜 스택으로 투입된 뒤 실행된다. 그다음엔 ‘hello’
를 출력하는 태스크를 수행한다.
그렇다면 이벤트 루프에 대한 이해를 기반으로 비동기를 다루는 웹 API
를 활용하면 모든 문제를 다 해결할 수 있는 걸까? 아쉽게도 그렇진 않다.
여전히 앞선 태스크 때문에 다음 태스크 실행이 가로막힐 수 있는 가능성이 남아 있다. 아래 예제를 보면, 코드가 차례로 수행되다가 고비용 연산 작업으로 가정한 someExpensive
함수를 먼저 콜 스택으로 밀어 넣었다. 이 때문에 ‘hello
’를 출력하는 태스크는 이벤트 루프에 막혀 버린다. 해당 작업이 완료되고 나서야 실행될 수 있다.
정리하자면, 태스크는 항상 이벤트 루프를 통해 순차적으로 실행되기 때문에 임의의 태스크가 완료되기 전까지는 다른 태스크가 실행될 수 없고, 마이크로 태스크 대기 열은 일반 태스크 대기 열보다 우선순위가 높기 때문에 마이크로 태스크 대기 열이 모두 비워지기 전까진 UI 이벤트가 실행될 수 없다.
즉 CPU에서 고 비용 연산을 포함한 태스크나 마이크로 태스크가 실행되고 있다면, UI와 직결된 클릭, 텍스트 입력, 렌더링과 같은 이벤트가 가로막힐 수 있고, 이것은 곧 사용자 경험을 해치는 요소가 될 수 있다는 것이다.
SetTimeout()
함수의 비동기 처리는 자바스크립트의 콜 스택에서 사라졌다가 콜 스택이 비어지면 다시 스택에 쌓이는 형태이며, 이는 Event Loop
와 동시성의 역할을 함WepAPIs
같은 것들을 제공하여(DOM
, Ajax
, SetTimeout
) 자바스크립트에서 호출할 수 있는 스레드를 효과적으로 지원하는 역할을 함SetTimeout
의 딜레이 시간은 최소의 시간이며, 더 늘어날 수도 있음Event Loop
의 역할은 콜 스택과 태스크 큐를 주시하다가 스택이 비워지면 큐의 첫 번째 콜백을 스택에 쌓는 역할을 함Fetch API
의 Request
도 비슷한 처리 방식임(요청하고 스택 밖에서 기다리다가 스택이 비워지고 처리되는 방식)addEventListner
도 마찬가지로 이벤트가 발생하면 Wep API
에서 콜백 큐로 보내지고, 차례대로 콜 스택에 쌓여서 실행되는 구조임다음의 글을 참고하였습니다.