자바스크립트 고급 #3 프로토타입 체인

자바스크립트의 객체 모델은 다른 OOP 언어들과 달라요. **클래스 기반(class-based)**이 아니라 프로토타입 기반(prototype-based) 입니다. ES2015의 class 문법은 프로토타입 위에 입혀진 설탕(syntactic sugar)입니다. 이번 글은 그 안쪽을 들여다봅니다.

모든 객체는 다른 객체를 참조한다 #

자바스크립트의 모든 객체는 [[Prototype]] 이라는 내부 슬롯을 가집니다. 이게 다른 객체를 가리켜요.

프로토타입 보기
const obj = { a: 1 };
Object.getPrototypeOf(obj);   // Object.prototype

빈 객체 {}의 프로토타입은 Object.prototype 이라는 빌트인 객체. 거기에는 toString, hasOwnProperty 같은 메서드가 들어있습니다.

자기에게 없는 속성도 동작
const obj = { a: 1 };
obj.toString();   // '[object Object]'
//        ↑ obj 에 직접 없지만 Object.prototype 에 있음

obj.toString을 찾을 때 — 자바스크립트는 먼저 obj 자체를 보고, 없으면 프로토타입 체인을 따라 위로 올라가며 찾습니다. Object.prototype.toString까지 가서 발견.

이걸 프로토타입 체인 룩업 이라 부릅니다. 자바스크립트의 거의 모든 메서드 호출이 이 동작 위에서 일어나요.

함수의 prototype 속성 #

함수는 모두 prototype 이라는 속성을 가집니다.

함수의 prototype
function User(name) {
  this.name = name;
}

User.prototype.greet = function() {
  console.log(`안녕, ${this.name}`);
};

const u = new User('커티스');
u.greet();   // 안녕, 커티스

new User('커티스')가 실행되면:

  1. 새 빈 객체를 만듦
  2. 그 객체의 [[Prototype]]User.prototype으로 설정
  3. User 본문을 실행하면서 this에 속성 추가
  4. 객체를 반환

그래서 u.greet을 부르면 — u 에는 없지만 프로토타입 체인을 따라 User.prototype.greet을 찾아 호출합니다.

클래스 = 프로토타입의 설탕 #

ES2015의 class 문법으로 같은 코드를 다시 적으면:

class 문법
class User {
  constructor(name) {
    this.name = name;
  }
  greet() {
    console.log(`안녕, ${this.name}`);
  }
}

const u = new User('커티스');
console.log(u);                   // User { name: '커티스' }
console.log(u.greet);              // [Function: greet]

내부적으로 정확히 같은 일이 일어나요.

class와 프로토타입 등가성
class User {
  greet() { /* ... */ }
}
// 위는 사실상 다음과 같음
function User() {}
User.prototype.greet = function() { /* ... */ };

class가 등장한 뒤로 직접 prototype에 메서드를 다는 코드는 거의 없어졌지만, 이 구조는 그대로입니다.

프로토타입 체인 — 깊이 들어가기 #

상속 관계는 체인이 길어지는 것으로 표현됩니다.

상속 = 프로토타입 체인 연장
class Animal {
  speak() { console.log('소리'); }
}

class Dog extends Animal {
  bark() { console.log('멍멍'); }
}

const d = new Dog();
d.bark();    // 'd' → 'Dog.prototype' 에서 발견
d.speak();   // 'd' → 'Dog.prototype' (없음) → 'Animal.prototype' 에서 발견

체인:

d
 ↓ [[Prototype]]
Dog.prototype  (bark)
 ↓ [[Prototype]]
Animal.prototype  (speak)
 ↓ [[Prototype]]
Object.prototype  (toString, hasOwnProperty)
 ↓
null

속성을 찾을 때 이 체인을 위로 올라가며 검색합니다. 끝까지 못 찾으면 undefined.

체인 끝은 null #

체인 끝
Object.getPrototypeOf(Object.prototype);   // null

Object.prototype의 프로토타입은 null 입니다. 여기서 체인이 끝나요.

자기 속성 vs 상속된 속성 #

own vs inherited
class User {
  constructor(name) {
    this.name = name;
  }
  greet() {}
}

const u = new User('커티스');

Object.hasOwn(u, 'name');     // true — 인스턴스 자기 속성
Object.hasOwn(u, 'greet');    // false — 프로토타입에 있음

'greet' in u;                  // true ('in' 은 체인까지 검사)

Object.hasOwn은 자기 속성만 검사. in 연산자는 체인까지 따라갑니다. 둘의 차이를 의식해야 할 때가 가끔 있습니다.

옛 코드에는 obj.hasOwnProperty('key')가 자주 보이는데, 이건 obj 자체에 hasOwnProperty가 정의되어 있으면 깨질 수 있습니다. ES2022의 Object.hasOwn이 안전한 답입니다.

