본문 바로가기
JavaScript Deep Dive 스터디

[모던 자바스크립트 Deep Dive] - 16~18장 정리 (re)

by 림졍 2026. 4. 21.
728x90
반응형

JavaScript Deep Dive 스터디 노트 (16~18장)

너무 모르겠어서 다시정리하는 프로풔튀.

 

내 죄라면 대충 정리한 죄.

 

솔직히 말하면 책 읽고 "아 그렇구나~" 하고 넘기기엔 찜찜한 게 너무 많았다.
(사유 : 내가 내 발표때 제대로 못말할거 같아서)
"왜 이렇게 동작하는 거야?"가 계속 걸려서, ECMAScript 사양이 어떻게 명세하는지,
V8이 그걸 실제로 어떻게 구현하는지까지 다시 re-mind를 가져보고자 한다.

 

여기서 중요하게 가져가야하는건.. 사양과 엔진(V8)를 두 가지를 섞어 쓰지 않는 것이다.
[[DefineOwnProperty]], [[Call]], [[Construct]], SetIntegrityLevel 같은 것은 사양의 개념이고,
HiddenClass, DescriptorArray, fast properties, inline cache는 V8의 구현 전략이다.
사양은 "겉으로 반드시 어떻게 보여야 하는가"를 정하고, 엔진은 "그걸 내부에서 얼마나 빠르게 처리할까"를 고민하기 때문에
이 둘을 분리해서 본다면 덜 헷갈리게 볼 수 있을 것이다.

 

1. 프로퍼티 어트리뷰트 — 프로퍼티는 그냥 key-value 한 줄짜리가 아니다

프로퍼티를 어떻게 정의하냐

ECMAScript 스펙에서 프로퍼티에 대해 아래와 같이 설명한다.

설마 내가 이렇게 가져올 줄이야...

 

객체의 일부로서 키(String 또는 Symbol)와 값을 연결하는 것. 이게 전부다.

 

덧붙여 스펙은 NOTE로 이렇게 말한다 — 값은 데이터 값으로 직접 표현되거나(데이터 프로퍼티),
접근자 함수 쌍으로 간접 표현될 수(접근자 프로퍼티) 있다고. 프로퍼티가 두 종류인 이유가 여기 명시되어 있다.

근데 이 "연결"에 눈에 안 보이는 규칙들이 같이 딸려온다. 그게 바로 어트리뷰트~ 라고 한다.

 

프로퍼티 키는 사양상 String 또는 Symbol이어야 한다.

숫자처럼 보이는 키도 결국 문자열로 변환된다.

 

아래의 예시를 통해 조금 더 자세히 알아보도록 하자.

const obj = {
  name: '림졍',
  123: 'hi',
  [Symbol('id')]: 'sym',
};

console.log(obj[123] === obj['123']); // true — 숫자 키도 문자열로 변환

 

그리고 값이 함수인 프로퍼티를 특별히 메서드라고 부르는 거지, 메서드가 완전히 다른 존재인 건 아니다.

결국 이것도 "함수를 값으로 가진 프로퍼티"다.

const person = {
  name: '림졍',
  sayHi: function () {}, // 메서드 = 함수를 값으로 가진 프로퍼티
};

 

cf. 잠깐 — [[...]] 이중 대괄호가 뭐야?

13~15장 스코프/실행 컨텍스트 정리할 때 잠깐 나왔었는데, 여기서도 계속 나오니까 확실히 짚고 넘어가자.
"이거 오타인가?" 싶은데, 이건 문법이 아니라 ECMAScript 사양이 엔진 내부 동작을 설명하기 위해 쓰는 표기다.
즉 개발자가 직접 쓰는 프로퍼티가 아니라, 엔진 내부 개념을 설명하기 위한 내부 슬롯/내부 메서드다.

const o = {};

o.[[Prototype]]; // SyntaxError — 이런 문법은 없다
o.__proto__;     // Object.prototype — 간접적으로만 접근 가능

 

주의 ) ⚠️ 코드에서 [[]]를 직접 쓰면 안 된다. 사양 표기일 뿐이지, JS 문법이 아니기 때문이다.

__proto__처럼 일부 레거시 경로를 통해 간접적으로만 보이는 경우가 있다.
존재는 하지만 개발자에게 직접 API처럼 열려 있는 게 아니라는 뜻이다.

이런 설계를 둔 이유 중 하나는, 내부 구현이 바뀌더라도 기존 코드를 최대한 깨지 않게 하려는 데 있다.

 

ECMAScript 스펙 기준으로 어트리뷰트는 총 6개인데, 프로퍼티 종류에 따라 쓰이는 게 다르다.

 

데이터 프로퍼티 전용 (2개)

어트리뷰트 의미 기본값
[[Value]] 실제 저장되는 값. get 접근 시 반환됨 undefined
[[Writable]] false면 값 변경 시도가 조용히 무시됨 (strict mode에서는 TypeError) false

 

접근자 프로퍼티 전용 (2개)

어트리뷰트 의미 기본값
[[Get]] 프로퍼티를 읽을 때 자동 호출되는 함수. 반환값이 프로퍼티 값이 됨 undefined
[[Set]] 프로퍼티에 값을 할당할 때 자동 호출되는 함수. 할당값이 인수로 들어옴 undefined

 

데이터 + 접근자 공통 (2개)

어트리뷰트 의미 기본값
[[Enumerable]] true for...in, Object.keys에 뜸 false
[[Configurable]] false면 삭제, 데이터↔접근자 전환, 어트리뷰트 변경이 전부 막힘
(단 
[[Value]] 수정과 [[Writable]] false로 바꾸는 건 예외)
false

 

Object.defineProperty로 생략하면 전부 false/undefined로 들어가고,

점 표기법으로 만들면 [[Writable]], [[Enumerable]], [[Configurable]]이 전부 true로 초기화된다.

Object.defineProperty(person, 'name', {
  value: '림졍',       // [[Value]]
  writable: false,     // [[Writable]]   — false면 값 변경 무시
  enumerable: false,   // [[Enumerable]] — false면 for...in/Object.keys에 안 뜸
  configurable: false, // [[Configurable]] — false면 삭제/재정의 불가
});

person.name = '홍길동';           // 무시 — writable: false
console.log(person.name);         // '림졍' — 그대로
console.log(Object.keys(person)); // [] — enumerable: false라 안 뜸
delete person.name;               // 무시 — configurable: false

 

 

