본문 바로가기

Backend/Javascript

JavaScript - 프로토타입 (Prototype) 에 대해

자바스크립트는 프로토타입 기반 언어입니다.

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

 

- 프로토타입의 개념

 

 

프로토타입 개념을 설명하기 위해 다이어그램을 하나 그려봤습니다. 이를 텍스트로 풀어서 설명하면 아래와 같습니다.

 

• 어떠한 생성자 함수 (Constructor) 를 new 연산자와 함께 호출하면, Constructor 에 정의된 내용을 바탕으로 새로운 인스턴스 (instance) 가 생성됩니다.

• 이 instance 에는 __proto__ 라는 프로퍼티가 자동으로 부여되는데, 이 프로퍼티는 Constructor 의 prototype 이라는 프로퍼티를 참조합니다.

 

prototype 프로퍼티와 __proto__ 프로퍼티 간의 관계가 프로토타입 개념의 핵심입니다. 둘 다 객체고, prototype 객체 내부에는 인스턴스가 사용할 메서드를 저장합니다. 그러면 인스턴스에 숨겨진 프로퍼티인 __proto__ 를 통해 이 메서드들에 접근할 수 있게 됩니다.

 

FYI) __proto__ 는 사실 이미 deprecated 되었고, [[Prototype]] 이 자바스크립트의 표준 스펙입니다. 명세에선 [[Prototype]] 에 직접 접근을 허용하지 않고 (private 속성) 오직 Object.getPrototypeOf(), Object.setPrototypeOf() 를 통해서 접근하도록 정의되어 있습니다. 하지만 아직 많은 브라우저에선 __proto__ 를 사용중인데, __proto__ 는 브라우저들이 [[Prototype]] 을 구현한 대상에 지나지 않으나 호환성을 고려해서 __proto__ 를 인정하고 있습니다. 다만 권장하지 않는 방법이므로 위에 얘기한 Object 의 static method 를 사용해야 하며, 이 포스팅에선 설명의 편의를 위해 __proto__ 를 계속 사용하도록 하겠습니다.

• 이미 예전부터 자바스크립트에서도 class 키워드를 통해 클래스를 정의하고 객체를 생성하는데 사용합니다. 이를 생성자 함수의 synthetic sugar 라고 하는 여러 의견도 있지만 다른 점도 꽤 있습니다. class 또한 prototype 을 차용하였으며 prototype, chain 등을 설명하기 위해 이 포스팅에선 생성자 함수를 사용하도록 하겠습니다.

 

const Person = function (name) {
  this._name = name;
};

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

 

위처럼 Person 이라는 생성자 함수의 prototype 에 getName 메서드를 지정했을 때, Person 의 인스턴스는 __proto__ 프로퍼티를 통해 getName 을 호출할 수 있습니다. instance 의 __proto__ 가 Constructor 의 prototype 프로퍼티를 참조하므로 둘을 같은 객체를 바라보기 때문입니다.

 

const neo = new Person('neo');
neo.__proto__.getName(); // undefined

Person.prototype === neo.__proto__ // true

 

위의 getName() 메서드 호출 결과가 undefined 인 점을 주목해야 합니다. 에러는 발생하지 않았으니, 호출할 수 있는 함수이긴 합니다. 만약 함수가 아니었다면 TypeError 가 발생했겠죠.

getName 은 this._name 을 리턴하도록 되어있는데, 어떤 함수를 '메서드로서' 호출할 때는 메서드명 바로 앞의 객체가 곧 this 입니다. 따라서 여기선 neo.__proto__.getName() 내부에서의 this 가 neo 가 아닌 neo.__proto__ 객체가 되어 undefined 를 리턴했습니다. this 에 바인딩된 대상이 잘못되었다는 것입니다.

 

const neo = new Person('neo');
neo.__proto__._name = 'proto neo';
neo.__proto__.getName(); // proto neo

 

위처럼 __proto__ 객체에 name 프로퍼티를 강제 주입하면, getName() 메서드 호출 결과가 원하는 대로 출력됩니다.

하지만 이건 올바른 방법이 아니고, 우리는 이러한 강제 주입 없이 getName() 의 결과로 'neo' 를 얻고 싶습니다.

방법은 간단하게 __proto__ 없이 인스턴스에서 바로 메서드를 쓰면 됩니다.

 

const neo = new Person('neo');
neo.getName(); // neo

 

이게 왜 되는거지? 라고 좀 이상하게 느껴질 수 있지만, 그 이유는 __proto__ 가 생략 가능한 프로퍼티기 때문입니다.

