General15분 읽기

모던 자바스크립트 Deep Dive 1~6장 리뷰

35

백엔드 개발자인데 자바스크립트를 제대로 모르고 쓰고 있다는 자각이 있었다. AI한테 "이거 구현해"라고 던지면 쉬운 건 잘 해주는데, 조금만 어려워지면 이상한 짓을 하고, 그걸 검토하느라 시간을 더 쓰는 루프를 반복하고 있었다. 그래서 자바스크립트를 처음부터 제대로 잡아보자는 마음으로 이 책을 펼쳤다.

기본기가 여전히 중요하다고 생각한다. A to Z를 알아야 AI를 시키더라도 제대로 제어할 수 있다. 결과물이 맞는지 틀린지, 내가 판단하려면 밑바닥을 알아야 한다.


1장 — 프로그래밍이란 A to Z를 설명하는 일

1장은 프로그래밍이 뭔지, 컴파일러니 인터프리터니 하는 용어 정리가 나온다. 중요한 건 딱히 없다고 생각했는데, 저자가 결국 강조하는 한 가지가 있다.

프로그래밍이란 0과 1밖에 모르는 기계가 실행할 수 있게 정확하고 상세하게 요구사항을 설명하는 작업이다.

단순히 "코드를 짜는 것"이 아니라 커뮤니케이션이라는 거다. 컴퓨터에게 "무엇을" 해야 하는지 정의하는 것. 이 정의가 정확하지 않으면 프로그램은 엉뚱한 짓을 한다.

"문제 해결 능력"이란 결국 직관의 영역이다

개발하면서 "문제 해결 능력이 중요하다"는 말을 귀에 딱지가 앉도록 들었다. 근데 항상 좀 추상적이라고 느꼈다. 개발에는 다양한 능력치가 있고, 사람마다 아는 것과 모르는 게 다를 텐데.

이 책에서 명쾌하게 대답한다.

문제 해결 능력은 직감과 직관의 영역이다. 문제를 바라보는 우리의 사고와 경험에 영향을 받는다.

이 문장이 진짜 공감이 됐다. 결국 많이 알면 직관이 생기고, 직관이 있으면 문제를 더 잘 푼다는 말이다. 시스템의 추상적인 부분을 구체적으로 알수록, 모든 것을 의심하는 태도를 가질수록 직관이 쌓인다. 당연한 말인데, 이렇게 직설적으로 들으니까 묵직하게 와닿았다.

실제로 경험적으로도 그렇다. 뭔가를 깊이 알고 있는 영역에서는 문제를 보자마자 "아, 이거 이쪽이 문제겠다"하는 감이 온다. 그 감이 직관이고, 직관은 결국 축적된 지식에서 나온다. "문제 해결 능력을 기르세요"라는 추상적인 조언보다, "많이 알면 직관이 생기고 그게 문제 해결 능력이에요"가 훨씬 실질적이다.

AI 시대에 이 말이 더 와닿는 이유

최근에 AI를 쓰면서 소위 "딸깍"을 많이 했다. 로그 읽기 귀찮아서 로그 자체를 복사해서 프롬프트에 던지고, "이거이거 안 돼, 이거 구현해"라고 두리뭉실하게 시키고.

쉬운 문제는 이게 효율적이다. 그런데 조금만 어려워지거나 맥락이 부족하면 AI가 이상한 짓을 한다. 그걸 검토하느라 시간이 더 든다. 그러면서도 그 작업에 대해 A to Z까지 알아보고 시키지는 않았다.

결국 책에서 말하는 "프로그래밍 = A to Z를 설명하는 작업"이 AI에게도 그대로 적용되는 거다. AI가 계속 발전하고 있지만, 어디까지 알아서 풀 수 있고 어디서부터 못 푸는지 그 경계가 아직 명확하지 않다. 그 경계를 판단하는 것도 결국 내 직관이다. 내가 그 영역을 깊이 알아야 "여기까지는 맡기고 여기서부터는 내가 잡는다"는 판단이 가능하다.

이 생각이 이번 책 읽기의 동기가 됐다. 자바스크립트의 A to Z를 알아보자.


2장 — 자바스크립트의 역사

자바스크립트의 탄생 배경이 쭉 나온다. 넷스케이프, 브렌던 아이크, 10일 만에 만들었다는 이야기. 대부분은 흥미롭되 핵심은 아니었다. 몇 가지 메모.

