dev.syw

V8 파이프라인, 히든 클래스·인라인 캐시, GC, 메모리 누수까지 자바스크립트 실행의 내부를 파헤친다.

엔진 내부 동작과 메모리 모델

입문편에서 클로저, this, 프로토타입의 문법적 사용법을 익혔다면, 이제는 그것들이 실제로 어떻게 메모리에 놓이고 CPU가 어떤 경로로 처리하는지를 이해할 차례입니다. "코드가 왜 빠르고 왜 느린지"는 엔진 내부를 모르면 추측에 의존할 수밖에 없습니다. 이 레슨은 V8 엔진이 코드를 파싱하고 최적화하는 전체 흐름, 그리고 힙과 스택이 어떻게 관리되는지를 구체적인 메커니즘과 함께 다룹니다.

성능 버그와 메모리 누수는 대부분 "엔진이 어떻게 생각하는지"를 의식하지 않은 코드에서 비롯됩니다. 이 레슨을 마치고 나면 DevTools를 열어 힙 스냅샷을 읽고, 코드를 작성할 때 엔진의 최적화 경로를 의식적으로 활용할 수 있게 됩니다.

학습 목표

  • V8 실행 파이프라인의 각 단계(파싱 → Ignition → TurboFan)와 역최적화(deoptimization)가 발생하는 조건을 설명할 수 있다.
  • **히든 클래스(shape)**와 **인라인 캐시(IC)**가 객체 프로퍼티 접근 속도에 미치는 영향을 이해하고 최적 코드 패턴을 작성할 수 있다.
  • 콜 스택, , **세대별 GC(generational GC)**의 동작 원리를 설명할 수 있다.
  • 분리된 DOM 노드, 떠도는 클로저, 타이머·리스너 누적 등 메모리 누수 패턴을 식별하고 수정할 수 있다.
  • Chrome DevTools Memory 패널로 힙 스냅샷과 할당 타임라인을 분석할 수 있다.

V8 실행 파이프라인

파싱부터 바이트코드까지

V8은 JavaScript 소스 코드를 받으면 다음 순서로 처리합니다.

소스 코드
  ↓ Scanner (토큰화)
  ↓ Parser   → AST (Abstract Syntax Tree)
  ↓ Ignition → 바이트코드(Bytecode)  ← 실제 실행 시작
  ↓ (hot path 감지)
  ↓ TurboFan → 기계어(Native Code)   ← 최적화 완료

파서는 두 가지 모드로 동작합니다. 처음 만나는 함수는 "지연 파싱(lazy parsing)"으로 AST 없이 스킵하고, 실제로 호출될 때 완전 파싱합니다. 번들 크기가 클수록 이 지연 효과가 체감됩니다.

Ignition은 레지스터 기반의 인터프리터입니다. 바이트코드는 기계어보다 크기가 작아서 캐시 효율이 좋고, 최적화 전에도 즉시 실행이 가능합니다.

TurboFan은 함수가 충분히 "뜨거워지면(hot)" 동작합니다. 구체적으로는 해당 함수가 반복 호출되거나 루프 안에서 많이 실행될 때 인터프리터가 수집한 **타입 피드백(type feedback)**을 바탕으로 특정 타입에 최적화된 기계어를 생성합니다.

역최적화(Deoptimization)

TurboFan이 "이 함수의 인자는 항상 정수다"라고 가정하고 기계어를 만든 뒤, 갑자기 문자열이 들어오면 해당 최적화 코드를 버리고 Ignition 바이트코드로 돌아갑니다. 이것이 역최적화이며, 성능 버그의 주범입니다.

// ❌ 역최적화를 유발하는 패턴
function add(a, b) {
  return a + b;
}

for (let i = 0; i < 100_000; i++) add(i, i);   // 정수로 최적화됨
add("hello", "world");                           // 타입 변경 → 역최적화 발생

// ✅ 타입을 일관되게 유지
function addNumbers(a, b) {
  return a + b;
}
function addStrings(a, b) {
  return a + b;
}
// 용도별로 함수를 분리하면 각자의 최적화 경로를 유지
JavaScript

💡 TIP node --trace-deopt your-script.js 명령으로 역최적화가 발생하는 함수와 이유를 로그로 확인할 수 있습니다.


히든 클래스(Shape)와 인라인 캐시

히든 클래스란

JavaScript 객체는 동적으로 프로퍼티를 추가/삭제할 수 있습니다. V8은 이 유연성을 유지하면서도 정적 언어에 가까운 속도를 내기 위해 **히든 클래스(Hidden Class, 내부적으로는 "Shape" 또는 "Map"이라고도 함)**를 사용합니다.