V8은 이걸 어떻게 저장하냐 — HiddenClass, 즉 내 베이킹 레시피 노트

김딸기가 될거야...

 

갑자기 뜬금없긴 하지만 요새 내적 스트레스 풀겸? 베이킹을 하고있는중인데
레퍼런스는 유튜브 영상이지만... 어떻게 하면 더 맛있게 가져갈 수 있을까 하면서 실험도 하는 중이다.

(덕분에 살도 왕창찐건 안비밀 ^_^)
대부분의 베이킹 영상 보면 재료가 "버터 몇g, 설탕 몇g"이런 식인데...
손이 큰(?) 내 입장에서는 늘 2배합이나 버터기준으로 환산을 하는 편이다.. (???: 재료는 다 써버려야지)
여튼 그럴때마다 매번 g 환산하면서 때리는 편인데
생각보다 이게... 여러번 만들다보면 귀찮다. 매우.
이 귀차니즘을 해결하기 위해 메모지나 a4에 림졍표(?) 레시피 노트를 적곤 하는데
"버터 230g, 설탕 200g" 등 미리 내가 쓸 재료들 기준으로 적어두기 때문에 유튜브나 다른 영상을 열면서 볼 필요가 없게끔 만들었다.

 

좀 길어졌지만 왜 이런 이야기가 나왔느냐...
V8의 HiddenClass가 딱 이 역할이기 때문. (V8 소스코드에선 Map이라고도 부름)

"Most JavaScript engines use a dictionary-like data structure as storage for object properties — each property access requires a dynamic lookup to resolve the property's location in memory."
(대부분의 JS 엔진은 딕셔너리 형태의 자료구조로 프로퍼티를 저장하는데, 프로퍼티에 접근할 때마다 메모리에서 해당 프로퍼티의 위치를 찾는 동적 탐색이 필요하다.) — V8 Design Elements

 

JS는 동적 언어라 이론적으로 프로퍼티를 읽을 때마다 "어디 있더라?" 하고
위의 내용처럼 딕셔너리를 뒤져야 하는데, 딕셔너리 탐색이 느린 이유는 두 가지다.

 

1) 해시 계산 비용 — 키("name")를 해시 함수로 변환해서 버킷 위치를 찾아야 한다. 이 해시 계산 자체가 매 접근마다 발생한다.

 

2) 해시 충돌 처리 — 서로 다른 키가 같은 해시값을 가지면 다음 인덱스를 순차적으로 탐색하는 추가 비용이 발생한다.

 

Java나 C++ 같은 정적 언어는 컴파일 타임에 오프셋이 이미 결정되어 있어서 메모리 주소 하나로 바로 접근 가능하다.

JS는 런타임에 프로퍼티가 추가/삭제될 수 있으니까 매번 이 과정을 거쳐야 한다.

 

"these routines are still non-trivial, and executing them every time you read or write a property is slow. V8 will avoid using this representation whenever possible."
(이 루틴들은 여전히 간단하지 않고, 프로퍼티를 읽거나 쓸 때마다 실행하면 느리다. V8은 가능한 한 이 방식을 피하려 한다.)
— Jay Conrad, A tour of V8: object representation

 

저어기 위에 있는 글처럼 V8은 이 문제를 피하기 위해

각 프로퍼티가 메모리 어느 위치에 있는지 미리 기록해둔 HiddenClass를 만든다.

HiddenClass C2:
  name → offset 0  (메모리 주소 기준 0번 위치)
  age  → offset 4  (메모리 주소 기준 4번 위치)

 

이를 통해 person.name을 읽을 때 "C2 보니까 offset 0이네" 하고 바로 그 주소로 점프한다.
딕셔너리 탐색 없이 메모리 직접 읽기. 이게 훨씬 빠르다.

 

 

프로퍼티 추가 = 새 노트 페이지로 넘어가는 것

function Point(x, y) {
  this.x = x;  // C0 → C1 (x: offset 0)
  this.y = y;  // C1 → C2 (x: offset 0, y: offset 4)
}

const p1 = new Point(1, 2); // C2 도달
const p2 = new Point(3, 4); // C2 — p1이랑 같은 노트 공유

 

같은 순서로 같은 프로퍼티를 추가하면 같은 HiddenClass를 공유한다.
마치 like... 동일한 레시피를 두 번 만들 때 같은 노트를 보는 것처럼.

하지만- 중간에 누군가 다른 내용을 추가한다면??

 

이런거 없으면 이해가 안갈거같ㅇr....

 

const p3 = new Point(5, 6);
p3.color = 'red';   // C2 → C3 (x, y, color)

const p4 = new Point(7, 8);
p4.size = 'large';  // C2 → C4 (x, y, size)

 

p3, p4는 이제 다른 HiddenClass — 다른 노트를 사용하게 된다.
V8 입장에서 둘은 "형태가 다른 객체"가 되어서 최적화가 어려워진다.

"Even though p2 and p3 have the same properties, they have different hidden classes. To V8, they are entirely different shapes."
(p2와 p3가 같은 프로퍼티를 가지고 있더라도, 서로 다른 HiddenClass를 가진다. V8 입장에서는 완전히 다른 형태의 객체다.)
— The Node Book, Inside the V8 Engine

 

생성자에서 모든 프로퍼티를 선언 순서대로 초기화하라는 조언의 진짜 이유가 여기 있다.
레시피 노트를 공유할 수 있어야 V8이 최적화할 수 있으니까.

V8 프로퍼티 저장 방식 세 가지

"There are three different named property types: in-object, fast and slow/dictionary."
(named property의 저장 방식에는 세 가지가 있다: in-object, fast, 그리고 slow/dictionary.)
— V8 Blog, Fast properties in V8
  1. In-object vs Fast (normal)
    V8은 객체 생성 시점에 일정 슬롯을 객체 자체 안에 확보한다. 이게 in-object properties다.
    indirection(간접 참조) 없이 바로 접근하기 때문에 제일 빠르다.
    슬롯이 가득 차면 초과된 프로퍼티는 별도의 properties store 배열로 넘어간다. 이게 fast properties다.
    한 단계 indirection이 추가되지만, 배열을 독립적으로 늘릴 수 있다.
    HiddenClass의 descriptor array가 "이 이름은 properties store의 몇 번 인덱스"를 기록해두고, 접근 시 그걸 참조한다.

 

 

  1. Fast (normal) vs Slow (Dictionary)
    프로퍼티가 많이 추가/삭제되거나 delete, Object.freeze 등이 발생하면
    V8은 descriptor array와 HiddenClass를 유지하는 비용이 너무 커진다고 판단하고 dictionary mode로 전환한다.
    dictionary mode에서는 객체가 자체적인 해시맵을 들고 다닌다.
    HiddenClass도, descriptor array도 더 이상 관여하지 않는다.
    그래서 인라인 캐시(IC)가 동작하지 않아 — 앞서 외워뒀던 위치 정보가 전부 무효화된다.
