V8 Inline Cache와 Monomorphism
Inline Cache와 Monomorphism
들어가며
What’s up with monomorphism? 를 읽고 설명을 더해 정리한 글입니다. JavaScript 성능에 대한 글들은 종종 monomorphic 코드의 중요성을 강조하는데요. 이 글에서는 monomorphism과 polymorphism이 무엇이고, 왜 중요한지에 대해 V8의 Inline Cache와 함께 이 개념들을 알아보려고 합니다.
전통적인 프로그래밍에서 다형성은 보통 기본 클래스의 동작을 오버라이드할 수 있는 능력을 의미하는데요. 이 글에서 언급하는 다형성은 기존의 개념과는 조금 다른 call site polymorphism을 의미합니다. 쉽게 말해서, ‘이 코드 라인이 얼마나 다양한 객체 형태를 받는지’를 의미합니다.
동적 탐색의 문제
function f(o) {
return o.x
}
f({ x: 1 })
f({ x: 2 })
JavaScript에서 o.x 같은 프로퍼티 접근을 생각해봅시다:
프로퍼티 조회를 가장 단순하게 구현하는 방법은 ECMA 262 스펙의 [[Get]] 알고리즘을 그대로 코드로 옮기는 거예요. 실제로 대부분의 JS 인터프리터가 이런 식으로 동작하지만, 이 방식은 같은 객체의 같은 프로퍼티를 여러 번 조회해도 매번 처음부터 전체 탐색 과정을 반복해야 하는 단점을 갖고있어요. 이전에 찾아본 경험을 전혀 활용하지 못하는 셈이죠. 그래서 성능을 중시하는 현대 JS 엔진들은 이와 다른 접근 방식, 즉 인라인 캐시(Inline Cache(IC))를 사용합니다.
Inline Cache의 세 가지 상태
각 IC는 캐시이기 때문에 크기(현재 캐시된 항목 수)와 용량(최대 캐시 가능 항목 수)이 있습니다.
Monomorphic
function f(o) {
return o.x
}
f({ x: 1 })
f({ x: 2 })
// IC 상태: monomorphic
{ x: 1 }과 { x: 2 }는 같은 shape를 가집니다. IC는 하나의 shape만 봤으므로 monomorphic 상태예요. mono-(하나) + -morphic(형태)라는 뜻이죠.
Polymorphic
f({ x: 3 }) // 여전히 monomorphic
f({ x: 3, y: 1 }) // polymorphic, degree 2
{ x: 3 }과 { x: 3, y: 1 }은 다른 shape입니다. 이제 IC는 두 개의 캐시 항목을 가지게 되고, polymorphic 상태가 됩니다.
Megamorphic
다른 shape의 객체들로 계속 호출하면:
f({ x: 4, y: 1 }) // polymorphic, degree 2
f({ x: 5, z: 1 }) // polymorphic, degree 3
f({ x: 6, a: 1 }) // polymorphic, degree 4
f({ x: 7, b: 1 }) // megamorphic
V8의 프로퍼티 로드 IC는 최대 4개까지만 캐시합니다. 그 이상이 되면 megamorphic 상태로 전환돼요. “너무 많은 shape를 봤으니 추적을 포기한다”는 의미입니다.
그런데 shape가 뭘까?
V8은 내부적으로 객체의 구조를 Hidden Class로 관리해요. “이 객체가 어떤 프로퍼티를 어떤 순서로 갖고 있는지”에 대한 정보인데, 같은 구조로 만들어진 객체들은 같은 Hidden Class를 공유하고, IC는 이걸 기준으로 캐시합니다. 우리가 “shape”라고 부르던 게 바로 이거예요.
var a = { x: 1, y: 2 }
var b = { x: 3, y: 4 }
// 같은 Hidden Class → 같은 shape
var c = { y: 2, x: 1 }
// 프로퍼티 순서가 다름 → 다른 shape
그래서 어떻게 써야 할까?
Hidden Class는 생각보다 쉽게 달라져요. 이 점을 알고 있으면 자연스럽게 monomorphic한 코드를 작성할 수 있어요.
1. 프로퍼티는 항상 같은 순서로 초기화하기
// Bad: 순서가 다르면 다른 shape
function createUser(name, age) {
if (age) return { age, name }
return { name, age: null }
}
// Good: 항상 같은 순서
function createUser(name, age) {
return { name, age: age ?? null }
}
2. 객체 생성 후에 프로퍼티 추가하지 않기
// Bad: 나중에 프로퍼티 추가 → shape 변경
const user = { name: "deea" }
user.age = 30
// Good: 처음부터 선언
const user = { name: "deea", age: 30 }
3. 조건부 프로퍼티 추가 피하기
// Bad: a와 b의 shape가 달라짐
const a = new User()
const b = new User()
if (something) {
a.extra = 1
}
// Good: 필요한 프로퍼티는 항상 선언
class User {
constructor() {
this.extra = null
}
}
4. delete 대신 null 할당
// Bad: delete는 Hidden Class를 변형시킴
delete obj.x
// Good: 값만 비우기
obj.x = null
마무리
IC의 상태에 따라 V8의 최적화 수준이 달라집니다. 하지만 원문 저자의 조언처럼, 다형성 걱정은 대부분 불필요해요. 일상적인 코드에서 이걸 신경 쓰다 보면 오히려 가독성만 해치는 경우가 있어요. 실제로 프로파일링해서 핫스팟이 확인된 경우에만 적용하는 게 좋습니다.