객체가 생성될 때 V8은 초기 히든 클래스를 할당합니다. 프로퍼티가 추가될 때마다 새 히든 클래스로 **전환(transition)**됩니다. 같은 순서로 같은 프로퍼티를 가진 객체들은 같은 히든 클래스를 공유합니다.

// ✅ 같은 히든 클래스를 공유하는 패턴
function Point(x, y) {
  this.x = x;   // Shape: { x }
  this.y = y;   // Shape: { x, y }
}

const p1 = new Point(1, 2);
const p2 = new Point(3, 4);
// p1과 p2는 동일한 히든 클래스를 공유 → 빠른 프로퍼티 접근

// ❌ 히든 클래스를 분리시키는 패턴
const a = {};
a.x = 1;  // Shape: { x }
a.y = 2;  // Shape: { x, y }

const b = {};
b.y = 2;  // Shape: { y }       ← 순서가 다름!
b.x = 1;  // Shape: { y, x }

// a와 b는 다른 히든 클래스를 가짐 → IC 최적화 불가
JavaScript

프로퍼티 삭제(delete) 역시 히든 클래스 전환을 일으키고, 경우에 따라 객체를 "dictionary mode"로 강등시켜 해시 테이블 기반 접근으로 전락시킵니다.

인라인 캐시(Inline Cache, IC)

Ignition 인터프리터obj.x를 실행하면서 관찰한 정보, 즉 "이 히든 클래스를 가진 객체라면 오프셋 0에 x가 있다"는 사실을 인라인 캐시(IC)에 기록하고 갱신합니다. 이것이 인라인 캐시이며, IC는 최적화 컴파일러가 만드는 산출물이 아니라 인터프리터(및 베이스라인) 단계에서 설치·갱신되어 타입 피드백을 수집하는 메커니즘입니다. TurboFan은 이렇게 IC가 모아둔 피드백을 소비해, 범용 프로퍼티 탐색 대신 해당 히든 클래스에 특수화된 기계어를 생성합니다.

IC 상태설명성능
Monomorphic항상 같은 히든 클래스최고
Polymorphic2~4가지 히든 클래스중간
Megamorphic5가지 이상 히든 클래스최저(범용 경로)
// ✅ Monomorphic: 하나의 Shape만 처리
function getX(point) {
  return point.x;
}
const points = [new Point(1,2), new Point(3,4), new Point(5,6)];
points.forEach(p => getX(p));  // 항상 같은 Shape → Monomorphic IC

// ❌ Megamorphic: 다양한 Shape가 섞임
function getValue(obj) {
  return obj.value;
}
getValue({ value: 1 });
getValue({ name: "a", value: 2 });
getValue({ id: 1, label: "x", value: 3 });
getValue({ x: 0, y: 0, z: 0, value: 4 });
getValue({ items: [], count: 0, value: 5 });
// 5가지 이상의 Shape → Megamorphic → 최적화 포기
JavaScript

⚠️ 주의 같은 "구조"처럼 보여도 프로퍼티 추가 순서가 다르거나 delete를 사용했다면 V8은 다른 히든 클래스로 취급합니다.


콜 스택, 힙, 그리고 메모리 배치

원시값 vs 참조값의 메모리 배치

JavaScript의 메모리는 크게 **스택(Call Stack)**과 **힙(Heap)**으로 나뉩니다.

구분저장 위치특징
number, boolean, undefined, null콜 스택 프레임의 로컬 슬롯 또는 SMI 태깅으로 인라인 인코딩 가능복사가 값 복사
string힙 (불변 문자열 풀)인터닝(interning)으로 재사용
object, array, function스택에는 참조(포인터)만 저장

📝 참고 "원시값은 스택, 객체는 힙"은 이해를 돕기 위한 단순화된 모델입니다. 실제 V8에서는 원시값이 항상 스택에만 머무는 것은 아니며, 클로저에 캡처되거나 객체 프로퍼티로 들어가면 힙에 위치할 수 있고 레지스터에 머무를 수도 있습니다. SMI 또한 '스택에 저장'이라기보다 포인터 슬롯(레지스터·스택·객체 필드 어디든)에 태그 형태로 인코딩되는 것입니다.