ECMAScript가 왜 필요했나

ECMA는 "문법 표준화"라고 한다. 처음에 "그러면 이게 있기 전에는 문법이 달랐다는 건가?" 싶었는데, 맞다. 초기에는 넷스케이프의 JavaScript와 마이크로소프트의 JScript가 미묘하게 달랐다. 같은 웹페이지를 브라우저마다 다르게 해석하는 지옥이 있었고, 그래서 표준이 필요했다.

ES6라는 건 ECMAScript 2015 버전을 말하는 거였다. let, const, 화살표 함수, 템플릿 리터럴 등이 여기서 들어왔다. 브라우저마다 ES6 지원 시점이 달라서, 진짜 옛날 브라우저는 ES6 문법을 아예 실행 못 한다.

V8 엔진과 Node.js

구글 맵스가 나오면서 자바스크립트로 "어플리케이션"을 만들 수 있다는 가능성이 확인됐고, 그러려면 엔진이 빨라야 했다. 그래서 구글이 V8 엔진을 만들었다고 한다. Node.js는 이 V8 엔진 위에 서버사이드 기능을 올린 런타임 환경이다.

이 부분은 궁금한 게 많이 생겼는데, 이번 글의 범위를 넘어서니까 다음에 따로 파보기로 했다. 노트에는 이렇게만 적어뒀다.

"그놈의 V8 언젠가 발가벗겨주마..."


3장 — 브라우저 vs Node.js

처음 자스를 하며 헷갈렸던 게 이거다. 어디서는 브라우저를 쓰고 어디서는 노드를 쓰는 것.... (그냥 진짜 프론트면 브라우저 아님 노드인거엿다)

  • 브라우저: 화면 렌더링이 목적. DOM API를 제공한다.
  • Node.js: 브라우저 밖에서 JS를 실행하는 게 목적. 파일 시스템을 제공한다.

둘 다 ECMAScript를 실행할 수 있다. ECMAScript라는 공통 언어 사양 위에 각각 다른 API를 올려놓은 구조다. 브라우저는 DOM, BOM, Web API를 올리고, Node.js는 파일 시스템, HTTP 모듈 같은 걸 올린다.

처음에 ECMAScript를 엔진 비슷한 거로 착각했었는데, ECMAScript는 "언어 사양(스펙)"이고, 그 스펙을 구현한 것이 V8(Chrome, Node.js), SpiderMonkey(Firefox), JavaScriptCore(Safari) 같은 엔진이다. 이 구분이 명확해지니까 구조가 깔끔하게 보였다.


4장 — 변수

변수는 메모리 주소에 붙인 이름

변수는 하나의 값을 저장하기 위해 확보한 메모리 공간, 또는 그 공간을 식별하기 위한 이름이다. 메모리 주소를 직접 기억할 수 없으니, 주소에 이름을 붙여서 접근하는 것. 여기까지는 다른 언어와 크게 다를 게 없다.

실행 컨텍스트

"변수 이름을 비롯한 모든 식별자는 실행 컨텍스트에 등록된다"는 문장에서 궁금증이 생겼다. 변수뿐 아니라 함수 이름, 클래스 이름, 매개변수 이름 등 모든 식별자가 여기 등록된다고 한다.

실행 컨텍스트: 자바스크립트 엔진이 소스코드를 평가하고 실행하기 위해 필요한 환경을 제공하고, 코드의 실행 결과를 관리하는 영역. 식별자와 스코프를 관리한다.

13장(스코프)과 23장(실행 컨텍스트)에서 자세히 다룬다고 하니까, 거기서 제대로 파볼 예정이다.

근데 기다리는 게 성격에 안 맞아서 좀 미리 찾아봤다.

📌 실행 컨텍스트, 미리 까보기

ECMAScript 스펙 기준으로, 실행 컨텍스트의 핵심은 LexicalEnvironment다. 이 녀석은 두 가지로 구성된다.

  • Environment Record — 현재 스코프의 식별자와 값을 저장하는 곳. let x = 10;이라고 쓰면 여기에 x → 10이 기록된다. "해시맵 같은 건가?" 싶었는데, 개념적으로는 맞다. 키-값 저장소다.
  • Outer Environment Reference — 바깥 스코프의 LexicalEnvironment를 가리키는 참조. 스코프 체인이 이 참조를 따라 올라가며 식별자를 찾는다.
hljs javascript
let a = 1;

