9. event loop
- reference : https://www.youtube.com/watch?v=8aGhZQkoFbQ
자바스크립트 런타임
자바스크립트의 런타임을 보면 메모리 할당이 일어나는 힙과 콜 스택이있다.
그런데 V8 프로젝트를 클론해서 코드 베이스를 들여다보면 setTimeout
이나 DOM
, HTTP
요청을 관리하는 코드들은 찾아볼 수 없다.
위 그림을 보면 V8 런타임과 DOM, AJAX, setTimout과 같이 브라우저가 제공하는 webAPI
가 있고, 그 유명한 event loop
와 콜백 큐
가 있다.
하지만 이것들이 어떤식으로 연결되어 움직이는지 정확하게 이해하는 사람들이 많이 없을 것이다.
자바스크립트는 싱글 스레드 프로그래밍 언어 = 하나의 콜스택 = 하나의 프로그램은 동시에 하나의 코드만 실행 가능
call stack
1 | function multiply(a, b) { |
call stack
은 자료 구조 중 하나로 코드가 실행되는 순서를 기억하고 있다. 함수를 실행하려면 스택에 해당하는 함수를 넣고 함수에서 리턴이 일어나면 스택의 가장 위쪽에서 함수를 꺼낸다.
- 위 코드를 실행하면 실행되는 코드 자체를 말하는
main
함수를 스택에 먼저 넣게 된다. - 함수들을 정의한다.
- 마지막의
printSquare
함수를 호출하므로 스택에 함수를 추가한다. printSquare
함수 안의square
함수가 호출되고있으니 스택에square
함수를 추가한다.square
함수에서는multiply
함수를 호출하고 그 안에서 return을 시켜주고 있으니 a와 b의 곱을 반환한다.- 리턴할때마다 스택의 맨 위의 함수를 꺼내야하니
multiply
->square
->printSquare
순으로 pop된다. printSquare
에서console.log(squared)
를 실행하게되고, 여기에서 리턴은 보이지 않지만 함수의 마지막줄에 도달했기 때문에 암묵적으로 리턴한다.
1 | function foo() { |
baz
함수가 bar
함수를 호출하고, bar
함수가 foo
함수를 호출하는데 foo함수는 ‘Oops!’라는 에러를 던지게되면 크롬 개발자 도구에서는 스택의 꼬리를 물면서 Oops!
라는 에러를 표시하게 된다. 에러가 발생한 스택의 상태를 보여주는 것이다. Uncaught error는 foo
에서 발새했는데 bar
가 foo
를 호출했고 bar
는 또 baz
에게서 호출되었다. 이런식으로 익명함수인 main함수까지 올라가게 된다.
“스택을 날려먹었다”라는 표현을 사용하기도 하는데, foo함수를 호출하는 foo함수가 있다면 어떻게 될까?
1 | function foo(){ |
1 | RangeError : Maximum call stack size exceeded |
크롬에서 스스로 호출하는 함수를 16,000번이나 반복한것은 아니라고 판단하여 자체적으로 중지시켜버린다.
blocking이라는 것에 대해 정확한 정의는 존재하지 않고 그저 느리게 동작하는 코드라고 할 수 있다.
console.log는 느리지 않지만 while문에서 수십억번 실행된다면 느릴 것이다. 네트워크 요청이나 이미지 프로세싱은 느릴 것이다. 이런 느린 동작이 스택에 남아있는 것을 보통 blocking이라고 말한다.
만약 아래의 코드가 동기적으로 실행된다고 가정해보자.
동기적으로 ajax 요청을 보내는 jquery 함수 getSync가 있다고 가정하면
1 | var foo = $.getSync('//foo.com'); |
foo
의 getSync
함수를 실행하고 우리는 기다리게된다. 성공적으로 실행되었다고 한다면, 다음 줄로 넘어가고 다시 기다린다.
싱글 스레드 프로그래밍 언어에서는 네트워크 요청을 하고는 끝날때까지 마냥 기다린다.
이것은 웹 브라우저에서 코드가 실행되고 있기 때문이다. 브라우저는 모든 요청이 완료될때까지 멈춰있을 것이다. 멈춰있는 동안 행동을 기억하고 있었지만 렌더링뿐만아니라 아무것도 할 수 없었다. 왜냐하면 콜 스택에 어떤 것들이 남아있으면 동기적으로 실행되는 네트워크 요청이 콜 스택을 블로킹하여 브라우저는 다른 일들을 할 수 없다. 렌더링이나 다른 코드들을 실행하지 못하고 멈춰버린다.
어떻게 해결해야할까? 제일 쉽게 접할 수 있는 것은 비동기 콜백이다. 브라우저나 노드는 비동기로 만들어져서 블로킹 함수가 거의 없다. 이것은 결국 콜백을 받고 나중에 실행한다는 말이다.
그렇다면 이 코드는 실제로 어떤식으로 실행될까?
1 | console.log('hi'); |
'hi'
가 가장 먼저 출력되고 setTimeout
을 실행하게 되는데 그 안의 console.log
는 큐에 등록되어 나중에 실행되게 된다. 그 후 'JSconfEU'
를 먼저 출력하고 5초 뒤에 'there'
를 출력한다.
어떻게 가능할까? 이벤트 루프와 동시성으로 설명할 수 있다.
자바스크립트는 한 번에 한 가지 일밖에 못한다고 했지만 이걸 동시에 할 수 있는 이유는 브라우저는 단순 런타임 이상을 의미하기 때문이다.
브라우저에서 제공하는 webAPI는 자바스크립트에서 호출할 수 있는 스레드를 효과적으로 지원한다. 여기에 동시성이 들어온다.
setTimeout
은 브라우저에서 제공하는 API이고 v8 소스코드에는 존재하지 않는다. 자바스크립트가 실행되는 런타임 환경에 존재하는 별도의 API이다. 따라서 브라우저가 타이머를 실행하고 카운트 다운을 시작한다. 이것은 setTimeout
호출 자체는 완료되었다는 의미이고, 스택에서 함수를 지울 수 있다. 그리고 'JSconfEU'
를 출력하고 지워진다.
이제 webAPI에서 실행하고있는 타이머가 남아있는데 webAPI는 어느 순간 갑자기 스택에 함수를 집어넣던가 하는 행위를 할 수 없다. 따라서 task queue
와 call back queue
가 이 행위를 해주어야한다.
모든 webAPI는 작동이 완료되면 콜백을 task queue
에 밀어넣는다. 그 후 이벤트 루프가 역할을 해야하는데, 이벤트 루프는 콜 스택과 테스크 큐를 주시하는 역할을 한다. 스택이 비어있으면 테스크 큐의 첫번째 콜백을 스택에 쌓아 효과적으로 실행할 수 있게 해준다. 스택은 자바스크립트의 영역이라는 것을 기억하고 이제 v8엔진으로 돌아가서 콜백함수의 console.log
를 실행하고 'there'
를 출력한다.
이제 자바스크립트의 비동기 함수가 어떤식으로 동작하는지 알게되었을 것이다. 특히 알 수 없는 문제가 생겼을 때 누군가 “setTimeout 0초를 사용하면 해결될거야” 라고 말하는 상황이 생겼을 때를 이해하게 되었을 것이다. 일반적으로 이것은 스택이 비어있을때까지 기다리게 하기 위해서이다. 어떠한 콜백을 마지막까지 지연시키고 싶기 때문이다.
모든 이런 종류의 webAPI는 동일한 방식으로 동작한다.
1 | console.log('hi'); |
ajax 요청은 url로 호출할 때 콜백을 함께 실행하게 된다. 이것도 동일하게 작동한다. 'hi'
를 출력하고 ajax요청을 하고, ajax요청은 자바스크립트 런타임이 아니라 브라우저의 webAPI에서 실행되고, XHR(XMLHttpRequest) webAPI가 실행되는 동안 다른 코드들은 정상적으로 실행할 수 있다. XHR실행이 완료되었다면 cb은 큐에 쌓이게되고, 이벤트 루프에 의해 실행된다.
다음은 실제로 맞닥뜨릴만한 상황들이지만 async API와 관련해서 생각하지 못했을 부분들에 대해 살펴보자.
1 | setTimeout(function timeout(){ |
만약 1분 딜레이가 설정된 setTimeout
을 4번 호출하고 'hi'
를 콘솔에 찍는다면, timeout
콜백들이 큐에 들어가는 것을 알 수 있다. 콜백이 큐에 쌓인 후 네번째 콜백이 1초후에 실행되어야 함에도 불구하고 여전히 실행되지않고 기다리고있다. 이것을 보면 setTimeout
이 실제로 정해진 시간과는 달리 제대로 작동하지 않을 수도 있고, 다만 딜레이되는 최소의 시간만을 지정할 수 있다는 것을 알 수 있다. 마치 0초로 설정된 코드가 바로 실해되지 않는 것 처럼 말이다.
다음 예시에서는 콜백에 대해서 더 얘기해보려 한다.
누구에게 묻느냐에따라 콜백의 정의는 다르겠지만 둘 중에 하나로 묘사된다.
1 | 1. 콜백은 다른 함수가 부르는 함수이다. |
1 | [1,2,3,4].forEach(function(){ |
forEach
의 경우 콜백이라고 할 수 있지만 자신의 자체적 스택에서 실행시키고 비동기적으로 실행하지는 않는다. 한편 asyncForEach
를 하나 선언해서 배열과 콜백을 받아 각 요소에서 setTimeout
을 0초로 실행하는 것도 가능하다.
실행을 해보면, forEach
의 경우 실행이 다 끝날때까지 스택을 차지한다. 하지만 asyncForEach
에서는 여러개의 콜백을 큐에 쌓게되고 스택이 비워지면 쌓인 콜백들이 실행된다.
만약 forEach
로 각 배열 요소에 대해 오래 걸리는 처리를 해야한다면 렌더가 막히게 된다. 렌더가 막히면 화면의 텍스트를 선택하거나 반응을 보거나 하는 것들이 불가능하다.
브라우저는 우리가 자바스크립트로 하는 무언가로 인해 제약을 받는다. 브라우저는 기본적으로 화면을 매 16.6밀리세컨드, 즉 1초에 60프레임을 repaint하는 것이 이상적이다. 하지만 브라우저는 우리가 자바스크립트로 하는 것들로 인해 제약을 받는다. 그래서 스택에 코드가있으면 렌더링을 못한다. 렌더도 하나의 콜백처럼 행동하기 때문이다. 그래서 스택이 비워질 때 까지 기다려야 한다. 다른 점은 렌더는 다른 콜백들에 비해 더 높은 우선순위를 갖는다. 매 16ms 마다 큐에 렌더가 들어가고, 스택이 깨끗해진 후에야 렌더링을 한다.
사람들이 “event loop를 막지마”라고 할때 바로 이런 현상을 뜻하는 것이다."스택에 필요없는 느린 코드를 쌓아서 브라우저가 할 일을 못하게 만들지 말아라."
, "유동적인 UI를 만들어라."
이미지 처리나 애니메이션이 너무 잦아졌을 때 큐 관리에 주의를 기울이지 않으면 이런 일들이 일어난다. 예를 들어 DOM에서 매우 자주 발생하는 스크롤 이벤트가 매 16ms 마다 발생한다고 가정하고 스크롤 이벤트가 발생할때 애니메이션을 넣거나 무언가를 한다고 했을 때, 스크롤 할 때마다 큐에 엄청나게 많은 콜백을 쌓는다. 그리고 매번 이걸 처리하면서 각각의 느린 프로세싱이 일어날 때 마다, 스택을 채우지는 않지만 큐를 이벤트들로 범람시킨다.
이는 매 몇초마다 혹은 유저가 스크롤을 멈출때까지 작업량을 줄인다던지 하는 방향으로 해결책을 생각해볼 수 있다.