V8에서 **SMI(Small Integer)**는 -2³¹ ~ 2³¹-1 범위의 정수를 포인터 비트에 직접 인코딩합니다. 별도의 힙 할당이 없어서 매우 빠릅니다. 이 범위를 벗어나거나 부동소수점이 되면 HeapNumber로 힙에 박스(boxing)됩니다.

// SMI 범위 내 정수 — 힙 할당 없음
let a = 42;         // SMI
let b = 2147483647; // 여전히 SMI (2^31 - 1)

// HeapNumber — 힙에 박스(boxing)됨
let c = 2147483648; // SMI 범위 초과 → HeapNumber
let d = 3.14;       // 부동소수점 → HeapNumber
let e = NaN;        // HeapNumber

// 반복 연산에서 boxing 비용 예시
// ❌ 루프마다 HeapNumber 생성
function sumFloats(arr) {
  let sum = 0.1;  // HeapNumber
  for (let i = 0; i < arr.length; i++) {
    sum += arr[i]; // 매 반복마다 새 HeapNumber 할당 가능
  }
  return sum;
}

// ✅ 정수 연산 유지 (SMI 경로)
function sumInts(arr) {
  let sum = 0;    // SMI
  for (let i = 0; i < arr.length; i++) {
    sum += arr[i]; // SMI + SMI → SMI (힙 할당 없음)
  }
  return sum;
}
JavaScript

세대별 GC와 mark-and-sweep

V8의 힙은 Young GenerationOld Generation으로 나뉩니다.

힙(Heap)
├── Young Generation (새로 할당된 객체)
│   ├── From-space
│   └── To-space    ← Scavenge(Minor GC) 대상
└── Old Generation  ← Major GC(Mark-and-Sweep) 대상
    ├── Old Space   (오래된 객체)
    ├── Code Space  (컴파일된 코드)
    └── Large Object Space

Minor GC (Scavenge): Young Generation에서 실행됩니다. From-space의 살아있는 객체만 To-space로 복사하고 나머지를 버립니다. 두 번의 Minor GC를 살아남은 객체는 Old Generation으로 **승격(promotion)**됩니다. 빠르지만 Young Generation에만 국한됩니다.

Major GC (Mark-and-Sweep-Compact): Old Generation 전체를 대상으로 합니다.

  1. Mark: GC 루트(전역 변수, 스택 변수)에서 도달 가능한 모든 객체를 표시합니다.
  2. Sweep: 표시되지 않은 객체를 메모리 해제합니다.
  3. Compact: 단편화를 줄이기 위해 살아있는 객체를 한쪽으로 모읍니다.

V8은 증분(Incremental) 마킹동시(Concurrent) 스위핑으로 GC 중 발생하는 stop-the-world 멈춤을 최소화합니다(참고로 V8은 concurrent marking·concurrent sweeping과 parallel scavenge를 구분합니다. Concurrent=동시, Parallel=병렬).

// Young Generation에 압력을 주는 패턴 (Minor GC 빈발)
// ❌ 루프 안에서 매번 임시 객체 생성
function processItems(items) {
  return items.map(item => ({
    id: item.id,
    label: `[${item.type}] ${item.name}`, // 매 호출마다 새 문자열 + 객체
    active: item.status === "active",
  }));
}

// ✅ 재사용 가능한 버퍼나 사전 할당 활용 (성능 민감 코드)
const result = new Array(items.length); // 배열 크기 사전 할당
for (let i = 0; i < items.length; i++) {
  result[i] = {
    id: items[i].id,
    label: items[i].type + " " + items[i].name,
    active: items[i].status === "active",
  };
}
JavaScript

메모리 누수 패턴과 진단

메모리 누수는 GC가 "도달 가능"하다고 판단하지만 실제로는 더 이상 사용되지 않는 객체가 힙에 쌓이는 현상입니다. 도달 불가능한 객체는 GC가 알아서 수거하므로, 누수는 항상 참조가 의도치 않게 유지되는 경우입니다.

패턴 1: 분리된 DOM 노드(Detached DOM)

DOM 노드를 JS 변수에 저장한 뒤 DOM 트리에서 제거해도 JS 참조가 남아 있으면 노드 전체 서브트리가 힙에 유지됩니다.

// ❌ 메모리 누수: detached DOM
let detachedList = null;

function createList() {
  const ul = document.createElement("ul");
  for (let i = 0; i < 1000; i++) {
    ul.appendChild(document.createElement("li"));
  }
  document.body.appendChild(ul);
  detachedList = ul; // 전역 변수에 참조 보관
}

function removeList() {
  document.body.removeChild(detachedList);
  // detachedList 참조가 살아있으므로 1001개 노드 힙에 잔류
}

