상속과 프로토타입 체인

상속과 프로토타입 체인 (Inheritance and the prototype chain)

프로그래밍에서 **상속(inheritance)**은 부모에서 자식으로 특성을 전달하여, 새 코드 조각이 기존 코드의 기능을 재사용하고 그 위에 구축할 수 있게 하는 것을 말한다. JavaScript는 객체를 사용해 상속을 구현한다. 각 객체는 **프로토타입(prototype)**이라고 하는 다른 객체에 대한 내부 링크를 가진다. 그 프로토타입 객체는 자신만의 프로토타입을 가지며, 프로토타입이 null인 객체에 도달할 때까지 이어간다. 정의상 null은 프로토타입이 없으며 이 프로토타입 체인의 마지막 연결 고리 역할을 한다. 프로토타입 체인의 어떤 멤버든 변경할 수 있고, 런타임에 프로토타입을 교체할 수도 있다. 그래서 정적 디스패칭(static dispatching) 같은 개념은 JavaScript에 존재하지 않는다.

JavaScript는 동적이고 정적 타입이 없기 때문에 클래스 기반 언어(Java나 C++ 같은)에 익숙한 개발자에게는 다소 혼란스러울 수 있다. 이 혼란이 JavaScript의 약점 중 하나로 여겨지기도 하지만, 프로토타입 상속 모델 자체는 사실 고전적 모델보다 더 강력하다. 예를 들어 프로토타입 모델 위에 고전적 모델을 구축하는 것은 꽤 간단한데, 이것이 바로 클래스가 구현되는 방식이다.

클래스는 이제 널리 채택되어 JavaScript의 새 패러다임이 되었지만, 클래스는 새로운 상속 패턴을 가져오지 않는다. 클래스가 대부분의 프로토타입 메커니즘을 추상화하지만, 프로토타입이 내부적으로 어떻게 동작하는지 이해하는 것은 여전히 유용하다.

출처: Inheritance and the prototype chain - JavaScript | MDN

본문

프로토타입 체인으로 상속하기 (Inheritance with the prototype chain)

속성 상속하기 (Inheriting properties)

JavaScript 객체는 속성(own properties라고 함)의 **동적인 "가방(bags)"**이다. JavaScript 객체는 프로토타입 객체에 대한 링크를 가진다. 객체의 속성에 접근하려 할 때, 그 속성은 객체에서뿐 아니라 객체의 프로토타입, 프로토타입의 프로토타입 등에서도 찾는다. 일치하는 이름의 속성을 찾거나 프로토타입 체인의 끝에 도달할 때까지 계속한다.

참고: ECMAScript 표준을 따라 someObject.[[Prototype]] 표기가 someObject의 프로토타입을 지정하는 데 사용된다. [[Prototype]] 내부 슬롯은 Object.getPrototypeOf()Object.setPrototypeOf() 함수로 각각 접근·수정할 수 있다. 이것은 비표준이지만 많은 JavaScript 엔진이 실제로 구현하는 접근자 __proto__와 동등하다. 혼동을 피하면서 간결하게 유지하기 위해, 우리 표기는 obj.__proto__를 피하고 obj.[[Prototype]]을 사용한다. 이것은 Object.getPrototypeOf(obj)에 해당한다.

이것을 함수의 func.prototype 속성과 혼동해서는 안 된다. func.prototype은 주어진 함수가 생성자로 사용될 때 그 함수가 만드는 모든 객체 인스턴스에 할당될 [[Prototype]]을 지정한다. 생성자 함수의 prototype 속성은 이후 절에서 논의한다.

객체의 [[Prototype]]을 지정하는 방법은 여러 가지가 있으며, 이후 절에서 나열한다. 지금은 설명을 위해 __proto__ 문법을 사용한다. { __proto__: ... } 문법은 obj.__proto__ 접근자와 다르다는 점을 알아둘 만하다. 전자는 표준이고 deprecate되지 않았다.

{ a: 1, b: 2, __proto__: c } 같은 객체 리터럴에서 값 c(null 또는 다른 객체여야 함)는 리터럴이 나타내는 객체의 [[Prototype]]이 되고, a, b 같은 다른 키는 객체의 own property가 된다. 이 문법은 매우 자연스럽게 읽히는데, [[Prototype]]이 단지 객체의 "내부 속성"이기 때문이다.

속성에 접근할 때 일어나는 일은 다음과 같다.

const o = {
  a: 1,
  b: 2,
  // __proto__는 [[Prototype]]을 설정한다. 여기서는 다른 객체 리터럴로
  // 지정되어 있다.
  __proto__: {
    b: 3,
    c: 4,
  },
};

