ghlee.dev

my profile picture

JavaScript의 구멍 뚫린 배열

2023. 6. 16.

JavaScript의 배열(Array)은 편리하다.

컴파일 언어처럼 배열의 용량(Capacity)을 강제 해줄 필요도 없고 어떤 타입으로 채워 넣을지 알려주지 않아도 된다. 원하는 값을 신나게 넣어도 아무런 문제가 없다. 표면상으로는 말이다.

JS
const a = [1, 2.12, '3', new Object(), null];

마치 타입 제한이 없는 튜플(Tuple)처럼 사용할 수도 있다.

하지만 컴파일 언어를 경험해본 사람이라면 컴파일러가 이러한 동적 배열을 효율적으로 처리하는 것이 얼마나 어려운 일인지 추측해볼 수 있다.

C++과 Rust같은 언어에서는 표준 라이브러리에서 제공하는 벡터를 사용하면 자바스크립트의 배열처럼 용량을 강제하지 않고 사용할 수 있다. 하지만 어디까지나 용량만 그리할 뿐 타입은 지정해주어야 한다.

C++
std::vector<int> numbers;
Rust
let mut numbers: Vec<i32> = vec![];

Rust에서는 타입 지정을 해주지 않아도 컴파일러가 추론한다. 하지만 동일한 타입의 값만 넣을 수 있는 건 변함 없다.

이제는 많이 알려진 사실이지만 JavaScript에도 컴파일러가 존재한다. 한때는 구글링하면 인터프리터 언어로만 소개된 글이 많아서 그저 인터프리터만 존재하는 것으로 오해하는 경우가 꽤 있었다. JavaScript 엔진에 있는 컴파일러는 JIT 컴파일러로 런타임 시 바이트 코드를 기계어로 변환하는 컴파일러가 포함되어 있다.

이 글에서 다룰 이야기는 아니지만 사실 컴파일러와 인터프리터는 한 끗 차이이다.

동적 타입 언어인 JavaScript에서도 예측이 가능한 코드는 최적화가 될 수 있다고 보면 이해하기 쉽다. 그렇다면 컴파일러 입장에서 예측 가능한 코드란 무엇 일까?

작성된 코드가 메모리 용량을 예측할 수 있는 코드라면 최적화하기 쉽다고 추측해볼 수 있다. 배열의 경우 용량을 강제하고 타입을 지정해줌으로써 컴파일러에게 최적화 할 수 있는 힌트를 줄 수 있다.

하지만 JavaScript의 배열은 용량을 강제하는 기능은 없다. new Array(4) 이 구문은 뭐냐고 물을 수 있는데 강제성 없이 4개의 요소를 담을 수 있는 배열을 생성하는 기능일 뿐이다. 언제든 동적으로 배열의 용량을 늘릴 수 있다. 뒤에서 설명하겠지만 new Array()로 생성된 배열은 최적화되기 어렵다.

그렇다면 JavaScript의 엔진은 배열을 어떤 방식으로 최적화할까? V8 엔진을 기준으로 알아보자.

V8의 배열 종류

설명하기 앞서 JavaScript 코드를 해석하는 V8 엔진은 C++로 작성되어있다. 즉 JavaScript의 어떤 타입이던 C++가 지원하는 타입에서 벗어날 일이 없다고 생각하면 이해하기 편할거다.

JavaScript에서 숫자는 정수와 부동소수점을 구분하지 않는다. 그저 64비트 부동소수점을 지원하는 Number 객체만 존재할 뿐이다. 예를 들어 아래와 같은 배열이 있다고 하자.

JS
const temp = [1, 2];

JavaScript에서는 이를 정수 배열로 보지않는다. 실제 메모리에 들어가는 값을 얘기하는게 아닌 언어 스펙에서의 이야기이다. 하지만 V8 엔진에서는 이를 정수로 된 배열로 읽고 처리한다. 왜냐하면 부동소수점보다 정수로 처리하는 게 빠르고 메모리 영역을 덜 차지하기 때문이다. 이러한 게 JavaScript 엔진에서의 최적화의 일종이다.

JS
const temp = [1, 2, 3.14];

이건 어떨까? 유리수 3.14를 추가했다. 이제 V8 엔진에서는 이를 부동소수점 배열로 본다. 12 또한 부동소수점 타입으로 처리한다. 당연히 정수만 있었던 배열보다 메모리 영역을 더 차지할 것이다.

V8 엔진에서는 요소에 따라 바뀌는 배열의 종류를 아래처럼 enum으로 명시해두었다.

C++
// https://github.com/v8/v8.git
// elements-kind.h
 
enum ElementsKind : uint8_t {
  PACKED_SMI_ELEMENTS,
  PACKED_DOUBLE_ELEMENTS,
  PACKED_ELEMENTS,
  PACKED_NONEXTENSIBLE_ELEMENTS,
  PACKED_FROZEN_ELEMENTS,
  ...
};