태생부터 생략 가능하도록 정의되어 있고, 약간 과장을 보태 이 정의를 바탕으로 자바스크립트의 전체 구조가 구성되었습니다.

언어가 만들어 질 때부터 '생략 가능한 프로퍼티' 라는 개념이 들어갔기에 그런가보다.. 하고 넘어가긴 해야 합니다. (아이러니 하네요)

 

neo.__proto__.getName -> neo.(__proto__).getName -> neo.getName

 

계속 머리에 맴도는 의문은 생략이 가능하게 만들어졌으면 생략을 하건 안하건 동일한 결과가 나와야 하는게 아닌가.. 이지만 자바스크립트에서의 this 바인딩과 충돌하는 부분인 것 같습니다. 우선 넘어갑니다.

 

 

__proto__ 가 생략이 가능하기 때문에, 처음의 다이어그램은 이제 이렇게 표시하는게 가능합니다.

 

• new 연산자로 Constructor 를 호출하면 instance 가 생성되는데, 이 instance 의 생략 가능한 프로퍼티인 __proto__ 는 자동으로 생성되며 Constructor 의 prototype 을 참조한다.

• __proto__ 가 생략 가능하므로 (가능하도록 구현되어있으므로) 생성자 함수의 prototype 에 어떤 메서드나 프로퍼티가 있다면 인스턴스에서도 마치 자신의 것처럼 해당 메서드나 프로퍼티에 접근할 수 있다.

 

자바스크립트에서 대표적인 내장 생성자 함수인 Array 로 한번 더 살펴보도록 하겠습니다.

 

const arr = [1, 2];

console.dir(arr);
console.dir(Array);

 

console.dir(arr)
console.dir(Array)

 

__proto__ 가 정식 스펙에선 [[Prototype]] 이라고 위에서 얘기했으니 arr 의 [[Protoype]] 을 봐야 합니다.

여기엔 concat, push, pop, sort 등 배열 전용 메서드들이 거의 모두 들어있습니다.

이처럼 arr 의 __proto__ (= [[Prototype]]) 이 Array 의 prototype 을 참조하고 있으므로, arr 은 배열의 메서드들을 자기 자신의 메서드인것처럼 호출할 수 있는 것입니다.

 

한편 위의 console.dir(Array) 출력 결과 중엔 Array 함수의 정적 메서드인 from, isArray 등도 보이고 이 메서드들은 prototype 내부에 있지 않습니다. 이러한 메서드들은 인스턴스가 직접 호출할 수 없으며, Array 생성자 함수에서 직접 접근해야 실행이 가능합니다.

 

const arr = [1, 2];
Array.isArray(arr); // true
arr.isArray(); // TypeError: arr.isArray is not a function

 

- constructor 프로퍼티

 

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

 

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

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

 

constructor 는 읽기 전용 속성이 부여된 경우 (기본형 리터럴 변수 - number, string, boolean) 를 제외하고는 값을 변경할 수 있습니다.

 

const NewConstructor = function () {
  console.info('new constructor');
};

const dataTypes = [1, true, [], new Number(), new Date(), new Error()];

dataTypes.forEach(function (dataType) {
  dataType.constructor = NewConstructor;
  console.info(dataType.constructor.name, '-', dataType instanceof NewConstructor);
});
/*
Number - false
Boolean - false
NewConstructor - false
NewConstructor - false
NewConstructor - false
NewConstructor - false
*/

 

모든 데이터 타입이 dataType instanceof NewConstructor 명령에 대해 false 를 반환합니다.

constructor 를 변경하더라도 참조하는 대상이 변경될 뿐 이미 만들어진 인스턴스의 원형이 바뀌거나 데이터 타입이 변하진 않지만,

인스턴스의 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는게 (위에서 dataType.constructor.name) 항상 안전하지는 않다는 점을 나타냅니다.

 

- 메서드 오버라이드

 

prototype 객체를 참조하는 인스턴스는 prototype 에 정의된 프로퍼티나 메서드를 자신의 것처럼 사용할 수 있습니다.

이 때 만약 인스턴스가 동일한 이름의 프로퍼티 또는 메서드를 갖고 있는 상황이라면?

 

const Person = function (name) {
  this.name = name;
};

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

const neo = new Person('neo');

console.info(neo.getName()); // neo

neo.getName = function () {
  return `this is ${this.name}`;
};

console.info(neo.getName()); // this is neo

 

위와 같은 현상을 메서드 오버라이드 라고 합니다. (덮어씌웠다는 의미, 교체가 아닙니다)

자바스크립트 엔진이 getName 이라는 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그 다음으로 가까운 대상인 __proto__ 를 검색하는 순서로 진행됩니다.

