개발일지

코어 자바스크립트 6장 프로토타입 본문

코어 자바스크립트 스터디

코어 자바스크립트 6장 프로토타입

박수미/ 2024. 7. 22. 22:37

6장 프로토타입

자바스크립트는 프로토타입기반 언어입니다. 클래스 기반 언어에서는 '상속'을 사용하지만 프로토타입 기반 언어에서는 어떤 객체를 원형으로 삼고 이를 복제함으로써 상속과 비슷한 효과를 얻습니다.

 

1. 프로토타입의 개념 이해

6-1-1 constructor, prototype, instance

var instance = new Constructor();

 

출처 코어 자바스크립트

  • 어떤 생성자 함수를 new 연산자와 함께 호출하면
  • Constructor에서 정의된 내용을 바탕으로 새로운 인스턴스가 생성됩니다.
  • 이때 instance에는 __proto__라는 프로퍼티가 자동으로 부여되는데,
  • 이 프로퍼티는 Constructor의 prototype이라는 프로퍼티를 참조합니다.

prototype이라는 프로퍼티와 __proto__라는 프로퍼티가 새로 등장했는데, 이 둘의 관계가 프로토타입 개념의 핵심입니다.

prototype은 객체입니다. 이를 참조하는 __proto__역시 당연히 객체입니다. prototype 객체 내부에서는 인스턴스가 사용할 메서드를 저장합니다. 그러면 인스턴스에서도 숨겨진 프로퍼티인 __proto__를 통해 이 메서드들에 접근할 수 있게 됩니다.

 

예제 1

Person이라는 생성자함수의 prototype에 getName이라는 메서드를 지정했다고 해보겠습니다.

var Person = function (name) {
  this._name = name;
};
Person.prototype.getName = function() {
  return this._name;
};

이제 Person의 인스턴스는 __proto__프로퍼티를 통해 getName을 호출할 수 있습니다.

var wonpil = new Person('wonpil');
wonpil.__proto__.getName();      // undefined

왜냐하면 instance의 __proto__가 Constructor의 prototype 프로퍼티를 참조하므로 결국 둘은 같은 객체를 바라보기 때문입니다.

Person.prototype === wonpil.__proto__   // true

메서드의 호출 결과로 undefined가 나온 점에 주목해 봅시다. 'wonpil'이라는 값이 나오지 않은 것보다는 '에러가 발생하지 않았다'는 점이 우선입니다. 어떤 변수를 실행해 undefined가 나왔다는 것은 이 변수가 '호출할 수 있는 함수'에 해당한다는 것을 의미합니다. 즉 값이 에러가 아닌 다른 값이 나왔으니까 getName이 실행됐음을 알 수 있고, 이로부터 getName이 함수라는 것이 증명되었습니다.

 

그럼 undefined가 나온 이유를 알아봅시다.

위 함수는 this.name 값을 리턴하는 내용으로 구성되어 있습니다. 그렇다면 this에 원래 의도와는 다른 값이 할당된 것이 아닐까라는 의심을 가져볼 수 있습니다. 우리는 앞에서 this를 공부하며 상황별로 어떤 값이 this에 할당되는지 알아보았습니다. 이 지식을 바탕으로 생각해 보면 문제는 this에 바인딩된 대상이 잘못 지정됐다는 것입니다.

어떤 함수를 '메서드로서' 호출할 때는 메서드명 바로 앞의 객체가 곧 this가 된다고 했습니다. 그러니까 thomas.__proto__. getName()에서 getName 함수 내부에서의 this는 thomas가 아니라 thomas.__proto__라는 객체가 되는 것입니다. 이 객체 내부에는 name 프로퍼티가 없으므로 '찾고자 하는 식별자가 정의돼 있지 않을 때는 Error 대신 undefined를 반환한다'라는 자바스크립트 규약에 의해 undefined가 반환된 것입니다.

 

__proto__객체에 name 프로퍼티가 있는 경우