// o.[[Prototype]]은 속성 b와 c를 가진다.
// o.[[Prototype]].[[Prototype]]은 Object.prototype이다 (이것이 무엇을
// 의미하는지는 나중에 설명한다).
// 마지막으로 o.[[Prototype]].[[Prototype]].[[Prototype]]은 null이다.
// 이것이 프로토타입 체인의 끝이다. 정의상 null은 [[Prototype]]이
// 없기 때문이다.
// 따라서 전체 프로토타입 체인은 다음과 같다:
// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> Object.prototype ---> null

console.log(o.a); // 1
// o에 'a' own property가 있는가? 있다, 그 값은 1이다.

console.log(o.b); // 2
// o에 'b' own property가 있는가? 있다, 그 값은 2이다.
// 프로토타입에도 'b' 속성이 있지만 방문되지 않는다.
// 이것을 Property Shadowing(속성 가리기)이라고 한다.

console.log(o.c); // 4
// o에 'c' own property가 있는가? 없다, 프로토타입을 확인한다.
// o.[[Prototype]]에 'c' own property가 있는가? 있다, 그 값은 4다.

console.log(o.d); // undefined
// o에 'd' own property가 있는가? 없다, 프로토타입을 확인한다.
// o.[[Prototype]]에 'd' own property가 있는가? 없다, 그 프로토타입을 확인한다.
// o.[[Prototype]].[[Prototype]]은 Object.prototype이고 기본적으로
// 'd' 속성이 없으므로 그 프로토타입을 확인한다.
// o.[[Prototype]].[[Prototype]].[[Prototype]]은 null, 검색을 멈추고
// 속성이 없으므로 undefined를 반환한다.

객체에 속성을 설정하면 own property가 만들어진다. getter나 setter가 가로챌 때만 속성 획득·설정 동작 규칙의 유일한 예외가 된다.

마찬가지로 더 긴 프로토타입 체인을 만들 수 있고, 속성은 그것들 모두에서 찾는다.

const o = {
  a: 1,
  b: 2,
  // __proto__는 [[Prototype]]을 설정한다. 여기서는 다른 객체 리터럴로
  // 지정되어 있다.
  __proto__: {
    b: 3,
    c: 4,
    __proto__: {
      d: 5,
    },
  },
};

// { a: 1, b: 2 } ---> { b: 3, c: 4 } ---> { d: 5 } ---> Object.prototype ---> null

console.log(o.d); // 5

"메서드" 상속하기 (Inheriting "methods")

JavaScript에는 클래스 기반 언어가 정의하는 형태의 "메서드"가 없다. JavaScript에서는 어떤 함수든 객체에 속성 형태로 추가될 수 있다. 상속된 함수는 위에서 본 속성 가리기(이 경우 메서드 오버라이딩의 한 형태)를 포함해 다른 속성처럼 작동한다.

상속된 함수가 실행될 때 this의 값은 그 함수가 own property인 프로토타입 객체가 아니라 상속하는 객체를 가리킨다.

const parent = {
  value: 2,
  method() {
    return this.value + 1;
  },
};

console.log(parent.method()); // 3
// 이 경우 parent.method를 호출할 때 'this'는 parent를 가리킨다

// child는 parent에서 상속받는 객체
const child = {
  __proto__: parent,
};
console.log(child.method()); // 3
// child.method가 호출되면 'this'는 child를 가리킨다.
// 따라서 child가 parent의 메서드를 상속받을 때,
// 'value' 속성은 child에서 찾는다. 그러나 child는
// 'value'라는 own property가 없으므로, 그 속성은
// [[Prototype]]에서 발견되는데, 그것은 parent.value다.

child.value = 4; // child의 'value' 속성에 값 4를 할당한다.
// 이것은 parent의 'value' 속성을 가린다.
// child 객체는 이제 다음과 같아 보인다:
// { value: 4, __proto__: { value: 2, method: [Function] } }
console.log(child.method()); // 5
// child가 이제 'value' 속성을 가지므로 'this.value'는
// child.value를 의미한다

생성자 (Constructors)

프로토타입의 힘은, 특히 메서드의 경우, 일련의 속성이 모든 인스턴스에 존재해야 한다면 그 속성 집합을 재사용할 수 있다는 점이다. getValue 함수로 접근할 수 있는 값을 포함하는 객체인 상자(box)들의 시리즈를 만들려 한다고 하자. 순진한 구현은 다음과 같을 것이다.

const boxes = [
  { value: 1, getValue() { return this.value; } },
  { value: 2, getValue() { return this.value; } },
  { value: 3, getValue() { return this.value; } },
];

