2일차 - javascript engine (v8)

iubns·2026년 4월 28일
post-thumbnail

해당 글은 프론트 개발자의 기본기 다지기의 두번째 글입니다.


JS 엔진 종류

JS는 인터프리터 언어로 해당 코드를 읽고 컴퓨터에서 동작을 실행 할 수 있는 엔진이 여러개 존재하며 가장 많이 알려진 엔진들의 목록은 다음과 같다.

  • v8(크로미움, 일렉트론)
  • 차크라(인터넷 익스플로러, 엣지 구버전)
  • 웹킷(사파리)
  • 스파이더 몽키(파이어폭스)

그중 현재(2021년) 가장 대중적은 v8 엔진에 대해 알아보자.

이 글을 작성할 시점은 2021년 이였다.
하지만 벨로그에 공개하는 2026년에도 크게 달라진 부분은 없다.


JS의 실행 흐름

JavaScript는 원래는 인터프리터 언어, 그러나 요즘엔 JIT(머신 전용 언어)로 컴파일 된다.

오해금지! C#이나 C++처럼 컴파일이 된 실행파일이 배포되는 것이 아닌, JS코드를 받으면 그 자리에서 기계어로 변환 한 뒤에 돈다는 것, 따라서 C#처럼 컴파일 과정에서의 최적화가 적다. 얼른 최적화 하고 실행까지 해야 하기 때문

그럼에도 엔진들이 많이 실행되는 코드는 최적화를 진행한다.
최적화를 살펴보기 위해선 어떠한 부분이 비효율적인지를 먼저 알아야 한다.
따라서 많은 예시 중 JS의 데이터 타입에 대해 살펴보자


JS는 데이터 타입을 어떻게 처리하는가?

컴퓨터의 구조상 인터프리터든, JIT든 안에 데이터가 어떠한 타입인지는 알고 있어야 한다.

심지어 개발자가 적어주지 않았을 지라도!

따라서 JS에서 모든 변수들(심지어 함수까지!)은 자신의 타입을 가지고 있는 object 형태이다.

여기서의 object 개체는 String으로 된 키와 변수의 값을 접근 할 수 있게 되어 있는 딕셔너리다.

이때, 해당 Object는 개체 뿐만 아니라 class(property attribures)에도 적힌다.

해당 내용은 oejct와 class 그리고 array에서 다시 다루어보자

위의 이미지를 통해 모든 JS 변수들은 타입을 내부적으로 가지고 있는 것을 확인 할 수 있다. 누군가가 JS에는 타입이 없다고 하면 믿고 거르길 바란다

이젠 인터프리터(JS)일 경우 변수에 대한 것을 읽자마자 해당 값이 어떠한 타입인지 알아야 한다는 것을 인정 할 것이다. 하지만 JIT의 수준에서는 해당 로직이 어떻게 되어야 할까?

위의 이미지는 두개의 변수를 더하는 JS 코드가 JIT(ARM 기반)로 변환 되었을때의 코드이다.
단지 두 변수를 더하기 위해서도 추가적인 검사와 예외 처리가 필요할 수 밖에 없다.

따라서, JS는 C#이나 C++과 같은 언어에 비해 기본적으로 느릴수 밖는 구조를 태생적으로 가지고 있다.

하지만, 우린 항상 그래왔듯이 방법을 찾을 것이다.

데이터 타입에 대한 최적화를 해보자

해당 내용만 파고들어도 논문 몇십개가 나올 수준이니 아주 가볍게 비유를 하며 느낌만 이해하고 가보자.

위의 코드를 다시 JS로 돌려본다면 아래와 같은 모양일 것이다.

function a(){
	let v1 = 0
    let v2 = 1
    let v3 = v1 + v2
}

하지만 아래와 같은 상황일수도 있다.

function a(){
  let r = 0;
  for(let index = 0; index < 1000; index++){
    r += index;
  }
}

만약, 전자라면 V8 엔진은 a() 실행시 v3을 더하는 과정을 한번만 실행하게 될 것이다.
이땐 방법이 없다. (일반적으론)타입 검사부터 더하기까지 모두 그냥 해야한다.

하지만 만약 후자라면? V8엔진 입장에서 첫번째 r을 더할땐 타입 검사까지 하면서 더하겠지만 두번째에 더할 때 또 타입 검사를 꼭 해야 할까?

이렇게 자주 반복되는 부분을 V8 엔진은 hotspot이라 부른다.