따라서 __proto__ 에 있던 getName 메서드는 검색 순서에서 밀려 호출되지 않은 것입니다. 교체가 아니라고 한 점은, 검색 순서에서 밀렸을 뿐 여전히 존재하며 호출 또한 가능합니다. 물론 단순 __proto__ 에 접근한 호출은 this 바인딩 문제가 있으니 되지 않을 것입니다.

 

console.info(neo.__proto__.getName()); // undefined

console.info(neo.__proto__.getName.call(neo)); // neo

 

this 가 바라보고 있는 대상을 prototype 에서 인스턴스로 call 이나 apply 를 사용해서 변경하며 됩니다.

 

- 프로토타입 체인

 

console.dir({ id: 1 });
console.dir([1, 2]);

 

console.dir({ id: 1 })
console.dir([1, 2])

 

console.dir({ id: 1 }) 을 보면, 첫 줄을 통해 Object 의 인스턴스임을 알 수 있고 [[Prototype]] 내부에 hasOwnProperty, isPrototypeOf 등의 메서드가 보입니다. constructor 는 생성자 함수인 Object 를 가리키고 있습니다.

 

console.dir([1, 2]) 를 보면 [[Prototype]] 안에 [[Prototype]] 이 또 있습니다. 이 [[Prototype]] 의 내용은 아래와 같습니다.

 

[[Prototype]] of [[Prototype]]

 

내용은 console.dir({ id: 1 }) 에서 본 내용과 동일합니다. 이 이유는 prototype 객체가 '객체' 이기 때문입니다.

기본적으로 모든 객체의 __proto__ 에는 Object.prototype 이 연결됩니다.

 

 

이를 다시 다이어그램으로 나타내봤습니다. __proto__ 는 생략 가능하기 때문에 배열이 Array.prototype 내부 메서드를 자신의 것처럼 실행할 수 있습니다. 마찬가지로 Object.prototype 내부의 메서드도 자신의 것처럼 실행할 수 있습니다. (__proto__ 를 한 번 더 따라가면 Object.prototype 참조가 가능하기 때문입니다)

 

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

 

어떤 데이터의 __proto__ 프로퍼티 내부에 다시 __proto__ 프로퍼티가 연쇄적으로 이어진 것을 프로토타입 체인이라 하고, 이 체인을 따라가며 검색하는 것을 프로토타입 체이닝이라고 합니다. (메서드 오버라이드와 동일한 맥락입니다)

 

const arr = [1, 2];
arr.toString = function () {
  return this.join('-');
};

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

 

이러한 체이닝 구조라 해도 실제 메모리 상에서 무한대의 구조 전체 데이터를 들고 있진 않고, 사용자가 이런 루트를 접근할 때만 해당 정보를 얻을 수 있게 되어있습니다. (특별한 메모리 낭비는 없습니다)

 

- 객체 전용 메서드 예외사항

 

어떤 생성자 함수든 prototype 은 반드시 객체이기 때문에 Object.prototype 이 언제나 프로토타입 체인의 최상단에 있습니다.

따라서 객체에서만 사용할 메서드는 다른 데이터 타입처럼 프로토타입 객체 안에 정의할 수 없습니다.

객체에서만 사용할 메서드를 Object.prototype 내부에 정의하면 다른 데이터 타입도 해당 메서드를 사용할 수 있게 되기 때문입니다.

 

Object.prototype.getSomething = function () {
  const tmp = [];
  for (const key in this) {
    if (this.hasOwnProperty(key)) {
      tmp.push([key, this[key]]);
    }
  }
  return tmp;
};

const data = [
  ['object', { a: 1, b: 2, c: 3 }],
  ['number', 123],
  ['string', '123'],
  ['boolean', false],
  ['array', [1, 2, 3]]
];

data.forEach(function(d) {
  console.info(d[1].getSomething());
});

/*
[['a', 1], ['b', 2], ['c', 3]]
[]
[['0', '1'], ['1', '2'], ['2', '3']]
[]
[['0', 1], ['1', 2], ['2', 3]]
*/

 

객체에서만 사용할 목적으로 getSomething 이라는 메서드를 만들었지만, 모든 데이터가 에러 없이 결과를 반환하고 있습니다.

객체가 아닌 데이터 타입에서도 프로토타입 체이닝을 통해 getSomething 메서드에 접근할 수 있으니 이렇게 동작하는 것입니다.

 