이것은 각 인스턴스가 같은 일을 하는 자신만의 함수 속성을 가지므로 중복되고 불필요하다. 대신 getValue를 모든 상자의 [[Prototype]]으로 옮길 수 있다.

const boxPrototype = {
  getValue() {
    return this.value;
  },
};

const boxes = [
  { value: 1, __proto__: boxPrototype },
  { value: 2, __proto__: boxPrototype },
  { value: 3, __proto__: boxPrototype },
];

이렇게 하면 모든 상자의 getValue 메서드가 같은 함수를 참조하게 되어 메모리 사용량이 줄어든다. 그러나 매 객체 생성마다 __proto__를 수동으로 바인딩하는 것은 여전히 매우 불편하다. 이때 **생성자 함수(constructor function)**를 사용하는데, 생성자 함수는 제조되는 모든 객체의 [[Prototype]]을 자동으로 설정한다. 생성자는 new로 호출되는 함수다.

// 생성자 함수
function Box(value) {
  this.value = value;
}

// Box() 생성자로 만들어진 모든 상자가 가질 속성
Box.prototype.getValue = function () {
  return this.value;
};

const boxes = [new Box(1), new Box(2), new Box(3)];

new Box(1)Box 생성자 함수로 만들어진 인스턴스라고 말한다. Box.prototype은 이전에 만든 boxPrototype 객체와 크게 다르지 않다 — 그저 평범한 객체다. 생성자 함수로 만들어진 모든 인스턴스는 자동으로 생성자의 prototype 속성을 자신의 [[Prototype]]으로 가진다 — 즉 Object.getPrototypeOf(new Box()) === Box.prototype이다. Constructor.prototype은 기본적으로 constructor라는 own property 하나를 가지며, 그것은 생성자 함수 자체를 가리킨다 — 즉 Box.prototype.constructor === Box이다. 이를 통해 어떤 인스턴스에서든 원래 생성자에 접근할 수 있다.

참고: 생성자 함수에서 비-프리미티브(non-primitive)가 반환되면 그 값이 new 표현식의 결과가 된다. 이 경우 [[Prototype]]이 올바르게 바인딩되지 않을 수 있다 — 하지만 실제로는 자주 발생하지 않는다.

위 생성자 함수는 클래스로 다음과 같이 다시 쓸 수 있다.

class Box {
  constructor(value) {
    this.value = value;
  }

  // 메서드는 Box.prototype에 만들어짐
  getValue() {
    return this.value;
  }
}

클래스는 생성자 함수에 대한 **문법적 설탕(syntax sugar)**이다. 즉 여전히 Box.prototype을 조작해 모든 인스턴스의 동작을 바꿀 수 있다. 그러나 클래스는 기본 프로토타입 메커니즘을 추상화하도록 설계되었으므로, 프로토타입이 어떻게 동작하는지 완전히 보여주기 위해 이 튜토리얼에서는 더 가벼운 생성자 함수 문법을 사용하겠다.

Box.prototype이 모든 인스턴스의 [[Prototype]]과 같은 객체를 참조하므로, Box.prototype을 변경하면 모든 인스턴스의 동작을 바꿀 수 있다.

function Box(value) {
  this.value = value;
}
Box.prototype.getValue = function () {
  return this.value;
};
const box = new Box(1);

// 인스턴스가 이미 만들어진 후에 Box.prototype 변경
Box.prototype.getValue = function () {
  return this.value + 1;
};
box.getValue(); // 2

결과적으로, Constructor.prototype을 재할당(Constructor.prototype = ...)하는 것은 두 가지 이유로 나쁜 생각이다.

  • 재할당 전에 만들어진 인스턴스의 [[Prototype]]은 재할당 후 만들어진 인스턴스의 [[Prototype]]과 다른 객체를 참조하게 된다 — 하나의 [[Prototype]]을 변경해도 다른 것을 변경하지 않는다.
  • constructor 속성을 수동으로 다시 설정하지 않는 한, instance.constructor에서 생성자 함수를 더 이상 추적할 수 없다. 이는 사용자 기대를 깨뜨릴 수 있다. 일부 내장 연산도 constructor 속성을 읽으며, 설정되어 있지 않으면 예상대로 동작하지 않을 수 있다.

Constructor.prototype은 인스턴스를 만들 때만 유용하다. 생성자 함수 자신의 프로토타입인 Constructor.[[Prototype]](즉 Function.prototype)과는 아무 관련이 없다 — 즉 Object.getPrototypeOf(Constructor) === Function.prototype이다.

리터럴의 암묵적 생성자 (Implicit constructors of literals)

일부 JavaScript 리터럴 문법은 [[Prototype]]을 암묵적으로 설정하는 인스턴스를 만든다. 예:

// 객체 리터럴은 (`__proto__` 키 없이) 자동으로
// `Object.prototype`을 `[[Prototype]]`으로 가진다
const object = { a: 1 };
Object.getPrototypeOf(object) === Object.prototype; // true

// 배열 리터럴은 자동으로 `Array.prototype`을 `[[Prototype]]`으로 가진다
const array = [1, 2, 3];
Object.getPrototypeOf(array) === Array.prototype; // true

// 정규식 리터럴은 자동으로 `RegExp.prototype`을 `[[Prototype]]`으로 가진다
const regexp = /abc/;
Object.getPrototypeOf(regexp) === RegExp.prototype; // true

이것들을 생성자 형태로 "설탕 제거(de-sugar)"할 수 있다.

const array = new Array(1, 2, 3);
const regexp = new RegExp("abc");

예를 들어 map() 같은 "배열 메서드"는 단순히 Array.prototype에 정의된 메서드이므로 모든 배열 인스턴스에서 자동으로 사용 가능하다.

⚠️ 경고: 한때 널리 퍼진 잘못된 기능(misfeature)이 하나 있다 — Object.prototype 또는 다른 내장 프로토타입을 확장하는 것이다. 이 잘못된 기능의 예는 Array.prototype.myMethod = function () {...}를 정의한 다음 모든 배열 인스턴스에서 myMethod를 사용하는 것이다.

이 잘못된 기능을 monkey patching이라고 한다. monkey patching은 앞으로의 호환성을 위험에 빠뜨린다. 언어가 나중에 이 메서드를 추가하되 다른 시그니처로 추가하면 코드가 깨질 수 있기 때문이다. 이것은 SmooshGate 같은 사건을 일으켰고, JavaScript가 "웹을 깨지 않으려" 하기 때문에 언어가 발전하는 데 큰 방해가 될 수 있다.

내장 프로토타입을 확장하는 유일한 좋은 이유는 Array.prototype.forEach처럼 더 새로운 JavaScript 엔진의 기능을 백포트(backport)하는 것이다.

흥미롭게도 역사적 이유로 일부 내장 생성자의 prototype 속성은 그 자체가 인스턴스다. 예를 들어 Number.prototype은 숫자 0, Array.prototype은 빈 배열, RegExp.prototype/(?:)/이다.

Number.prototype + 1; // 1
Array.prototype.map((x) => x + 1); // []
String.prototype + "a"; // "a"
RegExp.prototype.source; // "(?:)"
Function.prototype(); // Function.prototype은 그 자체로 no-op 함수

그러나 이것은 사용자 정의 생성자나 Map 같은 현대 생성자에는 해당하지 않는다.

Map.prototype.get(1);
// Uncaught TypeError: get method called on incompatible Map.prototype

더 긴 상속 체인 구축하기 (Building longer inheritance chains)

Constructor.prototype 속성은 생성자의 인스턴스의 [[Prototype]]이 된다 — Constructor.prototype 자신의 [[Prototype]]을 포함해 그대로. 기본적으로 Constructor.prototype은 평범한 객체다 — 즉 Object.getPrototypeOf(Constructor.prototype) === Object.prototype이다. 유일한 예외는 [[Prototype]]nullObject.prototype 자체다 — 즉 Object.getPrototypeOf(Object.prototype) === null이다. 따라서 일반적인 생성자는 다음 프로토타입 체인을 구축한다.

function Constructor() {}

const obj = new Constructor();
// obj ---> Constructor.prototype ---> Object.prototype ---> null

더 긴 프로토타입 체인을 구축하려면 Object.setPrototypeOf() 함수로 Constructor.prototype[[Prototype]]을 설정할 수 있다.

function Base() {}
function Derived() {}
// `Derived.prototype`의 `[[Prototype]]`을
// `Base.prototype`으로 설정
Object.setPrototypeOf(Derived.prototype, Base.prototype);

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

클래스 용어로 이것은 extends 문법을 사용하는 것과 동등하다.

class Base {}
class Derived extends Base {}

const obj = new Derived();
// obj ---> Derived.prototype ---> Base.prototype ---> Object.prototype ---> null

상속 체인을 구축하기 위해 Object.create()를 사용하는 일부 레거시 코드를 볼 수도 있다. 그러나 이것은 prototype 속성을 재할당하고 constructor 속성을 제거하므로 더 오류가 나기 쉬울 수 있고, 생성자가 아직 인스턴스를 만들지 않았다면 성능 이득도 뚜렷하지 않을 수 있다.

