← 목록으로 돌아가기

V8 Elements kinds

javascript v8 elements kind

들어가며

Elements kinds in V8 를 읽고 설명을 더해 정리한 글입니다.

JavaScript 객체는 어떤 이름의 프로퍼티든 가질 수 있습니다. 그 중에서 V8이 특별히 최적화하는 케이스가 있는데요, 바로 숫자로 된 프로퍼티 이름, 특히 배열 인덱스입니다.

V8은 숫자 인덱스 프로퍼티를 일반 프로퍼티와 분리해서 저장합니다. 내부적으로 이걸 elements라고 부르는데, 이에 대해서 알아보려고 합니다.

Elements Kind의 종류

V8은 각 배열이 어떤 종류의 요소를 담고 있는지 추적합니다. 이 정보를 통해 배열 연산을 최적화할 수 있어요.

const array = [1, 2, 3] // PACKED_SMI_ELEMENTS
array.push(4.56) // → PACKED_DOUBLE_ELEMENTS
array.push("x") // → PACKED_ELEMENTS

이 배열의 typeof는 number라고만 알려주지만, V8 엔진 레벨에서는 더 정밀하게 구분합니다. 이 배열의 elements kind는 PACKED_SMI_ELEMENTS인데요, Smi는 Small Integer(작은 정수)를 저장하는 V8의 특별한 포맷입니다.

기본적인 elements kind는 세 가지가 있습니다:

  • Smi: 작은 정수
  • Double: 부동소수점 숫자 (Smi로 표현 못하는 정수 포함)
  • Regular elements: Smi나 Double로 표현할 수 없는 값 (문자열, 객체 등)

중요한 점은 elements kind 전환은 한 방향으로만 일어난다는 것입니다. 구체적인 것에서 일반적인 것으로만 바뀌고, 반대는 불가능해요.

PACKED vs HOLEY kinds

지금까지는 밀집된(packed) 배열만 다뤘는데요. 배열에 구멍(hole)을 만들면 “holey” 버전으로 전환됩니다:

const array = [1, 2, 3, 4.56, "x"]
// elements kind: PACKED_ELEMENTS
array.length // 5
array[9] = 1 // array[5]~array[8]이 hole이 됨
// elements kind: HOLEY_ELEMENTS

V8이 이렇게 구분하는 이유는 packed 배열에 대한 연산을 더 효율적으로 최적화할 수 있기 때문입니다. holey 배열은 프로토타입 체인 조회 같은 추가 작업이 필요해서 더 느려요.

각 기본 타입(Smi, Double, Regular)은 packed와 holey 두 가지 버전이 있습니다:

  • PACKED_SMI_ELEMENTS / HOLEY_SMI_ELEMENTS
  • PACKED_DOUBLE_ELEMENTS / HOLEY_DOUBLE_ELEMENTS
  • PACKED_ELEMENTS / HOLEY_ELEMENTS

Elements Kind Lattice

V8은 이 전환 시스템을 lattice(격자) 구조로 구현합니다:

PACKED_SMI_ELEMENTS → HOLEY_SMI_ELEMENTS
        ↓                    ↓
PACKED_DOUBLE_ELEMENTS → HOLEY_DOUBLE_ELEMENTS
        ↓                    ↓
PACKED_ELEMENTS → HOLEY_ELEMENTS

전환은 아래 방향과 오른쪽 방향으로만 가능합니다. 한번 부동소수점을 추가하면 Smi로 덮어써도 DOUBLE로 남고, 한번 hole이 생기면 나중에 채워도 HOLEY로 남습니다.

V8은 현재 21가지의 서로 다른 elements kind를 구분합니다. 더 구체적인 elements kind일수록 더 세밀한 최적화가 가능해요.

성능 팁

1. 배열 길이를 넘어서 조회하는 것을 지양

array.length가 5인데 array[42]를 읽으면 어떻게 될까요? 인덱스 42는 배열 범위를 벗어났고, 해당 프로퍼티는 배열 자체에 없습니다. 그래서 V8은 프로토타입 체인을 조회해야 해요.

