-
[JavaScript] 6. 프로토타입(prototype)개발자 이야기/JavaScript 2021. 7. 22. 10:31
자바스크립트는 프로토타입 기반 언어다.
클래스 기반 언어에서는 '상속'을 사용하는 반면, 프로토타입 기반 언어에서는 어떤 객체를 원형(prototype)으로 삼고 이를 복제(참조)함으로써 상속과 비슷한 효과를 얻는다.
프로토타입 개념
constructor(생성자 함수), prototype, instance
- 어떤 생성자 함수(Constructor)를 new 연산자와 함께 호출하면
- Constructor에서 정의된 내용을 바탕으로 새로운 인스턴스(instance)가 생성된다.
- 이때 인스턴스에는 __proto__라는 프로퍼티(instance.__proto__)가 자동으로 부여된다.
- 이 프로퍼티는 Constructor의 prototype이라는 프로퍼티(Constructor.prototype)를 참조한다.
prototype과 이를 참조하는 __proto__ 모두 객체다. prototype 객체 내부에는 인스턴스가 사용할 메서드를 저장한다. 그리고 인스턴스에서도 숨겨진 프로퍼티 __proto__를 통해 이 메서드들에 접근할 수 있다.
* __proto__는 'dunder proto(던더 프로토)'로 불린다. dunder는 'double underscore'의 줄임말이다.
** 이해의 편의를 위해 __proto__로 설명을 이어가지만, 실무에서는 가급적 __proto__를 사용하지 않기를 권장한다. 대신, Object.getPrototypeOf() / Object.create() 등을 이용한다.
*** 객체를 생성하는 함수를 생성자 함수라고 부른다. 생성자 함수(constructor function)와 일반 함수에 기술적인 차이는 없다. 다만 생성자 함수는 다음 두 관례를 따른다. 1) 함수 이름의 첫 글자는 대문자로 시작한다. 2) 반드시 'new' 연산자를 붙여 실행한다.
참고로 모든 함수는 생성자 함수가 될 수 있다.
let Person = function (name) { this._name = name; }; Person.prototype.getName = function() { return this._name; }; let suzi = new Person('Suzi'); suzi.__proto__.getName(); // undefined suzi.getName(); // Suzi Person.prototype === suzi.__proto__ // true
위 예제에서 Person이라는 생성자 함수의 prototype에 getName이라는 메서드를 지정한 것을 알 수 있다.
그렇다면 생성자 함수 Person에 this.getName으로 직접 메서드를 추가하는 것과는 어떤 차이가 있을까? 결론부터 말하자면, this.getName 보다 prototype에 Person.prototype.getName으로 메서드를 추가하는 것이 더 효율적이다. prototype은 모든 객체가 공유하지만, this를 통해 추가하는 것은 객체 하나를 생성할 때마다 메서드의 메모리 공간을 차지하기 때문에 불필요한 메모리를 낭비하게 된다. 참고로 메서드 뿐만 아니라 프로퍼티(속성)까지 모두 prototype에 넣기도 한다.
suzi.__proto__.getName()가 undefined인 이유는 this에 바인딩된 대상이 잘못 지정되었기 때문이다. 즉, this는 suzi.__proto__라는 것이다. 그렇다면 this를 인스턴스로 하고 싶다면 어떻게 해야 할까?
__proto__ 없이 인스턴스에서 곧바로 메서드를 쓰면 된다. suzi.getName()처럼 말이다. 그 이유는 __proto__이 생략 가능한 프로퍼티이기 때문이다.
프로토타입에 대해 정리하자면,
- constructor는 생성자 함수 그 자체를 가리킨다.
- prototype은 생성자 함수를 통해 생성한 모든 객체가 공유할 원형의 객체다.
- __proto__는 생성자 함수를 new로 호출할 때, 정의해두었던 prototype을 참조한 객체다.
자바스크립트는 함수에 자동으로 객체인 prototype 프로퍼티를 생성한다. 해당 함수를 생성자 함수로서 사용할 경우, 즉 new 연산자와 함께 함수를 호출할 경우, 그로부터 생성된 인스턴스에는 숨겨진 프로퍼티인 __proto__가 자동으로 생성되며, 이는 생성자 함수의 prototype 프로퍼티를 참조한다.
= new 연산자로 Constructor를 호출하면 instance가 만들어지는데, 이 instance의 생략 가능한 프로퍼티인 __proto__는 Constructor의 prototype을 참조한다.
constructor 프로퍼티
생성자 함수의 프로퍼티인 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.constructor(3, 4); console.log(arr2); // [3, 4]
constructor는 읽기 전용 속성이 부여된 예외적인 경우(기본형 리터럴 변수 : number, string, boolean)를 제외하고는 값을 변경할 수 있다. 하지만 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.getName의 결과가 '바로 지금'인 이유는 iu.__proto__.getName이 아닌 iu 객체에 있는 getName 메서드가 호출되었기 때문이다. 이러한 현상을 메서드 오버라이드라고 부른다.
자바스크립트 엔진이 getName 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그다음으로 가까운 대상인 __proto__를 검색하는 순서로 진행된다. 즉, 검색 순서에서 밀려 __proto__.getName이 호출되지 않은 것이다.
메서드 오버라이드는 메서드 위에 메서드를 덮어씌웠다는 의미이다. 즉, 원본은 제거하고 다른 대상으로 교체하는 것이 아니라 원본이 그대로 있는 상태에서 다른 대상을 그 위에 얹은 상황을 말합니다. '교체'가 아닌 '얹은'이란 표현에서 알 수 있듯이, 얹은 형태라면 원본이 아래에 유지되고 있으니 원본에 접근할 수 있는 방법이 존재한다. 바로 call이나 apply로 해결 가능하다.
console.log(iu.__proto__.getName.call(iu)); // 지금
프로토타입 체인
프로토타입 체인(prototype chain)이란 어떤 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__ 프로퍼티가 연쇄적으로 이어진 것을 말한다. 그리고 이 체인을 따라가며 검색하는 것을 프로토타입 체이닝(prototype chaining)이라 한다.
배열 리터럴의 __proto__에 또다른 __proto__가 존재한다. prototype의 객체가 말그대로 객체이기 때문이다. 기본적으로 모든 객체의 __proto__에는 Object.prototype이 연결된다. 그러므로 Object.prototype이 언제나 프로토타입 체인 최상단에 존재한다. 그리고 배열 등은 객체의 내부 메서드를 __proto__ 없이 실행할 수 있다.
let arr = [1, 2, 3]; arr.push(4); // [1, 2, 3, 4] arr.hasOwnProperty(1); // true
프로토타입 체이닝은 메서드 오버라이드와 동일한 맥락이다. 어떤 메서드를 호출하면 자바스크립트 엔진은 데이터 자신의 프로퍼티를 먼저 검색한 후 원하는 메서드가 없다면 __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이 아닌 Object에 *스태틱 메서드(static method)로만 부여할 수 있다. 반대로 같은 이유로, Object.prototype에는 어떤 데이터에서도 사용 가능한 범용적인 메서드들만 존재한다.
*static : 클래스의 정적 메서드를 정의한다. 정적 메서드는 클래스의 인스턴스 없이 호출이 가능하며 클래스가 인스턴스화되면 호출할 수 없다.
다중 프로토타입 체인
자바스크립트의 기본 내장 데이터 타입들의 프로토타입 체인은 사용자가 새롭게 정의한다면 무한대로 체인 관계를 이어나갈 수 있다.
let Grade = function () { let args = Array.prototype.slice.call(arguments); for (let i = 0; i < args.length; i++) { this[i] = args[i]; } this.length = args.length; }; let g = new Grade(100, 80); console.log(g); // Grade {0: 100, 1: 80, length: 2}
위 예제에서 변수 g는 배열의 형태를 지니지만 배열 메서드를 사용할 수는 없는 유사배열객체다. 유사배열객체에 배열 메서드를 적용하기 위해 call이나 apply 메서드를 사용하면 되지만, 만약 인스턴스에서 배열 메서드를 직접 사용 가능하도록 하고 싶다면 어떻게 해야 할까? 그러기 위해서는 Grade.prototype이 배열의 인스턴스를 바라보게 만들면 된다.
let Grade = function () { let args = Array.prototype.slice.call(arguments); for (let i = 0; i < args.length; i++) { this[i] = args[i]; } this.length = args.length; }; Grade.prototype = []; // 새롭게 추가한 코드 let g = new Grade(100, 80); console.log(g); // Grade(2) [100, 80] g.pop() console.log(g); // Grade [100]
'개발자 이야기 > JavaScript' 카테고리의 다른 글
[JavaScript] new 연산자 (0) 2021.07.19 [JavaScript] 4. 콜백 함수 (0) 2021.07.15 [JavaScript] 3. this (0) 2021.07.13 [JavaScript] 함수 선언식 vs 함수 표현식 (0) 2021.07.10 [JavaScript] 2. 실행 컨텍스트 (0) 2021.07.10