"Since inline caches don't work with dictionary properties, the latter are typically slower than fast properties."
(인라인 캐시는 dictionary 프로퍼티와 함께 동작하지 않기 때문에, dictionary 방식은 일반적으로 fast 방식보다 느리다.)
— V8 Blog, Fast properties in V8

 

 

 

처음부터 적어둔 재료(in-object)가 제일 빠르고,
나중에 포스트잇으로 붙인 재료(fast normal)가 그다음,
노트를 포기하고 매번 찾아야 하는 상태(dictionary)가 제일 느리다.

 

방식 저장 위치 접근 방식 IC 최적화
In-object 객체 자체 오프셋 직접 접근 (가장 빠름)
Fast (normal) 별도 properties store descriptor array 참조 후 접근
Slow (Dictionary) 객체 내부 해시맵 해시 계산 + 충돌 처리 (IC 무효화)

 

deleteObject.freeze가 dictionary mode를 유발하는 이유,
null 할당이 더 나은 이유가 여기서 나온다.

 

 

Object.freeze — 단골 카페 원두 라인업이 바뀌는 순간

Object.freeze가 성능에 영향을 준다는 게 무슨 의미인지.... 솔직히 잘 모르겠어서
간단한 예시를 통해 알아가보고자 한다.

자주 가는 단골 카페가 있다고 하자. (근데 진짜 있다... 읍읍)
하도 자주가게 되다보면 메뉴가 자연스럽게 외워지게 된다.
조금 특이한 케이스로 한정된 원두를 팔며 원두 라인업이 매번 바뀌는 스페셜티 카페를 가정해보도록 한다면..
"그 자주 먹던 피치펀치 게이샤는 오늘도 있지...않을까?" 하고 생각하게 될 것이고?
이제 메뉴판 볼 필요 없이 바로 이름을 부르게 될 것이다.

 

V8의 인라인 캐시(IC) 가 딱 이 역할이다. person.name을 자주 읽는 코드가 있으면
"이 객체는 항상 C2 HiddenClass이고, name은 offset 0에 있다"고 외워버린다.
다음번엔 "이번에도 C2 HiddenClass야?" 하는 빠른 체크 한 번만 하고, 맞으면 바로 그 위치로 날아간다.
(descriptor array 전체 스캔 같은 풀 조회는 건너뛴다.)

 

마치 like... 헤이 바텐더. 늘 먹던거로.

 

근데 Object.freeze를 하면 각 프로퍼티의 어트리뷰트([[Configurable]], [[Writable]])가 변경되면서
HiddenClass 전환이 발생한다. 새로운 HiddenClass로 바뀌는 순간,
IC가 외워뒀던 "C2 HiddenClass → offset 0" 정보는 더 이상 유효하지 않아서 무효화(invalidate) 된다.

const obj = { x: 1, y: 2 };
// 여기까지 — HiddenClass C2 기반, IC가 "x는 offset 0"이라고 외워둠

Object.freeze(obj);
// 이후 — [[Configurable]], [[Writable]] 변경으로 새 HiddenClass로 전환
// IC가 "C2야?" 하고 체크했더니 이미 다른 HiddenClass
// 외워둔 C2 기반 offset 정보 무효화 → 다음 접근 시 새 HiddenClass 기준으로 재학습

 

라인업이 자꾸 바뀌는 카페에서 이름을 불렀다가 품절이라는 걸 알게 되는 순간과 같다.
(오늘 내 피치펀치가 없어졌어. 슬프다 진차.)

 

어헝헝...

 

외워둔 정보인 늘~먹던 원두가 이제 없으니
더 이상 유효하지 않은 값이므로... 이제 매번 "오늘 뭐 있어요?" 하고 처음부터 물어봐야 한다.
(단, 다시 물어보고 나면 새 메뉴판 기준으로 또 외울 수 있다 — IC도 마찬가지로 새 HiddenClass 기준으로 재학습한다.)

 

Object.freeze는 내부적으로 이 세 가지를 처리한다.
스펙에서는 SetIntegrityLevel(O, "frozen")라는 추상 연산을 호출하는데, 이 안에서 아래 순서로 어트리뷰트를 건드린다.

 

① [[PreventExtensions]]() 호출 → [[Extensible]] false로 (새 프로퍼티 추가 차단)
② 모든 프로퍼티의 [[Configurable]] → false  (삭제 및 재정의 차단)
③ 데이터 프로퍼티의 [[Writable]] → false  (값 변경 차단)

 

delete도 마찬가지다.

프로퍼티를 delete하는 순간 HiddenClass 전환 체인이 무너지고 dictionary mode로 강제 전환된다.

delete user.premium;      // ❌ dictionary mode 전환 — 외워둔 정보 다 날아감
user.premium = null;      // ✅ HiddenClass 유지 — 그냥 값만 바뀜
user.premium = undefined; // ✅ 마찬가지

 

"null 넣는 거랑 delete랑 결과가 비슷한데 왜 굳이 구분하냐"의 답이 여기에 있다.

(참고) ES5까지는 Object.freeze에 객체가 아닌 값을 넣으면 TypeError였는데, ES2015부터는 그냥 그 값을 그대로 반환한다.

// ES5 이하
Object.freeze(1); // TypeError

// ES2015+
Object.freeze(1); // → 1
Object.freeze('hello'); // → 'hello'

 

[[DefineOwnProperty]] — 스펙이 정의하는 프로퍼티 생성 과정

Object.defineProperty를 만나면 JS 엔진은 내부적으로 이 호출 체인을 탄다.
ECMA 문서에서 나오는 [[DefineOwnProperty]] 자체는 OrdinaryDefineOwnProperty로 위임하는 한 줄짜리다.
실제 동작 순서는 ValidateAndApplyPropertyDescriptor 기준으로 보면 된다.

 

 

