...
자바스크립트 비동기와 이벤트 루프
브라우저의 멀티 스레드로 작업을 동시에
Javascript는 싱글 스레드 언어라고 들어본 적이 있을 것이다. '싱글' 스레드라 한 번에 하나의 작업만 수행이 가능하다. 반면 Java 나 Python은 멀티 스레드를 지원하여 원하는 코드 로직을 동시에 수행 시키는 멀티 작업이 가능하다.
그런데 웹 애플리케이션에서는 네트워크 요청이나 이벤트 처리, 타이머와 같은 작업을 멀티로 처리해야 하는 경우가 많다. 만일 싱글 스레드로 브라우저 동작이 한번에 하나씩 수행하게 되면, 우리가 파일을 다운로드 받을 동안 브라우저는 파일을 다 받을 때까지 웹서핑도 못하고 멈춰 대기해야 할 것이다. 따라서 파일 다운, 네트워크 요청, 타이머, 애니메이션 이러한 오래 걸리고 반복적인 작업들은 자바스크립트 엔진이 아닌 브라우저 내부의 멀티 스레드인 Web APIs에서 비동기 + 논블로킹으로 처리된다. 비동기 + 논블로킹(Async + Non blocking)는 메인 스레드가 작업을 다른 곳에 요청하여 대신 실행하고, 그 작업이 완료되면 이벤트나 콜백 함수를 받아 결과를 실행하는 방식을 말한다. (쉽게 말해 파일 다운로드 요청 작업을 백그라운드 작업으로 전이하여 동시에 처리가 가능하도록 한 것으로 이해하면 된다)
즉, 비동기로 동작하는 핵심요소는 자바스크립트 언어가 아니라 브라우저라는 소프트웨어가 가지고 있다고 보면 된다. Node.js 에서는 libuv 내장 라이브러리가 처리한다.
이벤트 루프는 브라우저 동작을 제어하는 관리자
싱글 스레드인 자바스크립트의 작업을 멀티 스레드로 돌려 작업을 동시에 처리시키게 하던가, 또는 여러 작업 중 어떤 작업을 우선으로 동작시킬 것인지 결정하는 세심한 컨트롤을 하기 위해 존재하는 것이 바로 이벤트 루프(Event Loop) 이다. 이벤트 루프는 브라우저 내부의 Call Stack, Callback Queue, Web APIs 등의 요소들을 모니터링하면서 비동기적으로 실행되는 작업들을 관리하고, 이를 순서대로 처리하여 프로그램의 실행 흐름을 제어하는 녀석이다. 간단히 표현하자면 브라우저의 동작 타이밍을 제어하는 관리자라고 보면 된다.
이벤트 루프의 동작 과정을 간단히 살펴보자면, 자바스크립트의 setTimeout이나 fetch 와 같은 비동기 자바스크립트 코드를 브라우저 Web APIs에게 맡기고, 백그라운드 작업이 끝난 결과를 콜백 함수 형태로 큐(Callback Queue)에 넣고 처리 준비가 되면 호출 스택(Call Stack)에 넣어 마무리 작업을 진행한다.
이러한 이벤트 루프를 이용한 프로그램 방식을 이벤트 기반(Event Driven) 프로그래밍이라고 한다. 이벤트 기반 프로그래밍은 프로그램의 흐름이 이벤트에 의해 결정되는 방식이다. 예를 들어 사용자의 클릭이나 키보드 입력과 같은 이벤트가 발생하면, 그에 맞는 콜백 함수가 실행한다. 대표적으로 자바스크립트의 addEventListener(이벤트명, 콜백함수) 가 있겠다.
이벤트 기반 프로그래밍은 으로 비동기 작업을 쉽게 처리할 수 있고, 멀티 스레드 언어에 비해 단순하고 직관적인 코드 작성을 가능하게 하며, 브라우저와 같은 환경에서도 안정적인 실행을 가능하게 하여 사용자와의 상호작용을 높일 수 있다. 따라서 이를 이해하고 적절한 방식으로 비동기 작업을 처리하는 것은, 자바스크립트를 이용한 웹 애플리케이션 개발에 있어서 매우 중요하다.
💡 자바스크립트는 왜 싱글 스레드 인가?
자바스크립트는 1995년에 넷스케이프에서 웹 브라우저에서 동적인 웹 페이지를 만들기 위해 개발된 스크립트 언어이다. 당시에는 멀티 코어 프로세서가 보편화되지 않았고, 자바스크립트는 웹 브라우저에서 간단한 스크립트 동작을 수행하는 데 주로 사용되었기 때문에 복잡한 병렬 처리를 필요로 하지 않아, 메모리 사용량이 적고, 동기화 문제를 피할 수 있는 싱글 스레드로 구현하였다. 그러나 싱글 스레드는 오래 걸리는 작업이 실행되면 다른 작업들이 대기해야 하므로 응답성이 떨어진다. 또한 CPU 코어를 여러 개 사용할 수 없으므로 성능이 제한된다. 이러한 문제들을 해결하기 위해 언어 자체의 설계를 바꾸는 것 보단, 브라우저의 멀티 스레드를 이용하는 자바스크립트의 비동기 프로그래밍을 지원하는 것이다. 그리고 이 비동기 프로그래밍의 핵심이 이벤트 루프인 것이다. (다만 Web worker 최신 기술을 통해 자바스크립트도 멀티 스레드 구현이 가능해졌다)
자바스크립트 엔진 구동 환경
자바스크립트를 실행하는 소프트웨어로는 우리가 잘 알고 있는 웹브라우저와 런타임인 Node.js 가 있다. 이벤트 루프 동작 원리를 배우기 앞서, 싱글 스레드인 자바스크립트 엔진이 어느 곳을 거쳐 비동기 작업을 수행하는지 우선 이 둘의 내부 구성도를 눈에 익혀보자.
본 포스팅에서 예시를 드는 웹브라우저는 Chrome 브라우저이다. 다른 브라우저는 자바스크립트 엔진이나 내부 구성이 약간식 다를수 있다는 점은 유의하자
브라우저의 내부 구성도
브라우저는 웹 사이트를 화면에 보여주기 위해 여러가지 역할을 하는 부품들로 이루어져 있다. 그중 우리가 알아볼 것은 자바스크립트 비동기 코드의 동작 과정이니, 이에 관련된 구성 요소로는 Web APIs, Event Table, Callback Queue, Event Loop 등이 있다.
- Call Stack : 자바스크립트 엔진이 코드 실행을 위해 사용하는 메모리 구조
- Heap : 동적으로 생성된 자바스크립트 객체가 저장되는 공간
- Web APIs: 브라우저에서 제공하는 API 모음으로, 비동기적으로 실행되는 작업들을 전담하여 처리한다. (AJAX 호출, 타이머 함수, DOM 조작 등)
- Callback Queue : 비동기적 작업이 완료되면 실행되는 함수들이 대기하는 공간
- Event Loop : 비동기 함수들을 적절한 시점에 실행시키는 관리자
- Event Table: 특정 이벤트(timeout, click, mouse 등)가 발생했을 때 어떤 callback 함수가 호출되야 하는지를 알고 있는 자료구조 (위 그림에는 없음)
Web APIs의 종류
Web APIs는 타이머, 네트워크 요청, 파일 입출력, 이벤트 처리 등 브라우저에서 제공하는 다양한 API를 포괄하는 총칭이다. Web API는 브라우저(Chrome)에서 멀티 스레드로 구현되어 있다. 그래서 브라우저는 비동기 작업에 대해 메인 스레드를 차단하지 않고 다른 스레드를 사용하여 동시에 처리할수 있는 것이다.
예를 들어, setTimeout 비동기 작업은 Web APIs의 한 종류인 Timer API 에서 타이머 스레드를 사용하여 타이머를 수행한다. 마찬가지로, XMLHttpRequest , fetch와 같은 네트워크 관련 API는 네트워크 스레드를 사용하여 네트워크 요청과 응답을 처리된다.
Web APIs의 대표적인 종류로는 다음과 같다.
- DOM : HTML 문서의 구조와 내용을 표현하고 조작할 수 있는 객체
- XMLHttpRequest: 서버와 비동기적으로 데이터를 교환할 수 있는 객체. AJAX기술의 핵심.
- Timer API: 일정한 시간 간격으로 함수를 실행하거나 지연시키는 메소드들을 제공
- Console API : 개발자 도구에서 콘솔 기능을 제공
- Canvas API:
<canvas>요소를 통해 그래픽을 그리거나 애니메이션을 만들 수 있는 메소드들을 제공 - Geolocation API: 웹 브라우저에서 사용자의 현재 위치 정보를 얻을 수 있는 메소드들을 제공
이때 오해하지 말하야할 것이 모든 Web API들이 비동기로 동작되는 것이 아니다. Web API에는 동기적으로 처리되는 것과 비동기적으로 처리되는 것이 모두 있다. 예를 들어 DOM API나 Console API는 동기적으로 처리되고, XMLHttpRequest나 Timer API는 비동기적으로 처리된다.
Callback Queue의 종류
Web APIs가 여러 API들을 묶어 말하듯이, Callback Queue도 여러가지 종류의 Queue를 묶어 총칭하는 개념이다. Callback Queue에는 (macro)task queue와 microtask queue 두 가지 종류가 있다.
- Task Queue :
setTimeout,setInterval,fetch,addEventListener와 같이 비동기로 처리되는 함수들의 콜백 함수가 들어가는 큐 (macrotask queue 는 보통 task queue 라고 부른다) - Microtask Queue :
promise.then,process.nextTick,MutationObserver와 같이 우선적으로 비동기로 처리되는 함수들의 콜백 함수가 들어가는 큐 (처리 우선순위가 높음)
Callback Queue의 종류에 따라 이벤트 루프가 콜 스택으로 옮기는 순서가 달라진다. 일반적으로 microtask queue가 가장 우선순위가 높아 먼저 microtask queue를 처리하여 먼저 비우고 그라음 task queue의 콜백을 처리한다.
이벤트 루프를 배우기 전에Promise.then결과가setTimeout보다 우선 된다는 것을 미리 배웠다면, 왜 프로미스가 먼저 처리되는지에 대한 이유가 이벤트 루프의 동작 원리와 관련이 있다는걸 알 수 있을 것이다.
또한 같은 queue 안에 적재되는 콜백이라도 어떠한 비동기 작업이냐에 따라 우선순위가 다른 태스크들이 있을 수 있다. 예를들어 Microtask Queue에 적재되는 Promise 와 Mutation Observer 콜백 중 Mutation Observer이 먼저 처리되는 식이다.
AnimationFrame Queue
브라우저의 큐는 콜백 큐 뿐만 아니라 브라우저 애니메이션 작업에 대한 처리를 담당하는 AnimationFrame Queue도 있다. 자바스크립트 애니메이션 동작을 제어하는 requestAnimationFrame 메소드를 통해 콜백을 등록하면, 이 큐에 적재되어 브라우저가 repaint 직전에 AnimationFrame Queue에 있는 작업들을 전부 처리한다. 따라서 자바스크립트 스타일 관련 코드들을 AnimationFrame Queue에 비동기로 처리하도록 구성하면 브라우저가 애니메이션의 타이밍을 관리하고, 적절한 프레임 속도를 유지하고, 다른 탭이나 창에 있을 때 애니메이션을 중지함으로써 브라우저의 애니메이션 동작의 성능과 품질을 향상시킬 수 있다.
Node.js의 내부 구성도
NodeJS 환경에서도 브라우저와 거의 비슷한 구조를 볼 수 있는데, 차이점이 있다면 내장된 libuv 라이브러리를 사용하여 비동기 IO를 지원한다는 점이다. 또한 브라우저에서는 Web API를 사용하여 DOM 조작, AJAX 호출, 타이머 및 애니메이션 등과 같은 다양한 작업을 처리하지만, Node.js에서는 Web API가 아닌 Node.js API를 사용하여 파일 시스템 액세스, 네트워크 액세스, 암호화, 압축 및 해제 등과 같은 다양한 작업을 처리한다. 예를 들어, Node.js에서 HTTP 요청을 수행하려면 http 모듈을 사용한다. 단, Node.js에서도 일부 Web API를 사용할 수 있는데, setTimeout, setInterval 등이 그렇다.
이처럼 Node.js에서 자바스크립트 엔진은 비동기 작업을 위해서 Node.js의 API를 호출하며, 이때 넘겨진 콜백은 libuv 의 이벤트 루프를 통해 스케쥴되고 실행된다. Node.js의 내부 구성으로는 다음과 같다.
- V8 (JavaScript 엔진) : Node.js에서 사용하는 JavaScript 엔진으로 코드를 컴파일하고 실행한다
- Bindings (Node API) : Node.js 시스템과 V8 엔진 간의 상호작용을 가능하게 하는 C++ 라이브러리
- Libuv 라이브러리 : Node.js에서 비동기 I/O 작업을 처리하기 위한 C 라이브러리
- Event Queue : 비동기 I/O 작업 결과를 저장하고 처리하기 위한 자료구조 (웹브라우저의 Task Queue와 비슷하다)
- Event Loop : Event Queue에 저장된 I/O 작업 결과를 처리하고, 다음 작업을 수행하도록 하는 관리자
- Worker Threads : CPU 집약적인 작업을 처리하기 위해 Node.js 10 버전부터 추가된 멀티 스레드. worker threads는 메인 스레드와 독립적인 V8 엔진 인스턴스를 가진다.
자바스크립트 이벤트 루프 동작 과정
앞의 내용을 복습하자면, 싱글 스레드인 자바스크립트에서도 작업의 동시 처리을 지원할 수 있는 비결에는 이벤트 루프가 자바스크립트 엔진과 브라우저의 웹 API를 연결하여 비동기적인 일 처리를 가능케 하기 때문이다. 다만 모든 자바스크립트 코드를 비동기로 처리할 수 있는 것은 아니다. 자바스크립트에는 비동기로 동작하는 비동기 전용 함수가 있는데 대표적으로 setTimeout 이나 fetch, addEventListener 가 있다.
브라우저의 Web APIs는 위 그림과 같이 각각 전용 작업을 처리하는 API 스레드들로 구성된 집합을 말한다. 따라서 setTimeout 이 호출되면 Timer API 라는 별도의 스레드에서 타이머 동작이 별도로 실행되는 것이며, fetch 가 호출되면 Ajax API 스레드에서 네트워크 통신이 이루어 지는 것이다.
이벤트 루프(Event Loop)는 이 비동기 함수 작업을 Web API에 옮기는 역할을 하고 작업이 완료되면 콜백을 큐(Queue)에 적재했다가 다시 자바스크립트 엔진에 적재해 수행시키는 일종의 '작업을 옮기는 역할' 만을 한다. 작업을 처리하는 주체는 자바스크립트 엔진과 웹 API 이다. 그래서 이벤트 루프는 Call Stack에 현재 실행 중인 작업이 있는지 그리고 Task Queue에 대기 중인 작업이 있는지 반복적으로 확인하는 일종의 무한 루프만을 돌고, 대기 작업이 있다면 작업을 옮겨주는 형태로 동작한다고 보면 된다. (그래서 이벤트 '루프' 이다)
// 이벤트 루프의 동작을 나타내는 가상의 코드
while(queue.waitForMessage()){ // 큐에 메시지가 있을 때까지 대기
queue.processNextMessage(); // 가장 오래된 메시지를 큐에서 꺼내서 호출 스택으로 옮김
}
지금부터 알아볼 이벤트 루프 동작 과정은 자바스크립트 코드가 브라우저 내부에서 작업 이동이 어떠한 형식으로 어떠한 원리로 진행이 되는지 알아보는 것이다. 마침 자바스크립트 이벤트 루프 과정을 Lydia Hellie 분이 아주 고퀄리티로 gif 애니메이션으로 표현한 이미지가 있어서 이를 참고하여 소개해본다.
setTimeOut 내부 동작 과정
우선 가장 흔한 타이머 비동기 함수의 이벤트 루프 과정을 알아보도록 하자.
function bar() {
setTimeout(() => {
console.log("Second")
}, 500);
}
function foo() {
console.log("First");
}
function baz() {
console.log("Third");
}
bar();
foo();
baz();
위의 자바스크립트 코드 실행 과정은 다음의 순서로 진행된다.
bar()함수가 호출되고 그안의setTimeout()함수가 호출되어 스택에 쌓인다.setTimeout()함수의 매개변수에 할당된 콜백 함수를 Timer Web API에 전달한다. 그리고 Timer Web API 에서는 백그라운드로 500 밀리초를 셈한다.- 다음
foo()함수가 호출되고 콘솔창(output)에 "First" 가 출력된다. - 이때 500 밀리초 대기 시간이 만료되면서, 이벤트 루프는Timer Web API에서 가지고 있던 콜백 함수를 Task Queue 로 옮긴다.
- 그다음
baz()함수가 호출되고 콘솔창에 "Third" 가 출력된다. - 스택에 있는 모든 메인 자바스크립트 코드가 실행 완료 되어 Call Stack이 비워지게 된다.
- 이벤트 루프는 Call Stack 이 비어있는 경우를 탐지하여, Task Queue 에 있는 콜백 함수를 Call Stack 으로 옮긴다.
- Call Stack 에서 콜백 함수 코드를 실행하게 되고 콜솔창에는 "Second" 가 출력된다.
이 동작 원리의 핵심은 특정한 작업에 대해 비동기로 멀티 작업을 할 수 있다는 것이다. 비동기 동작 예시가 고작 타이머를 셈하는 setTimeout 이라 잘 와닿지 않을 수 있다. 하지만 파일 입출력이나 키보드 타이핑 하는 이벤트 동작일 경우라면 어떻까? 비동기가 없다면 파일을 다운 받거나 키보드를 타이핑 하는 동안에는 동안 웹사이트는 멈추게 되어 아무것도 못하게 될 것이다. 바로 이벤트 루프는 이러한 작업들을 별도로 브라우저의 멀티 스레드에게 인가하여 비동기로 처리해주는 핸들러 역할을 하는 것이다.
웹브라우저와 Node.js의 Web API 차이
웹브라우저의 Web APIs 와 Node.js 의 Node.js APIs 들은 구성은 비슷하지만 동작 측면에서 약간 차이가 있다. 웹브라우저의 Web APIs는 비동기 작업이 끝나면 스스로 callback queue에 적재하지만, Node.js API들은 이벤트 루프가 직접 옮겨준다. 예를들어 Timer Web API에서 타이머가 모두 지나가면, 자바스크립트 환경이 웹브라우저냐 Node.js 냐에 따라 차이가 갈린다.
- Node.js : Timer API가 타이머 완료 이벤트를 발생시키고, 이벤트 루프가 이를 감지하여 Task Queue에 콜백 함수를 추가한다.
- 웹브라우저 : Timer API가 스스로 Task Queue에 콜백 함수를 추가한다.
Promise 내부 동작 과정
Task Queue 와 Microtask Queue
Callback Queue는 Web API가 수행한 비동기 함수를 넘겨받아 Event Loop가 해당 함수를 Call Stack에 넘겨줄 때까지 비동기 함수들을 쌓아놓는 곳이다. 위에서 Callback Queue의 종류에는 (Macro)Task Queue, MicroTask Queue 2가지가 있다고 하였는데, 그중 자바스크립트 Promise 객체의 콜백이 쌓이는 곳이 바로 MicroTask Queue이다. 그리고 MicroTask Queue는 그 어떤 곳보다 가장 먼저 우선으로 콜백이 처리되게 된다. (심지어 브라우저 화면 렌더링하기 전보다 말이다)
MicroTask Queue 처리 과정
실제 예시 코드에서 setTimeout 가 Promise 객체의 콜백이 동시에 주어졌을때 어떻게 처리되는지 확인해보자. 코드 내용을 살펴보면 먼저 setTimeout 을 통해서 0초동안 대기 하였다가 "Timeout!" 을 출력하는 콜백 함수를 실행한다. 그다음 Promise 객체에 의해 "Promise!" 라는 텍스트를 출력하는 then 핸들러의 콜백 함수를 실행한다. 이 코드를 실행해보면 아래와 같이 동작 애니메이션이 발생한다.
console.log('Start!');
setTimeout(() => {
console.log('Timeout!');
}, 0);
Promise.resolve('Promise!').then(res => console.log(res));
console.log('End!');
1. Call Stack에 console.log('Start!') 코드 부분이 쌓인 뒤 실행 되어 콘솔창에 "Start!" 가 출력
2. setTimeout 코드가 콜 스택에 적재되고 실행되면, 그 안의 콜백 함수가 이벤트 루프에 의해 Web API로 옮겨지고 타이머가 작동하게 된다. (0초라서 사실상 바로 타이머는 종료된다)
3. 타이머가 종료됨에 따라 setTimeout 의 콜백 함수는 MacroTask Queue에 이벤트 루프에 의해 적재되게 된다.
4. Promise 코드가 콜스택에 적재 되어 실행되고 then 핸들러의 콜백 함수가 이벤트 루프에 의해 MicroTask Queue에 적재되게 된다.
5. console.log('End!') 코드가 실행되고 "End!" 텍스트가 콘솔창에 출력된다.
6. 모든 메인 스레드의 자바스크립트 코드가 실행이되어 더이상 Call Stack엔 실행할 스택이 없어 비워지게 된다.
7. 그러면 이벤트 핸들러가 이를 감지하여, Callback Queue에 남아있는 콜백 함수들을 빼와 Call Stack에 적재하게 된다.
8. 이때 2종류의 Queue 중 MicroTask Queue에 남아있는 콜백이 우선적으로 처리된다. (만일 콟개이 여러개가 있다면 전부 처리된다)
9. MicroTask Queue가 비어지면, 이제 이벤트 루프는 MacroTask Queue에 있는 콜백 함수를 Call Stack에 적재해 실행되게 된다.
따라서 최종 코드의 실행 순서는 아래와 같이 된다.
Async/Await 내부 동작 과정
자바스크립트의 Async/Await 는 비동기 논블로킹 동작을 동기적으로 처리하기 위해 ES7 부터 새롭게 도입된 것으로 복잡한 콜백이나 then 핸들러의 지옥(hell) 코드를 극복하는 핵심이다. Async/Await 의 기본적인 사용 문법에 대해서는 독자분들도 익히 알고 있을 것이다. 하지만 많은 사람들이 Async/Await 을 단순히 비동기를 동기적으로 처리해준다는 효과만 알고있어서인지, 비동기 코드와 동기 코드가 같이 쓰여져 있을 경우 이들의 확실한 처리 과정을 정확하게 알지 못한다.
예를들어 다음과 같이 async 함수를 동일 코드 레벨에서 실행한다고 해보자.
const one = () => Promise.resolve('One!');
async function myFunc(){
console.log('In function!');
const res = await One();
console.log(res);
}
console.log('Before Function!');
myFunc();
console.log('After Function!');
1. 콘솔에 'Before Function!' 이 출력된다.
2. async 함수인 myFunc() 이 호출된다.
3. async 함수 안에 있는 콘솔 함수가 실행되어 콘솔에 'In Function!' 이 출력된다.
가끔 async 함수를 메인 코드가 모두 실행되어야 나중에 실행되는 비동기 함수로 알고 있는 사람들이 많은데, async 함수는 블럭 내부에서 await 키워드를 사용하기 위한 함수 키워드 일 뿐 그냥 프로미스 객체를 반환하는 조금 특벽한 일반적인 함수이다. 따라서 async 함수라도 일반 함수처럼 호출 스택이 쌓이고, async 함수 내의 비동기가 아닌 동기 코드는 일반 코드와 같이 실행된다.
4. Promise 객체를 반환하는 one() 비동기 함수를 호출한다.
5. 이때 one() 비동기 함수 왼쪽에 await 키워드로 인해, myFunc 함수의 내부 실행은 잠시 중단되고 Call stack 에서 빠져나와 나머지 부분은 Microtask Queue 에 적재된다. 이는 자바스크립트 엔진이 await 키워드를 인식하면 async 함수의 실행은 지연되는 것으로 처리하기 때문이다.
6. 마지막으로 콘솔에 'After Function!' 이 출력된다.
7. 모든 메인 스레드의 자바스크립트 코드가 실행이되어 더이상 Call Stack엔 실행할 스택이 없어 비워지게 된다.
8. 그러면 이벤트 핸들러가 이를 감지하여, Microtask Queue에 남아있는 async 함수를 빼와 Call Stack에 적재하게 된다.
8. Promise 객체의 결과물인 'One!' 문자열을 변수 res 에 받고 이를 콘솔에 출력한다.
Async/Await 오해와 진짜 동작
그럼 이번에는 myFunc() 함수를 호출하는 메인 스택에서 await 키워드를 붙여주면 어떻게 될까? 참고로 최신 자바스크립트에서는 최상위 await 을 지원하기 때문에 별도로 즉시 실행 IIFE를 묶지 않아도 동작이 가능하다.
const one = () => Promise.resolve('One!');
async function myFunc(){
console.log('In function!');
const res = await one();
console.log(res);
}
console.log('Before Function!');
await myFunc();
console.log('After Function!');
출력 결과를 보면, 위의 Async/Await 처리 과정 1 과 다른 결과가 나오는걸 볼 수 있다. 일단 기본적인 Async/Await 문법 개념을 알고 있다면 이 부분은 그렇게 어렵지 않을 것이다. 하지만 우리는 지금 이벤트 루프 동작에 대해 배우고 있다. 그럼 저 최상위 await 코드 동작을 이벤트 루프로 내부적으로 따져보자.
아마 많은 새내기들이 여기서 막힐 것이다. 우리는 await 키워드를 사용하는 Promise 반환 비동기 one() 함수의 결과 콜백이 Microtask Queue에 적재된다는 것을 알고 있다. 그런데 Microtask Queue의 콜백들은 우선 메인 Call Stack이 비워져 있어야 이벤트 루프가 옮겨 처리한다. 하지만 아직 메인 Call Stack에는 console.log(‘After Function!’) 이 남아 있다. 그런데 출력 결과를 보면 console.log(‘After Function!’) 이 실행되기 전에 await myFunc() 먼저 처리되고 콘솔을 출력함을 볼 수 있다.
이게 어떻게 된 일 일까? 우리가 지금까지 배운 이벤트 루프 동작이 잘못된 것일까?
사실 Async/Await의 진짜 동작은 다음과 같다. 예를 들어, 아래와 같은 코드가 있다고 하자.
let x = await bar(); // bar() 함수 정의는 생략
console.log(x);
console.log('Done');
위의 코드는 사실 다음과 같은 코드와 같은 의미를 가진다.
bar().then(x => {
console.log(x);
console.log('Done');
});
즉, await 키워드 다음에 나오는 동일 라인의 코드들은 모두 await bar() 의 then 핸들러의 콜백 함수로 들어간다는 뜻이다.
실제로 async/await 키워드는 promise.then() 메소드를 사용하지 않고도 비동기 코드를 작성할 수 있게 해주는 문법적인 편의 기능(syntactic sugar)일 뿐이다. 즉, async/await 키워드는 promise.then() 메소드를 대체하는 것이 아니라, promise.then() 메소드를 간결하고 명확하게 작성할 수 있게 해주는 것이다.
따라서 최종 정리하자면 myFunc() 코드는 프로미스 핸들러로 풀면 아래와 같이 구성되게 되고, 각 then 핸들러들이 MicroTask Queue에 차곡 차곡 쌓임으로써 순서대로 출력되는 것이다.
const one = () => Promise.resolve('One!');
async function myFunc(){
console.log('In function!');
const res = await one();
console.log(res);
}
console.log('Before Function!');
await myFunc();
console.log('After Function!');
/* ---------------- ↓↓↓ 변환 ↓↓↓ ---------------- */
const one = () => Promise.resolve('One!');
function myFunc(){
console.log('In function!');
return one().then(res => {
console.log(res);
});
}
console.log('Before Function!');
myFunc().then(() => {
console.log('After Function!');
});
즉, await myFunc() 다음에 나오는 코드들이 myFunc()의 then 핸들러의 콜백으로서 Microtask Queue에 적재되고 이벤트 루프에 다시 Call stack에 옮겨져서 실행되는 것이다.
MicroTask Queue의 무한 루프
Task Queue와 MicroTask Queue의 차이점은 단순한 우선순위 차이 정도 밖에 없지만, 좀더 확장해서 브라우저 동작 관점에서 보면 이는 굉장히 중요한 요소이다. MicroTask Queue의 콜백은 가장 높은 우선 순위로 처리되지만 이는 브라우저가 화면을 렌더링하는 과정보다 더 먼저 처리되게 되는데, 만일 MicroTask Queue에 잘못된 무한 루프 동작의 버그의 코드가 들어가게 되면 웹페이지가 먹통이 되어버리는 현상이 발생할 수 있기 때문이다.
아래 코드를 봐보자. 버튼이 하나 있고 여기에 이벤트 콜백이 등록 되어 있다. 이 이벤트 콜백은 Task Queue에 적재되어 실행되게 된다.
<button id="btn">Click me</button>
<script>
// 버튼을 클릭하면 콘솔에 Clicked를 출력하는 이벤트 핸들러를 등록합니다.
document.getElementById("btn").addEventListener("click", () => {
document.body.insertAdjacentHTML('beforeend', "<p>Clicked</p>");
});
</script>
See the Pen MicroTask Queue의 무한 루프 1 by barzz12 (@inpaSkyrim) on CodePen.
이 상태에서 한번 setTimeout 무한 루프를 구현해보자. 아래 '무한루프 시작' 버튼을 누르면 타이머가 계속 돌여 "Loop" 콘솔을 출력할 것이다. 그 상태에서 버튼을 Click me 해보자. 아무 이상 없이 화면에 'Clicked'가 표시될 것이다.
setTimeout 의 루프 콜백이 Task Queue에 실시간으로 적재되면서 처리되는 도중, 버튼 클릭 이벤트를 발생시키면 이벤트 콜백 역시 Task Queue에 적재되어 실행이 되기 때문에, 비록 무한 루프 코드라도 브라우저 동작 자체는 문제없는 것이다.
<button id="btn">Click me</button>
<button id="btn2">무한루프 시작</button>
<div id="loopArea" style="border: 1px solid black; max-height: 150px; overflow-y: auto;"></div>
<script>
// 버튼을 클릭하면 콘솔에 Clicked를 출력하는 이벤트 핸들러를 등록합니다.
document.getElementById("btn").addEventListener("click", () => {
document.body.insertAdjacentHTML('beforeend', "<p>Clicked</p>");
});
document.getElementById("btn2").addEventListener("click", () => {
// setTimeout 함수는 Task Queue에 콜백을 추가
function loop() {
setTimeout(() => {
document.getElementById("loopArea").append("Loop\n");
loop(); // loop 함수가 setTimeout 객체의 콜백으로 자신을 계속해서 추가
}, 0);
}
// loop 함수를 호출
loop();
});
</script>
See the Pen MicroTask Queue의 무한 루프 2 by barzz12 (@inpaSkyrim) on CodePen.
이번에는 Promise 객체를 이용해 재귀 함수 호출로 무한 루프를 구현해 보자. 로프 동작 코드 자체는 위의 setTimeout과 별다른 차이가 없다. 다만 이 루프 콜백이 Task Queue 가 아닌 MicroTask Queue에 들어간다는 차이점이 있는데, MicroTask Queue 가 무한 루프에 빠지게 되면 어떻게 되는지 보자. (예비 브라우저를 하나 준비해서 테스트하기를 권장한다)
<button id="btn">Click me</button>
<button id="btn2">무한루프 시작</button>
<div id="loopArea" style="border: 1px solid black; max-height: 150px; overflow-y: auto;"></div>
<script>
// 버튼을 클릭하면 콘솔에 Clicked를 출력하는 이벤트 핸들러를 등록합니다.
document.getElementById("btn").addEventListener("click", () => {
document.body.insertAdjacentHTML('beforeend', "<p>Clicked</p>");
});
document.getElementById("btn2").addEventListener("click", () => {
// Promise 객체는 MicroTask Queue에 콜백을 추가
function loop() {
Promise.resolve().then(() => {
document.getElementById("loopArea").append("Loop\n");
loop(); // loop 함수가 Promise 객체의 콜백으로 자신을 계속해서 추가
});
}
// loop 함수를 호출
loop();
});
</script>
See the Pen MicroTask Queue의 무한 루프 3 by barzz12 (@inpaSkyrim) on CodePen.
웹사이트가 먹통이 되어 버릴 것이다. 똑같은 무한 루프 동작인데 하나는 웹사이트 동작에 문제가 없었고, 하나는 웹사이트 자체가 블락이 되어버려 어떠한 동작을 할 수 없게 된 것이다. 왜냐하면 MicroTask Queue가 비어있지 않으므로 다른 매크로태스크나 이벤트 콜백이 실행될 수 없기 때문이다. 무한 루프 코드 때문에 끊임없이 MicroTask Queue에 콜백이 적재되게 되고 이벤트 루프가 이들을 브라우저 화면 렌더링을 하기전에 우선으로 끊임 없이 처리 또 처리하고 있기 때문에, 결국 사용자 이벤트에 반응하지 못하고, 페이지가 멈추거나 응답하지 않게 되는 것이다.
이 예시 코드의 사례는 조금 극단적인 억지 코드라 잘 와닿지 않을 수 있다. 하지만 이 파트의 핵심은 만일 MicroTask Queue를 사용하는 Promise 코드나 Mutation Observer 의 콜백에서 무한 루프 까지는 아니더라도 지나치게 MicroTask Queue에 콜백을 지나치게 적재하는 작업이 있다면 이를 조심히 다루어야 한다는 점이다.
Animation Frames
Animation Frames는 브라우저가 화면을 다시 그릴 때 실행되는 함수들을 담아두는 곳이다. 예를 들어, 웹 페이지에서 움직이는 동그라미를 그리고 싶다면, requestAnimationFrame이라는 함수를 사용해서 동그라미의 위치를 바꿔주는 콜백 함수를 매개변수로 넣어줘 Animation Frames에 적재되어 실행된다. 그러면 브라우저가 화면을 갱신할 때마다 동그라미의 위치가 바뀌면서 움직이는 것처럼 보이게 된다.
// setTimeout() 함수 매개변수에 콜백 함수를 넣어주듯이 requestAnimationFrame도 마찬가지이다
requestAnimationFrame(function() {
// 동그라미의 요소를 찾아서 변수에 저장
let circle = document.getElementById("circle");
// 동그라미의 스타일을 변경하여 위치를 반영
circle.style.left = x + "px";
circle.style.top = y + "px";
})
Animation Frames의 우선순위는 브라우저에 따라 다를 수 있다. Task Queue와 Microtask Queue가 모두 처리된 후에 실행될 수도 있고, Task Queue와 Microtask Queue 사이에 실행될 수도 있다. 이는 이벤트 루프의 명세가 업데이트 렌더링 단계에서 언제 콜백을 호출할지 정확하게 정의하지 않기 때문이다.
console.log("script start");
// task queue에 적재
setTimeout(function () {
console.log("setTimeout");
}, 0);
// microtask queue에 적재
Promise.resolve()
.then(function () {
console.log("promise1");
})
.then(function () {
// 8. microtask 실행
console.log("promise2");
});
// AnimationFrame에 적재
requestAnimationFrame(function () {
console.log("animation");
});
console.log("script end");
- "script start"를 출력
- setTimeout 함수가 Task Queue에 “setTimeout” 태스크를 추가
- Promise.resolve 함수가 MicroTask Queue에 “promise1” 태스크를 추가
- requestAnimationFrame 함수가 Animation Frames Queue에 “animation” 태스크를 추가
- "script end"를 출력
- "promise1"을 출력
- Promise.then 함수가 마이크로태스크 큐에 “promise2” 태스크를 추가
- "promise2"를 출력
- "animation"을 출력
- "setTimeout"을 출력
자바스크립트 이벤트 루프 시각화
직접 코드를 쓰고 조작하여 애니메이션으로 볼 수 있는 시각화 사이트가 있어 소개해본다. 이것저것 실험해보며 브라우저 내부에서 비동기 동작이 어떠한 순서로 일어나는지 알아보자.
# 참고자료
[10분 테코톡] 병민의 브라우저의 Event Loop
https://dev.to/lydiahallie/javascript-visualized-event-loop-3dif
https://dev.to/lydiahallie/javascript-visualized-promises-async-await-5gke
https://iamsjy17.github.io/javascript/2019/07/20/how-to-works-js.html
https://meetup.nhncloud.com/posts/89
https://sculove.github.io/post/javascriptflow/
이 글이 좋으셨다면 구독 & 좋아요
여러분의 구독과 좋아요는
저자에게 큰 힘이 됩니다.