[JavaScript] 런타임과 엔진

2023-09-18

  • JavaScript 코드를 실행하는 프로그램 또는 인터프리터입니다. 대체적으로 웹 브라우저에서 사용됩니다.
  • 엔진에 의한 인터프리터 방식이므로 별도의 컴파일 과정이 필요 없습니다. (즉, 웹 브라우저에서 즉시 해석되어 실행됩니다. = 런타임)

브라우저 별 엔진 종류


스파이더 몽키(Spider Monkey)

  • 최초의 JavaScript 엔진
  • 넷스케이프 브라우저를 위해 브렌던 아이크(JS 창시자)에 의해 개발
  • C++ 기반
  • JavaScript 1.5 버전에서 ECMA-262 에디션 3을 준수하여 업데이트 되었으며, 현재는 Mozilla 재단에서 관리하고 FireFox에서 사용

Chakra

  • 마이크로소프트가 개발한 엔진이며, Edge 브라우저에서 사용
  • Chakra 엔진의 중요 부분은 Chakra Core라는 오픈소스로 구성

JavaScript Core

  • 애플에서 개발한 JavaScript Core는 처음에 Webkit 프레임워크를 위해 개발
  • 최근에는 Safari와 React Native App에서 사용

V8

  • JavaScript 엔진의 대표적인 예는 Google V8 엔진으로 C++로 작성되었으며 구글이 개발한 오픈소스
  • V8은 Chrome과 Node.js에서 사용

주요 브라우저들의 런타임 구조


거의 모든 JavaScript 개발자들이 setTimeout과 같은 브라우저 내장 API를 사용합니다. 하지만, 이 API를 JavaScript 엔진에서 제공하지는 않습니다.

그렇다면 이런 브라우저 내장 API들은 어디서 오는 것일까요? 사실 현실은 조금 더 복잡합니다.

230922-151250

위 그림은 브라우저에서 동작하는 JavaScript의 런타임 환경을 나타낸 것입니다. JavaScript 엔진 이외에도 JavaScript에 관여하는 다른 요소들이 많습니다. DOM, Ajax, setTimeout과 같이 브라우저에서 제공하는 API들을 Web API라고 합니다.

JavaScript 엔진만으로 웹이 동작하지는 않습니다. 그 외적인 요소들의 동작도 런타임으로 이루어집니다.

JavaScript 엔진의 메모리 관리


JavaScript는 콜스택과 메모리힙이라는 메모리 구조를 통해 데이터 및 코드 실행을 관리합니다.

  • Memory Heap: 메모리 할당이 일어나는 곳
  • Call Stack: 코드 실행에 따라 호출 스택이 쌓이는 곳

메모리 힙(Memory Heap)

메모리 힙은 메모리 할당이 일어나는 곳이라고 위에서 얘기했습니다. 그럼 어떤 데이터가 저장될까요?

메모리 힙에는 참조타입(객체, 배열, 함수 등)의 데이터가 저장됩니다. 참조타입 데이터가 저장된 메모리힙의 주소값은 콜스택의 변수 식별자의 값으로 각각 저장됩니다.

참조타입에 대한 주소값이 지정되면, 함수 실행시 주소값을 통해 해당 참조타입을 찾을 수 있도록 주소값이 콜스택의 변수 식별자 값으로 저장됩니다.

여기서 중요한 점은 콜스택에 할당되는 변수 식별자 자체는 콜스택 상의 실행 컨텍스트의 렉시컬 환경이라는 곳에 저장됩니다.

230922-151306

호출 스택(Call Stack)

JavaScript는 기본적으로 싱글 쓰레드 기반 언어입니다. 호출 스택이 하나라는 얘기입니다. 따라서 한 번에 한 작업만 처리할 수 있습니다.

호출 스택은 기본적으로 우리가 프로그램 상에서 어디에 있는지를 기록하는 자료구조입니다. 만약 함수를 실행하면(실행 커서가 함수 안에 있으면), 해당 함수는 호출 스택의 가장 상단에 위치하는 것입니다. 함수의 실행이 끝날 때(리턴 값을 돌려줄 때), 해당 함수를 호출 스택에서 제거합니다.

function multiply(x, y) {
	return x * y;
}
 
