브라우저의 런타임
동작 그만, 비동기 함수냐? 쫄리면 await 쓰시든지.
JavaScript는 싱글 스레드 언어(Single-Threaded Language)다. 그래서 JavaScript 엔진은 코드를 (쉽게 말해) 한 줄씩 순차적으로만 실행할 수 있다.
하지만 비동기 요청을 만난다면 얘기는 다르다. 이때 JavaScript는 마치 여러 코드를 동시에 실행하는 것처럼 보인다. JavaScript는 논 블로킹(Non-Blocking) 방식의 동시성(Concurrency)을 지원한다고 하는데… 도대체 비동기는 무엇이고, JavaScript는 어떻게 이것이 가능했던 것일까?
이를 이해하려면 브라우저의 런타임이 어떻게 동작하는지 알아야 한다.
먼저 런타임(Runtime)이란, 프로그래밍 언어가 구동되는 환경을 말한다. 브라우저의 런타임 환경은 JavaScript 엔진, Web API, Task Queue, Event Loop 등의 구성 요소를 가지고 있는데:
대충 보았을 때 JavaScript 엔진, Web API, Task Queue, Event Loop 사이에서 사이클을 도는 것이 보일 것이다. 뭐가 어떻게 생겼든 간에, 일단 모든 시작은 코드를 실행하는 것부터겠다.
코드를 실행하는 건 JavaScript 엔진의 역할이다.
JavaScript 엔진은 (브라우저에 따라 다르지만) 렌더링 엔진 하위에 존재하는데, JS 코드를 해석하고 즉시 실행한다. 다시 말해 인터프리터(Interpreter)인 셈인데, 우리가 런타임이라 부르는 이유도 여기에 있다. 인터프리터는 컴파일러(Compiler)와는 다르게 코드를 해석하고 직접 실행까지 하기 때문이다.
다른 구성 요소의 역할은 천천히 알아보고, 누가 코드를 실행하는지는 알았으니 일단 아래 코드를 실행해보자.
console.log(1)
setTimeout(() => { console.log(2) }, 1000)
console.log(3)
첫 번째로 console.log(1)
가 실행된다.
이후 다음 코드 setTimeout(() => { console.log(2) }, 1000)
가 실행된다.
마지막으로 console.log(3)
가 실행된다.
결과는 어떻게 나왔을까?
이유가 무엇일까? JavaScript 엔진은 동기적 코드를 만나면 즉시 실행하지만, 비동기적 코드를 만나면 이 녀석이 완료될 때까지 어딘가에 치워두고 다음 코드를 실행하기 때문이다.
비동기적 코드? 지금처럼 setTimeout
이나 Promise, HTTP 요청 등을 말하는데 이러한 비동기적 코드들은 Web API에 포함되어있다. 물론 ‘모든 Web API = 비동기적 API?’ 이건 아니고.
Web API란, 웹을 개발하는 데에 사용하도록 브라우저가 제공하는 API를 말한다.
때문에 Web API에는 당연히(?) 동기적 API도 포함되어 있다. 우리가 흔히 접하는 DOM이나 window
객체 또한 Web API에 속하는데, SSR 등의 이유로 서버에서 프론트엔드 코드를 실행할 때 window
에 접근하지 못하는 것도 이런 이유 때문. 결국 브라우저 환경에서만 실행할 수 있는 내장 JavaScript API는 대부분 Web API라는 뜻이다. setTimeout
은 Node.js에도 있는데? 마찬가지로 Node.js의 Timers API가 브라우저와 동일한 메서드를 제공하는 것일 뿐.
왜 모두가 아니라 대부분일까? ECMAScript에 비동기 프로그래밍의 구현 방식은 정의되어 있지 않지만, 스펙은 정의되어 있기 때문이다. Promise와 async/await. 그래서 Promise는 브라우저에 의존한다.
수많은 Web API의 스펙은 Ecma International이 아닌 W3C에서 관리한다. Web API는 브라우저 상의 JavaScript로 접근하고 호출할 수 있는 것일 뿐, ECMAScript 스펙이 아니기 때문이다. Web API의 모듈별 스펙을 다 찾아 올릴 순 없으니 MDN 문서를 첨부한다.
지금까지 내용을 정리해보면 이렇다:
- JavaScript 엔진은 JS 코드를 실행하는 녀석.
- Web API는 브라우저에서 제공하는 API. 브라우저에서만 실행 가능한 내장 JavaScript API는 대부분 이에 해당.
- JavaScript 엔진이 비동기적 Web API를 만나면 잠깐 저리 치우고, 다음 코드를 실행.
그래서 대체 어디에 어떻게 치운다는 걸까?
위에서 보았던 setTimeout(() => { console.log(2) }, 1000)
코드는 약 1초를 기다린 후 console.log(2)
를 호출하는 코드이다.
이때 1초를 기다리는 것은 JavaScript 엔진이 아니라 Web API다. 쉬운 설명을 위해 알람 기능에 비유할 수 있겠는데, JavaScript 엔진이 라면을 아주 맛있게 끓이려고 불 앞에서 냄비만 3분째 뚫어져라 쳐다보는 그런 모양새가 아니라, Web API를 통해 3분 타이머를 맞춰두고 JavaScript 엔진은 자기 할 일 하는 그런 그림이다.
물론 이는 setTimeout
의 역할을 비유한 것일 뿐, 모든 Web API의 역할이 ‘몇 분 뒤에 알려줘’ 는 아니다. 만약 HTTP Request API라면 통신과 함께 응답을 기다릴 것이고, Web Workers API라면 마치 새로운 JavaScript 엔진처럼 일할 것이다. 이벤트 리스너 또한 Web API에서 처리하는데, 사용자가 무엇을 클릭하는지 Web API가 지켜보다 클릭한 순간 알려준다.
즉, 비동기적 Web API를 호출한 순간부터 해당 영역은 이미 JavaScript 엔진의 손을 떠난 셈이다. 덕분에 JavaScript 엔진은 Web API에게 작업을 맡겨두고 다음 코드를 실행할 수 있다.
이제 JavaScript 엔진이 비동기 작업을 제쳐두고 다음 코드를 실행할 수 있는 이유는 알았다. 그렇다면 비동기 작업이 완료되었을 때 Web API는 이를 JavaScript 엔진에게 어떻게 알리는 걸까?
여기서부턴 브라우저의 구성 요소에 대해서 일부 알아야 하는데, Web API는 무엇인지 알았고, 다음은 Call Stack이다.
Call Stack은 실행 중인 코드 블록을 stack 구조로 담고 있다.
만약 Call Stack이 무엇인지 모른 채 이 글을 읽는다면, 지금 여기서는 ‘실행 중인 코드 덩어리들을 함수 단위로 쌓아 놓은 것’ 정도로 생각하면 된다. JavaScript 엔진은 실행 중인 코드 덩어리를 Call Stack에 쌓고 실행 완료되면 Call Stack에서 꺼내는데, 한참 전의 예제 코드를 기준으로 Call Stack에 쌓일 코드 덩어리는 console.log(1...3)
같은 것들이다.
글의 흐름에 방해되지 않게 이 정도로 알고 넘어가면 된다고 했으나, 사실 Call Stack이 무엇인지 알고 읽는 게 이해하기 쉬울 것이다. Call Stack의 자세한 설명은 다음글에서 읽을 수 있다.
이제부터는 이미지가 대부분일 텐데, 다음 이미지에서는 동기적인 console.log()
코드가 Call Stack에 쌓여 실행되고, 즉시 빠져나가는 것을 볼 수 있다. (실행 완료)
하지만 아래에 이어서 보게 될 이미지에서는 다르게 동작한다. 비동기적인 setTimeout(/* ... */)
코드가 Call Stack에 쌓이고 Web API인 setTimeout
이 벗겨지면서, Callback 함수가 Web API Tasks로 옮겨가는 것을 볼 수 있다.
정확한 Web API의 정의와, Web API를 처리하는 공간의 이름을 구분하고자, 이미지 안의 Web API를 ‘Web API Tasks’로 부르겠다. 내가 이미지까지 만들긴 힘들어서 ㅎ
Web API Tasks에서 처리가 완료되면, 이어서 Callback 함수는 Web API Tasks에서 (Macro)Task Queue로, 그리고 Event Loop를 거쳐 Call Stack으로 이동한다. 결국 Call Stack으로 이동했기에 JavaScript 엔진은 이를 처리하고 Callback 함수가 Call Stack에서조차 빠져나가는 모습을 볼 수 있다.
여기서 Callback 함수가 (Macro)Task Queue에서 Call Stack으로 이동하는 사이, Event Loop의 역할은 무엇인지 궁금할 텐데:
Event Loop는 Call Stack이 비는 순간, 우선순위에 따라 task를 꺼내 Call Stack으로 전달한다.
다시 말해 Task Queue가 task를 Call Stack에 전달한 게 아니라, Event Loop가 대기 중인 task을 꺼내 Call Stack으로 옮긴 것이다. Event Loop는 이를 위해 Call Stack과 Task Queue를 감시한다고 보면 되겠다. 덕분에 JavaScript 엔진은 비동기 작업에 대한 구현을 알 필요 없이, Web API 요청 후 다음 작업을 이어서 하다 Event Loop가 다음 코드를 떠먹여 주면, 그때 실행하면 된다.
그리고 Task Queue. 여기까지 읽었다면 특별한 설명이 없어도 무엇인지 감이 올 것이다.
Task Queue는 '브라우저가 처리 완료한' task의 대기열이다.
굳이 Task Queue와 Event Loop가 존재하는 이유는, Web API는 언제 완료될지 모르고, 완료되었다고 해서 곧바로 Call Stack에 쌓는다면 실행 흐름이 전혀 제어되지 않기 때문이다. 그래서 Event Loop는 완료된 task의 코드가 안전한 흐름으로 실행되는 것을 보장한다.
이제 우리는 JavaScript가 싱글 스레드 언어임에도 비동기 호출이 이루어질 수 있었던 모든 과정을 지켜봤다. 하나 남은 게 있다면 Task Queue의 구분이다.
Task Queue는 (Macro)Task Queue, Microtask Queue, Animation Frames, 세 가지로 나뉜다. 각 queue별로 담당하는 task는 다음과 같다:
- (Macro)Task Queue:
이벤트 발생 및 리스너 함수,setTimeout()
등의 타이머,requestIdleCallback()
등 - Microtask Queue:
Promise,queueMicrotask()
등 - Animation Frames:
requestAnimationFrame()
왜 이렇게 세분화 되었을까?
나도 궁금하다. 스펙 설계 의도를 찾다 너무 늦어서 말았는데, 누가 알면 JavaScript 엔진에게 다음 실행할 코드 떠먹여 주는 Event Loop처럼, 나에게도 지식을 떠먹여주라.
아무튼 분류한 의도를 알진 못해도 결과적으로는 의도한바, 셋은 이렇게 실행 순서가 달라진다. 일반적으로 Microtask, Animation Frame, (Macro)Task 순으로 실행되는데, 당연히 셋을 번갈아 실행하는 건 아니고 순서대로 queue를 전부 비운다. (flush)
console.log('Start')
setTimeout(() => { console.log('Timeout') }, 0)
setTimeout(() => { console.log('Timeout') }, 0)
requestAnimationFrame(() => { console.log('Animation') })
requestAnimationFrame(() => { console.log('Animation') })
Promise.resolve()
.then(() => { console.log('Promise') })
.then(() => { console.log('Promise') })
console.log('End')
위의 코드는 Timeout, Animation, Promise 순으로 실행하는데, 실제 결과는 Promise, Animation, Timeout 순으로 실행된 것을 볼 수 있다.
하지만 이 실행 순서는 항상 정확한 것은 아니다. 브라우저는 (Macro)Task와 Microtask는 별도의 queue로 철저히 구분하는 반면, Animation Frame은 그저 우선순위가 조금 높은 (Macro)Task로 취급한다. 때문에 Promise는 항상 Animation과 Timeout보다 먼저 실행되지만, Animation은 종종 Timeout보다 늦게 실행되는 모습을 보인다.
자세히 보면 Promise 다음에undefined
가 출력된 것을 볼 수 있는데, 이는 정확히 Microtask까지 처리한 후에 출력된 것이다. 크롬 DevTools의 콘솔은 마지막 코드의 결과를 로그로 작성한다. 즉condole.log('End')
의 반환 값인 void가 표현된 것.
다음 링크에서 Microtask에 대한 자세한 내용과 task에 따른 렌더링 결과를 볼 수 있다.
정리
- JavaScript는 싱글 스레드 언어(Single-Threaded Language)이면서, 브라우저에서 논 블로킹(Non-Blocking) 방식의 동시성(Concurrency)을 지원한다.
- 코드는 JavaScript 엔진에서 실행하며, 실행 중인 코드 블록은 JavaScript 엔진의 Call Stack에 쌓인다.
- Web API 웹 사이트 또는 웹 앱을 개발할 때 사용할 수 있는 API로, JavaScript 엔진에 내장되지 않고 브라우저가 제공한다. DOM, Timers 등.
- Web API는 처리가 완료된 task의 Callback 함수를 실행하기 위해(Call Stack으로 보내기 위해), task의 역할에 맞는 Task Queue로 보낸다.
- Event Loop는 각 Task Queue의 우선순위를 매기고, Call Stack이 비어있는지 확인해 적절한 시점에 queue를 shift, Call Stack으로 전달한다. Call Stack으로 전달된 Callback 함수는 즉시 실행된다.
Event Loop와 Promise의 동작 구현은 HTML Living Standard에도 정의되어 있다. 브라우저 환경이라지만... 나만 의외인가?