이미지로 보면 솔직히 모를거같아서... 대충 한국어로 정리하면 이렇다.

① 현재 프로퍼티가 존재하지 않을 때 (current is undefined)
   └ extensible이 false → return false (새 프로퍼티 추가 차단)
   └ extensible이 true  → 새로 생성 (누락 항목은 false/undefined 기본값으로)

② 현재 프로퍼티가 존재할 때
   └ [[Configurable]]이 false면 → return false
     (호출 측 DefinePropertyOrThrow가 이걸 받아서 TypeError로 변환)
   └ [[Configurable]]이 true면  → ③번으로

③ 디스크립터에 있는 항목만 덮어씀 (없는 건 기존 값 유지)

 

TypeError가 직접 여기서 나는 게 아닌,

return false를 받은 DefinePropertyOrThrow가 던지는 구조라는 게 포인트다.
점 표기법이랑 Object.defineProperty 기본값이 다르기 때문에 기억하자. 한 번쯤은 낚인다. (나도 낚임)

 

[정리 표]

생성 방식 Writable Enumerable Configurable
점 표기법 (obj.x = 1) true true true
Object.defineProperty (생략 시) false false false

 

 

 

프로퍼티 (2가지 종류)

이것도 이번에 처음 제대로 안 사실중 하나.. (걍 데이터만 있는줄 알았다!!!!)

  • 데이터 프로퍼티 : 우리가 맨날 쓰는 key: value 방식
  • 접근자 프로퍼티 : get / set 함수로 만드는 것
const person = {
  firstName: '졍',
  lastName: '림',

  // 접근자 프로퍼티
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  },
  set fullName(name) {
    [this.firstName, this.lastName] = name.split(' ');
  }
};

person.fullName = '졍 림'; // setter 호출
console.log(person.fullName); // getter 호출 → '졍 림'

 

핵심은 person.fullName이라는 프로퍼티 자체에는 값이 없다는 거다.
firstName, lastName이 실제 값을 들고 있고, fullName은 그 값들을 읽거나 쓸 때 중간에서 처리만 한다.

Object.getOwnPropertyDescriptor로 보면 구조가 아예 다르다.

// 데이터 프로퍼티 (firstName)
// { value: '졍', writable: true, enumerable: true, configurable: true }

// 접근자 프로퍼티 (fullName)
// { get: ƒ, set: ƒ, enumerable: true, configurable: true }

 

[[Value]], [[Writable]] 대신 [[Get]], [[Set]]이 들어간다.
스펙도 명확하게 구분한다.

"An accessor property associates a key value with one or two accessor functions, and a set of Boolean attributes." (접근자 프로퍼티는 키 값을 하나 또는 두 개의 접근자 함수, 그리고 Boolean 어트리뷰트 집합과 연결한다.) — ECMAScript §6.1.7

 

person.fullName을 읽을 때 내부적으로 OrdinaryGet이 호출되고, 이 안에서 프로퍼티가 접근자 프로퍼티임을 확인한 뒤 [[Get]] 함수를 호출한다. 스펙 기준으로 보면:

OrdinaryGet(O, "fullName", Receiver):
  1. desc = O.[[GetOwnProperty]]("fullName")
  2. IsAccessorDescriptor(desc) → true
  3. getter = desc.[[Get]]
  4. Call(getter, Receiver)  ← getter 함수 실행, this = Receiver
  
 — ECMAScript Living Standard §10.1.8, OrdinaryGet

 

getter만 있으면 읽기 전용, setter만 있으면 쓰기 전용이 된다.

 

 

React useState랑 철학이 닮은 이유

getter/setter 패턴이 useState[value, setValue]랑 비슷하다고 느꼈는데, 정확히는 이 부분이 닮았다.

// getter/setter
get fullName() { return `${this.firstName} ${this.lastName}`; }
set fullName(name) { [this.firstName, this.lastName] = name.split(' '); }

// useState
const [value, setValue] = useState('');
// value로 읽고, setValue로만 쓴다

 

둘 다 "직접 건드리지 말고, 정해진 방법으로만 읽고 써라"는 설계다.
setter 없이 getter만 만들면 읽기 전용이 되는 것처럼, setValue 없이 value만 쓰면 외부에서 상태를 못 바꾸는 것처럼 — "값에 직접 접근하는 대신 인터페이스를 두는" 캡슐화 철학이 근본적으로 같다.

 

객체 변경 방지 — 세 단계로 점점 강해진다

총 세 단계가 있다. 정도가 점점 강해지는 구조.

메서드 추가 삭제 읽기 쓰기 재정의
Object.preventExtensions
Object.seal
Object.freeze

 

스펙 기준으로 세 메서드 모두 동일한 구조를 갖는다.

공통 구조:
  1. O가 Object가 아니면 → O를 그대로 반환 (ES2015+, 원시값 그냥 통과)
  2. 핵심 연산 수행 → status 받음
  3. status가 false면 → TypeError
  4. O 반환

 

각각 2단계에서 다른 연산을 호출한다:

  • preventExtensionsO.[[PreventExtensions]]()을 직접 호출. [[Extensible]]false로. 새 프로퍼티 추가만 막힘.
  • sealSetIntegrityLevel(O, SEALED) 호출. preventExtensions + 모든 프로퍼티의 [[Configurable]]false로. 삭제/재정의 막힘.
  • freezeSetIntegrityLevel(O, FROZEN) 호출. seal + 데이터 프로퍼티의 [[Writable]]까지 false로. 읽기만 남음.

 

근데 함정이 있다 — 얕은 변경 방지

세 개 다 얕은 변경 방지(Shallow Only) 라는 게 함정이다.
(처음 봤을 때 "왜 막다가 뚫려??"싶었는데 이유가 있었다)

const person = {
  name: '림졍',
  address: { city: 'Cheonan' }
};

 

메모리에서 이 객체는 이렇게 생겼다.

person
  ├─ name ──────▶ '림졍'          (원시값, 여기 바로 저장)
  └─ address ───▶ [ 참조 주소 ] ──▶ { city: 'Cheonan' }  (별도 객체)

 

Object.freeze(person)을 하면 person 객체 자체의 어트리뷰트만 잠근다.