function outer() {
  let b = 2;
  function inner() {
    let c = 3;
    console.log(a + b + c); // 6
  }
  inner();
}
outer();

inner에서 a를 찾을 때: inner의 Environment Record에서 찾는다 → 없다 → Outer Reference를 따라 outer로 간다 → 없다 → 또 Outer Reference를 따라 전역으로 간다 → a = 1 찾음.

이 체인을 타고 올라가는 게 스코프 체인이다. 코드로 보면 당연한 건데, 구체적인 구조를 알고 나니 "왜 클로저가 동작하는지"도 자연스럽게 연결됐다. 클로저는 결국 함수가 생성될 때의 LexicalEnvironment 참조를 계속 들고 있는 것이니까.

Environment Record에도 종류가 있다고 한다. Declarative, Object, Global 등. 왜 나뉘는지는 23장에서 다시 확인할 예정인데, 큰 그림은 여기서 잡힌 것 같다.

var의 선언과 초기화

var는 선언 단계와 초기화 단계가 동시에 진행된다. 값을 할당하지 않아도 undefined가 자동으로 들어간다.

  • 선언 단계: 변수 이름을 등록해서 존재를 알림
  • 초기화 단계: 메모리 공간을 확보하고 undefined로 초기화

letconst는 이 두 단계가 분리되어 있다고 한다. 이것도 추후에 더 알아보기로.

변수 호이스팅

hljs javascript
console.log(score); // undefined
var score;

변수 선언이 안 됐으니 참조 에러가 날 거라고 생각하지만, undefined가 출력된다. 변수 선언이 런타임이 아니라 그 이전 단계에서 먼저 실행되기 때문이다.

소스코드를 실행하기 전에 평가 과정을 거치면서 변수 선언 같은 준비 작업을 먼저 처리한다. 그래서 코드상으로는 아래에 있는 변수 선언이 위에서도 참조가 가능한 것.

이걸 "변수 선언문이 코드의 선두로 끌어올려진 것처럼 동작한다"고 해서 변수 호이스팅이라고 부르는데, 솔직히 이 비유가 오히려 헷갈리게 만든다. 실제로 코드가 물리적으로 이동하는 게 아니라, 실행 전 평가 과정에서 선언이 미리 처리되는 것이다.

📌 호이스팅이 엔진 레벨에서는 뭔가

"코드가 물리적으로 이동하는 게 아니다"까지는 알겠는데, 그러면 엔진은 정확히 뭘 하는 건지 궁금했다.

V8 기준으로 보면 이렇다. 소스코드를 실행하기 전에 파싱(parsing) 단계가 있다. 파서가 소스코드를 AST(Abstract Syntax Tree)로 변환하면서 스코프를 분석한다. 이때 var, let, const, function 선언을 모두 수집해서 각 스코프의 변수 목록을 확정한다.

이게 "평가 과정"의 실체다. 코드가 끌어올려지는 게 아니라, 실행 전에 선언을 미리 수집하는 것. "호이스팅"이라는 이름이 꽤 오해를 부른다는 생각이 더 강해졌다.

📌 그러면 let/const의 TDZ는?

책에서 let/const도 호이스팅은 된다고 했다. 근데 에러가 난다고. 이게 뭔 소린가 싶어서 파봤다.

ECMAScript 스펙에서 변수 라이프사이클은 세 단계다.

  1. 선언 — 스코프에 등록
  2. 초기화 — 메모리 확보 + 값 세팅
  3. 할당 — 실제 값 넣기

var는 1번과 2번이 동시에 일어난다. 평가 과정에서 선언하면서 undefined로 초기화까지 끝낸다.

let/const는 1번만 먼저 일어난다. 스코프에 등록은 되지만 초기화는 아직이다. 이 "등록은 됐는데 초기화는 안 된" 구간이 **TDZ(Temporal Dead Zone)**다.

여기서 좀 놀랐다. 등록은 된다는 거다. 엔진이 변수의 존재를 모르는 게 아니라, 알면서도 접근을 막는다.

이걸 확인할 수 있는 코드가 있다.

hljs javascript
let x = 'outer';

{
  console.log(x); // ReferenceError — 'outer'가 아니다!
  let x = 'inner';
}

만약 호이스팅이 안 됐다면 블록 안의 console.log(x)는 바깥의 'outer'를 참조해야 한다. 그런데 ReferenceError가 난다. 블록 안의 let x가 호이스팅되어 스코프에 등록됐기 때문에, 엔진은 바깥 x 대신 아직 초기화 안 된 안쪽 x를 바라본다. 바라보되 접근은 거부한다.

