Post

JavaScript 동작원리(feat. Single thread, Event loop, Asynchronous)

JavaScript는 싱글스레드로 동작하는 언이다.

싱글스레드는 말 그대로 한번에 하나의 작업만 수행할 수 있음을 의미한다.

그러나 JavaScript의 특징들을 보면 비동기, 동시성, 논블로킹(Non Blocking) I/O 등의 상반된 개념들이 등장한다.

싱글스레드 인데, 어떻게 동시성을 갖을 수 있을까?

이유는 다음과 같다.

JavaScript 동작원리

img01

JavaScript의 런타임은 메모리 힙(memory heap)과 콜스택(call stack)으로 구성되어 있다.

1
2
- 메모리 힙 : 메모리 할당을 담당하는 곳.
- 콜 스택 : 코드가 호출되면서, 스택으로 쌓이는 곳.

콜스택

하나의 메인스레드에서 호출되는 함수들이 콜스택에 쌓일 것이고, 이 함수들은 LIFO(Last In First Out)방식으로 실행된다.

콜스택과 싱글 스레드를 연관지어 생각해보면, JavaScript가 싱글 스레드 기반의 언어라는 말은 JavaScript가 하나의 메인 스레드와 하나의 콜스택을 갖고 있기 때문이다.

예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const foo = () => {
  bar()
  console.log('foo')
}
const bar = () => {
  console.log('bar')
}
foo();
console.log('foo and bar')

/*
bar
foo
foo and bar
*/

콜스택 순서

  1. foo 함수 실행
  2. foo 함수 내부에서 bar 함수 실행
  3. console.log(‘bar’) 실행 후 콜스택에서 제거
  4. bar 함수 모두 실행되었으니 제거
  5. foo 함수로 돌아와서 console.log(‘foo’) 실행 후 콜스택에서 제거
  6. foo 함수 모두 실행되었으니 제거
  7. console.log(‘foo and bar’)가 콜스택에 추가, 실행 후 제거

Javascript 런타임은 자체적으로 비동기를 지원하는가?

상당수의 개발자들이 오해하는 부분이 JavaScript의 런타임 자체에서 비동기 API를 지원하다는 점이다. 이는 틀린 사실이다.

동시성을 보장하는 비동기, 논블로킹 작업들은 JavaScript 엔진을 구동하는 런타임 환경에서 담당을 한다. 여기서의 런타임 환경이란, 브라우저 혹은 Node.js를 말한다.

JavaScript의 엔진은 단지 임의의 코드에 대한 온디멘드 실행 환경이다. 각 이벤트를 스케쥴링하는 것은 그것을 둘러싸고 있는 환경이며, 비동기 작업들도 이러한 환경에서 지원한다.

img01

JavaScript의 엔진과 외부의 런타임 환경들이 조합된 모습이다.
(참고) Node.js가 런타임 환경일 경우, Node.js가 지원하는 라이브러리 및 api등을 사용한다.

(콜백 큐 대신 태스크 큐(task queue)라는 용어를 사용하겠다.)

1
2
3
4
5
6
7
- 이벤트 루프 : 이벤트 발생 시 호출되는 콜백 함수들을 관리하여 태스크 큐에 전달하고, 태스크 큐에 담겨있는 콜백 함수들을 콜스택에 넘겨준다.
( 이벤트 루프가 태스크 큐에서 콜스택으로 콜백 함수를 넘겨주는 작업은 콜스택에 쌓여 있는 함수가 없을 때만 수행된다. )

- 태스크 큐 : web api에서 비동기 작업들이 실행된 후 호출되는 콜백함수들이 기다리는 공간이다. 이벤트 루프가 정해준 순서대로 줄을 서있으며, FIFO(First In First Out) 방식을 따른다.
( 태스크 큐는 하나의 큐로 이뤄지지 않는다. Microtask Queue, Animation Frames 등 여러 개의 큐로 이뤄져 있다. 단, 이 글에서는 이해의 편의 를 위해 태스크 큐로 통합하여 명칭한다. )