// ✅ 제거 후 참조도 해제
function removeListFixed() {
  document.body.removeChild(detachedList);
  detachedList = null; // GC가 수거할 수 있도록 참조 해제
}
JavaScript

패턴 2: 떠도는 클로저(Lingering Closure)

클로저가 외부 스코프의 큰 객체를 캡처하고 있고, 클로저 자체가 오래 살아있으면 캡처된 객체도 수거되지 않습니다.

// ❌ 클로저가 큰 데이터를 불필요하게 캡처
function setupHandler() {
  const hugeData = new Array(1_000_000).fill("*"); // 8MB+

  document.getElementById("btn").addEventListener("click", function handler() {
    // hugeData를 실제로 사용하지 않지만 클로저가 스코프를 통해 캡처
    console.log("clicked");
  });
  // hugeData는 handler 클로저가 살아있는 한 수거되지 않음
}

// ✅ 필요한 데이터만 클로저 안으로 가져오거나, IIFE로 스코프 분리
function setupHandlerFixed() {
  // hugeData를 사용하는 작업을 먼저 처리
  const hugeData = new Array(1_000_000).fill("*");
  const summary = hugeData.length; // 필요한 값만 추출
  // hugeData 참조 종료 (setupHandlerFixed 함수 내 지역 변수이므로
  // addEventListener 콜백이 캡처하지 않음)

  document.getElementById("btn").addEventListener("click", function handler() {
    console.log("clicked, items:", summary); // 작은 값만 캡처
  });
}
JavaScript

패턴 3: 타이머·이벤트 리스너 누적

setIntervaladdEventListener는 명시적으로 해제하지 않으면 콜백이 영구적으로 참조를 유지합니다.

// ❌ 컴포넌트 제거 후에도 타이머가 계속 실행
class DataPoller {
  constructor(url) {
    this.url = url;
    this.data = null;
    // intervalId를 보관하지 않으면 clearInterval 불가
    setInterval(() => this.fetch(), 5000);
  }

  fetch() {
    // this(DataPoller 인스턴스)를 클로저가 참조 → GC 불가
    fetch(this.url).then(r => r.json()).then(d => { this.data = d; });
  }
}

// ✅ cleanup 메서드로 타이머와 참조 해제
class DataPollerFixed {
  constructor(url) {
    this.url = url;
    this.data = null;
    this._intervalId = setInterval(() => this.fetch(), 5000);
  }

  fetch() {
    fetch(this.url).then(r => r.json()).then(d => { this.data = d; });
  }

  destroy() {
    clearInterval(this._intervalId); // 타이머 해제
    this._intervalId = null;
  }
}

// React 환경 예시: useEffect cleanup
// ✅
import { useEffect } from "react";

function usePoller(url) {
  useEffect(() => {
    const id = setInterval(() => {
      fetch(url).then(r => r.json()).then(console.log);
    }, 5000);

    return () => clearInterval(id); // 컴포넌트 언마운트 시 해제
  }, [url]);
}
JavaScript

⚠️ 주의 WeakMapWeakRef는 키/참조 대상이 다른 강한 참조 없이 수거될 때 GC가 자동으로 처리합니다. DOM 노드를 키로 써야 한다면 WeakMap을 활용하면 분리된 DOM 누수를 방지할 수 있습니다.


Chrome DevTools Memory 패널 활용

힙 스냅샷(Heap Snapshot) 읽기

  1. DevTools → Memory 탭 → Take snapshot 클릭
  2. 스냅샷 목록에서 Summary 뷰 선택
  3. Constructor 컬럼: 객체 유형별 집계
  4. Shallow Size: 해당 객체 자체의 메모리
  5. Retained Size: 이 객체가 수거될 때 함께 해제될 수 있는 전체 메모리

메모리 누수를 찾는 기본 워크플로우:

① 초기 스냅샷 촬영 (Snapshot 1)
② 의심스러운 액션 반복 실행 (예: 모달 열기/닫기 10회)
③ 두 번째 스냅샷 촬영 (Snapshot 2)
④ Comparison 뷰에서 "# New" 컬럼 기준 정렬
⑤ 줄어들었어야 할 객체가 늘어났다면 누수 확정
⑥ Retainers 패널에서 참조 체인 추적

할당 타임라인(Allocation Timeline)

실시간으로 힙 할당 패턴을 기록합니다. 타임라인에서 GC 후에도 사라지지 않는 파란 막대는 수거되지 못한 할당을 의미합니다.