이 코드를 직접 돌려보고 나서야 "호이스팅은 되지만 TDZ에 걸린다"는 말이 감각적으로 와닿았다.

왜 이렇게 만들었을까? var의 호이스팅은 버그를 유발하기 쉽다. 선언 전에 접근해도 에러가 안 나니까 실수를 잡기 어렵다. let/const의 TDZ는 "선언 전 접근은 프로그래머의 실수일 가능성이 높으니 에러를 내자"는 설계 의도로 읽힌다. 다만 이건 추론이다. TC39 미팅 노트에서 정확한 도입 이유를 찾진 못했다.

변수의 선언과 값의 할당은 실행 시점이 다르다

hljs javascript
console.log(score); // undefined
var score = 80;
console.log(score); // 80

변수 선언(var score)은 런타임 이전(평가 과정)에 처리되고, 값의 할당(score = 80)은 런타임에 순차적으로 실행된다. 정리하면:

  • 평가 과정: 소스코드 실행 전에 선언문 등을 먼저 처리하는 단계
  • 런타임: 평가 과정 이후 코드를 순차적으로 실행하는 단계

5장 — 표현식과 문

핵심만 정리하면:

  • 값(value): 표현식이 평가되어 생성된 결과. 10 + 20의 결과인 30이 값.
  • 리터럴(literal): 사람이 이해할 수 있는 문자 또는 약속된 기호로 값을 생성하는 표기법. 100, 'hello', true 같은 것.
  • 표현식(expression): 값으로 평가될 수 있는 문. 1 + 2, x = 5(할당도 표현식이다), getValue().
  • 문(statement): 프로그램을 구성하는 기본 단위이자 최소 실행 단위. var x;, if (...) { ... }, for (...).

"표현식인 문"과 "표현식이 아닌 문"의 구분이 좀 재밌었다. var x;undefined를 반환하긴 하지만 값으로 평가할 수 있는 표현식은 아니다. 크롬 콘솔에서 var x;를 치면 undefined가 나오는데, 이건 "값이 undefined"가 아니라 "완료 값(completion value)이 undefined"인 것이라고 한다.


6장 — 데이터 타입

숫자 타입 — 하나뿐이라고?

다른 언어는 int, long, float, double 같은 다양한 숫자 타입이 있다. 자바스크립트에는 64비트 부동소수점(IEEE 754) 하나뿐이다.

hljs javascript
console.log(1 === 1.0) // true

정수로 표시해도 내부적으로는 실수로 처리된다. 처음 봤을 때 꽤 놀라웠다. 왜 이렇게 설계했을까?

자바스크립트는 원래 10일 만에 만들어진 언어다. 복잡한 타입 시스템을 구축할 시간이 없었고, IEEE 754 더블 하나로 정수와 실수를 모두 커버하는 게 가장 단순한 설계였다. 정밀도 문제(0.1 + 0.2 !== 0.3)가 생기긴 하지만, 웹 스크립팅이라는 원래 목적에서는 충분했던 셈이다.

세 가지 특별한 값도 있다:

  • Infinity (양의 무한대)
  • -Infinity (음의 무한대)
  • NaN (Not a Number — 산술 연산 불가)

📌 근데 부동소수점뿐인 언어가 어떻게 빠른가?

이게 계속 걸렸다. 모든 숫자가 double이면 for (let i = 0; i < 100; i++) 같은 단순 루프도 부동소수점 연산을 해야 한다는 건데, V8은 빠르기로 유명하다. 뭔가 트릭이 있을 거라 생각하고 찾아봤다.

V8 공식 블로그에서 답을 찾았다. V8은 내부적으로 숫자를 두 가지로 구분한다.

  • Smi(Small Integer) — 31비트 범위(-2³⁰ ~ 2³⁰-1)의 정수. 포인터 태깅이라는 기법으로 힙 할당 없이 값을 직접 들고 다닌다.
  • HeapNumber — Smi 범위를 넘어나거나 소수점이 있는 숫자. 힙에 64비트 double 객체를 할당한다.

스펙상으로는 모든 숫자가 double인데, 엔진은 "결과가 같으면 더 빠른 방법을 써도 된다"는 원칙으로 정수를 따로 최적화한다. i++ 같은 건 Smi 정수 연산으로 끝나니까 힙 할당도 없고 빠르다.

