[Javascript] 자바스크립트 런타임 (Runtime)

자바스크립트란?

JS는 경량, 인터프리터 혹은 JIT 컴파일 프로그래밍 언어로, 일급 함수를 지원한다. 프로토타입 기반, 다중 패러다임, 단일 스레드, 동적 언어이다. 객체지향형, 명령형, 함수형 프로그래밍 스타일을 지원한다.

JS를 공부하려는데 위 정의를 봐도 이해가 하나도 안된다. 그래서 이해를 해보고자 이 글을 작성했다. 위 단어들을 하나씩 꼬리에 꼬리를 물며 이해 될 때까지 보자.

이번 포스트는 전반적으로 자바스크립트 코드가 어떻게 돌아가는지, 특히 비동기 코드들이 실행될 때 자바스크립트는 어떻게 이를 처리하는지, 어떤 순서로 실행되는지, 어떤 부품들이 내부에서 유기적으로 상호작용 하는지에 대해 파헤쳐보려고 한다.

자바스크립트의 싱글 스레드, 논 블로킹(Non-blocking), 비동기(Asynchronous) 처리에 대한 개념을 알아보고 끊임없이 변화하는 ECMAScript에 대해 근본적인 이해를 유지한 채 빠르게 팔로우업 할 수 있는 개발자가 되어보자.

런타임(runtime)

런타임이란, 말 그대로 프로그램이 실행되고 있는 시간을 의미한다. 런타임 환경(Runtime Environment)이란 프로그램이 실행되는 공간이며, 실행하기 위한 여러 구성요소들을 의미한다.

runtime.jpeg

이해를 하며 직접 그려본 자바스크립트 런타임

자바스크립트 런타임이라고 하면 보통 런타임 환경을 의미하며 총 4가지의 요소를 말한다. 위 사진은 전체적인 자바스크립트 코드 실행 순서구조를 나타낸다. 자세히 살펴보자.

  • 실행 엔진: 자바스크립트 엔진으로 자바스크립트 코드를 해석, 실행하는 부분이다. 크롬, 노드에서 사용하는 V8 엔진이 대표적이다.
  • 이벤트 루프: 콜 스택, 태스크큐, 백그라운드 환경에 대해 비동기 논블로킹을 처리하기 위한 메인 쓰레드
  • 백그라운드(ex: Web API, Node API 등): 실행 환경에 따라 다르지만, 비동기 태스크를 수행하는 멀티 쓰레드 환경. 실제 자바스크립트 런타임과는 다른 공간이다.
  • 태스크 큐(콜백 큐): 백그라운드에서 수행이 끝나고 콜백을 반환할 때, 이를 관리하는 큐

자바스크립트 런타임 환경을 이루는 위 4가지 요소들을 하나씩 알아보려고 한다.

자바스크립트 엔진

자바스크립트 엔진은 .js 파일을 실행하는 부분으로 자바스크립트 코드를 해석하고 수행한다

자바스크립트 엔진에 대해서는 다음 링크에서 자세하게 다루었다. 따라서 간략하게 다루겠습니다.

[Javascript] 자바스크립트 엔진 (Javascript Engine - V8)

자바스크립트 엔진은 다음과 같은 요소로 구성되어 있다.

  • JIT 컴파일러: 코드를 컴파일 하여 실제로 실행하는 곳.
  • 콜 스택(call stack): 자바스크립트는 단일 콜스택을 사용하며, 현재 실행되는 함수들의 흐름을 스택으로 관리하는 것. 수행이 완료된 함수는 콜 스택에서 제거된다. 새로운 함수 호출이 일어나면 콜 스택의 최상단에 쌓인다. 비동기 처리일 경우에 대해서는 이벤트 루프와 함께 설명하도록 하겠음.
  • 힙 메모리(heap memory): 자바스크립트 엔진에서는 힙 메모리를 여러 구역으로 나누어 사용하며 대표적으로 가비지 컬렉션이 일어나는 공간이다.

엔진의 역할을 다음과 같다.

  • 최적화 컴파일: JIT 컴파일 방식으로 TurboFan을 통해 최적화 기계어를 생성하여 성능을 높인다.
  • 메모리 관리: 할당, 해제(가비지 컬렉션)
  • 인라인 캐싱

가장 간단하게 요약하면 .js 파일을 읽으며 콜 스택에 채우고, 이를 순서대로 처리한다.

백그라운드 (Web API, Node API, OS API)