function Base() {}
function Derived() {}
// `Derived.prototype`을 `Base.prototype`을 `[[Prototype]]`으로 가진
// 새 객체로 재할당
// 이렇게 하지 말라 — 변경하려면 Object.setPrototypeOf를 사용하라
Derived.prototype = Object.create(Base.prototype);

프로토타입 검사: 더 깊이 (Inspecting prototypes: a deeper dive)

배후에서 일어나는 일을 조금 더 자세히 살펴보자.

앞서 언급했듯 JavaScript에서 함수는 속성을 가질 수 있다. 모든 함수는 prototype이라는 특별한 속성을 가진다. 아래 코드는 독립적이다(웹페이지에 아래 코드 외에 다른 JavaScript가 없다고 안전하게 가정할 수 있다). 최상의 학습 경험을 위해 콘솔을 열고 "console" 탭으로 이동해 아래 JavaScript 코드를 복사-붙여넣기하고 Enter/Return 키로 실행하는 것을 강력히 권장한다. (콘솔은 대부분의 웹 브라우저의 Developer Tools에 포함되어 있다.)

function doSomething() {}
console.log(doSomething.prototype);
// 함수를 어떻게 선언하든 상관없다. JavaScript의 함수는
// 항상 기본 prototype 속성을 가진다 — 한 가지 예외가 있는데
// 화살표 함수는 기본 prototype 속성이 없다:
const doSomethingFromArrowFunction = () => {};
console.log(doSomethingFromArrowFunction.prototype);

위에서 보듯 doSomething()은 콘솔이 보여주듯 기본 prototype 속성을 가진다. 이 코드를 실행한 후 콘솔은 이것과 유사한 객체를 표시했을 것이다.

{
  constructor: ƒ doSomething(),
  [[Prototype]]: {
    constructor: ƒ Object(),
    hasOwnProperty: ƒ hasOwnProperty(),
    isPrototypeOf: ƒ isPrototypeOf(),
    propertyIsEnumerable: ƒ propertyIsEnumerable(),
    toLocaleString: ƒ toLocaleString(),
    toString: ƒ toString(),
    valueOf: ƒ valueOf()
  }
}

참고: Chrome 콘솔은 사양 용어를 따라 객체의 프로토타입을 나타내기 위해 [[Prototype]]을 사용하고, Firefox는 <prototype>을 사용한다. 일관성을 위해 우리는 [[Prototype]]을 사용한다.

아래와 같이 doSomething()의 프로토타입에 속성을 추가할 수 있다.

function doSomething() {}
doSomething.prototype.foo = "bar";
console.log(doSomething.prototype);

이 결과는:

{
  foo: "bar",
  constructor: ƒ doSomething(),
  [[Prototype]]: {
    constructor: ƒ Object(),
    hasOwnProperty: ƒ hasOwnProperty(),
    isPrototypeOf: ƒ isPrototypeOf(),
    propertyIsEnumerable: ƒ propertyIsEnumerable(),
    toLocaleString: ƒ toLocaleString(),
    toString: ƒ toString(),
    valueOf: ƒ valueOf()
  }
}

이제 new 연산자를 사용해 이 프로토타입을 기반으로 doSomething()의 인스턴스를 만들 수 있다. new 연산자를 사용하려면 함수를 평소대로 호출하되 new를 앞에 붙이면 된다. new 연산자로 함수를 호출하면 그 함수의 인스턴스인 객체를 반환한다. 그러면 이 객체에 속성을 추가할 수 있다.

다음 코드를 시도하라.

function doSomething() {}
doSomething.prototype.foo = "bar"; // 프로토타입에 속성 추가
const doSomeInstancing = new doSomething();
doSomeInstancing.prop = "some value"; // 객체에 속성 추가
console.log(doSomeInstancing);

결과는 다음과 유사하다.

{
  prop: "some value",
  [[Prototype]]: {
    foo: "bar",
    constructor: ƒ doSomething(),
    [[Prototype]]: {
      constructor: ƒ Object(),
      hasOwnProperty: ƒ hasOwnProperty(),
      isPrototypeOf: ƒ isPrototypeOf(),
      propertyIsEnumerable: ƒ propertyIsEnumerable(),
      toLocaleString: ƒ toLocaleString(),
      toString: ƒ toString(),
      valueOf: ƒ valueOf()
    }
  }
}

위에서 보듯 doSomeInstancing[[Prototype]]doSomething.prototype이다. 이것은 무엇을 하는가? doSomeInstancing의 속성에 접근하면 런타임은 먼저 doSomeInstancing이 그 속성을 가졌는지 본다.