포인터 태깅이 뭔지 좀 더 파봤다.
포인터는 값을 직접 담는 게 아니라, 그 값이 저장된 메모리 주소를 가리키는 값이다. V8 같은 자바스크립트 엔진은 실행 중인 값을 다룰 때, 어떤 값이 힙에 있는 객체를 가리키는 포인터인지, 아니면 작은 정수인지 빠르게 구분해야 한다.

여기서 등장하는 게 포인터 태깅(pointer tagging) 이다. 컴퓨터 메모리는 정렬 단위에 맞춰 배치되기 때문에, 포인터 값의 일부 하위 비트는 항상 0인 경우가 많다. V8은 이런 비트를 그냥 버리지 않고 태그로 재활용해서, 이 값이 Smi인지 힙 객체를 가리키는 포인터인지 구분한다.
즉, 숫자를 전부 객체로 만들어 힙에 올리는 게 아니라, 작은 정수는 가능한 한 값 자체를 바로 담아 처리하는 식이다.

처음엔 “어차피 안 쓰는 비트를 플래그로 쓴다고?” 싶어서 좀 뜬금없게 느껴졌는데, 생각해보면 꽤 합리적이다. 작은 정수를 표현할 때마다 별도 메모리를 할당하지 않아도 되니까, 메모리 사용도 줄고 접근 비용도 아낄 수 있다. 엔진 입장에서는 자주 등장하는 값을 훨씬 싸게 다룰 수 있는 셈이다.

이걸 보고 나니 자바스크립트를 보는 느낌도 조금 달라졌다. 언어 스펙만 보면 그냥 number 하나로 보이지만, 실제 구현 안에서는 그 값을 더 싸고 빠르게 다루기 위해 꽤 많은 트릭이 들어간다. 결국 우리가 자바스크립트에서 아무렇지 않게 쓰는 숫자 하나도, 엔진 내부에서는 단순한 값이 아니라 성능과 메모리 효율을 위해 세심하게 포장된 표현인 셈이다. 그래서 이제는 “자바스크립트는 느리다”는 말을 들으면, 단순히 언어 자체의 한계라기보다 그 추상적인 스펙 뒤를 받치는 구현의 복잡성을 먼저 떠올리게 된다.

템플릿 리터럴

ES6부터 백틱으로 새로운 문자열 표기법이 생겼다. 이스케이프 시퀀스 없이 줄바꿈이 가능하고, 표현식 삽입도 된다.

hljs javascript
// 기존
console.log('My name is ' + first + ' ' + last + '.');
// 템플릿 리터럴
console.log(`My name is ${first} ${last}.`);

이런 건 솔직히 좋다.

undefined vs null

처음에 "undefined라는 놈은 왜 만든 걸까? null과는 뭐가 다르지?"라는 의문이 들었는데, 바로 답이 나온다.

  • undefined: 변수가 선언됐지만 값이 할당되지 않았을 때 엔진이 자동으로 넣어주는 값.
  • null: 개발자가 의도적으로 "값이 없음"을 명시할 때 사용하는 값.

그리고 GC(가비지 컬렉션)와의 관계가 궁금했는데, 핵심은 이거다. GC는 **"이 객체에 도달할 수 있는 참조가 남아 있느냐"**로 수거 여부를 판단한다. null을 넣든 undefined를 넣든, 기존 객체에 대한 참조가 끊어지면 GC가 수거한다. 어떤 값을 넣었느냐가 아니라 참조가 살아있느냐가 중요하다.

hljs javascript
let obj = { name: "test" };
obj = null; // { name: "test" }에 대한 참조가 끊어짐 → GC 수거 대상

심볼(Symbol) 타입

변경 불가능한 원시 타입이면서 중복되지 않는 유일한 값을 만드는 타입이라고 한다. 키 생성용으로 쓰는 것 같은데, 내부적으로 어떻게 유일성을 보장하는지는 궁금하다.

📌 Symbol의 유일성, 직접 파봤다

"해시로 만드나? 전역 카운터를 증가시키나?" 궁금해서 찾아봤는데, 결론은 허무할 정도로 단순했다.

ECMAScript 스펙에서 Symbol은 고유한 불변 원시 값으로 정의된다. Symbol()을 호출할 때마다 "새로운 고유 값"이 만들어진다고만 되어 있고, 구현은 엔진에 맡긴다.