프로토타입을 직접 다루는 도구 #

1) Object.create — 프로토타입을 명시해 만들기 #

Object.create
const animalProto = {
  speak() { console.log('소리'); },
};

const dog = Object.create(animalProto);
dog.bark = function() { console.log('멍멍'); };

dog.speak();   // 소리 (프로토타입에서)
dog.bark();    // 멍멍 (자기 속성)

클래스 없이 프로토타입을 직접 정해서 객체를 만드는 방법. 옛 자료에서 자주 보이지만, 모던 코드에서는 거의 class가 표준입니다.

2) Object.create(null) — 프로토타입 없는 객체 #

진짜 깨끗한 사전(map)
const dict = Object.create(null);
dict.toString;    // undefined — Object.prototype 도 없음

const normal = {};
normal.toString;  // [Function: toString]  — 빌트인이 보임

키-값 사전으로 객체를 쓸 때 — toString 같은 빌트인 키 충돌을 막으려면 Object.create(null)이 안전합니다. 모던 자바스크립트에는 Map이 더 좋은 선택지 이지만, 라이브러리 내부 코드에서 가끔 만납니다.

프로토타입 수정 — 권장하지 않음 #

기존 빌트인 수정 — 절대 하지 말 것
Array.prototype.first = function() {
  return this[0];
};

[1, 2, 3].first();   // 1

빌트인의 프로토타입에 메서드를 더하는 패턴(이른바 monkey patching)은 매우 강력하지만 거의 항상 사고를 만듭니다. 다른 코드(라이브러리, 표준 신규 메서드)와 충돌하기 쉬워요.

자기 클래스의 prototype에는 평범하게 메서드를 추가할 수 있고 그게 정상입니다. 빌트인을 건드리는 건 피하세요.

__proto__ — 옛 동작 #

옛 자바스크립트에는 __proto__ 라는 비공식 속성이 있었습니다. ES2015에서 표준화됐지만 사용은 권장되지 않습니다.

__proto__ — 옛 방식
const obj = {
  __proto__: animalProto,   // 사용하지 마세요
};

// 권장
const obj2 = Object.create(animalProto);

Object.getPrototypeOf / Object.setPrototypeOf / Object.create가 모던 정답입니다.

instanceof — 프로토타입 체인 검사 #

instanceof
class Animal {}
class Dog extends Animal {}

const d = new Dog();

d instanceof Dog;       // true
d instanceof Animal;    // true (체인을 따라 부모도)
d instanceof Object;    // true

x instanceof CC.prototype이 x의 프로토타입 체인 어딘가에 있는가를 검사합니다. 체인 룩업의 또 다른 응용입니다.

함수도 객체 — 자기 속성 가질 수 있음 #

자바스크립트에서 함수는 객체이기도 합니다. 속성을 직접 달 수 있습니다.

함수에 속성 달기
function counter() {
  counter.count = (counter.count ?? 0) + 1;
  return counter.count;
}

counter();   // 1
counter();   // 2
counter();   // 3

이런 패턴은 잘 쓰지 않지만, 자바스크립트의 함수가 1급 객체(first-class object)라는 의미가 드러나는 예시입니다.

구체적인 예 — 배열의 메서드 체인 #

배열 메서드는 어디서?
const arr = [1, 2, 3];

Object.hasOwn(arr, 'map');    // false
Object.hasOwn(arr, 'length'); // true

arr.__proto__ === Array.prototype;             // true
Array.prototype.__proto__ === Object.prototype; // true

체인:

[1, 2, 3]   (length, 0, 1, 2)
   ↓
Array.prototype   (map, filter, reduce, ...)
   ↓
Object.prototype   (toString, ...)
   ↓
null

arr.map을 호출하면 — arr 자체에는 없으니 Array.prototype에서 찾아 실행합니다. 모든 배열이 같은 Array.prototype을 공유하니 메서드가 한 번만 메모리에 있습니다.

마무리 #

이번 글에서 정리한 내용:

  • 자바스크립트는 프로토타입 기반 — 클래스 문법은 설탕
  • 모든 객체는 [[Prototype]]으로 다른 객체를 가리킴
  • 속성을 찾을 때 프로토타입 체인을 위로 따라감
  • 함수의 prototype 속성이 new 호출 시 인스턴스의 [[Prototype]]으로 설정
  • Object.hasOwn으로 자기 속성 검사 (in은 체인까지)
  • Object.create(...)로 프로토타입을 명시해 만들기
  • 빌트인 prototype 수정은 권장 X
  • instanceof는 프로토타입 체인 검사

다음 글(#4 이벤트 루프와 태스크)에서는 자바스크립트 비동기의 실제 동작 — 이벤트 루프, 마이크로태스크와 매크로태스크의 차이, 그리고 Promise.thensetTimeout이 왜 다른 순서로 실행되는지를 다룹니다.

X