Event Loop(이벤트루프)와 Javascript Concurrency(동시성)

Browser Architecture


Browser Architecture
출처: Browser Architecture

Browser안에는 위와 같이 자바스크립트엔진 뿐만 아니라 다른 것들이 같이 포함되어 있다.

  1. 자바스크립트엔진(크롬: V8)
    • Heap과 Stack으로 구성되어있고, 자바스크립트가 브라우저에서 실행되는 곳이 자바스크립트엔진, 정확히는 Stack이다.
  2. Web APIs
    • 비동기 방식으로 호출되는 것들(Timer 함수/DOM Event/Ajax)이 정의되는 영역이다.
  3. Callback Queue
    • Web APIs으로부터 옮겨와 자바스크립트엔진의 Stack으로 이동하기 위해 기다리는 곳이다.
  4. Event Loop
    • Callback Queue에서 기다리고 있는 비동기 방식으로 호출되는 것들(Timer 함수/DOM Event/Ajax)을 Stack이 완전히 비어있을 때 이동시키는 역할을 한다.


자바스크립트 기본 특징


자바스크립트는 싱글쓰레드(single-threaded), 즉 1개의 쓰레드를 가지고있다. 1개의 쓰레드를 가지고 있다는 말은 한번에 하나일을 처리한다는 것이다. 자바스크립트엔진의 Stack에서 한번에 하나의 일(함수 호출)을 처리한다는 뜻이다. 그런데, 실제로 자바스크립트 기반의 애플리케이션은 여러가지일이 동시에 일어난다.

왜 그럴까? 이걸 전문적으로 말하면 자바스크립트는 동시성(Concurrency)을 지원한다는 것인데, 어떻게 그럴 수 있을까? 여기서 이벤트루프(Event Loop)에 대한 개념이 나온다.

먼저 동기적(한번에 하나의 일을 처리)으로 작동하는 기본적인 자바스크립트 코드가 돌아가는 원리를 알아보자.


(기본) 자바스크립트 동작 원리


다시 한번 Architecture를 보자.

Browser Architecture
출처: Browser Architecture

비동기로 호출되는 것들(Timer 함수/DOM Event/Ajax)이 없는 자바스크립트 코드의 경우, 자바스크립트엔진의 Stack에서만 하나씩 호출되어 코드가 돌아간다. 즉, Web AIPs, Callback Queue, 그리고 Event Loop 등을 사용하지 않는다.

아래 동기적 코드를 보자.

1
2
3
4
5
6
7
8
9
function multiply(a, b) {
return a*b;
}
function square(a) {
const sq = multiply(a, a);

console.log(sq);
}
square(3);

이 코드는 어떤 순서로 호출되어 값이 나오게 될까?

호출되는 순서는 다음과 같다.

  1. square(3) 때문에, square 함수가 먼저 호출된다.
  2. sq에 multiply 함수를 할당하기 위해, multiply 함수가 호출된다.
  3. multiply의 return 값이 나온다.
  4. console.log(sq)의 결과값이 나온다.

이 순서를 Stack에서 확인해보자.

동기적 자바스크립트
출처: 동기적 자바스크립트

위 그림과 같이, Stack에서 push로 하나씩 함수가 쌓이고, pop으로 마지막에 쌓인 것부터 하나씩 호출되어 실행되고, 제거된다. 이런식으로, 한번에 하나씩 순서대로 일이 진행되어, 결과값이 나오게 된다. 이게 동기적 방식이다.


Error: Maximum call stack size exceeded

Stack안에 쌓이는 각 함수를 Stack Frame이라고 한다. 보통, 이런 각 Stack Frame은 ms(1초=1000ms)안에 동작이 끝난다. 그런데, Stack에는 Stack Frame의 수가 정해져 있고, 이 수를 넘게되면 에러가 발생하게 된다.

아래 코드를 보자.

1
2
3
4
function foo() {
return foo();
}
foo();

위 코드를 작성하고 실행하게 되면, 다음과 같은 에러가 발생한다.

1
RangeError: Maximum call stack size exceeded

이 에러의 원인은 Stack안에 정해진 Stack Frame의 수를 초과했다는 뜻이다. 즉, foo 함수가 호출될 때, return 값으로 foo 함수가 호출되기 때문에, 계속해서 호출되게 되고, 그 말은 Stack에 계속해서 foo 함수가 쌓인다는 뜻이다. 그래서 정해진 Stack Frame의 수를 초과하게 되어, 위와 같은 에러가 발생하게 된다. 이런 자바스크립트의 Stack에 대한 개념을 모르게되면, 이 에러가 발생했을 때 해결하기가 어려울 수 있다.

그럼, 자바스크립트에 동시성(Concurrency)을 어떻게 지원하는지 알아보자.