function printSquare(x) {
	var s = multiply(x, x);
	console.log(s);
}
 
printSquare(5);

처음 엔진이 이 코드를 실행하는 시점에는 호출 스택이 비어있습니다. 하지만 코드가 실행되면서 호출 스택은 아래와 같이 변합니다.

230922-151324

호출 스택의 각 단계를 스택 프레임(Stack Frame)이라고 합니다.

그리고 보통 예외가 발생했을 때 콘솔 로그 상에서 나타나는 스택 트레이스(Stack Trace)가 오류가 발생하기까지의 스택 트레이스들로 구성됩니다. 간단히 말해서 에러가 발생했을 때의 호출 스택 단계를 의미하는 것입니다.

function foo() {
	throw new Error('SessionStack will help you resolve crashes :)');
}
 
function bar() {
	foo();
}
 
function start() {
	bar();
}
 
start();

위 코드가 foo.js에 있다고 가정하고 크롬에서 실행하면

230922-151341

이렇게 나오게 됩니다. 호출 스택이 최대 크기가 되면 ‘스택 날려버리기’가 일어납니다. 이는 반복문 코드를 광범위하게 테스트하지 않고 실행했을 때 자주 발생합니다.

function foo() {
	foo();
}
 
foo();

엔진에서 이 코드를 실행할 때, foo()에 의해서 foo 함수가 호출됩니다. 그런데 여기서 foo 함수가 반복적으로 자신을 다시 호출하는 재귀 호출을 수행합니다. 그러면 매번 수행할 때마다 호출 스택에 foo()가 쌓이게 됩니다. 아래와 같이 말이죠.

230922-151356

그러다가 특정 시점에 함수 호출 횟수가 호출 스택의 최대 허용치를 넘게 되면 브라우저가 아래와 같은 에러를 발생시킵니다.

230922-151411

싱글 스레드 기반 코딩은 멀티 스레드 환경에서 제기되는 복잡한 문제나 시나리오를 고민하지 않아도 되기 때문에 상당히 쉽습니다. 예를 들면, 데드락 같은 것이 있습니다.

그러나, 싱글 스레드에서 코드를 실행하는 건 상당히 제약이 많습니다. 한 개의 호출 스택을 갖고 있는 JavaScript의 실행이 느려지면 어떻게 될까요?

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


호출 스택의 처리 시간이 엄청 오래걸리는 함수가 있으면 무슨 일이 발생할까요? 예를 들어, 브라우저에서 JavaScript로 매우 복잡한 이미지 프로세싱 작업을 한다고 가정합시다.

이게 대체 어때서?라고 의문이 생길지도 모르지만, 여기서 문제는 호출 스택에서 해당 함수가 실행되는 동안 브라우저는 아무 작업도 못하고 대기 상태가 된다는 것입니다.

이 뜻은 브라우저는 페이지를 그리지도 못하고, 어느 코드도 실행을 못한다는 것입니다. 말 그대로 그냥 가만히 있는 것입니다. 만약 매끄럽고 자연스러운 화면 UI를 가진 앱을 원한다면 이 경우는 문제가 됩니다.

문제는 이 뿐만이 아닙니다. 브라우저가 호출 스택의 정말 많은 작업들을 처리하다 보면 화면이 오랫동안 응답하지 않게 됩니다. 이 경우에 대부분의 브라우저가 아래와 같은 에러를 띄우면서 페이지를 종료할 것인지 물어봅니다.

230922-151427

이는 사용자 경험(UX)에 있어 최악이라고 할 수 있습니다.

그렇다면 어떻게 페이지 렌더링 동작을 방해하지 않고 브라우저의 응답도 끊지 않으면서 연산량이 많은 코드를 실행할 수 있을까요? 정답은 바로 비동기 콜백입니다.

JIT 컴파일러


Just In Time으로 실시간(런타임) 컴파일 기법이라고 합니다.

바이트 코드를 기계어로 번역하는 역할을 합니다. (바이트 코드란, VM이 읽어 실행할 수 있도록 하는 기계어입니다. CPU가 읽을 수 있는 기계어랑은 다릅니다.)

  • 가상머신이 JS에서는 Chrome 브라우저와 node에서 사용되는 V8 엔진입니다.