person (잠김)
  ├─ name ──────▶ '림졍'         ← 못 바꿈
  └─ address ───▶ [ 참조 주소 ] ← 주소 자체는 못 바꿈
                        ↓
               { city: 'Cheonan' }  ← 이 객체는 freeze 안 됨. 마음대로 바꿀 수 있음

 

address가 들고 있는 건 객체 자체가 아니라 그 객체를 가리키는 주소다.
freeze는 그 주소를 못 바꾸게 막을 뿐이지, 주소를 따라가서 내부 객체까지 잠그진 않는다.

Object.freezeperson 객체의 프로퍼티들에만 [[Configurable]], [[Writable]]false로 건드린다.

 

address라는 프로퍼티가 "가리키는 주소값 자체"를 못 바꾸게 막는 거지,
그 주소를 따라가서 { city: 'Cheonan' } 객체에도 freeze를 적용하진 않는다.
{ city: 'Cheonan' } 입장에서는 freeze를 당한 적이 없다

[[Writable]], [[Configurable]]이 여전히 true다.

 

Object.freeze(person);

person.name = 'Kim';           // 무시 — name의 [[Writable]]이 false
person.address = {};           // 무시 — address의 [[Writable]]이 false (주소 교체 불가)
person.address.city = 'Busan'; // 된다 — { city: 'Cheonan' } 자체는 freeze 안 됨
                               //        이 객체의 city 프로퍼티는 [[Writable]]: true 상태

console.log(person.address.city); // 'Busan' — 바뀜!

 

 

deepFreeze — 중첩 객체까지 잠그려면

중첩된 객체까지 잠그려면 재귀적으로 freeze를 돌려야 한다는 건 맞다.
MDN도 deepFreeze 패턴을 공식 예제로 소개한다.

function deepFreeze(target) {
  const propNames = Reflect.ownKeys(target); // Symbol 키까지 커버
  for (const name of propNames) {
    const value = target[name];
    if ((value && typeof value === 'object') || typeof value === 'function') {
      deepFreeze(value);
    }
  }
  return Object.freeze(target);
}

 

근데 웃긴건 바로 이어서 경고해주긴 한다.deepFreeze "전부 잠그는 만능 해결책"이 아니라고...

"Use the pattern on a case-by-case basis based on your design when you know the object contains no cycles in the reference graph." (참조 그래프에 순환이 없다는 걸 알고 있을 때, 설계에 맞게 케이스별로 이 패턴을 사용하라.)
"You still run a risk of freezing an object that shouldn't be frozen, such as window." (window처럼 얼리면 안 되는 객체를 실수로 얼릴 위험이 여전히 있다.) — MDN, Object.freeze()

  • plain 데이터 객체 (값만 있고 함수 없는 구조) → 안전하게 동작
  • ⚠️ 함수가 포함된 객체 → 일반 함수는 prototype.constructor가 자기 자신을 가리키는 순환 참조가 기본으로 있어서 deepFreeze가 위험
  • window 같은 전역 객체 → 얼리면 안 되는 것들이 얼려질 수 있음
    화살표 함수는 prototype이 없어서 순환 참조가 없으니 freeze해도 괜찮다.
// ❌ 일반 함수 — prototype 순환 참조로 deepFreeze 위험
function sayHi() {}
// sayHi.prototype.constructor === sayHi (순환)

// ✅ 화살표 함수 — prototype 없어서 괜찮음
const sayHi = () => {};

 

순환 참조 방어는 WeakSet으로 처리할 수 있다.

function deepFreeze(target, visited = new WeakSet()) {
  if (visited.has(target)) return target; // 이미 방문했으면 스킵
  visited.add(target);

  const propNames = Reflect.ownKeys(target);
  for (const name of propNames) {
    const value = target[name];
    if ((value && typeof value === 'object') || typeof value === 'function') {
      deepFreeze(value, visited);
    }
  }
  return Object.freeze(target);
}

 

결론: 책에서는 "재귀적으로 freeze를 돌리면 된다"고만 하는데,
MDN 기준으로는 설계상 plain 데이터 객체임을 알고 있을 때 케이스별로 써야 한다는 게 정확한 표현이다.

 

 

TypeScript as const랑 뭐가 다른가

const person = { name: '림졍', address: { city: 'Cheonan' } } as const;
person.name = 'Kim'; // 타입 에러 — 컴파일 단계에서 막힘

 

as const는 TypeScript 컴파일러가 잡아주는 거라 런타임에는 아무 효력이 없다.
컴파일 결과 JS 코드에는 as const가 사라지고, 런타임에서 그냥 일반 객체다.

런타임 불변까지 필요하다면 Object.freeze를 써야 한다.
다만 앞서 정리했듯이, 함수가 없는 plain 데이터 객체일 때는 deepFreeze와 함께 쓰는 게 효과적이다.

// plain 데이터 객체 — deepFreeze + as const 조합이 잘 맞는 케이스
const person = deepFreeze({
  name: '림졍',
  address: { city: 'Cheonan' }
} as const);
// 타입 레벨(as const) + 런타임 레벨(deepFreeze) 둘 다 불변

 

함수가 포함되거나 외부 참조가 있는 복잡한 객체라면 deepFreeze를 쓸 때 주의가 필요하다는 점은 앞 섹션에서 정리한 그대로다.

 

 

2. 생성자 함수와 [[Construct]]

[[Call]]과 [[Construct]] — 스펙이 함수를 어떻게 나누나

"Function objects have an internal method called [[Call]] ... and an optional internal method called [[Construct]]."
(함수 객체에는 [[Call]] ...이라는 내부 메서드와 [[Construct]]이라는 선택적 내부 메서드를 갖는다.)
— ECMAScript §10.2

 

예시로 공신력 있는 커피맛집들을 모아놓은 콜럼버스 가이드를 하나 들도록 해보겠다.

(왜냐.. 아는게 그래도 커피라?)

 

짜-란 커피맛집 = 콜럼버스가이드 기억하십쇼.

 

[[Call]] = 커피 내리는 능력. 모든 함수가 갖고 있다. 동네 카페도 다 된다.

[[Construct]] = 콜럼버스 가이드 인증. 있는 함수도 있고, 없는 함수도 있다.
인증 없어도 카페는 열 수 있지만, 콜럼버스 인증이 있어야 new로 글로벌 맛집(인스턴스)을 만들 수 있다.