doSomeInstancing이 그 속성을 가지지 않으면, 런타임은 doSomeInstancing.[[Prototype]](일명 doSomething.prototype)에서 그 속성을 찾는다. doSomeInstancing.[[Prototype]]이 찾는 속성을 가지면, doSomeInstancing.[[Prototype]]의 그 속성이 사용된다.

그렇지 않고 doSomeInstancing.[[Prototype]]이 그 속성을 가지지 않으면, doSomeInstancing.[[Prototype]].[[Prototype]]이 속성을 검사한다. 기본적으로 어떤 함수의 prototype 속성의 [[Prototype]]Object.prototype이다. 그래서 doSomeInstancing.[[Prototype]].[[Prototype]](일명 doSomething.prototype.[[Prototype]](일명 Object.prototype))이 찾는 속성에 대해 조사된다.

속성이 doSomeInstancing.[[Prototype]].[[Prototype]]에서 발견되지 않으면 doSomeInstancing.[[Prototype]].[[Prototype]].[[Prototype]]이 조사된다. 그러나 문제가 있다: doSomeInstancing.[[Prototype]].[[Prototype]].[[Prototype]]은 존재하지 않는다. Object.prototype.[[Prototype]]null이기 때문이다. 그러면, 그제서야 [[Prototype]]의 전체 프로토타입 체인이 조사된 후에, 런타임은 속성이 존재하지 않는다고 단언하고 그 속성의 값이 undefined라고 결론 내린다.

콘솔에 더 많은 코드를 입력해 보자.

function doSomething() {}
doSomething.prototype.foo = "bar";
const doSomeInstancing = new doSomething();
doSomeInstancing.prop = "some value";
console.log("doSomeInstancing.prop: ", doSomeInstancing.prop);
console.log("doSomeInstancing.foo: ", doSomeInstancing.foo);
console.log("doSomething.prop: ", doSomething.prop);
console.log("doSomething.foo: ", doSomething.foo);
console.log("doSomething.prototype.prop:", doSomething.prototype.prop);
console.log("doSomething.prototype.foo: ", doSomething.prototype.foo);

결과는 다음과 같다.

doSomeInstancing.prop: some value
doSomeInstancing.foo: bar
doSomething.prop: undefined
doSomething.foo: undefined
doSomething.prototype.prop: undefined
doSomething.prototype.foo: bar

프로토타입 체인을 만들고 변경하는 다양한 방법 (Different ways of creating and mutating prototype chains)

객체를 만들고 그 프로토타입 체인을 바꾸는 많은 방법을 만났다. 각 접근 방식의 장단점을 비교하며 다른 방법들을 체계적으로 요약하자.

문법 구성으로 만든 객체 (Objects created with syntax constructs)

const o = { a: 1 };
// 새로 만들어진 객체 o는 Object.prototype을 [[Prototype]]으로 가진다
// Object.prototype은 null을 [[Prototype]]으로 가진다.
// o ---> Object.prototype ---> null

const b = ["yo", "sup", "?"];
// 배열은 Array.prototype에서 상속받는다
// (이것은 indexOf, forEach 등의 메서드를 가진다)
// 프로토타입 체인은 다음과 같다:
// b ---> Array.prototype ---> Object.prototype ---> null

function f() {
  return 2;
}
// 함수는 Function.prototype에서 상속받는다
// (이것은 call, bind 등의 메서드를 가진다)
// f ---> Function.prototype ---> Object.prototype ---> null

const p = { b: 2, __proto__: o };
// 새로 만들어진 객체의 [[Prototype]]을 __proto__ 리터럴 속성으로
// 다른 객체를 가리키게 하는 것이 가능하다. (Object.prototype.__proto__
// 접근자와 혼동하지 말 것)
// p ---> o ---> Object.prototype ---> null

객체 초기화자에서 __proto__ 키를 사용할 때, __proto__ 키를 객체가 아닌 것으로 가리키면 예외를 던지지 않고 조용히 실패한다. Object.prototype.__proto__ setter와 달리, 객체 리터럴 초기화자의 __proto__는 표준화되고 최적화되어 있으며 심지어 Object.create보다 더 빠를 수도 있다. 생성 시 객체에 추가 own property를 선언하는 것이 Object.create보다 더 인체공학적이다.

생성자 함수 사용 (With constructor functions)

function Graph() {
  this.vertices = [];
  this.edges = [];
}

Graph.prototype.addVertex = function (v) {
  this.vertices.push(v);
};

const g = new Graph();
// g는 own property 'vertices'와 'edges'를 가진 객체다.
// g.[[Prototype]]은 new Graph()가 실행될 때의 Graph.prototype 값이다.