var wonpil = new Person('wonpil');
wonpil.__proto__.name = 'WONPIL__proto__';
wonpil.__proto__.getName();     // WONPIL__proto__

출력: WONPIL__proto__

 

__proto__없이 인스턴스에서 곧바로 메서드를 쓰는 방법

var wonpil = new Person('Wonpil', 20);
wonpil.getName();    // Wonpil
var dowoon = new Person('Dowoon' 20);
dowoon.getName()     // Dowoon

__proto__를 빼면 this는 instance가 되는 게 맞지만, 이대로는 메서드가 호출되고 심지어 원하는 값이 나오는 건 좀 이상합니다. 그 이유는 바로 __proto__가 생략 가능한 프로퍼티이기 때문입니다. '생략 가능한 프로퍼티'라는 개념은 언어를 창시하고 전체 구조를 설계한 브랜든 아이크의 아이디어로, 이해의 영역이 아니므로 '그냥 그런가 보다'하는 수밖에 없습니다. 우리는 __proto__가 생략 가능하다는 점만 기억하면 됩니다.

 

프로토타입의 개념을 좀 더 상세히 설명하지면 자바스크립트는 함수에 자동으로 객체인 prototype 프로퍼티를 생성해 놓는데, 해당 함수를 생성자 함수로서 사용할 경우, 즉 new 연산자와 함께 함수를 호출할 경우, 그로부터 생성된 인스턴스에는 숨겨진 프로퍼티인 __proto__가 자동으로 생성되며, 이 프로퍼티는 생성자 함수의 prototype 프로퍼티를 참조합니다. __proto__프로퍼티는 생략 가능하도록 구현돼 있기 때문에 생성자 함수의 prototype에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 해당 메서드나 프로퍼티에 접근할 수 있게 됩니다.

 

6-1-2 constructor 프로퍼티

생성자 함수의 프로퍼티인 prototype 객체 내부에는 constructor라는 프로퍼티가 있습니다. 인스턴스 __proto__ 객체 내부에도 마찬가지입니다. 이 프로퍼티는 단어 그대로 원래의 생성자 함수(자기 자신)를 참조합니다. 인스턴스로부터 그 원형이 무엇인지를 알 수 있는 수단입니다.

constructor 프로퍼티

var arr = [1, 2];
Array.prototype.constructor === Array  // true
arr.__proto__.constructor === Array    // true

var arr2 = new arr.constructor(3, 4);
console.log(arr2)        // [3, 4]

인스턴스의 __proto__가 생성자 함수의 prototype 프로퍼티를 참조하며 __proto__가 생력 가능하기 때문에 인스턴스에서 직접 constructor에 접근할 수 있는 수단이 생긴 것입니다. 그러니까 6번째 줄과 같은 명령도 오류 없이 동작하게 됩니다.

한편 constructor은 읽기 전용 속성이 부여된 예외적인 경우(기본형 리터럴 변수 - number, string, boolean)를 제외하 거는 값을 바꿀 수 있습니다.

 

constructor 변경

var NewConstrutor = function () {
  console.log('this is new constuctor!');
};
var dataTypes = [
  1,        // Number & false
  'test',   // String & false
  true,     // Boolean & false
  {},       // NewConstructor & false
  [],       // NewConstructor & false
  function () {},  // NewConstructor & false
  /test/,   // NewConstructor & false
  new Number(),    // NewConstructor & false
  new String(),    // NewConstructor & false
  new Boolean,     // NewConstructor & false
  new Object(),    // NewConstructor & false
  new Array(),     // NewConstructor & false
  new Function(),  // NewConstructor & false
  new RegExp(),    // NewConstructor & false
  new Date(),      // NewConstructor & false
  new Error()      // NewConstructor & false
  ];
  
  dataTypes.forEach(function (d) {
    d.constructor = NewConstructor;
    console.log(d.constructor.name, '&', d instanceof NewConstructor);
    });