foo();     // [[Call]] — 커피 한 잔 마심
new foo(); // [[Construct]] — 콜럼버스 인증 있으니까 글로벌 맛집 오픈

 

[[Construct]]가 없는 함수에 new를 쓰면 TypeError.
"콜럼버스 인증도 없는데 글로벌 맛집으로 등록하려다 거절됨"이라고 보면 된다. (...)

const arrow = () => {};
new arrow(); // TypeError: arrow is not a constructor
             // "커피는 잘 내리는데 콜럼버스 인증은 없음"

"The abstract operation IsConstructor determines if argument is a function object with a [[Construct]] internal method."
(추상 연산 IsConstructor는 인수가 [[Construct]] 내부 메서드를 사용하는 함수 객체인지 판별한다.)
— ECMAScript §7.2.4

 

종류 [[Call]] [[Construct]] 비유
함수 선언문/표현식 콜럼버스 인증 받은 스페셜티 카페
화살표 함수 커피 잘 내리지만 인증 없는 동네 카페
ES6 메서드 축약 사내 카페 — 내부에서만 통하는 곳
클래스 new 없이 ❌ 콜럼버스 인증 필수 스페셜티 카페

 

prototype도 콜럼버스 인증 받은 함수만 갖는다.

const arrow = () => {};
arrow.hasOwnProperty('prototype'); // false — 인증 현판도 없음

function foo() {}
foo.hasOwnProperty('prototype');   // true

콜럼버스 인증(=[[Construct]])은 어떻게 붙는가

함수 리터럴이 평가되면 OrdinaryFunctionCreate가 호출되는데, 이 단계에서 [[Call]]만 붙는다.

"It is used to specify the runtime creation of a new function with a default [[Call]] internal method and no [[Construct]] internal method."
(기본 [[Call]] 내부 메서드를 갖고 [[Construct]] 내부 메서드는 없는 새 함수의 런타임 생성을 명세하는 데 쓰인다.)
— ECMAScript Living Standard §10.2.3, OrdinaryFunctionCreate

 

[[Construct]]는 별도로 MakeConstructor 라는 추상 연산이 호출될 때 추가된다.
이 연산은 함수 객체에 [[Construct]]를 붙이고, prototype 프로퍼티도 함께 생성한다.

 

화살표 함수와 메서드 축약 표현에는 MakeConstructor가 호출되지 않는다.
콜럼버스 심사(MakeConstructor)를 안 받으면 인증([[Construct]])을 안 준다는 얘기.

함수 선언문/표현식:
  1. OrdinaryFunctionCreate → [[Call]]만 있는 함수 객체 (§10.2.3)
  2. MakeConstructor 호출   → [[Construct]] 추가 + prototype 생성 (§10.2.5)

화살표 함수:
  1. OrdinaryFunctionCreate → [[Call]]만 있는 함수 객체 (§10.2.3)
  2. MakeConstructor 호출 없음 → [[Construct]] 없음, prototype 없음

 

 

[[Construct]] 내부 동작 — 카페 오픈 3단계

new Circle(5)를 실행하면 ECMAScript 사양 §10.2.2 기준으로 아래와 같은 순서가 된다.

① OrdinaryCreateFromConstructor — 빈 공간(객체) 확보 (§10.1.13)
   → 생성자의 prototype 프로퍼티를 [[Prototype]]으로 하는 새 빈 객체 생성

② OrdinaryCallBindThis — 그 공간에 this 팻말 꽂기
   → 빈 객체를 this로 바인딩

③ 함수 본문 실행 — 가구 채워넣기
   → this.radius = radius, this.getDiameter = ...

④ 반환값 처리 — 완성된 카페 넘겨주기
   → 객체를 명시적으로 return하면 그 객체
   → 원시값 return이거나 return 없으면 this 반환

 

OrdinaryCreateFromConstructor가 새 객체의

[[Prototype]]을 생성자의 prototype 프로퍼티로 설정한다는 게 핵심이다.

이게 프로토타입 체인이 연결되는 시점이고, 19장에서 다시 나오니 참고하도록 하자.

function Circle(radius) {
  // 1단계: 이미 빈 객체가 생성되어 this에 바인딩됨
  console.log(this); // Circle {} — 빈 공간

  // 2단계: 개발자가 직접 쓰는 초기화 코드 — 가구 배치
  this.radius = radius;
  this.getDiameter = function() { return 2 * this.radius; };

  // 3단계: 암묵적으로 this 반환 — 완성된 카페 오픈
}

 

 

new.target — 나 지금 콜럼버스 인증 들고 왔어? (ft. 나몰빼미)

new.target은 ES6에서 도입된 meta-property다.
현재 실행 컨텍스트의 [[NewTarget]] 필드를 통해 구현된다.

콜럼버스 가이드 인증을 받으려면 반드시 공식 심사 루트(new)를 통해야 한다.
new.target은 "지금 공식 루트로 들어온 거 맞아?" 하고 확인하는 장치다.

new로 호출 → [[NewTarget]] = 생성자 함수 자기 자신
             (= 공식 심사 루트로 들어온 상태 → 콜럼버스 인증 발급 가능)
일반 호출  → [[NewTarget]] = undefined
             (= 그냥 손님으로 들어온 상태 → 인증 발급 불가)
function Circle(radius) {
  if (!new.target) {
    // 공식 루트 아니면 → 다시 공식 루트로 보내버림
    return new Circle(radius);
  }
  this.radius = radius;
}

const c = Circle(5); // new 없이 불러도 내부에서 공식 루트로 리다이렉트
console.log(c.radius); // 5

 

"콜럼버스 인증 루트 아니면 접수 안 받아요, 다시 공식 창구로 가세요" 같은 거다.

 

 

3. 함수와 일급 객체 — 레시피 카드가 값이 될 수 있다는 것

일급 객체가 뭔지

"A programming language is said to have first-class functions when functions in that language are treated like any other variable."
(어떤 프로그래밍 언어에서 함수가 다른 변수와 동일하게 취급될 때, 그 언어는 일급 함수를 지원한다고 한다.)
— MDN, First-class Function

 

"함수가 다른 변수처럼 취급된다"는 게 뭔 소리냐.