생성자 함수는 아주 초기 JavaScript부터 사용 가능했다. 따라서 매우 빠르고, 매우 표준적이며, 매우 JIT 최적화가 가능하다. 그러나 기본적으로 이렇게 추가된 메서드가 열거 가능해 클래스 문법이나 내장 메서드의 동작과 일치하지 않으므로 "제대로 하기"도 어렵다. 앞서 보여준 것처럼 더 긴 상속 체인을 만드는 것도 오류가 나기 쉽다.

Object.create() 사용 (With Object.create())

Object.create()를 호출하면 새 객체를 만든다. 이 객체의 [[Prototype]]은 함수의 첫 인수다.

const a = { a: 1 };
// a ---> Object.prototype ---> null

const b = Object.create(a);
// b ---> a ---> Object.prototype ---> null
console.log(b.a); // 1 (상속됨)

const c = Object.create(b);
// c ---> b ---> a ---> Object.prototype ---> null

const d = Object.create(null);
// d ---> null (d는 null을 프로토타입으로 직접 가진 객체다)
console.log(d.hasOwnProperty);
// undefined, d는 Object.prototype에서 상속받지 않기 때문

객체 초기화자의 __proto__ 키와 유사하게, Object.create()는 생성 시점에 객체의 프로토타입을 직접 설정할 수 있게 해주며, 런타임이 객체를 더 최적화할 수 있게 한다. 또한 Object.create(null)을 사용해 null 프로토타입 객체를 만들 수 있게 한다. Object.create()의 두 번째 매개변수는 새 객체의 각 속성의 속성(attributes)을 정밀하게 지정할 수 있게 하는데, 이것은 양날의 검이 될 수 있다.

  • 객체 생성 중에 비열거 가능 속성 등을 만들 수 있게 해주는데, 이는 객체 리터럴로는 가능하지 않다.
  • 객체 리터럴보다 훨씬 더 장황하고 오류가 나기 쉽다.
  • 특히 많은 속성을 만들 때 객체 리터럴보다 느릴 수 있다.

클래스 사용 (With classes)

class Rectangle {
  constructor(height, width) {
    this.name = "Rectangle";
    this.height = height;
    this.width = width;
  }
}

class FilledRectangle extends Rectangle {
  constructor(height, width, color) {
    super(height, width);
    this.name = "Filled rectangle";
    this.color = color;
  }
}

const filledRectangle = new FilledRectangle(5, 10, "blue");
// filledRectangle ---> FilledRectangle.prototype ---> Rectangle.prototype ---> Object.prototype ---> null

클래스는 복잡한 상속 구조를 정의할 때 최고의 가독성과 유지보수성을 제공한다. private 요소는 프로토타입 상속에서 쉽게 대체할 수 없는 기능이다. 그러나 클래스는 전통적인 생성자 함수보다 최적화가 덜 되어 있고 오래된 환경에서는 지원되지 않는다.

Object.setPrototypeOf() 사용 (With Object.setPrototypeOf())

위의 모든 메서드가 객체 생성 시점에 프로토타입 체인을 설정하는 반면, Object.setPrototypeOf()기존 객체의 [[Prototype]] 내부 속성을 변경할 수 있게 한다. 심지어 Object.create(null)로 만든 프로토타입 없는 객체에 프로토타입을 강제하거나, 객체의 프로토타입을 null로 설정해 제거할 수도 있다.

const obj = { a: 1 };
const anotherObj = { b: 2 };
Object.setPrototypeOf(obj, anotherObj);
// obj ---> anotherObj ---> Object.prototype ---> null

그러나 가능하면 프로토타입을 생성 중에 설정해야 한다. 프로토타입을 동적으로 설정하면 엔진이 프로토타입 체인에 대해 만든 모든 최적화를 방해하기 때문이다. 사양에 따라 동작하게 하기 위해 일부 엔진이 코드를 재컴파일해 비최적화(de-optimization)할 수도 있다.

proto 접근자 사용 (With the proto accessor)

모든 객체는 Object.prototype.__proto__ setter를 상속받는다. 이 setter는 (__proto__ 키가 객체에서 재정의되지 않았다면) 기존 객체의 [[Prototype]]을 설정하는 데 사용할 수 있다.

⚠️ 경고: Object.prototype.__proto__ 접근자는 비표준이고 deprecate되었다. 거의 항상 Object.setPrototypeOf를 대신 사용해야 한다.

const obj = {};
// 이렇게 쓰지 말라: 설명용으로만.
obj.__proto__ = { barProp: "bar val" };
obj.__proto__.__proto__ = { fooProp: "foo val" };
console.log(obj.fooProp);
console.log(obj.barProp);