모든 데이터 d instanceof NewConstructor 명령에 대해 false를 반환합니다. 이로부터 constructor를 변경하더라도 참조하는 대상이 변경될 뿐 이미 만들어진 인스턴스의 원형이 바뀐다거나 데이터 타입이 변하는 것은 아님을 알 수 있습니다. 어떤 인스턴스의 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는 게 항상 안전하지 않은 것이죠.

 

다양한 constructor 접근 방법

var Person = function (name) {
  this.name = name;
};
var p1 = new Person('사람1');
var p1Proto = Object.getPrototypeOf(p1);
var p2 = new Person.prototype.construtor('사람2');
var p3 = new p1Proto.constructor('사람3');
var p4 = new p1.__proto__.constructor('사람4');
var p5 = new p1.constructor('사람5');

[p1, p2, p3, p4, p5].forEach(function (p) {
  console.log(p, p instanceof Person);
});

 

 

 

 

 

 

p1부터 5까지는 모두 Person의 인스턴스입니다. 따라서 다음 두 공식이 성립합니다. 옆의 그림과 보면 이해하기 쉽습니다.

 

첫째, 다음 각 줄은 모두 동일한 대상을 가리킵니다.

 

 

 

 

[Constructor]
[instance].__proto__.constructor
Object.getPrototypeOf([instancs]).constructor
[Constructor].prototype.constructor

 둘째, 다음 각 줄은 모두 동일한 객체에 접근할 수 있습니다.

[Constructor].prototype
[instance].__proto__
[instancs]
Object.getPrototypeOf([instancs])

 

1. 프로토타입 체인

6-2-1 메서드 오버라이드

prototype 객체를 참조하는 __proto__를 생략하면 인스턴스는 prototype에 정의된 프로퍼티나 메서드를 마치 자신의 것처럼 사용할 수 있습니다. 그런데 만약 인스턴스가 동일한 이름의 프로퍼티 또는 메서드를 가지고 있는 상황이라면 어떨까요?

 

메서드 오버라이드

var Person = function (name) {
  this.name = name;
};
Person.prototype.getName = function () {
  return this.name;
};

var iu = new Person('지금');
iu.getName = function () {
  return '바로' + this.name;
};
console.log(iu.getName());     // 바로 지금

iu.__proto__.getName이 아닌 iu 객체에 있는 getName 메서드가 호출됐습니다. 이 현상을 메서드 오버라이드라고 합니다.

메서드 위에 메서드를 덮어씌웠다는 표현입니다.

 

자바스크립트 엔진이 getName이라는 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그다음으로 가까운 대상인 __proto__를 검색하는 순서로 진행됩니다. 그러니까 __proto__에 있는 메서드는 자신에게 있는 메서드보다 검색 순서에서 밀려 호출되지 않은 것이죠. 원본이 위에 얹혀있는 형태라면 원본이 아래 유지되고 있으니 원본에 접근할 수 있는 방법도 있겠죠. 그렇다면 오버라이딩이 이뤄져 있는 상황에서 prototype에 있는 메서드에 접근하려면 어떻게 하면 될까요?

console.log(iu.__proto__.getName());   // undefined

undefined가 출력되었습니다. this가 prototype 객체를 가리키는데 prototype 상에는 name 프로퍼티가 없기 때문이겠죠, 만약 prototype에 name 프로퍼티가 있다면 그 값을 출력할 것입니다.

Person.prototype.name = '이지금';
console.log(iu.__proto__.getName());   // 이지금

원하는 메서드가 호출되고 있다는 것이 확실해졌습니다. 다만 this가 prototype을 바라보고 있는데 이걸 인스턴스를 바라보도록 바꿔주면 되겠습니다. 

  • call, apply 사용가능
console.log(iu.__proto__.getName.call(iu));  // 지금

 

6-2-2 프로토타입 체인

객체의 내부 구조