V8 소스코드를 직접 까보진 못했지만, 여러 분석 글을 종합하면 V8은 매번 새로운 힙 객체를 할당하고, 그 객체의 메모리 주소 자체가 식별자가 되는 것으로 보인다. 같은 주소를 가진 두 객체가 있을 수 없으니 유일성이 보장된다. 해시 계산도, 전역 카운터도 필요 없다.

Symbol.for()는 좀 다르다.

hljs javascript
Symbol('foo') === Symbol('foo')          // false
Symbol.for('foo') === Symbol.for('foo')  // true

Symbol.for()는 전역 Symbol 레지스트리에서 키를 찾아본다. 같은 키로 이미 만든 게 있으면 그걸 반환하고, 없으면 새로 만들어 등록한다. 이건 명확히 해시맵 기반의 캐싱이다.

실무에서 Symbol을 직접 쓸 일이 얼마나 있을지는 아직 모르겠다. 프레임워크나 라이브러리 내부에서 프로퍼티 키 충돌을 피하려고 쓰는 패턴이 주인 것 같다. 직접 부딪혀봐야 체감이 될 듯하다.

데이터 타입이 필요한 이유

"2진수를 어떻게 해석할지 구분하기 위해"라고 한다. 01000001이 숫자 65인지 문자 'A'인지, 타입 정보가 있어야 엔진이 판단할 수 있다.

동적 타이핑

자바스크립트는 정적 타입 언어가 아니다. C나 Java는 변수 선언 시 타입을 명시해서 컴파일 타임에 타입을 알 수 있지만, 자바스크립트는 선언이 아니라 할당에 의해 타입이 결정(타입 추론)되고, 재할당에 의해 언제든지 바뀔 수 있다.

트레이드오프가 명확하다:

  • 유연성은 높지만 신뢰성은 떨어진다
  • 변수 타입 추적이 어렵다

그래서 책에서도 변수를 남발하지 말고 스코프를 좁게 만들어 부작용을 억제하라고 강조한다.

솔직히 이 자유로움이 읽는 비용을 높이기 때문에 그렇게 매력적이진 않다. 하지만 이런 프리함 덕분에 TypeScript라는 선택지를 입맛대로 올릴 수 있다는 건 장점이기도 하다.

📌 동적 타이핑인데 V8은 어떻게 빠른가 — Hidden Class와 Inline Cache

숫자 타입에서 Smi 최적화를 봤으니까, 동적 타이핑 자체의 성능 문제도 궁금했다. 객체 프로퍼티가 런타임에 자유롭게 바뀌는데, obj.x를 읽을 때마다 "x가 어디 있지?"를 매번 찾으면 느릴 수밖에 없다.

V8은 이걸 **Hidden Class(Map)**로 해결한다. 같은 순서로 같은 프로퍼티를 추가한 객체들은 같은 Hidden Class를 공유한다.

hljs javascript
let a = {};
a.x = 1;
a.y = 2;

let b = {};
b.x = 3;
b.y = 4;
// a와 b는 같은 Hidden Class

Hidden Class에 "x는 오프셋 0, y는 오프셋 1" 같은 레이아웃 정보가 들어 있다. 프로퍼티 접근이 오프셋 계산 한 번으로 끝난다. 정적 타입 언어의 구조체처럼 동작하는 셈이다.

여기에 **Inline Cache(IC)**가 붙는다. obj.x를 처음 실행하면 "이 Hidden Class에서 x는 오프셋 0"을 캐싱해둔다. 다음에 같은 Hidden Class의 객체가 오면 탐색 없이 바로 접근한다.

근데 프로퍼티 추가 순서가 다르면 Hidden Class가 달라진다.

hljs javascript
let a = {};
a.x = 1;
a.y = 2;

let b = {};
b.y = 3;  // y를 먼저 추가
b.x = 4;
// a와 b는 다른 Hidden Class → IC miss

"같은 모양의 객체를 만들어라"는 최적화 팁이 여기서 나오는 거였다. 예전에 "그렇다더라"로만 알고 있었는데, Hidden Class를 알고 나니 이유가 납득됐다.

그리고 V8의 JIT 컴파일 구조도 같이 봤다. V8은 코드를 두 단계로 처리한다.

  1. Ignition(인터프리터) — 소스코드를 바이트코드로 바꿔서 바로 실행. 빠른 시작이 목적.
  2. TurboFan(JIT 컴파일러) — 자주 실행되는 "핫" 코드를 기계어로 컴파일. 타입 피드백을 기반으로 최적화.