베이킹 노트로 생각해보자. 내가 만든 레시피 카드를 예로 들면:

  • 레시피 카드를 레시피 박스(변수)에 보관할 수 있다
  • 레시피 카드를 친구한테 건네줄(인수로 전달) 수 있다
  • 레시피 책에서 특정 카드를 꺼내서 돌려줄(반환값) 수 있다
    함수가 바로 이 레시피 카드다. 실행 결과물(케이크)이 아니라, "어떻게 만드는가"라는 지식 자체를 넘길 수 있다는 게 핵심이다.

 

조건 1 — 즉석에서 만들기

const double = function(n) { return n * 2; };
setTimeout(function() { console.log('타이머 끝'); }, 1000);

 

React에서 onClick={() => doSomething()}을 JSX 안에 바로 쓸 수 있는 게 이 덕분이다.

 

조건 2 — 레시피 박스에 보관

const kitchen = { bake: function(recipe) { return recipe(); } };

const recipes = [makeScone, makeMuffin, makeCookie];
recipes[0](); // makeScone 실행

 

조건 3 — 레시피를 친구에게 건네기

const evens = [1,2,3,4,5].filter(n => n % 2 === 0);
<Button onClick={handleClick} />

 

조건 4 — 커스텀 레시피 생성기

function makeMultiplier(factor) {
  return function(n) { return n * factor; }; // 레시피 카드를 반환
}

const double = makeMultiplier(2);
double(5); // 10

 

React의 useCallback이 이 패턴이다. 함수(레시피 카드)를 반환하고, 그 반환된 함수를 이벤트 핸들러로 쓴다.

 

 

번외 — class의 정체: 슈거코드인가, 아닌가

저번 발표에서 하다가 "class는 슈거코드 아니냐"는 말이 나왔는데, 찾아보니 절반만 맞는 말이더..라 (엑)

스펙 레벨에서 class는 함수 객체인 건 사실이고, 프로토타입 기반 생성 모델 위에 올라간 문법이라는 것도 맞다.

typeof class Foo {} // 'function'

 

class 키워드로 선언한 것의 typeof'function'으로 나온다.
스펙도 class 선언을 평가할 때 함수 객체를 만든다고 명세한다.

 

17장에서 본 생성자 함수 + 프로토타입 기반 상속을 ES6 문법으로 포장한 것이라는 큰 틀은 맞다.
근데 의도적으로 다르게 설계된 부분들이 있어서, "완전히 같은 것을 예쁘게 쓴 것"이라고 하면 틀린다. 그 차이들은 아래서 정리한다.

 

한 가지 더 — class 함수 객체는 [[ConstructorKind]]라는 내부 슬롯을 갖는다.
extends 없는 일반 class면 "base", extends가 있는 파생 class면 "derived"로 설정된다.
이게 생성자 함수에는 없는 개념이고, 파생 클래스에서 super() 전에 this를 못 쓰는 이유랑 직결된다.
this 바인딩 메커니즘은 아래 번외 섹션에서 따로 정리한다.

 

 

생성자 함수 vs class — 같은 결과, 다른 문법

// ES5 방식
function Circle(radius) {
  this.radius = radius;
}
Circle.prototype.getDiameter = function() {
  return 2 * this.radius;
};

// ES6 class 방식 — 위와 동일한 결과
class Circle {
  constructor(radius) {
    this.radius = radius;
  }
  getDiameter() {
    return 2 * this.radius;
  }
}

 

둘 다 prototype에 메서드를 붙이고, [[Construct]]를 갖는 함수 객체를 만든다.
본질적으로 같은 일을 한다.

 

 

근데 의도적으로 다르게 설계된 것들도 있다

항목 생성자 함수 class
new 없이 호출 그냥 됨 (this가 전역으로 날아가지만) TypeError — 반드시 new로만
strict mode 명시 필요 class body는 항상 암묵적 strict mode
호이스팅 함수 선언문은 호이스팅됨 TDZ — 선언 전 참조 불가

 

class가 반드시 new로만 호출 가능한 이유는 스펙에서 [[Call]] 동작을 다르게 명세했기 때문이다.

일반 함수는 [[Call]]로 그냥 실행되는데, class는 [[Call]]이 호출되면

즉시 TypeError를 던지도록 스펙에 박혀 있다. (의도적으로 막아둔 거다)

 

 

class도 일급 객체라 이런 게 된다

 

class의 정체가 함수 객체이기 때문에, 값처럼 다룰 수 있다.

// 변수에 담기
const Circle = class {
  constructor(r) { this.radius = r; }
};

// 클래스 팩토리 패턴 — 클래스를 반환하는 함수
function createClass(defaultRadius) {
  return class {
    constructor() { this.radius = defaultRadius; }
    getDiameter() { return 2 * this.radius; }
  };
}
const SmallCircle = createClass(3);
new SmallCircle().getDiameter(); // 6

 

"class를 반환하는 함수"가 말이 되는 이유가 여기에 있다.
class 자체가 함수 객체(= 값)이니까.

V8 입장에서도 class로 만든 인스턴스는 생성자 함수로 만든 것과 동일하게 HiddenClass를 공유한다.

class Point {
  constructor(x, y) {
    this.x = x;  // C0 → C1
    this.y = y;  // C1 → C2
  }
}
const p1 = new Point(1, 2); // C2
const p2 = new Point(3, 4); // C2 — 같은 HiddenClass, 같은 최적화

 

그래서 슈거코드가 맞아, 아니야?

 

결론: "프로토타입 기반 모델을 더 구조적이고 안전하게 표현한 문법"이다.

슈거코드라는 큰 방향은 맞지만, 단순 편의 문법으로만 보면 틀리다.

 

typeof class Foo {} === 'function'이고, 프로토타입 기반 생성 모델 위에 올라간 문법이라는 건 사실이다.
근데 "슈거코드 = 완전히 동일한 것을 예쁘게 쓴 것"이라고 보면 아래가 문제가 된다.

 

생성자 함수로는 재현이 안 되거나 동작이 다른 것들:

  • new 없이 호출 → 생성자 함수는 그냥 됨, class는 스펙에서 TypeError로 막아둠 ([[IsClassConstructor]])
  • strict mode → 생성자 함수는 명시 필요, class body는 항상 암묵적 strict mode
  • 호이스팅 → 함수 선언문은 호이스팅됨, class는 TDZ
  • [[ConstructorKind]] → 파생 class는 super() 전에 this 자체가 없음. 생성자 함수엔 이 개념이 없다.
    이것들은 의도적으로 더 엄격하게 설계된 부분이라 "그냥 편의 문법"으로만 볼 수 없다.

 