DevTools → Memory → Allocation instrumentation on timeline
→ 레코딩 시작 → 액션 수행 → 레코딩 중지
→ 파란 막대(수거 안 됨) 클릭 → 해당 시점의 할당 스택 확인

💡 TIP Node.js 환경에서는 --inspect 플래그와 Chrome DevTools 또는 heapdump 패키지로 동일한 분석이 가능합니다. process.memoryUsage()heapUsed를 주기적으로 로깅해 누수 추세를 확인하는 방법도 자주 사용됩니다.

// Node.js에서 메모리 사용량 모니터링
setInterval(() => {
  const { heapUsed, heapTotal, rss } = process.memoryUsage();
  console.log({
    heapUsed: `${(heapUsed / 1024 / 1024).toFixed(1)} MB`,
    heapTotal: `${(heapTotal / 1024 / 1024).toFixed(1)} MB`,
    rss: `${(rss / 1024 / 1024).toFixed(1)} MB`,
  });
}, 10_000);
JavaScript

요약

  • V8 파이프라인: 소스 → AST → Ignition 바이트코드 → TurboFan 기계어. 타입 피드백이 일관성을 잃으면 역최적화가 발생해 성능이 급락한다.
  • **히든 클래스(Shape)**를 공유하도록 객체 프로퍼티를 항상 같은 순서로 초기화하고, delete를 피해야 인라인 캐시가 Monomorphic 상태를 유지한다.
  • SMI는 힙 할당 없이 스택에 인코딩되고, 범위 초과 정수나 부동소수점은 HeapNumber로 boxing된다. 성능 민감 루프에서는 정수 연산을 유지하는 것이 유리하다.
  • GC는 Young(Scavenge)Old(Mark-and-Sweep-Compact) 세대별로 동작하며, 단명 객체를 많이 만들수록 Minor GC 빈도가 높아진다.
  • 메모리 누수의 3대 패턴은 분리된 DOM, 떠도는 클로저, 해제되지 않은 타이머·리스너이며, 모두 "참조가 의도치 않게 살아있는" 경우다.
  • DevTools Heap Snapshot 비교Allocation Timeline으로 누수를 정량적으로 식별하고 Retainers 체인으로 근본 원인을 추적한다.

연습문제

  1. 아래 코드에서 히든 클래스 분리가 발생하는 원인을 설명하고, Monomorphic IC를 유지하도록 수정하세요.

    function createUser(name, role) {
      const user = {};
      if (role === "admin") {
        user.permissions = ["read", "write", "delete"];
      }
      user.name = name;
      user.role = role;
      return user;
    }
    
    JavaScript

    힌트 프로퍼티 추가 순서와 조건부 프로퍼티가 히든 클래스 전환에 어떤 영향을 미치는지 생각해 보세요.

  2. 다음 React 컴포넌트에는 메모리 누수가 있습니다. 누수 원인을 설명하고 수정하세요.

    import { useState, useEffect } from "react";
    
    function LiveClock() {
      const [time, setTime] = useState(new Date().toLocaleTimeString());
    
      useEffect(() => {
        setInterval(() => {
          setTime(new Date().toLocaleTimeString());
        }, 1000);
      }, []);
    
      return <div>{time}</div>;
    }
    

    힌트 컴포넌트가 언마운트될 때 인터벌이 어떻게 되는지 추적하세요.

  3. 아래 processLargeData 함수가 호출될 때마다 힙 사용량이 증가합니다. DevTools 없이 코드만 보고 원인을 찾아 수정하세요.

    const cache = {};
    
    function processLargeData(key, data) {
      const buffer = new Array(500_000).fill(0).map((_, i) => i * 2);
      cache[key] = function() {
        return buffer.reduce((a, b) => a + b, 0);
      };
    }
    
    JavaScript

    힌트 클로저가 어떤 값을 캡처하고 있는지, 실제로 캡처가 필요한 것은 무엇인지 분리해 보세요.

  4. node --trace-deopt로 역최적화를 관찰하려 합니다. 아래 코드를 실행하면 역최적화가 발생할 것으로 예상되는 지점을 찾고, 발생하지 않도록 수정하세요.

    function multiply(x, y) {
      return x * y;
    }
    
    for (let i = 0; i < 50_000; i++) multiply(i, i);
    multiply(1.5, 2.5);
    multiply("3", 4);
    
    JavaScript

    힌트 각 호출에서 인자 타입이 어떻게 바뀌는지 단계별로 추적하세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

JavaScript 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.