Ignition이 실행하면서 "이 함수의 인자가 항상 정수였다"같은 타입 정보를 수집한다. TurboFan은 이 정보를 보고 정수 덧셈 명령어를 직접 쓰는 식으로 최적화한다.

갑자기 문자열이 들어오면? Deoptimization이 일어난다. 최적화된 기계어를 버리고 다시 바이트코드로 돌아간다.

hljs javascript
function add(a, b) {
  return a + b;
}

// 수천 번 정수로 호출 → TurboFan이 정수 덧셈으로 최적화
for (let i = 0; i < 10000; i++) {
  add(i, i);
}

// 갑자기 문자열 → deoptimization
add("hello", "world");

동적 타이핑이 "언제든 타입이 바뀔 수 있다"는 자유를 주는 대신, 엔진은 낙관적 최적화와 롤백을 반복해야 한다. 동적 타이핑의 비용이 이렇게 구체적으로 드러나는구나 싶었다.


1~6장을 읽고 나서

가장 와닿은 것

"문제 해결 능력은 직감과 직관의 영역"이라는 문장이 가장 오래 남았다. 추상적인 "문제 해결 능력을 길러라"는 말 대신, 알면 알수록 직관이 생기고 그게 곧 능력이 된다는 구체적인 방향을 제시해준 느낌이다. AI를 쓰면서도 이 직관이 있어야 제대로 된 지시를 내릴 수 있고, 결과물을 제대로 검토할 수 있다.

이번에 새로 파본 것들 정리

읽으면서 미루지 않고 바로 파본 의문들이 있었다.

  • TDZlet/const도 호이스팅은 된다. 스코프에 등록은 되지만 초기화 전 구간(TDZ)에서 접근하면 에러가 난다. 블록 스코프 예제로 직접 확인하고 나서야 납득이 됐다.
  • 실행 컨텍스트 — LexicalEnvironment 안에 Environment Record(식별자-값 저장소)와 Outer Reference(스코프 체인)가 있다. 클로저가 왜 동작하는지도 자연스럽게 연결됐다.
  • V8 숫자 최적화 — 스펙상 모든 숫자가 double이지만, V8은 Smi로 정수를 따로 처리한다. 포인터 태깅으로 힙 할당 없이 정수를 들고 다닌다.
  • Hidden Class + IC — 동적 타이핑의 성능 비용을 Hidden Class와 Inline Cache로 줄인다. 같은 모양의 객체를 만들어야 하는 이유가 여기 있었다.
  • Symbol 유일성 — 해시도 카운터도 아니고, 매번 새 힙 객체를 할당하는 것으로 보인다. 주소가 다르니까 유일하다.

아직 해결 안 된 것들

  1. TDZ 도입의 정확한 의도 — TC39 미팅 노트를 찾아봐야 한다. 내 추론이 맞는지 확인하고 싶다.
  2. Environment Record의 종류별 차이 — Declarative, Object, Global이 왜 나뉘는지. with문이나 eval과 관련된다는 점만 메모해뒀다.
  3. V8 Smi의 32비트 시스템 처리 — 64비트에서는 상위 32비트를 Smi로 쓰는데, 32비트에서는 31비트밖에 안 된다고 한다. 실제로 영향이 있는 경우가 있는지.
  4. Symbol과 WeakMap의 관계 — Symbol을 WeakMap 키로 쓸 수 있게 된 게 최근이라고 들었다. 왜 처음에는 안 됐고 왜 바뀌었는지.

다음 글에서는

7장부터 이어서 읽으면서, 특히 스코프(13장)와 실행 컨텍스트(23장)에서 여기서 잡은 큰 그림이 얼마나 정확했는지 확인해볼 계획이다.

Reference

Comments 2

0/500
송주
송주헌19d ago

문제 해결 능력은 직감과 직관의 영역이다. 좋은 말이네요 자바 스크립트에서 숫자 타입이 하나인 것은 알고 있었는데, 부동 소수점 연산을 어떻게 빠르게 실행하는지에 대한 고민은 해본 적이 없었던 것 같네요. 좋은 관점 하나 배워가는 것 같습니다. 감사합니다.

미미
미미미누16d ago

절대 모든걸 의심해 그럼 보여

인기 글