같은 자바스크립트인데 왜 더 느릴까: V8 Hidden Class와 Inline Cache
겉으로 보기엔 비슷한 자바스크립트 코드인데, 어떤 코드는 유난히 빠르고 어떤 코드는 반복 호출에서 미묘하게 무거워집니다. 이 차이는 종종 "값"이 아니라 "객체의 모양(shape)"에서 시작됩니다.
예를 들어 아래 두 객체는 결국 x와 y를 모두 가지고 있으니 비슷해 보입니다.
function Point(x, y) {
this.x = x;
this.y = y;
}
const p1 = new Point(1, 2);
const p2 = {};
p2.y = 2;
p2.x = 1;하지만 V8은 이 둘을 같은 객체로 보지 않습니다. 프로퍼티가 추가된 순서가 다르기 때문입니다. 자바스크립트는 동적 언어라 실행 중에 프로퍼티가 붙고 빠질 수 있는데, 이걸 매번 딕셔너리처럼 탐색하면 너무 느립니다. 그래서 V8은 Hidden Class와 Inline Cache를 이용해 "이 객체는 이런 구조다"를 기억하고, 다음 접근을 더 빠르게 처리합니다.
이 글은 그 개념을 추상적으로 설명하기보다, 실무에서 어떻게 최적화가 깨지는지부터 보여주고 그 뒤에 원리를 붙이는 방식으로 정리해보려 합니다.
먼저 예제로 보자: 왜 같은 dotProduct가 느려질까
아래 코드는 얼핏 보기엔 단순합니다.
class Point {
constructor(x, y) {
if (x < 0 || y < 0) {
this.isNegative = true;
}
this.x = x;
this.y = y;
}
dotProduct(other) {
return this.x * other.x + this.y * other.y;
}
}먼저 양수만 넣어서 반복 호출해봅니다.
let a = new Point(1, 1);
let b = new Point(2, 2);
let result;
console.time("snippet1");
for (let i = 0; i < 10e6; i++) {
result = a.dotProduct(b);
}
console.timeEnd("snippet1");이 경우 a와 b는 모두 { x, y } 구조를 가집니다. V8 입장에서는 같은 모양의 객체만 계속 들어오니 최적화하기 좋습니다.
그런데 바로 다음에 음수 좌표를 넣어버리면 상황이 달라집니다.
a = new Point(-1, -1);
b = new Point(-2, -2);
console.time("snippet2");
for (let i = 0; i < 10e6; i++) {
result = a.dotProduct(b);
}
console.timeEnd("snippet2");이제 객체 구조는 { isNegative, x, y }가 됩니다. dotProduct는 원래 { x, y } 모양에 맞춰 최적화되어 있었는데, 어느 순간 다른 모양의 객체를 받기 시작한 겁니다. 그러면 엔진은 "이 객체가 예전 모양인가, 새 모양인가"를 확인하는 분기 비용을 추가하게 됩니다.
핵심은 간단합니다.
- 같은 값을 다뤄도 객체 구조가 흔들리면 최적화가 깨질 수 있습니다.
- V8은 값을 보기 전에 "이 객체가 어떤 shape인가"를 먼저 봅니다.
이제 왜 그런지 원리 쪽으로 넘어가보겠습니다.
Hidden Class는 객체의 레이아웃 메모다
자바스크립트 객체는 엔진 내부에서 Hidden Class 또는 Shape라고 부르는 레이아웃 정보를 가집니다. 쉽게 말해 "이 객체는 어떤 프로퍼티를 어떤 순서로 가지고 있고, 각 값은 메모리 어디쯤에 놓여 있는가"를 설명하는 내부 메타데이터입니다.
객체가 만들어질 때 V8은 히든 클래스를 만들고, 프로퍼티가 추가될 때마다 다음 상태로 이동합니다. 이 이동 경로를 보통 Transition이라고 부릅니다.
예를 들어 이런 식입니다.
const point = {};
point.x = 1;
point.y = 2;엔진은 대략 아래 같은 흐름으로 shape를 전환합니다.
- 빈 객체 shape 생성
x가 추가된 shape로 전환y가 추가된 shape로 전환
중요한 건, 같은 순서로 같은 프로퍼티를 갖는 객체들은 같은 히든 클래스를 공유할 수 있다는 점입니다. 반대로 프로퍼티를 추가하는 순서가 달라지면 최종적으로 비슷해 보여도 다른 히든 클래스를 가질 수 있습니다.
const a = {};
a.x = 1;
a.y = 2;
const b = {};
b.y = 2;
b.x = 1;a와 b는 결과적으로 둘 다 x, y를 가지지만, 만들어지는 과정이 달라서 V8은 서로 다른 구조로 취급합니다.
Inline Cache는 "다음에도 이 모양이면 바로 간다"는 약속이다
히든 클래스만으로도 이득이 있지만, 엔진은 한 단계 더 갑니다. 반복적으로 실행되는 코드에서 "이 지점에서는 항상 비슷한 shape의 객체가 들어온다"는 사실을 발견하면, 프로퍼티 탐색 결과를 호출 지점에 캐싱합니다. 이게 Inline Cache입니다.
예를 들어 dotProduct 안의 this.x, this.y 접근은 첫 몇 번의 실행에서 다음 같은 정보를 학습할 수 있습니다.
- 현재 객체의 히든 클래스는
Shape_A x는 몇 번째 오프셋y는 몇 번째 오프셋
그 다음부터 같은 shape가 들어오면, 엔진은 히든 클래스 테이블을 다시 뒤지지 않고 바로 해당 오프셋으로 점프합니다. 즉, 느린 조회 경로를 생략하고 빠른 길로 들어가는 겁니다.
여기서 자주 나오는 용어가 세 가지입니다.
Monomorphic: 한 호출 지점이 한 가지 shape만 경험한 상태Polymorphic: 몇 가지 다른 shape를 경험한 상태Megamorphic: 너무 다양한 shape가 섞여 최적화 이점이 크게 줄어든 상태
실무에서 가장 바람직한 상태는 대체로 Monomorphic입니다. "여기엔 늘 비슷한 모양의 객체만 온다"는 보장이 있을수록 엔진이 공격적으로 최적화할 수 있기 때문입니다.
그래서 생성자에서는 shape를 흔들지 않는 편이 낫다
앞선 Point 예제는 사실 생성자만 조금 바꾸면 shape를 고정할 수 있습니다.
class Point {
constructor(x, y) {
this.isNegative = x < 0 || y < 0;
this.x = x;
this.y = y;
}
dotProduct(other) {
return this.x * other.x + this.y * other.y;
}
}이제 isNegative는 어떤 입력이 오든 항상 먼저 초기화됩니다. 값이 true일 수도 있고 false일 수도 있지만, 구조 자체는 늘 동일합니다.
결국 중요한 건 "조건에 따라 필드를 만들지 말고, 필드를 항상 만들어둔 뒤 값만 바꾸라"는 점입니다.
React에선 어디서 shape가 흔들릴까
이 지점부터는 추상적인 런타임 이야기가 아니라, 우리가 평소에 자주 쓰는 코드 습관 이야기입니다.
1. 조건부 프로퍼티 추가
가장 흔한 패턴은 객체를 먼저 만든 뒤, 조건에 따라 필드를 붙이는 코드입니다.
const UserProfile = ({ user }) => {
const userData = {
id: user.id,
name: user.name,
};
if (user.isAdmin) {
userData.role = "admin";
userData.permissions = ["read", "write"];
}
return (
<div>
<h1>{userData.name}</h1>
{userData.role && <span>권한: {userData.role}</span>}
</div>
);
};이 코드는 admin 사용자와 일반 사용자가 서로 다른 shape를 갖게 만듭니다. 같은 컴포넌트 안에서 userData.name, userData.role에 접근하는 코드가 매번 다른 히든 클래스를 만나게 되는 셈입니다.
더 안정적인 형태는 처음부터 전체 스키마를 선언하는 방식입니다.
const UserProfile = ({ user }) => {
const userData = {
id: user.id,
name: user.name,
role: user.isAdmin ? "admin" : null,
permissions: user.isAdmin ? ["read", "write"] : [],
};
return (
<div>
<h1>{userData.name}</h1>
{userData.role === "admin" && <span>권한: 관리자</span>}
</div>
);
};값은 달라도 구조는 고정됩니다.
2. 조건부 spread
겉보기엔 아주 깔끔하지만, 아래 패턴도 shape를 자주 흔듭니다.
const data = {
id,
name,
...(isAdmin && { role: "admin" }),
};어떤 날은 role이 있고 어떤 날은 없으니, 엔진 입장에서는 서로 다른 객체 모양이 계속 생성됩니다. 이런 경우에는 차라리 필드를 고정하는 편이 낫습니다.
const data = {
id,
name,
role: isAdmin ? "admin" : undefined,
};3. props 순서가 매번 달라지는 경우
JSX는 선언형 문법이라 잘 체감되지 않지만, 결국 props도 객체입니다.
// Case A
<User name="Gemini" age={20} />
// Case B
<User age={25} name="John" />이 둘은 사람 눈엔 같은 정보처럼 보여도, 내부적으로는 서로 다른 순서로 프로퍼티가 구성될 수 있습니다. props 객체 shape가 흔들리면, 컴포넌트 내부에서 props.name 같은 접근도 동일한 최적화 혜택을 받기 어려워집니다.
물론 이 한 줄 때문에 앱 전체가 느려진다고 일반화할 필요는 없습니다. 다만 매우 자주 생성되는 객체나 핫패스라면, 이런 차이가 누적될 수 있다는 점은 기억할 만합니다.
4. 동적 props를 그대로 퍼뜨리는 경우
<UserProfile {...dynamicProps} />dynamicProps의 shape가 요청마다 달라진다면, 자식 컴포넌트는 매번 다른 모양의 props를 받게 됩니다. 이럴 때는 래퍼에서 한 번 정규화해주는 편이 좋습니다.
const UserProfileWrapper = ({ rawData }) => {
const stableProps = {
id: rawData.id ?? null,
name: rawData.name ?? "",
age: rawData.age ?? undefined,
isAdmin: !!rawData.isAdmin,
};
return <UserProfile {...stableProps} />;
};핵심은 복잡한 계산이 아니라, "필드를 미리 선언해서 구조를 일정하게 유지한다"는 점입니다.
5. HOC에서 객체를 단계적으로 변형하는 경우
아래 코드는 익숙하지만 shape 관점에서는 조금 아쉽습니다.
function withLogging(Component) {
return function Wrapped(props) {
const newProps = { ...props };
newProps.logger = myLogger;
newProps.timestamp = Date.now();
return <Component {...newProps} />;
};
}이 방식은 복사된 객체에 필드를 하나씩 추가하면서 전환 단계를 여러 번 거칩니다. 반면 객체 리터럴로 한 번에 최종 shape를 만드는 쪽이 보통 더 낫습니다.
function withLogging(Component) {
return function Wrapped(props) {
const enhancedProps = {
...props,
logger: myLogger,
timestamp: Date.now(),
};
return <Component {...enhancedProps} />;
};
}혹은 아예 추가 정보를 별도 prop으로 분리해서 원래 props shape를 보존하는 방법도 있습니다.
const withLogging = (Component) => {
return function Wrapped(props) {
return (
<Component
{...props}
_internalLogging={{
logger: myLogger,
timestamp: Date.now(),
}}
/>
);
};
};배열도 예외는 아니다
V8 최적화 이야기를 조금 더 따라가다 보면 배열도 결국 같은 맥락이라는 걸 알게 됩니다.
배열은 내부적으로 element kind를 관리합니다. 단순 정수 배열이던 것이 1.1, NaN, 객체 등을 만나면 더 일반적인 표현으로 내려가고, 한 번 범용 형태로 전이되면 다시 특화된 형태로 복구되지 않는 방향으로 동작합니다.
또 new Array(100)처럼 미리 큰 배열을 만드는 패턴은 holey element를 만들 수 있습니다. packed array보다 느릴 가능성은 있지만, 실제 성능 영향은 맥락에 따라 다르니 무조건 금지 규칙처럼 받아들일 필요는 없습니다.
여기서도 결론은 비슷합니다.
- 숫자 배열이면 가능하면 숫자 배열답게 유지하고
- 배열 내부 타입을 자주 섞지 말고
- 구조를 예측 가능하게 유지하는 편이 엔진 최적화에 유리합니다
정리
V8이 자바스크립트를 빠르게 실행하는 방식은 "동적 언어니까 어쩔 수 없지"가 아닙니다. 오히려 동적인 언어를 가능한 한 정적인 구조처럼 다루기 위해 많은 최적화를 합니다.
그 중심에 있는 것이 Hidden Class와 Inline Cache입니다.
- 프로퍼티의 존재 여부보다 프로퍼티가 만들어지는 순서가 중요하고
- 값의 변화보다 객체 구조의 안정성이 중요하고
- 같은 shape를 오래 유지할수록 엔진은 더 빠른 경로를 선택할 수 있습니다
그래서 실무에서 기억할 문장은 하나면 충분합니다.
값을 바꾸는 건 괜찮지만, 객체의 모양은 함부로 흔들지 말자.