번외 — this 바인딩, 실제로 어떻게 동작하나

면접 준비하면서 이렇게 적었던 적이 있었다.

"this는 함수를 어떻게 호출했느냐에 따라 가리키는 대상이 달라집니다. 일반 함수 호출이면 전역 객체(window), 객체의 메서드로 호출하면 그 객체, new로 호출하면 새로 생성되는 인스턴스를 가리킵니다. 화살표 함수는 자신만의 this가 없고 선언된 위치의 this를 그대로 씁니다."

 

틀린 말은 아닌데, 이번에 스펙이랑 [[Construct]] 동작을 파고 나서 보니까 설명할 수 있는 게 더 생겼다.

 

 

왜 호출 방식에 따라 this가 달라지냐

 

스펙 기준으로 함수에는 [[Call]][[Construct]] 두 가지 내부 메서드가 있다.

  • 일반 호출 ([[Call]]) — strict mode면 thisundefined, non-strict면 전역 객체. "window"라고 외웠는데 strict mode에서는 다르다는 게 포인트다.
  • 메서드 호출 — 점 앞의 객체가 this. obj.method()this === obj
  • new 호출 ([[Construct]]) — 스펙(§10.2.2)에서 OrdinaryCreateFromConstructor가 빈 객체를 생성하고 그걸 this에 바인딩한다. 개발자가 this.x = ...를 쓰는 시점엔 이미 this가 존재하는 이유가 여기에 있다.
  • call / apply / bindthis를 직접 지정. bind는 새 함수를 반환하고 [[BoundThis]] 슬롯에 고정해둔다.화살표 함수의 this — "상위 스코프"보다 "렉시컬"이 더 정확하다

"상위 스코프의 this"라고 외우면 약간 헷갈리는 경우가 생긴다. 정확하게는 화살표 함수는 자신만의 this 바인딩 자체가 없다. [[Call]]이 실행될 때 this를 새로 결정하는 게 아니라, 선언된 위치의 렉시컬 환경에서 this를 그대로 가져온다.

const obj = {
  name: '림졍',
  normal: function() {
    setTimeout(function() {
      console.log(this.name); // undefined — 일반 함수, this가 window로 바뀜
    }, 0);
  },
  arrow: function() {
    setTimeout(() => {
      console.log(this.name); // '림졍' — 화살표 함수, 선언 시점 this 유지
    }, 0);
  }
};

 

화살표 함수가 [[Construct]]도 없는 이유가 여기에 있다.

this를 바인딩하는 능력 자체가 없으니까 new로 인스턴스를 만들 수도 없다.

 

 

class 파생 클래스에서 super() 전에 this를 못 쓰는 이유

 

이것도 this 바인딩이랑 연결된다.

[[ConstructorKind]]"derived"인 파생 클래스는 스스로 this를 생성하지 않는다.

super()를 호출해야 부모 생성자가 실행되면서 비로소 this가 초기화된다.

super() 전에 this를 쓰면 ReferenceError가 나는 이유가 여기에 있다.

class Derived extends Base {
  constructor() {
    console.log(this); // ReferenceError — 아직 this 없음
    super();           // 여기서 this 초기화
    console.log(this); // 이제 됨
  }
}

 

결국 "호출 방식에 따라 this가 달라진다"는 맞는 말인데,

그 이유를 스펙 레벨로 파고 들면

[[Call]] vs [[Construct]], [[ConstructorKind]], 렉시컬 환경까지 이어진다.

 

 

마무리 — 다시 삽질하고 나니 비로소 보이는 것

 

 

물론 정리를 하다보니 결국 하나의 그림이었다는 걸 2번째 재탕하고나서 체감이 된 것 같다. (좀 억울하다)

프로퍼티는 키-값 연결에 어트리뷰트를 붙인 것이고,
V8은 이걸 HiddenClass(레시피 노트) 기반 메모리 구조로 구현한다.

생성자 함수[[Construct]](콜럼버스 인증)를 통해 새 객체를 만들고,
V8은 같은 생성자에서 나온 객체들이 HiddenClass를 공유하게 만들어서 최적화한다.

함수 객체[[Call]][[Construct]]를 가진 특수한 객체이고,
레시피 카드처럼 "어떻게 하는가"를 값처럼 주고받을 수 있어서 일급 객체다.
[[Environment]] 슬롯을 통해 렉시컬 환경을 기억하는 클로저의 기반이기도 하다.

그리고 정말 웃긴건 세 챕터가 전부 가리키는 곳은 다음 챕터인 프로토타입이다.(음?)
HiddenClass의 prototype 참조, 생성자 함수의 prototype 프로퍼티, 함수 객체의 [[Prototype]] 체인이 거기서 전부 합쳐지는데
어떻게 보면 지금 발표한게 다행일지도..? (뒤에서는 어떤 내용이 기다릴까 후후...)

 

 

앗차차 맞다 추가적으로 삽질할? 리스트들

 

  • Zustand로 상태 업데이트할 때 스프레드로 새 객체를 만들었던 게 불변성 때문이었다는 걸 이번에 제대로 이해했다. 쓰긴 했는데 왜 써야 하는지까지는 몰랐던 거다. 중첩이 깊어지면 스프레드가 지저분해지는 트레이드오프가 있는데, 이 부분은 나중에 더 들여다봐야 할 것 같다.
  • RTK를 예전에 겉핥기로 써봤는데, 불변성 개념을 이번에 제대로 정리하고 나니 왜 상태를 직접 mutate하면 안 된다고 하는지 감이 왔다. 내부적으로 어떻게 불변성을 보장하는지는 제대로 파본 적이 없어서 나중에 한 번 더 봐야겠다.
  • React 클래스 컴포넌트에서 constructor에 bind(this)를 붙이는 이유가 이제 조금 납득이 간다. 함수 컴포넌트 전환이 이 문제를 어떻게 해결했는지는 22장에서 call, apply, bind 배우고 나서 다시 봐야겠다.
  • prototype 프로퍼티가 인스턴스의 프로토타입을 가리킨다는 말이 아직 추상적이다. 19장 프로토타입 체인 배우면 class 상속이 내부적으로 어떻게 동작하는지 구체적으로 알 수 있을 것 같다

 

 

 

 

참고 자료

ECMAScript Living Standard (tc39.es/ecma262)

V8

MDN

기타

728x90
반응형