위와 같이 한번 봤던 코드를 또 보게 된다면 이때 엔진은 잠시 멈춘다. 이후 해당 부분을 최적화 하기 시작한다. 이번 경우에선 타입 검사를 빼는 방법을 선택할 수 있을 것이다. 처음에 최소한의 최적화를 하는 것은 JITC (baseline-JITC)라고 지징하며 너무 많이 나와서 매우 많이 최적화 되는 부분을 JITC(Optimizing-JITC)라고 지칭한다. 아래는 V8 엔진이 최적화 하는 방법을 간단히 나타낸 것이다.

이러한 방법 외에도 정말 많은 비효율적인 부분이 존재하며 그에 따른 수 많은 최적화 방법들이 연구되고 있으며 찾아보면 재밌는게 많으니 기술에 관심이 많은 개발자라면 한번 찾아보는것도 추천한다.

(참고) JIT에서 자주 사용될꺼 같은 함수나 로직들은 엔진이 미리가지고 있다가 필요할때 바로 바로 호출하기도 한다


V8 엔진의 구조

Javascript는 단일 스레드 언어이기 때문에 JS 실행과 V8엔진의 프로세서는 1:1 매칭이 된다.

v8은 C++ 언어로 만들어져 있기때문에 C++언어를 돌리 수 있는 환경이면 v8 엔진(node)를 통하여 Javascript를 실행 할 수 있다.

v8엔진의 대략적인 구조는 다음과 같다.

다른 언어들과 비슷한 부분은 call stack 그리고 heap이 존재한다는 것이다.

하지만 조금 다른점은 Event loop부분이다.
다른 대부분의 언어들은 멀티스레딩으로 처리하는 비동기 처리가 JS에선 이벤트 루프라는 방법으로 처리되기 때문이다.

요즘엔 JS와 비슷한 방법으로 비동기 처리를 하는 언어들이 많아지고 있다.
멀티스레딩은 대부분의 개발자에게 너무나 어렵고 복잡하며 실수를 유발하기 쉬운 방식이기 때문이다.

또 구조를 보다보면 놀라게 되는 부분중 하나는 아무렇지 않게 사용하던 setTimeout은 사실 javascript 언어의 함수가 아닌 web api였다는 것, 따라서 일부 몇몇 환경에선 JS를 실행 할 수 있는 환경일지라 하여도 해당 함수가 작동하지 않는 경우가 있다.

이미지 우측 하단에 있는 (이벤트) 콜백 큐는 call stack이 전부 비어야지만 실행되게 되어 있다.

따라서 다음과 같은 상황이 펼쳐지기도 한다.

setTimeout(() => {console.log('hello')}, 1000 * 3)
// 3초 이상 뒤에
setTimeout(() => {console.log('hello')}, 1000 * 3)
while(true){}

setTimeout에서 hello를 출력하게끔 하여 2번 호출 하였지만, 밑에 있는 true는 나오지 않는다.

무한 루프라서 call stack이 비워지지 않기 때문,

따라서 다음과 같은 현상도 나타나게 된다.

function test(){
    console.log('a');
    function c(){
	    setTimeout(() => {console.log('b'), 1})
    }
    c();
    for(let index = 0; index < 100; index++){
	    if(index % 10 === 0){
		    console.log(index);
	    }
	}
}
test()

c()를 먼저 실행 하였고 1ms(0.001)초 뒤에 실행해라 한 뒤에 for문이 돌았기에 1ms가 분명 지났지만 중간에 나타나지 않고 call stack이 전부 끝난 뒤에 b가 출력 된다.


V8 엔진의 메모리 구조

v8엔진의 메모리구조는 다음과 같다.

모든 개체정보 및 동적 데이터들은 전부 힙에 저장된다.

또한 GC의 관리를 받는다. (GC역시 다음번에 배워보자)

스택은 다른 언어들과 동일하게(?) 하나의 프로세스가 하나의 스택을 가지며 메서드의 주소, 개체 포인터의 값 등의 정적 데이터들이 존재한다. 전반적으로 다른 언어와 동일한 느낌.

