자바스크립트는 프로토타입 기반 언어입니다.
클래스 기반 언어에서는 '상속'을 사용하지만 프로토타입 기반 언어에서는 어떤 객체를 원형으로 삼고 이를 복제(참조)함으로써 상속과 비슷한 효과를 얻습니다.
let instance = new Constructor();
__proto__
라는 프로퍼티가 자동으로 부여되는데,prototype이라는 프로퍼티와 __proto__
라는 프로퍼티가 새로 등장했는데, 이 둘의 관계가 프로토타입 개념의 핵심입니다. prototype은 객체입니다. 이를 참조하는 __proto__
역시 당연히 객체입니다. prototype 객체 내부에는 인스턴스가 사용할 메서드를 저장합니다. 그러면 인스턴스에서도 숨겨진 프로퍼티인 __proto__
를 통해 이 메서드들에 접근할 수 있게 됩니다.
Person 이라는 생성자 함수의 prototype에 getName 이라는 메서드를 지정했습니다.
let Person = function (name) {
this._name = name;
};
Person.prototype.getName = function() {
return this._name;
};
이제 Person 의 인스턴스는 __proto__
프로퍼티를 통해 getName 을 호출할 수 있습니다.
왜냐하면 instance 의 __proto__
가 Constructor 의 prototype 프로퍼티를 참조하므로 결국 둘은 같은 객체를 바라보기 때문입니다.
let suzi = new Person('Suzi');
suzi.__proto__.getName(); // undefined
Person.prototype === suzi.__proto__ // true
메서드 호출 결과로 undefined 가 나왔습니다. Suzi
라는 값이 나오지 않은 것보다 "에러가 발생하지 않았다" 는 점을 주목해 봅시다. 어떤 변수를 실행하 undefined
가 나왔다는 것은 이 변수가 호출할 수 있는 함수
에 해당한다는 것을 의미합니다. 즉 함수가 아닌 다른 데이터 타입이었다면 TypeError 가 발생했을 것입니다.
다음으로 함수 내부에서 어떤 값을 반환하는지 살펴봅시다. this.name
값을 리턴하는 내용으로 구성돼 있는데, this에 원래의 의도와는 다른 값이 할당된 것이 아닐까라고 생각할 수 있습니다. 문제는 this에 바인딩된 대상이 잘못 지정됐다는 것입니다.
어떤 함수를 '메서드로서' 호출할 때는 메서드명 바로 앞의 객체가 곧 this가 되는데, 이 객체 내부에는 name 프로퍼티가 없으므로 '찾고자 하는 식별자가 정의돼 있지 않있지 않을 때는 Error 대신 undefined 를 반환한다'라는 자바스크립트 규약에 의해 undefined 가 반환된 것입니다.
__proto__
는 생략 가능한 프로퍼티 입니다. this를 인스턴스로 사용하고 싶다면 __proto__
를 생략하면 됩니다.
suzi.__proto__.getName
-> suzi(.__proto__).getName
-> suzi.getName
__proto__
를 생략하지 않으면 this 는 suzi.__proto__
를 가리키지만, 이를 생략하면 suzi
를 가리킵니다. suzi.__proto__
에 있는 메서드인 getName을 실행하지만 this는 suzi를 바라보게 할 수 있게 된 것입니다.
도식으로 보면 다음과 같습니다.
여기까지 요약하자면
new 연산자로 Constructor 를 호출하면 instance 가 만들어지는데, 이 instance 의 생략 가능한 프로퍼티인
__proto__
는 Constructor 의 prototype 을 참조한다.
더 상세히 설명하자면
자바스크립트는 함수에 자동으로 객체인 prototype 프로퍼티를 생성해놓는데, 해당 함수를 생성자 함수로서 사용할 경우, 즉 new 연산자와 함께 함수를 호출할 경우, 그로부터 생성된 인스턴스에는 숨겨진 프로퍼티인
__proto__
가 자동으로 생성되며, 이 프로퍼티는 생성자 함수의 prototype 프로퍼티를 참조합니다.__proto__
프로퍼티는 생략 가능하도록 구현돼 있기 때문에 생성자 함수의 prototype에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 해당 메서드나 프로퍼티에 접근할 수 있게 됩니다.
한편 Array 의 prototype 프로퍼티 내부에 있지 않은 from, isArray 등의 메서드들은 인스턴스가 직접 호출할 수 없기 때문에 이들은 Array 생성자 함수에서 직접 접근해야 실행이 가능합니다.
let arr = [1, 2];
arr.forEach(function() {}); // (o)
Array.isArray(arr); // (o) true
arr.isArray(); // (X) TypeError: arr.isArray is not a function
생성자 함수의 프로퍼티인 prototype 객체 내부에는 constructor 라는 프로퍼티가 있습니다. 인스턴스의 __proto__
객체 내부에도 마찬가지입니다. 이 프로퍼티는 단어 그대로 원래의 생성자 함수(자기 자신)를 참조합니다. 이 역시 인스턴스와의 관계가 있어서 필요한 정보입니다. 인스턴스로부터 그 원형이 무엇인지를 알 수 있는 수단이기 때문입니다.
let arr = [1, 2];
Array.prototype.constructor === Array // true
arr.__proto__.constructor === Array // true
arr.constructor === Array // true
let arr2 = new arr.constcutor(3, 4);
console.log(arr2); // [3, 4]
한편 construtcot 는 읽기 전용 속성이 부여된 예외적인 경우(기본형 리터럴 변수 - number, string, boolean) 를 제외하고는 값을 바꿀 수 있습니다.
let NewConstructor = function () {
console.log('this is new constructor!');
};
let 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 프로퍼티에 의존하는 게 항상 안전한 것은 아닌겁니다.
인스턴스가 동일한 이름의 프로퍼티 또는 메서드를 가지고 있다면?
let Person = function (name) {
this.name = name;
};
Person.prototype.getName = function() {
return this.name;
}:
let 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 객체 iu.__proto__
를 가리키는데 prototype 상에는 name 프로퍼티가 없기 때문입니다. 만약 prototype 에 name 프로퍼티가 있다면 그 값을 출력할 것입니다.
Person.prototype.name = '이지금'
console.log(iu.__proto__.getName()); // 이지금
원하는 메서드가 호출되고 있습니다.
다만 this가 prototype을 바라보고 있는데 이걸 인스턴스를 바라보도록 바꿔주면 되겠습니다.
call이나 apply로 해결하면 됩니다.
console.log(iu.__proto__.getName.call(iu)); // 지금
프로토타입 체인을 설명하기에 앞서 이번에는 객체의 내부 구조를 살펴보겠습니다.
첫 줄을 통해 Object의 인스턴스임을 알 수 있고, 프로퍼티 a의 값이 1이 보이고, __proto__
내부에는 hasOwnProperty, isPrototypeOf, toLocaleString, toString, valueOf 등의 메서드가 보입니다. constructor는 생성자 함수인 Object를 가리키고 있습니다.
이번에는 배열의 구조를 살펴보겠습니다.
배열의 __proto__
안에는 또다시 __proto__
가 등장합니다. 열어보니 앞에서 살펴본 객체의 __proto__
와 동일한 내용으로 이뤄져 있습니다. 왜냐면 prototype 객체가 '객체'이기 때문입니다. 기본적으로 모든 객체의 __proto__
에는 Object.prototype이 연결됩니다. prototype 객체도 예외가 아닙니다.
이 관계를 그림으로 표현하면 다음과 같습니다.
__proto__
는 생략이 가능하기 때문에, 배열은 Array.prototype 내부의 메서드를 자신의 것처럼 사용할 수 있습니다. 즉, 객체 메서드로 실행이 가능한 것입니다.
어떤 데이터의
__proto__
프로퍼티 내부에서 다시__proto__
프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인 이라 하고, 이 체인을 따라가며 검색하는 것을 프로토타입 체이닝이라고 합니다.
프로토타입 체이닝은 메서드 오버라이드와 동일한 맥락입니다. 어떤 메서드를 호출하면 자바스크립트 엔진은 데이터 자신의 프로퍼티들을 검색해서 원하는 메서드가 있으면 그 메서드를 실행하고, 없으면 __proto__
를 검색해서 있으면 그 메서드를 실행하고, 없으면 다시 __proto__
를 검색해서 실행하는 식으로 진행합니다.
let arr = [1, 2];
Array.prototype.toString.call(arr); // 1, 2
Object.prototype.toString.call(arr); // [object Array]
arr.toString(); // 1, 2
arr.toString = function() {
return this.join('_');
};
arr.toString(); //1_2
어떤 생성자 함수이든 prototype 은 반드시 객체이기 때문에 Object.prototype 이 언제나 프로토타입 체인의 최상단에 존재하게 됩니다. 따라서 객체에서만 사용할 메서드는 다르 여느 데이터 타입처럼 프로토타입 객체 안에 정의할 수가 없습니다. 객체에서만 사용할 메서드를 Object.prototype 내부에서 정의한다면 다른 데이터 타입도 해당 메서드를 사용할 수 있게 되기 때문입니다.
Object.prototype.getEntries = function() {
let res = [];
for (let prop in this) {
if (this.hasOwnProperty(prop)) {
res.push([prop, this[prop]]);
}
}
return res;
};
let 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())
});
원래 의도대로라면 객체가 아닌 다른 데이터 타입에 대해서는 오류를 던지게끔 돼야 할 텐데, 어느 데이터 타입이건 거의 무조건 프로토타입 체이닝을 통해 getEntries 메서드에 접근할 수 있으니 그렇게 동작하지 않는 것입니다.
이와 같은 이유로 객체만을 대상으로 동작하는 객체 전용 메서드들은 부득이 Object.prototype이 아닌 Object 에 스태틱 메서드로 부여할 수밖에 없었습니다. 또한 생성자 함수인 Object 와 인스턴스인 객체 리터럴 사이에는 this 를 통한 연결이 불가능하기 때문에 여느 전용 메서드처럼 '메서드 앞의 대상이 곧 this'가 되는 방식 대신 this 사용을 포기하고 대상 인스턴스를 인자로 직접 주입해야 하는 방식으로 구현돼 있습니다.
객체 한정 메서드들을 Object.prototype 이 아닌 Object 에 직접 부여할 수밖에 없었던 이유를 다시 강조하자면, Object.prototype 이 참조형 데이터뿐 아니라 기본형 데이터조차 __proto__
에 반복 접근함으로써 도달할 수 있는 최상위 존재이기 때문입니다.
어떤 생성자 함수를 new 연산자와 함께 호출하면 Constructor 에서 정의된 내용을 바탕으로 새로운 인스턴스가 생성되는데, 이 인스턴스에는
__proto__
라는, Constructor 의 prototype 프로퍼티를 참조하는 프로퍼티가 자동으로 부여됩니다.__proto__
는 생략 가능한 속성이라서, 인스턴스는 Constructor.prototype 의 메서드를 마치 자신의 메서드인 것처럼 호출할 수 있습니다.Constructor.prototype 에는 constructor 라는 프로퍼티가 있는데, 이는 다시 생성자 함수 자신을 가리킵니다. 이 프로퍼티는 인스턴스가 자신의 생성자 함수가 무엇인지를 알고자 할 때 필요한 수단입니다.
직각삼각형의 대각선 방향, 즉
__proto__
방향을 계속 찾아가면 최종적으로 Object.prototype 에 당도하게 됩니다. 이런식으로__proto__
안에 다시__proto__
를 찾아가는 과정을 프로토타입 체이닝이라고 하며, 이 프로토타입 체이닝을 통해 각 프로토타입 메서드를 자신의 것처럼 호출할 수 있습니다. 이때 접근 방식은 자신으로부터 가장 가까운 대상부터 점차 먼 대상으로 나아가며, 원하는 값을 찾으면 검색을 중단합니다.Object.prototype 에는 모든 데이터 타입에서 사용할 수 있는 범용적인 메서드만이 존재하며, 객체 전용 메서드는 여느 데이터 타입과 달리 Object 생성자 함수에 스태틱하게 담겨있습니다.