본문으로 건너뛰기
yutils

JavaScript 는 어떻게 파싱·실행될까?

source bytes 부터 V8 hidden class 까지 — lexer · parser · AST · hoisting · event loop · ASI 함정 · 동적 언어인 JavaScript 가 빨라지는 최적화.

약 9분 읽기

JavaScript 는 동적 타입 언어인데도 V8 / SpiderMonkey 같은 모던 엔진은 거의 native 속도로 실행한다. 어떻게? 그리고 var 가 hoisting 되는 이유, return 다음 줄에 객체 시작{ 박으면 깨지는 ASI 함정, for (...) 가 micro-task / macro-task 보다 먼저 실행되는 event loop — 이 모든 게 parser → AST → optimizer → bytecode → JIT 파이프라인의 결과. 이 가이드는 JavaScript 가 어떻게 파싱·실행되는지 정리한다.

전체 파이프라인

source bytes
   ↓
1. Lexer       — character stream → token stream
   ↓
2. Parser      — token → AST (abstract syntax tree)
   ↓
3. Bytecode    — AST → V8 의 경우 Ignition bytecode
   ↓
4. Interpreter — bytecode 실행 (start fast)
   ↓
5. Profiler    — hot function 감지
   ↓
6. JIT         — TurboFan / Maglev 로 machine code 컴파일
   ↓
machine code 실행

V8 (Chrome / Node), SpiderMonkey (Firefox), JavaScriptCore (Safari) — 세 엔진 모두 비슷한 구조. 단, 단계 이름·최적화 디테일 다름.

1. Lexer — source → token

source:
const x = 42 + foo();

tokens:
  KEYWORD     const
  IDENT       x
  PUNCT       =
  NUMBER      42
  PUNCT       +
  IDENT       foo
  PUNCT       (
  PUNCT       )
  PUNCT       ;

공백·주석 무시. 한 character 씩 state machine 진행. JavaScript 의 lexer 가 정규 expression 보다 어려운 이유 — 같은 글자가 context 따라 다른 token:

/abc/   ← regex 또는 나누기?
  context: 변수 뒤 (a = /abc/) → 나누기
           statement 시작 또는 ( 뒤 → regex

2. Parser — token → AST

source:
function add(a, b) {
  return a + b;
}

AST 일부:
FunctionDeclaration
├── name: "add"
├── params: [a, b]
└── body: BlockStatement
    └── ReturnStatement
        └── BinaryExpression
            ├── op: "+"
            ├── left: Identifier(a)
            └── right: Identifier(b)

Lookup — astexplorer.net에서 임의 JavaScript 의 AST 즉시 표시. Babel / ESLint / TypeScript / Prettier 가 모두 같은 AST 기반. JavaScript 포매터 도.

ASI — Automatic Semicolon Insertion 의 함정

JavaScript 가 ; 생략 허용. 그러나 parser 가 추측하는 규칙이 함정:

// 의도 — 객체 반환
function f() {
  return
  {
    name: "Alice"
  };
}

// 실제 동작 — ASI 가 return 뒤에 ; 박음
function f() {
  return;            // ← undefined 반환!
  { name: "Alice" }; // ← unreachable
}

규칙 — newline 이 있으면 ASI 가 ; 박음 (단, return, throw, break,continue, ++, -- 뒤).

해결 — 객체 반환 시 같은 줄에서 { 시작 또는 명시적 ; 사용.

3. Hoisting — parser 단계의 결과

// 코드 순서
console.log(x);  // undefined (에러 X)
console.log(y);  // ReferenceError
var x = 1;
let y = 2;

parser 가 scope 분석 시 변수 선언을 미리 발견 → hoisted. 그러나:

  • var — 선언 + undefined 초기화 hoist
  • let / const — 선언만 hoist, 초기화는 실제 라인까지. 그 사이 = temporal dead zone
  • function declaration — 전체 hoist (선언 + body)
  • function expression — var 와 동일 (undefined 초기화)

4. Bytecode — Ignition

V8 의 Ignition 은 register-based VM. AST 직접 실행 (interpretation) 은 너무 느려서 bytecode 로 컴파일:

source:
function add(a, b) {
  return a + b;
}

ignition bytecode (간략):
Ldar a1            // a 를 accumulator 로
Add a2             // accumulator += b
Return             // accumulator 반환

bytecode 는 source 보다 작고 빠름. 그러나 native code 보다 느림 — hot function 만 JIT compile.

5. JIT — TurboFan / Maglev

V8 의 JIT 계층 (2024 기준):

  1. Ignition — 모든 코드를 bytecode 로. start 빠름.
  2. Sparkplug (2021) — bytecode → simple machine code. 빠른 baseline JIT.
  3. Maglev (2023) — mid-tier optimizing JIT. inline cache 활용.
  4. TurboFan — top-tier optimizing JIT. 최강 최적화, compile 시간 가장 길음. hot loop 만.

Profiler 가 hot function 감지 → 더 높은 tier 로 promotion. deoptimization (가정 깨지면 lower tier 로 down) 도 자주.

Hidden Class — 동적 객체를 빠르게

JavaScript 객체는 동적 (필드 추가 가능):

const a = {x: 1};
a.y = 2;       // 추가
a.z = 3;       // 추가

Naive 구현 — 매번 dict lookup. 느림. V8 의 hidden class 트릭:

a = {x: 1}    → Class C0 [x: offset 0]
a.y = 2       → Class C1 [x: offset 0, y: offset 4]
a.z = 3       → Class C2 [x: offset 0, y: offset 4, z: offset 8]

같은 모양의 객체는 같은 hidden class. obj.x access 가 dict lookup 이 아닌 fixed offset → C++ struct 수준 속도.

Performance 함정

// Bad — 순서 다른 객체 → 다른 hidden class
function makeUser(name, age) {
  const u = {};
  if (name) u.name = name;
  if (age) u.age = age;
  return u;
}
makeUser("Alice", 30)    → {name, age}    → class A
makeUser(null, 30)       → {age}          → class B
makeUser("Alice")        → {name}         → class C
// 3 가지 hidden class → polymorphic site → JIT 비최적화

// Good — 항상 같은 모양
function makeUser(name, age) {
  return {name: name || null, age: age || null};
}
// 항상 class X → monomorphic site → JIT 최적화

Inline Cache — property access 최적화

function getName(obj) {
  return obj.name;
}

// 매 호출마다:
// 1. obj 의 hidden class lookup
// 2. class 안 "name" 의 offset lookup
// 3. memory access

// JIT 가 첫 호출 후 "이 site 는 항상 class X" 학습 → inline cache 저장
// 다음 호출 — class 확인만, offset 은 cache → 1 step

같은 함수에 다른 hidden class 객체 전달 = polymorphic / megamorphic → cache miss → 느림. 일관된 객체 모양 권장.

Event Loop — 비동기의 핵심

while (true) {
  if (callStack.empty) {
    if (microtaskQueue.notEmpty) {
      runMicrotask();  // Promise.then, queueMicrotask
    } else if (macrotaskQueue.notEmpty) {
      runMacrotask();  // setTimeout, setInterval, I/O
    } else {
      sleep();
    }
  }
  render();  // 매 frame
}

Microtask vs macrotask 우선도. 같은 tick 에서 microtask 가 모두 처리된 후 macrotask 1 개 → render.

console.log("1");
setTimeout(() => console.log("2"), 0);  // macrotask
Promise.resolve().then(() => console.log("3"));  // microtask
console.log("4");

출력: 1, 4, 3, 2
       │  │  │  │
       │  │  │  └─ macrotask
       │  │  └──── microtask (현재 tick 끝)
       │  └─────── sync
       └────────── sync

Closure — scope chain 의 결과

function counter() {
  let n = 0;
  return () => ++n;
}

const inc = counter();
inc();  // 1
inc();  // 2

inner function 이 outer 의 n 을 capture. parser 가 scope chain 만들 때 inner 가 outer 변수 참조하면 outer 의 stack frame 을 heap 으로 promote (escape analysis).

memory 누수 위험 — outer 가 큰 객체 capture 시 inner 가 살아있는 동안 GC X.

Bundler·Minifier 와 parser

  • Babel — 옛 JS → 새 JS transform. parser → AST 변형 → re-emit
  • esbuild / swc — Go/Rust 로 작성된 fast parser/ bundler. JS native parser 보다 10-100 배 빠름
  • Terser — minifier. parser → AST → name mangling / dead code elimination → re-emit
  • Prettier / formatter — parser → AST → 들여쓰기 rule 로 re-emit

JavaScript 포매터 도 같은 패턴 — parser → AST → prettify. JS Object → JSON 는 JS 객체 리터럴 (unquoted key, trailing comma) → 표준 JSON 변환. parser 가 relaxed JS 문법 받아 표준 JSON 출력.

흔한 함정

1. ASI 의 invisible bug

앞서 본 return + 새 줄. ++ / -- 도 동일:

a
++b    // a 가 ++b 의 결과로 평가? X — a; ++b; 로 ASI

2. polymorphic hidden class

앞서 본 객체 모양 다양화 → JIT 비최적화.

3. delete obj.x

delete obj.x;
// hidden class 변경 + dictionary mode 로 전환 → 모든 access 느려짐

// 대신
obj.x = undefined;
// hidden class 그대로

4. Array 의 hole

const arr = [1, 2, 3];
arr[10] = 100;
// 길이 11, 인덱스 3-9 는 hole (sparse)
// V8 가 dictionary mode 로 전환 → 느려짐

// 대신
const arr = new Array(11).fill(undefined);
arr[10] = 100;
// dense, 빠름

5. eval / with — JIT 비활성화

eval() / with 가 scope 동적 변경 → parser 가 정적 분석 못 함 → 해당 function 의 JIT 포기. 모던 코드 에서는 사용 X.

참고 자료

요약

  • source → lexer → parser → AST → bytecode → interpreter → (hot 시) JIT → machine code.
  • ASI 가 newline + return/throw/break/continue 뒤 자동 ; 박음. 객체 반환 시 같은 줄에서 {.
  • Hoisting — var (선언 + undefined) / let·const (TDZ) / function (full) / function expression (var 와 동일).
  • V8 의 hidden class — 같은 모양 객체 = 같은 class → monomorphic site → JIT 최적화. polymorphic 회피.
  • Event loop — sync → microtask → macrotask → render. Promise.then 이 setTimeout 보다 먼저.
  • Closure = outer 의 변수를 inner 가 capture → outer frame 이 heap 으로 promote.
  • esbuild / Babel / Prettier 모두 같은 parser → AST → emit 패턴.
  • 실험 — JavaScript 포매터 / JS Object → JSON / astexplorer.net (외부) 에서 AST 직접 보기.
가이드 목록으로