이 같은 이유로 객체만을 대상으로 동작하는 객체 전용 메서드들은 Object.prototype 이 아닌 Object 에 정적 메서드로 부여할 수 밖에 없습니다. 또한 생성자 함수인 Object 와 인스턴스인 객체 리터럴 사이에는 this 를 통한 연결이 불가능하기 때문에 다른 전용 메서드처럼 '메서드명 앞의 대상이 곧 this' 가 되는 방식 대신 this 의 사용을 포기하고 대상 인스턴스를 인자로 직접 주입해야 하는 방식의 구현으로 되어 있습니다.

 

• 프로토타입 체인상 가장 마지막엔 Object.prototpye 이 있지 않은 경우도 있습니다. Object.create 를 이용하면 Object.prototype 의 메서드에 접근할 수 없는 경우도 있습니다. 바로 Object.create(null) 을 사용할 때입니다.

 

const tmp = Object.create(null);
tmp.getValue = function (key) {
  return this[key];
};

const tmp2 = Object.create(tmp);
tmp2.a = 1;

console.info(tmp2.getValue('a')); // 1

 

Object.create(null) 은 __proto__ 가 없는 객체를 생성합니다. 위의 경우 tmp2 를 찍어보면, __proto__ 에는 getValue 메서드만 존재하고 __proto__ 와 constructor 프로퍼티가 없습니다. 이는 Object.create(null) 로 만들어진 tmp 에 __proto__ 가 없기 때문입니다.

이러한 방식으로 만든 객체는 일반적인 데이터에서 반드시 존재하는 내장 메서드 및 프로퍼티 들이 제거됨으로써 기본 기능에 제약이 생기지만 객체 자체의 무게는 가벼워져 성능상 이점이 있습니다.

 

- 다중 프로토타입 체인

 

__proto__ 를 연결해 나가기만 하면 무한대로 체인 관계를 이어나갈 수 있습니다.

__proto__ 를 연결하는 방법은 __proto__ 가 가리키는 대상, 생성자 함수의 prototype 이 연결하고자 하는 상위 생성자 함수의 인스턴스를 바라보게 하면 됩니다.

 

const Grade = function () {
  const args = Array.prototype.slice.call(arguments);
  for (let i = 0 ; i < args.length ; i++) {
    this[i] = args[i];
  }
  
  this.length = args.length;
};

const grade = new Grade(100, 80);

 

위 예제에서 Grade 의 인스턴스 (grade) 는 배열의 메서드를 사용할 수 없는 유사배열객체입니다.

배열 메서드를 적용하려면 call, apply 를 사용할 순 있겠지만 배열 메서드를 직접 쓸 수 있게 하려면 어떻게 해야 할까요.

g.__proto__ (= Grade.prototype) 이 배열의 인스턴스를 바라보게 하면 됩니다.

 

Grade.prototype = [];

const grade2 = new Grade(130, 90);

console.info(grade2); // Grade(2) [130, 90]

g.pop(); // 90

console.info(grade2); // Grade [130]

g.push(10); // 2

console.info(grade2); // Grade(2) [130, 10]

 

이로써 배열 전용 메서드들을 사용할 수 있게 되었습니다. (Grade.prototype 가 배열의 인스턴스를 바라보게 추가한 부분은 이미 만든 grade 에는 적용되지 않습니다)

 

- Summary

 

• 생성자 함수를 new 연산자와 함께 호출하면 Constructor 에 정의된 내용을 기반으로 인스턴스가 생성되는데, 여기엔 __proto__ 라는 프로퍼티가 자동으로 생기며 이는 Contructor 의 prototype 을 참조한다. 생략 가능한 __proto__ 덕분에 인스턴스는 Constructor.prototype 의 메서드를 자신의 메서드처럼 사용할 수 있다.

• Constructor.prototype 에 있는 constructor 프로퍼티는 생성자 함수 자신을 가리키며, 이 프로퍼티는 인스턴스가 자신의 생성자 함수가 무엇인지 알고자 하는 수단이다.

• __proto__ 안에 __proto__ 를 찾는 과정을 프로토타입 체이닝이라 하며, 이를 통해 각 프로토타입 메서드를 호출할 수 있다.

• Object.prototype 에는 모든 데이터 타입에서 사용할 수 있는 범용적인 메서드만이 존재하며 객체 전용 메서드는 다른 타입과 달리 정적 메서드로 담겨있다.

'Backend > Javascript' 카테고리의 다른 글

Javascript - 실행 컨텍스트란  (0) 2021.07.10
JavaScript - this 에 대해  (0) 2021.07.03
[Javascript] - Weakness  (0) 2021.02.20
[Javascript] - Transpiling  (0) 2021.02.14
[Javascript] - Optimization  (0) 2021.02.06