console.dir({ a: 1 });

출처 코어 자바스크립트

첫 줄에서 Object의 인스턴스임을 알 수 있고, 프로퍼티 a의 값 1이 보이고, __proto__ 내부에는 hasOwnProperty, isPrototypeOf 등의 메서드가 보입니다. constructor은 생성자 함수인 Object를 가리키고 있습니다.

 

배열의 내부 구조

 

출처 코어 자바스크립트

이 __proto__ 안에는 또다시 __proto__ 가 등장합니다. 열어보니 앞서 살펴본 객체의 __proto__ 과 동일한 내용으로 이루어져 있습니다. 왜 그럴까요? 바로 prototype 객체가 '객체'이기 때문입니다. 기본적으로 모든 객체의 __proto__ 에는 Object.prototype이 연결됩니다. prototype 객체도 예외가 아닙니다.

 

배열의 내부 도식

출처 코어 자바스크립트

__proto__ 는 생략 가능하다고 했습니다. 그렇기 때문에 배열 Array.prototype 내부의 메서드를 마치 자신의 것처럼 실행할 수 있었습니다. 마찬가지로 Object.prototype 내부의 메서드를 마치 자신의 것처럼 실행할 수 있습니다. 생략 가능한 __proto__를 한 번 더 따라가면 Object.prototype을 참조할 수 있기 때문입니다.

 

배열에서 배열 메서드 및 객체 메서드 실행

var arr = [1, 2];
arr(.__proto__).push(3);
arr(.__proto__)(.__proto__).hasOwnProperty(2);  // true

어떤 데이터의 __proto__프로퍼티 내부에 다시 __proto__프로퍼티가 연쇄적으로 이어진 것을 프로포타입 체인이라 하고, 이 체인을 따라가며 검색하는 것을 프로토타입 체이닝이라고 합니다.

 

프로토타입 체이닝은 앞서 말한 메서드 오버라이드와 동일한 맥락입니다. 어떤 메서드를 호출하면 자바스크립트 엔진은 데이터 자신의 프로퍼티들을 검색해서 원하는 메서드가 있으면 그 메서드를 실행하고, 없으면 __proto__를 검색해서 있으면 그 메서드를 실행하고, 없으면 다시 __proto__를 검색해서 실행하는 식으로 진행합니다.

 

전체 프로토타입의 구조

var arr = [1, 2];
Array.prototype.toString.call(arr);   // 1,2
Object.prototype.toString.call(arr);  // [object Array]
arr.toString();

arr.stString = function () {
  return this.join('_');
};
arr.toString();                       // 1_2

출처 코어 자바스크립트

각 생성자 함수는 모두 함수이기 때문에 Function 생성자 함수의 prototype과 연결됩니다. Function 생성자 함수 역시 함수이므로 다시 Function 생성자 함수 ptototype과 연결됩니다. 이런 식으로 __proto__의 constructor의 __proto__의 constructor ... 를 재귀적으로 반복하는 루트를 따르면 끝없이 찾아갈 수 있습니다. 실제 메모리 상에서 데이터를 무한대의 구조 전체를 들고 있는 것이 아니고, 사용자가 이런 루트를 통해 접근하고자 할 때 비로소 해당 정보를 얻을 수 있을 뿐입니다. 이미 생성자 함수를 알고 있는 이상, 어떤 인스턴스가 해당 생성자 함수의 인스턴스인지 여부를 알아야 하는 경우가 아니라면 그냥 생성자 함수를 사용하면 되지 굳이 인스턴스를 통해 접근해야 할 필요는 없습니다. 그러니까 우리는 일반적으로 인스턴스와 "직접적인 연관"이 있는 삼각형에만 주목하면 됩니다.

 

6-2-3 객체 전용 메서드의 예외사항