비동기적 자바스크립트 동작 원리


다시 한번 Architecture를 보자.

Browser Architecture
출처: Browser Architecture

비동기로 호출되는 것들(Timer 함수/DOM Event/Ajax)이 있는 자바스크립트 코드의 경우, 자바스크립트엔진의 Stack에서 Web AIPs로 이동하게 되고, 다시 Callback Queue로 이동하게 된다. 그리고 Stack에 있는 모든 함수가 제거되고 난뒤, Event Loop로 인해 Stack으로 이동되어, 함수가 호출되고 제거된다.

아래 비동기적 코드를 보자.

1
2
3
4
5
console.log("Print this 1st");
setTimeout(() => {
console.log("Print this 3rd");
}, 500);
console.log("Print this 2nd");

이 코드는 어떤 순서로 호출되어 값이 나오게 될까?

1
2
3
"Print this 1st"
"Print this 2nd"
"Print this 3rd"

비동기 함수인 setTimeout 함수가 0.5초뒤에 실행되니, 그 전에 마지막 console.log가 먼저 출력되어, 순서가 저렇게 된것일까? 이 과정을 정확하게 위해서는 이벤트루프(Event Loop) 개념을 이해해야 한다.

이 순서를 Architecture에서 확인해보자.
위 코드가 실제로 실행되는 과정은 아래와 같다.


비동기적 자바스크립트
출처: 비동기적 자바스크립트

  1. 먼저, 첫번째 console.log가 호출되고, 제거된다.
  2. 그리고, 비동기 함수인 setTimeout 함수가 호출된뒤, Web APIs로 이동한다.
  3. 이와 동시에, Stack에서 다음 console.log가 쌓인다.
  4. 이동한 setTimeout 함수는 Web APIs에서 0.5초(500ms) 동안 있다가, Callback Queue로 이동한다.
  5. Event Loop는 Stack에 있는 모든 함수가 제거된 것을 확인한 후, Callback Queue에 있는 setTimeout 함수를 Stack으로 보내고 실행한다.
  6. 그리고, 그 setTimeout 함수는 Stack에서 제거된다.

자바스크립트에서의 비동기적 방식은 위와 같이 진행되기 때문에, 비동기 함수보다 다른 일반 함수들이 먼저 실행되고, 그 다음에 비동기 함수가 진행된다. 결국, 자바스크립트엔진 밖에 있는 Web APIs, Callback Queue, 그리고 Event Loop가 비동기 함수를 따로 관리함으로써 자바스크립트에 동시성이 가능하게 된다. 따라서, 자바스크립트 기반의 애플리케이션은 여러가지 일들이 동시에 발생할 수 있는 것이다.


만약, setTimeout 함수의 두번째 인수가 ‘0’초면 어떻게 될까?

아래 코드를 보자.

1
2
3
4
5
console.log("Print this 1st");
setTimeout(() => {
console.log("Print this 3rd");
}, 0);
console.log("Print this 2nd");

이 코드는 어떤 순서로 호출되어 값이 나오게 될까?

1
2
3
"Print this 1st"
"Print this 2nd"
"Print this 3rd"

이 또한 setTimeout 함수의 두번째 인수가 0.5초일때와 동일하게 나온다. 즉, timer 함수의 시간과 상관없이, 이벤트루프는 Stack에 있는 다른 모든 함수가 제거된 뒤, timer 함수를 Stack으로 이동시키기 때문이다. 단지, Wep APIs에서 0.5초 걸리느냐, 0초 걸리느냐에 차이이다.


다른 비동기인 DOM 이벤트의 경우는 어떻게 될까?

아래 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function func1() {
console.log('func1');
func2();
}

function func2() {
// <button id="bar">bar</button>
var elem = document.getElementById('bar');

elem.addEventListener('click', function () {
this.style.backgroundColor = 'blue';
console.log('func2');
});

func3();
}

function func3() {
console.log('func3');
}

func1();

위 코드의 실행순서는 다음과 같다.

  1. 함수 func1()이 호출되면 함수 func1()은 Stack에 쌓인다.
  2. 함수 func1()은 함수 func2를 호출하므로 함수 func2()가 Stack에 쌓이고 addEventListener가 호출된다.
  3. 이 addEventListener는 Wep APIs로 이동한다.
  4. Stack에는 func3()가 쌓인다. 이때, 만약 bar 버튼이 클릭되면, ‘click’ 이벤트가 발생하게 되어, addEventListener는 Callback Queue로 이동하게 된다.
  5. Stack에 있던 모든 함수가 제거되어 완전히 비어지면, 이벤트 루프는 이 addEventListener를 Stack으로 이동시키고, 실행하게 된다.