힙 영역은 조금 복잡하다. New, Old, 라지 오브젝트, 코드, 셀, 속성 셀, 맵 영역으로 나눠지며 각각의 설명은 다음과 같다. (대충 읽어보자)

  • New 영역: New 영역 또는 "Young 제너레이션"은 새로 만들어진 모든 객체를 저장하고 이 객체들은 짧은 생명 주기를 가진다. 이 영역은 크기가 작고 JVM에서 S0와 S1과 같은 2개의 세미(semi) 영역을 가진다. 이 영역은 이후에 살펴볼 스캐벤져(Scavenger, 마이너 GC)가 관리한다. New 영역의 크기는 -min_semi_space_size(초기값)와 -max_semi_space_size(최대값) V8 엔진의 플래그 값을 사용해 조정할 수 있다.
  • Old 영역: Old 영역 또는 "Old 제너레이션"은 마이너 GC가 두 번 발생할 동안 "New 영역"에서 살아남은 객체들이 이동하는 영역이다. 이 영역은 이후에 살펴볼 메이저 GC(Mark-Sweep 및 Mark-Compact)가 관리한다. Old 영역의 크기는 V8 엔진의 플래그 값 -initial_old_space_size(초기값)와 max_old_space_size(최대값)을 사용해 조정할 수 있다. 이 영역은 다시 2개의 영역으로 나누어진다.
    • Old 포인터 영역: 살아남은 객체들을 가지며, 이 객체들은 다른 객체를 참조한다.
    • Old 데이터 영역: 데이터만 가진 객체들(다른 객체를 참조하지 않는다)을 가진다. 문자열, 박싱(boxing)된 숫자, 실수형(double)로 언박싱(unboxing)된 배열은 마이너 GC가 두 번 발생하면서 "New 영역"에서 살아남아 이 영역으로 이동한다.
  • 라지 오브젝트 영역: 다른 영역의 제한된 크기보다 큰 객체들이 살고 있는 영역이다. 각 객체는 자체 mmap 메모리 영역을 갖는다. 라지 오브젝트들은 가비지 컬렉터로 이동하지 않는다.
  • 코드 영역실시간(JIT) 컴파일러가 컴파일된 코드들을 저장하는 곳이다. 유일하게 실행 가능한 메모리가 있는 영역이다. (코드들은 "라지 오브젝트 영역"에 할당될 수도 있고 실행도 가능하다)
  • 셀 영역, 속성 셀 영역, 맵 영역: 이 영역들은 각각 CellsPropertyCellsMaps을 포함한다. 각 영역은 모두 같은 크기의 객체들을 포함하며, 어떤 종류의 객체를 참조하는지에 대한 제약이 있어서 수집을 단순하게 만든다.

간단하게 정리하면 역시 또 다른 GC언어들과 비슷하게 힙 영역은 여러 부분으로 나눠져 있으며 작은 공간에 개체들을 밀어넣고 다 차면 GC가 돌면서 관리하고 살아남은 애들은 따로 관리하는 곳으로 이동 시키는 방식이다. 조금 독특한 점은 포인터(다른 개체를 포함하는 개체)와 순수 데이터(여러 데이터 '들')을 따로 관리한다는 것? 그리고 코드를 힙에 넣는 다는 것,

스택과 힙이 어떻게 작동되는지는 아래에서 확인해보자

https://speakerdeck.com/deepu105/v8-memory-usage-stack-and-heap

해당 사이트에서 확인 할 수 있는 부분은

  • 전역 스코프는 스택에서 "전역 프레임"에 보관된다.
  • 모든 함수 호출은 프레임 블록으로 스택 메모리에 추가된다.
  • 반환 값과 인자를 포함한 모든 지역 변수들은 스택에서 함수 프레임 블록 안에 저장된다.
  • int와 string과 같은 모든 원시 타입 값은 스택에 바로 저장된다. 이는 전역 스코프에서도 적용되며, 자바스크립트에서 문자열은 원시 타입에 해당한다.
  • Employee와 Function과 같은 객체 타입의 값은 힙에서 생성되고 스택 포인터를 사용해 힙에서 스택을 참조한다. 함수들은 자바스크립트에서 객체이다. 전역 스코프에도 적용된다.
  • 현재 함수에서 호출된 함수들은 스택의 최상단에 추가된다.
  • 함수 프레임이 반환(역자주: 함수가 종료)될 때 스택에서 제거된다.
  • 주요 프로세스가 완료될 때 힙에 있는 객체들은 어떤 포인터도 가지고 있지 않고 혼자 남게 된다.
  • 명시적으로 복사하지 않으면, 다른 객체 내의 모든 객체 참조들은 참조 포인터를 사용해 연결된다.

이로써 스택 V8이 대략적으로 어떻게 코드를 읽고 어떠한 방법으로 동작하는지 대략적으로 알았다.

이제 다음 시간에는 힙 메모리에 대해 조사해보자

profile
97년생, 어쩌다보니 CTO

0개의 댓글