어떤 생성자 함수이든 prototype은 반드시 객체이기 때문에 Object.prototype이 언제나 프로토타입 체인의 최상단에 존재하게 됩니다. 따라서 객체에서만 사용할 메서드는 다른 여느 데이터 타입처럼 프로토타입 객체 안에 정의할 수가 없습니다. 객체에서만 사용할 메서드를 Object.prototype 내부에 정의한다면 다른 데이터 타입도 해당 메서드를 사용할 수 있게 되기 때문입니다.

 

Object.prototype에 추가한 메서드에의 접근

Object.prototype.getEntries = function() {
  var res = [];
  for (var prop in this) {
    if (this.hasOwnPrototype(prop)) {
      res.push([prop, this[prop]);
    }
  }
  return res;
};
var data = [
  ['object', { a: 1, b: 2, c: 3 }],  // [["a", 1], ["b", 2], ["c", 3]]
  ['number', 345],                   // []
  ['string', 'abc'],                 // [["0", "a"], ["1", "b"],["2", "c"],]]
  ['boolean', false],                // []
  ['func', function () {}],          // []
  ['array', [1, 2, 3]],              // [["0", 1], ["1", 2], ["2", 3]]
];
data.forEach(function (datum) {
  console.log(datum[1].getEntries());
});

18번째 줄의 forEach에 따라 11번째 줄부터 16번째 줄의 각 데이터마다 getEntries를 실행해 보니, 모든 데이터가 오류 없이 결과를 반환하고 있습니다. 원래 의도대로라면 객체가 아닌 다른 데이터 타입에 대해서는 오류를 던지게끔 돼야 할 텐데. 어느 데이터 타입이건 거의 무조건 프로토타입 체이닝을 통해 getEntries 메서드에 접근할 수 있으니 그렇게 작동하지 않는 것입니다.

 

6-2-4 다중 프로토타입 체인

자바스크립트의 기본 내장 데이터 타입들은 모두 프로토타입 체인 1단계(객체)이거나 2단계(나머지)로 끝나는 경우만 있었지만 사용자가 새롭게 만드는 경우에는 그 이상도 얼마든지 가능합니다. 대각선의 __proto__를 연결하는 방법은 __proto__가 가르키는 대상, 즉 생성자 함수의 prototype이 연결하고자 하는 상위 생성자 함수의 인스턴스를 바라보게끔 해주면 됩니다.

 

Grade 생성자 함수와 인스턴스

var Grade = function () {
  var args = Array.prototype.slice.call(arguments);
  for (var i = 0; i < args.length; i++) {
    this[i] = arr[i];
  }
  this.length = args.length;
};
var g = new Grade(100, 80);

Grade의 인스턴스는 여러 개의 인자를 받아 각각 순서대로 인덱싱 해서 저장하고 length 프로퍼티가 존재하는 등으로 배열의 형태를 지니지만, 배열의 메서드를 사용할 수는 없는 유사배열객체입니다. 유사배열객체에 배열 메서드를 적용하는 방법으로 call/apply이 있었지만, 이번에는 인스턴스에서 배열 메서드를 직접 쓸 수 있게 Grade.prototype이 배열의 인스턴스를 바라보게 해 보겠습니다.

Grade.prototype = [];

이 명령어에 의해 서로 별개로 분리되어 있던 데이터가 연결되어 아래 그림과 같이 하나의 프로토타입 체인 형태를 띠게 됩니다.

출처 코어 자바스크립트

이제는 Grade의 인스턴스인 g에서 직접 배열 메서드를 사용할 수 있습니다.

console.log(g);    // Grade(2) [100, 80]
g.pop();
console.log(g);    // Grade(1) [100]
g.push(90);
console.log(g);    // Grade(2) [100, 90]

g 인스턴스의 입장에서는 프로토타입 체인에 따라 g 객체 자신이 지니는 멤버, Grade의 prototype에 있는 멤버, Array.prototype에 있는 멤버 마지막으로 Object.prototype에 있는 멤버에까지 접근할 수 있게 되었습니다.