비동기 작업을 멀티 쓰레드 방식으로 처리하는 곳. Javascript 엔진의 쓰레드와는 다른 쓰레드들 에서 이루어진다.

JS 엔진이 코드를 수행하다가 백그라운드 API(setTimeout, Worker 등)를 만나면 콜 스택에 들어갔다가 비동기적으로 바로 반환되어 이벤트 루프에 의해 백그라운드 런타임 환경에 할당된다. setTimeout같은 부분은 Timer 핸들러에 의해 처리된다.

여기서 setTimeout을 호출하는 부분은 자바스크립트 런타임에서 발생한다. 이 함수가 이벤트 루프를 이용하여 실제 실행부로 간다면 그것은 자바스크립트 환경 밖에서 돌아간다. 즉, 다른 멀티 쓰레딩 환경에서 실행되고 완료되면, 콜백이 태스크 큐(콜백 큐)에 쌓이게 되는 것이다.

태스크 큐 (Task Queue, Callback Queue)

백그라운드에서 실행이 끝난 콜백들이 콜 스택에 의해 호출되기를 기다리는 큐

백그라운드에서 수행을 완료하고 콜백은 태스크 큐에 쌓인다.

콜 스택이 전부 비었을때, 이벤트 루프가 태스크 큐에 있던 콜백 함수를 콜스택에 올린다. 그러면 엔진은 해당 콜스택에 있는 함수를 실행시킨다. 여기서 중요한 포인트는 콜 스택이 전부 비었을때이다. 즉, js 엔진은 소스코드상의 실행을 우선시 여긴다는 것을 알 수 있다.

또한, setTimeout(function(){}, 1000); 과 같은 함수를 백그라운드에서 처리했을때 콜 스택이 CPU-intensive한 작업들 때문에 꽉 차서 자리를 내주지 않는다면 1초보다 훨씬 나중에 실행될 수도 있다. 여기서 JS의 run-to-complete방식의 단점이라면 단점이라고 할 수도 있다.

따라서 예시로 든 setTimeout같은 경우 최소 1초 후에 실행을 보장하는 것이지, 정확히 1초 후에 실행을 해준다는 의미는 아니다.

또한, 여러 비동기 함수의 실행이 순서가 보장되지 않음을 자연스럽게 알 수 있다. 비동기 함수를 여러개를 선언해서 각 함수들이 이벤트 루프에 의해 백그라운드로 넘어갔다고 해보자. 백그라운드에서 멀티 쓰레드들이 각 비동기 함수들을 처리할 것이고, 어떤 비동기 함수가 먼저 끝날지 우리는 알 수 없다. 끝나는 순서대로 백그라운드는 태스크 큐에 입력만 하는 것이다.

그래서 제일 중요한 결론은 JS는 싱글 스레드이기 때문에 run-to-complete 방식을 취하게 되고, 하나의 함수가 너무 큰 로직을 포함하게 된다면 콜스택을 오래 점유하게 되어 처리 효율, 쓰루풋이 안좋아진다.

Microtask Queue

Promise는 microtask queue에 push가 되며 만약, 해당 큐에 태스크가 있다면 기존 태스크 큐보다 먼저 콜 스택에 전달한다고 한다. 즉 Promise()와 setTimeout()의 콜백이 각각 마이크로 태스크 큐, 태스크 큐에 존재한다면 무조건 Promise가 먼저 실행되는 것이다.

이벤트 루프 (Event Loop)

싱글 스레드, 무한루프를 돌며 콜백을 태스크 큐로 이동, 태스크 큐에서 콜 스택으로 이동, 비동기 요청을 콜 스택에서 백그라운드로 이동 시키며 자바스크립트 비동기/논블로킹 처리의 핵심

이벤트 루프는 그저 코드가 돌아가는 추상적인 개념이다. 비동기 함수가 콜스택에서 바로 반환되어 백그라운드로 이동하고, 백그라운드에서 수행되고 반환되는 콜백이 태스크 큐(이벤트 큐, 콜백 큐)에 들어가고, 그게 다시 콜 스택에 들어가서 콜백이 실행되는 형태를 무한반복한다는 개념이다.

while (queue.waitForMessage()) {
    queue.processNextMessage()
}

위 코드는 MDN에서 설명한 이벤트 루프의 동작 방식이다. queue.waitForMessage() 는 동기적으로 새로운 메세지(콜백)가 들어오기를 기다린다. processNextMessage() 는 들어온 콜백 함수를 태스크 큐에 밀어넣는다는 뜻이다.