2025년 5월 13일 • ☕️☕️ 11 min read
이 글은 V8 엔진 v11.x 기준으로 작성되었으며, 단순한 가비지 컬렉터 소개를 넘어 V8이 어떻게 초당 수백만 번의 함수 호출과 GB 단위의 메모리를 효율적으로 관리하는지 알아본다.
자바스크립트가 단순한 스크립트 언어에서 고성능 애플리케이션 플랫폼으로 진화할 수 있었던 것은 V8의 혁신적인 메모리 관리 덕분이다. 초기 V8은 수십 밀리초의 GC 중단으로 사용자 경험을 해쳤지만, 현재는 수 밀리초 수준으로 단축되었다. 이러한 혁명적 변화의 시작점은 객체를 표현하는 방식부터이다.
V8은 JavaScript 객체를 내부적으로 HeapObject
로 표현하며, 각 객체는 다음과 같은 구조를 갖는다.
// V8 내부 객체 구조 (단순화)
class HeapObject {
Map* map_; // Hidden Class 포인터 (4/8 바이트)
Properties* props_; // 동적 속성 저장소
Elements* elements_; // 배열 요소 저장소
// ... 인라인 속성들
};
Hidden Classes (Maps) 는 V8의 핵심 최적화 기법으로, 동적 타입 언어에서 정적 타입 언어 수준의 성능을 달성하게 해준다. 객체 구조가 변경될 때마다 새로운 Hidden Class로 전환(transition)되며, 이는 Inline Cache(IC)와 결합되어 속성 접근을 최적화한다.
Hidden Classes는 동적 타입 언어인 자바스크립트에서 정적 타입 언어 수준의 성능을 달성하게 해주는 핵심 기술이다. 하지만 이러한 복잡한 객체 구조를 효율적으로 관리하려면 정교한 메모리 관리 전략이 필요하다.
현대 웹 애플리케이션은 많은 힙 메모리를 사용하며, 60FPS의 애니메이션과 실시간 상호작용을 요구한다. V8의 GC는 다음과 같은 도전 과제를 해결해야 한다.
특히 Chrome의 Site Isolation 아키텍처에서는 각 iframe이 별도의 V8 isolate를 가지므로, 메모리 효율성이 더욱 중요해졌다. 이러한 도전 과제를 해결하기 위해 V8은 세대별 힙 구조라는 혁신적인 접근법을 도입했다.
V8의 힙은 단순한 Young/Old 구분을 넘어 복잡한 계층 구조를 가진다.
V8 Heap (총 크기: nn MB ~ n GB)
├── Young Generation (1-32MB)
│ ├── Nursery (Semi-space 1)
│ ├── Intermediate (Semi-space 2)
│ └── Survivor Space
├── Old Generation
│ ├── Old Object Space
│ ├── Code Space (실행 가능 코드)
│ ├── Map Space (Hidden Classes)
│ └── Large Object Space (>256KB 객체)
└── Non-movable Spaces
├── Read-only Space
└── Shared Space (cross-isolate)
이러한 계층적 구조는 객체의 수명에 따라 최적화된 처리를 가능하게 한다. TLAB (Thread-Local Allocation Buffer) 기법을 통해 각 스레드는 독립적인 할당 버퍼를 가지며, 이는 동시성 경합을 최소화한다. 할당은 bump pointer 방식으로 O(1) 시간에 이루어진다.
하지만 세대별 힙 구조는 하나의 가정에 기반한다.
V8의 객체 승격은 단순한 age 기반이 아닌 복합적인 휴리스틱을 사용한다.
// Pretenuring 예시 - V8이 패턴을 학습
function createLargeObject() {
return new Array(1000000); // 여러 번 호출 시 Old Space 직접 할당
}
Write Barrier는 세대 간 참조를 추적한다. Old -> Young 참조 시 remembered set에 기록되어 Minor GC 시 루트로 처리된다.
// 단순화된 쓰기 배리어
if (is_old_object(obj) && is_young_object(value)) {
remembered_set.insert(obj_address);
}
V8 팀의 실측 데이터에 따르면
이러한 통계는 세대별 GC가 효과적인 이유를 설명해준다. 하지만 React 같은 SPA 프레임워크에서는 이 가정이 완전히 깨진다.
React 16부터 도입된 Fiber 아키텍처는 V8의 세대별 가설과 정면으로 충돌하고 있다.
// React Fiber 노드 구조 (단순화)
class FiberNode {
constructor(element) {
this.type = element.type;
this.key = element.key;
this.props = element.props;
// 이 참조들이 문제의 핵심
this.child = null; // 자식 Fiber
this.sibling = null; // 형제 Fiber
this.return = null; // 부모 Fiber
this.alternate = null; // 이전 렌더링의 Fiber (더블 버퍼링)
// 오래 살아남는 참조들
this.memoizedState = null; // Hooks 상태
this.memoizedProps = null; // 이전 props
this.updateQueue = null; // 업데이트 큐
}
}
// 실제 React 앱에서의 Fiber 트리
const fiberRoot = {
current: rootFiber, // 현재 트리 (Old Generation으로 승격)
workInProgress: null, // 작업 중인 트리 (Young Generation)
pendingTime: 0,
finishedWork: null
};
문제점
// 흔한 메모리 누수 패턴
function ExpensiveComponent() {
const [data, setData] = useState([]);
useEffect(() => {
// 이 클로저가 전체 컴포넌트 스코프를 캡처
const timer = setInterval(() => {
setData(prev => [...prev, generateLargeObject()]);
}, 1000);
// cleanup 함수를 잊으면 메모리 누수
return () => clearInterval(timer);
}, []); // 의존성 배열이 비어있어도 클로저는 생성됨
// 각 렌더링마다 새로운 함수 생성 (Young Generation 압박)
const handleClick = useCallback(() => {
// 이 함수는 data 전체를 클로저로 캡처
console.log(data.length);
}, [data]);
}
// V8이 최적화하기 어려운 Hook 패턴
function useComplexState() {
const [state, setState] = useState(() => {
// 이 초기화 함수는 한 번만 실행되지만
// V8은 이를 예측하기 어려워함
return createExpensiveInitialState();
});
// Hook의 연결 리스트 구조가 GC에 부담
const hook = {
memoizedState: state,
queue: updateQueue,
next: nextHook // 다음 Hook 참조
};
}
// Virtual DOM 객체 생성 패턴
function createElement(type, props, ...children) {
return {
$$typeof: REACT_ELEMENT_TYPE,
type,
key: props?.key || null,
ref: props?.ref || null,
props: { ...props, children },
_owner: currentOwner // Fiber 참조
};
}
// 매 렌더링마다 생성되는 임시 객체들
function render() {
// 이 모든 객체가 Young Generation에 생성됨
return (
<div className="container">
{items.map(item => (
<Item
key={item.id}
data={item}
onClick={() => handleClick(item.id)}
/>
))}
</div>
);
// Reconciliation 후 대부분 즉시 버려짐
}
// Reconciliation 중 생성되는 작업 객체들
const updatePayload = {
type: 'UPDATE',
fiber: currentFiber,
partialState: newState,
callback: commitCallback,
next: null // Update 큐의 연결 리스트
};
// React DevTools가 추가하는 메모리 오버헤드
if (__DEV__) {
// 각 Fiber에 디버깅 정보 추가
fiber._debugSource = element._source;
fiber._debugOwner = element._owner;
fiber._debugHookTypes = hookTypes;
// 프로파일링을 위한 타이밍 정보
fiber.actualDuration = 0;
fiber.actualStartTime = 0;
fiber.selfBaseDuration = 0;
fiber.treeBaseDuration = 0;
}
// 메모리 프로파일링 최적화 전략
class MemoryOptimizedComponent extends React.Component {
shouldComponentUpdate(nextProps) {
// 불필요한 렌더링 방지로 Virtual DOM 생성 감소
return !shallowEqual(this.props, nextProps);
}
componentDidMount() {
// WeakMap 사용으로 GC 친화적 캐싱
this.cache = new WeakMap();
}
componentWillUnmount() {
// 명시적 정리로 메모리 누수 방지
this.cache = null;
this.subscription?.unsubscribe();
}
}
// React 18의 자동 배치 처리
function handleMultipleUpdates() {
// 이전: 각 setState가 별도 렌더링 트리거
// 현재: 자동으로 배치 처리되어 GC 부하 감소
setCount(c => c + 1);
setFlag(f => !f);
setItems(i => [...i, newItem]);
}
// Suspense와 메모리 관리
const LazyComponent = React.lazy(() => {
// 동적 import로 초기 메모리 사용량 감소
return import('./HeavyComponent');
});
// useDeferredValue로 우선순위 기반 렌더링
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
// 긴급하지 않은 업데이트는 지연 처리
// Young Generation 부하 분산
return <ExpensiveList query={deferredQuery} />;
}
// Facebook에서 사용하는 메모리 최적화 패턴
const RecyclerListView = {
// 객체 풀링으로 GC 압력 감소
viewPool: [],
getView() {
return this.viewPool.pop() || this.createView();
},
releaseView(view) {
view.reset();
this.viewPool.push(view);
}
};
// Relay의 GC 친화적 캐시 전략
class RelayCache {
constructor() {
// WeakMap으로 자동 메모리 관리
this.records = new WeakMap();
// TTL 기반 만료로 Old Generation 증가 방지
this.ttl = 5 * 60 * 1000; // 5분
}
gc() {
// 주기적으로 오래된 레코드 정리
const now = Date.now();
for (const [key, record] of this.records) {
if (now - record.fetchTime > this.ttl) {
this.records.delete(key);
}
}
}
}
React의 이러한 메모리 패턴들은 V8의 기본 가정과 충돌하지만, V8 팀과 React 팀의 지속적인 협업으로 최적화가 이루어지고 있다. 특히 React 18의 Concurrent Features는 V8의 Incremental GC와 잘 맞물려 동작하도록 설계되었다. 참고
세대별 힙 구조만으로는 충분하지 않다. 어떻게 하면 가비지를 수집하는 동안 애플리케이션을 멈추지 않을 수 있을까? V8의 역사는 이 문제에 대한 답을 찾아가는 과정이었다.
2008년 초기 V8은 대표적인 Copy Algorithm인 Cheney’s Algorithm 기반 Semi-space 컬렉터를 사용했다.
// Cheney 알고리즘 의사 코드
void scavenge() {
scan = next = to_space.bottom;
// 1. 루트 스캐닝
for (root in roots) {
*root = copy(*root);
}
// 2. 너비 우선 탐색
while (scan < next) {
for (slot in slots_in(scan)) {
*slot = copy(*slot);
}
scan += object_size(scan);
}
}
이 알고리즘은 간단하고 효율적이지만, 현대 웹 애플리케이션에는 치명적인 문제를 있다.
V8은 Tri-color Marking 알고리즘을 도입하여 증분마킹을 구현했다.
// Tri-color invariant
enum MarkColor {
WHITE = 0, // 미방문, 회수 대상
GREY = 1, // 방문했으나 자식 미처리
BLACK = 2 // 방문 완료, 살아있음
};
// 증분 마킹을 위한 Barrier
void WriteBarrier(HeapObject* obj, Object** slot, Object* value) {
if (marking_state == INCREMENTAL &&
IsBlack(obj) && IsWhite(value)) {
// tri-color 위반
MarkGrey(value); // 불변성 유지
marking_worklist.Push(value);
}
}
이 방식은 JavaScript 실행 중에도 점진적으로 마킹을 진행할 수 있게 해준다. 하지만 여전히 메인 스레드가 GC 작업을 수행해야 한다는 근본적인 문제가 남아있었다. 이를 해결하기 위해 V8 팀은 더 과감한 시도를 한다.
다음 하편에서는 Orinoco 프로젝트의 혁신적인 세 가지 기술(병렬, 증분, 동시 처리)과 현대적인 최적화 전략, 그리고 실제 프로덕션에서의 성능 측정 결과를 살펴보겠습니다.