문제는 한번 이런 상황이 발생하면, V8이 “이 코드는 특수 케이스를 처리해야 함”이라고 기억한다는 거예요. 이후 해당 코드는 영영 빠른 경로로 돌아가지 못합니다.

// ❌ 나쁜 예: (범위 초과)
for (let i = 0, item; (item = items[i]) != null; i++) {
  doSomething(item)
}

// ✅ 좋은 예: length로 범위 체크, for-of, forEach
for (let index = 0; index < items.length; index++) {
  doSomething(items[index])
}

2. Elements kind 전환을 지양

배열에 많은 연산을 해야 한다면, 가능한 구체적인 elements kind를 유지하는게 성능에 도움이 됩니다. ‘-0’을 더하기만 해도 PACKED_DOUBLE_ELEMENTS로 전환되기 때문에 지키기가 어려운데요. NaN과 Infinity도 마찬가지로 double로 표현되기 때문에, 하나만 추가해도 DOUBLE_ELEMENTS로 전환됩니다.

const array = [3, 2, 1, +0] // PACKED_SMI_ELEMENTS
array.push(-0) // → PACKED_DOUBLE_ELEMENTS

정수 배열에 많은 연산을 할 거라면, 초기화할 때 -0을 정규화하고 NaN, Infinity를 걸러내는 걸 고려하세요. 이 일회성 비용이 이후의 최적화로 충분히 보상됩니다.

숫자 배열에 수학 연산을 많이 한다면, TypedArray 사용도 고려해보세요. V8은 TypedArray에 대해서도 특화된 최적화를 합니다.

3. Array-like 객체보다 진짜 배열 사용

JavaScript에는 배열처럼 생겼지만 진짜 배열이 아닌 객체들이 있습니다.

const arrayLike = {}
arrayLike[0] = "a"
arrayLike[1] = "b"
arrayLike[2] = "c"
arrayLike.length = 3

이런 array-like 객체에도 Array.prototype.forEach.call()로 배열 메서드를 쓸 수 있지만, V8이 진짜 배열에 대해서만 최적화하기 때문에 진짜 배열보다 훨씬 느립니다. array-like 객체에 여러 번 연산할 거라면, 미리 진짜 배열로 변환하는 게 좋습니다.

대표적인 array-like 객체가 arguments입니다. 배열 내장 메서드를 call할 수 있지만, 진짜 배열처럼 완전히 최적화되지는 않아요:

const logArgs = function () {
  Array.prototype.forEach.call(arguments, (value, index) => {
    console.log(`${index}: ${value}`)
  })
}
logArgs("a", "b", "c")

arguments 대신 ES2015에서 도입된 rest 파라미터를 사용하세요. array-like인 arguments 대신 진짜 배열을 우아하게 만들 수 있어요:

const logArgs = (...args) => {
  args.forEach((value, index) => {
    console.log(`${index}: ${value}`)
  })
}
logArgs("a", "b", "c")

4. 다형성(Polymorphism)을 지양

같은 함수에 서로 다른 elements kind의 배열을 전달하면 다형성이 발생합니다.

V8은 처음에 “이 함수는 한 종류의 배열만 받겠지”라고 낙관적으로 가정해요. 그런데 다른 종류가 들어오면, 매번 elements kind를 체크하는 코드가 추가됩니다.

const each = (array, callback) => {
  for (let i = 0; i < array.length; ++i) {
    callback(array[i])
  }
}

each(["a", "b", "c"], doSomething) // PACKED_ELEMENTS - IC 저장
each([1.1, 2.2, 3.3], doSomething) // PACKED_DOUBLE_ELEMENTS → 다형성 발생!
each([1, 2, 3], doSomething) // PACKED_SMI_ELEMENTS → 더 느려짐

성능이 중요한 상황에서는 Array.prototype.forEach 같은 내장 메서드를 쓰세요. 내장 메서드는 이런 다형성을 더 효율적으로 처리합니다.


결론적으로, 배열의 elements kind를 가능한 한 구체적이고 일관적으로 유지하는 것이 성능에 좋습니다. PACKED 상태를 유지하고, 타입을 일관되게 유지하고, hole을 만들지 않는 것이 핵심이에요.