바이트 코드에서 번역된 기계어는 캐시에 저장되어 있기 때문에 재사용시 다시 번역할 필요가 없습니다. 그러므로 반복되는 코드가 들어올 때 인터프리터가 다시 기계어로 번역하는 과정이 필요 없이 캐시에 저장된 것을 사용하면 되므로 시간이 단축됩니다.

JIT가 컴파일도 하고 인터프리터랑 같이 쓰인다는 건 OK. 그래서 누가 쓰는데요?

230922-151437

  • Java가 이 컴파일러를 사용합니다.

JIT? 이걸 왜 사용하나요?

  • 같은 연산을 100번 실행하는 코드가 있을 때 인터프리터 방식은 같은 줄을 100번 재번역해야 합니다. 컴파일 방식의 경우 미리 기계어로 모든 결과를 번역해 놓기에 문제가 되진 않지만 인터프리터는 한줄한줄 읽어 실행시킨다는 점에 있어 비효율적일 수 밖에 없습니다.
  • 위와 같은 비효율성을 없애기 위해 브라우저는 캐시를 사용할 수 있고 JIT 컴파일 방식을 혼합해 반복되는 코드를 미리 기계어로 번역해두어 최적화가 가능함으로 사용하기 시작했고, Chrome의 V8에서 사용되고 있습니다.(이외에도 많이 사용됩니다.)
  • JS 엔진을 인터프리터로 구현하거나 JIT를 사용해서 구현하기에 브라우저마다 JS 코드를 해석하고 실행하는 방식은 차이가 있습니다.

이렇듯 JS는 컴파일도 되고 인터프리터로도 실행되는 언어라고 할 수 있는데 그렇다면 브라우저의 JS 엔진은 무엇을 하길래 해석하고 실행할 수 있을까요?

JavaScript 엔진(V8)의 동작 원리


JavaScript 엔진은 로드된 스크립트를 해석, 실행하는 역할입니다. 가장 많이 사용되고 있는 엔진은 Chrome과 node의 V8입니다.

앞서 JavaScript 엔진 내부에서는 컴파일 과정이 있다고 했습니다. 그렇다면 JavaScript는 컴파일 언어일까요 아니면 인터프리터 언어일까요? 답은 컴파일도 하고 인터프리터도 하지만, 대외적으로는 인터프리터 언어라고 부릅니다.

이제 JS 엔진이 컴파일도 하고 인터프리터도 한다는 것을 알았으니 어디서 컴파일이 일어나는지 알아봅시다.

렌더링 엔진은 HTML을 파싱하다 script 태그를 만나면 잠시 작업을 중단하고 제어권을 JavaScript 엔진으로 넘깁니다.

이때 읽어들인 JS 코드가 JavaScript 엔진에서 해석, 실행될 때까지 실행을 멈추는 것입니다.

엔진이 코드를 해석하는 과정은 다음과 같습니다. (해석하는 과정을 컴파일레이션이라고 합니다.)

230922-151453

엔진은 텍스트 형식인 코드를 실행하기 전 컴파일 과정을 거칩니다. 컴파일은 총 3개의 단계로 이루어져 있습니다.

  1. 코드를 의미있는 조각으로 나누는 렉싱/토크나이징(이때 스코프가 결정됩니다. - 렉시컬스코프라고 부르는 이유)
  2. 코드를 추상 구문 트리(AST)로 파싱
  3. VM이 실행할 수 있도록 트리를 가지고 바이트 코드로 변환

컴파일레이션을 통해 JS 코드가 실행되기 직전에 컴파일된다는 것을 알 수 있습니다.

실행


Ignition이라는 인터프리터가 바이트 코드를 만들고 실행함으로써 코드가 동작됩니다.

230922-151509

Reference

https://watermelonlike.tistory.com/170 https://joshua1988.github.io/web-development/translation/javascript/how-js-works-inside-engine/

예상 질문

  • JavaScript 엔진의 주요 특징은 무엇인가요?
  • V8 엔진의 동작 원리에 대해 간단히 설명해주세요.
  • V8 엔진은 메모리 관리를 어떻게 하나요?
javascriptruntimebrowserv8

프로필 사진
TaeWoo Kim
Junior Frontend Engineer