하나씩 살펴보자.

PACKED_SMI_ELEMENTS

우선 PACKED는 요소가 연속되게 할당되어 요소 사이에 빈 공간이 없는 배열을 의미한다. SMI는 Small Integer의 줄임말로 PACKED_SMI_ELEMENTS는 작은 정수(Smi)만 담긴 배열로 추측해볼 수 있다.

PACKED_DOUBLE_ELEMENTS

배열의 요소에 정수가 아닌 부동소수점이 하나라도 추가되면 PACKED_DOUBLE_ELEMENTS로 바뀐다.

JS
const temp = [1, 2]; // 배열의 요소로 작은 정수만 있어서 PACKED_SMI_ELEMENTS 유형이다.
temp.push(3.14); // 배열은 PACKED_DOUBLE_ELEMENTS 유형으로 바뀐다.

요소를 작은 정수로 모두 덮어써도 한번 바뀐 배열의 유형은 PACKED_SMI_ELEMENTS로 돌아갈 수 없다. Array.map(), Array.filter() 등으로 새로운 배열을 만들지 않고서는 말이다.

PACKED_ELEMENTS

위 예시에 문자열을 추가하면 어떻게 될까?

JS
const temp = [1, 2]; // PACKED_SMI_ELEMENTS
temp.push(3.14); // PACKED_DOUBLE_ELEMENTS
temp.push('hello!'); // PACKED_ELEMENTS

작은 정수와 Double로도 표현할 수 없는 값이 들어오면 PACKED_ELEMENTS 유형으로 바뀐다. 뒤로 갈수록 일반화해가는 과정이라 생각하면 된다. 당연하지만 일반화 될수록 성능은 떨어진다.

그 외 PACKED 유형

  • PACKED_NONEXTENSIBLE_ELEMENTS
    • 배열의 크기가 고정된 유형
  • PACKED_FROZEN_ELEMENTS
    • 배열에 어떤 변경사항도 없는 유형

이 외에도 굉장히 많은 유형이 있으니 궁금하면 직접 코드를 다운로드해서 보길 권한다.

HOLEY_ELEMENTS

앞서 설명한 PACKED외에도 사실 HOLEY 유형이 있다. HOLEY는 배열 요소 사이에 구멍이 뚫려있다고 생각하면 되는데 아래 같은 예시가 HOLEY 유형이다.

JS
const array = [1, 2]; // PACKED_SMI_ELEMENTS
array[8] = 3; // HOLEY_SMI_ELEMENTS
 
// 3번째 요소부터 8번째까지 비어(Hole)있어서  HOLEY라고 표현한다.

HOLEY 유형이 되는 순간 최적화에 불리해진다. 앞서 설명한 PACKED 유형 들은 모두 HOLEY 유형으로 바뀔 수 있으며 마찬가지로 한번 바뀌면 이전 유형으로 돌아갈 수 없다.

JS
const array = [1, 2]; // PACKED_SMI_ELEMENTS
 
array[8] = 'hello!'; // 삽입하는 순간 최악의 유형인 HOLEY_ELEMENTS로 바뀐다.

성능을 위한 작은 습관

배열 요소의 유형이 바뀌면 최적화에 영향을 준다는 사실을 보았다. 또한 배열을 순서대로 채워가지 않고 건너뛰면 이른바 구멍이 생겨서 최적화에 불리해지는 걸 알았다.

숫자를 사용할 때는 가급적 한가지 유형에 맞춰 사용하거나 TypedArray를 이용해보는 것도 나쁘지않은 방법이다. 사실 일반적인 응용 애플리케이션 개발에서 TypedArray를 사용할 일이 많지 않지만 많은 수의 숫자를 처리할 때는 고려해볼만 하다.

그리고 유사 배열 객체인 arrayLike는 배열의 프로토타입에서 함수를 호출할 수 있지만 유형 별 배열로 최적화하기는 힘들다. 어차피 배열처럼 쓰일 객체라면 배열로 바꾸고 사용하는 것도 괜찮다.

또한 new Array()는 선언하는 순간 HOLEY_SMI_ELEMENTS로 초기화된다. 가급적 [] 리터럴 문법을 사용하자.

마치며

기존에 C++를 사용해보았고 머리 속에서 타입 별로 할당되는 메모리 공간을 상상할 수 있다면 어려울 것 없는 내용이었을 수 있다. JavaScript를 사용하다보면 동적 타입의 편리함에 빠져 이러한 최적화를 잊고 작성할 때가 많다. 그리고 TypeScript가 나오면서 타입 명시가 가능해졌지만 여전히 정수와 부동소수점을 구별해주지는 않는다. 시간이 많이 들지 않는다면 이정도 간단한 최적화는 신경써보며 작성해보면 어떨까 싶다.

JavaScript 엔진에서의 최적화 관련 내용을 더 찾아보고 싶다면 javascript inline caches 키워드로 검색해보길 바란다.