- Web API : Web API는 브라우저에서 자체 지원하는 API이다. Web API는 DOM 이벤트, Ajax (XmlHttpRequest), setTimeout 등의 비동기 작업들을 수행할 수 있도록 API를 지원한다.

런타임 환경에서 어떻게 비동기 코드가 실행되는가?

  1. 먼저 이 코드는 호출 스택에 쌓인 후 실행되면 JavaScript의 엔진은 비동기 작업을 Web API에게 위임한다.
  2. Web API는 해당 비동기 작업을 수행하고 콜백 함수를 이벤트 루프를 통해 태스크 큐에 넘겨주게 된다.
  3. 이벤트 루프는 콜스택에 쌓여있는 함수가 없을 때 태스크 큐에서 대기하고 있던 콜백함수를 콜스택으로 넘겨준다.
  4. 콜스택에 쌓인 콜백함수가 실행되고, 콜스택에서 제거된다.

논블로킹

논블로킹 I/O의 개념이 이 부분을 통해 설명된다. 만약 http 요청의 작업을 동기로 수행했다면 해당 함수가 콜스택에 쌓인 채로 머물것이고, JS엔진은 해당 작업이 끝날 때 까지 어떠한 작업도 수행할 수 없다.

즉, 동기 작업이 다른 코드들을 블로킹한 것이다. 그러나 JavaScript는 비동기 작업들을 Web API에게 넘겨줌으로써, 해당 작업이 완료될 때 까지 다른 코드들을 실행할 수 있다. 이것이 바로 논블로킹이다.

예시

1
2
3
4
5
6
7
8
console.log('첫번째로 실행됩니다.');
setTimeout(() => console.log('최소 1초 후에 실행됩니다.'), 1000);
console.log('언제 실행될까요?');
/*
* 첫번째로 실행됩니다.
* 언제 실행될까요?
* 최소 1초 후에 실행됩니다.
*/
  1. 제일 먼저 console.log(‘첫번째로 실행됩니다.’)가 콜스택에 쌓이고, 이는 바로 실행되어 제거된다.
  2. 다음으로 setTimeout이 콜스택에 쌓인다. setTimeout이 실행되고, Web api에서 timer가 생성된다.
  3. console.log(‘언제 실행될까요?’)가 콜스택에 쌓인 후, 바로 실행되고 제거된다.
  4. Web api에서 생성된 timer는 생성된 시점을 기준으로 최소 1초 후에 태스크 큐로 콜백함수를 전달한다.
  5. 태스크 큐에 전달되어있던 setTimeout의 콜백함수가 콜스택에 스택이 없는것을 확인한 후, 콜스택에 호출되어 실행한다.

만약 setTimeout의 시간을 0초로 둔다면

1
2
3
4
5
6
7
8
console.log('첫번째로 실행됩니다.');
setTimeout(() => console.log('최소 0초 후에 실행됩니다.'), 0);
console.log('언제 실행될까요?');
/*
* 첫번째로 실행됩니다.
* 언제 실행될까요?
* 최소 0초 후에 실행됩니다.
*/

결과는 바뀌지 않는다. 시간이 중요한 것이 아니다. setTimeout 함수는 Web API가 지원하는 비동기 함수라는 점이 중요하다. setTimeout의 콜백함수는 바로 콜스택에 쌓이는 것이 아니라, Web API에서 비동기 처리된 후 콜백함수가 태스크 큐에 전달된다. 즉, 시간을 0초로 해놨을지라도, 콜스택에 바로 쌓이는 다른 함수들보다 늦게 호출되는 것이다.

여기서 한 걸음 더 나아가 보자. setTimeout은 인자로 콜백함수와 딜레이 시간을 갖는다. 여기서 딜레이 시간은 정확한 시간일까?

그렇지 않다. 만약 setTimeout의 콜백함수가 태스크 큐에 쌓인다 할지라도, 콜스택에 얼마나 많은 함수들이 쌓여있는지 여부에 따라 실행되는 시간이 지연될 수 있다.

🔗 ref :
This post is licensed under CC BY 4.0 by the author.

© nollae. Some rights reserved.