Object.setPrototypeOf와 비교해, __proto__를 객체가 아닌 것으로 설정하면 예외를 던지지 않고 조용히 실패한다. 또한 브라우저 지원이 약간 더 좋다. 그러나 비표준이고 deprecate되었다. 거의 항상 Object.setPrototypeOf를 대신 사용해야 한다.

성능 (Performance)

프로토타입 체인 위쪽에 있는 속성의 조회 시간은 성능에 부정적인 영향을 줄 수 있으며, 성능이 중요한 코드에서는 이 영향이 상당할 수 있다. 또한 존재하지 않는 속성에 접근하려 하면 항상 전체 프로토타입 체인을 순회한다.

또한 객체의 속성을 순회할 때 프로토타입 체인에 있는 모든 열거 가능한 속성이 열거된다. 객체가 프로토타입 체인 어딘가가 아니라 자기 자신에 정의된 속성을 가지는지 확인하려면 hasOwnProperty 또는 Object.hasOwn 메서드를 사용해야 한다. [[Prototype]]null인 객체를 제외한 모든 객체는 — 프로토타입 체인 아래쪽에서 재정의되지 않는 한 — Object.prototype에서 hasOwnProperty를 상속받는다. 구체적인 예를 들자면 위의 graph 예제 코드를 가져와 설명하자.

function Graph() {
  this.vertices = [];
  this.edges = [];
}

Graph.prototype.addVertex = function (v) {
  this.vertices.push(v);
};

const g = new Graph();
// g ---> Graph.prototype ---> Object.prototype ---> null

g.hasOwnProperty("vertices"); // true
Object.hasOwn(g, "vertices"); // true

g.hasOwnProperty("nope"); // false
Object.hasOwn(g, "nope"); // false

g.hasOwnProperty("addVertex"); // false
Object.hasOwn(g, "addVertex"); // false

Object.getPrototypeOf(g).hasOwnProperty("addVertex"); // true

참고: 속성이 undefined인지 확인하는 것만으로는 충분하지 않다. 속성은 매우 잘 존재할 수 있는데, 그 값이 우연히 undefined로 설정되어 있을 뿐이다.

결론 (Conclusion)

JavaScript는 Java나 C++에서 온 개발자에게 다소 혼란스러울 수 있다. 전부 동적이고, 전부 런타임이며, 정적 타입이 전혀 없기 때문이다. 모든 것은 객체(인스턴스) 또는 함수(생성자)이며, 함수 자신도 Function 생성자의 인스턴스다. "클래스"라는 문법 구성조차 런타임에는 그저 생성자 함수다.

JavaScript의 모든 생성자 함수는 new 연산자와 함께 동작하는 prototype이라는 특별한 속성을 가진다. 프로토타입 객체에 대한 참조는 새 인스턴스의 내부 [[Prototype]] 속성으로 복사된다. 예를 들어 const a1 = new A()를 하면, JavaScript는 (객체를 메모리에 만들고 this를 그것으로 설정하여 함수 A()를 실행하기 전에) a1.[[Prototype]] = A.prototype을 설정한다. 그런 다음 인스턴스의 속성에 접근하면 JavaScript는 먼저 그 객체에 직접 존재하는지 확인하고, 없으면 [[Prototype]]에서 찾는다. [[Prototype]]은 재귀적으로 조사된다. 즉 a1.doSomething, Object.getPrototypeOf(a1).doSomething, Object.getPrototypeOf(Object.getPrototypeOf(a1)).doSomething 식으로, 발견되거나 Object.getPrototypeOfnull을 반환할 때까지 계속된다. 이는 프로토타입에 정의된 모든 속성이 사실상 모든 인스턴스에 의해 공유되며, 나중에 프로토타입의 일부를 변경하고 그 변경이 기존의 모든 인스턴스에 나타나게 할 수도 있다는 것을 의미한다.

위의 예에서 const a1 = new A(); const a2 = new A();를 한다면, a1.doSomething은 실제로 Object.getPrototypeOf(a1).doSomething을 가리킬 것이다 — 이것은 당신이 정의한 A.prototype.doSomething과 같다. 즉 Object.getPrototypeOf(a1).doSomething === Object.getPrototypeOf(a2).doSomething === A.prototype.doSomething이다.

프로토타입 상속 모델을 사용하는 복잡한 코드를 작성하기 전에 그 모델을 이해하는 것이 필수적이다. 또한 코드에서 프로토타입 체인의 길이를 인지하고, 가능한 성능 문제를 피하기 위해 필요하면 그것들을 나누어야 한다. 또한 네이티브 프로토타입은 더 새로운 JavaScript 기능과의 호환성을 위한 경우를 제외하고는 절대 확장해서는 안 된다.

더 알아보기