[JS] 프로토타입이란?

고정원·2021년 7월 9일
0

1Q/1Day

목록 보기
12/13
post-thumbnail

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

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

1.1 constructor, prototype, instance

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

prototype이라는 프로퍼티와 __proto__라는 프로퍼티의 관계가 프로토타입의 핵심 개념이다. prototype은 객체이다. 이를 참조하는 __proto__역시 당연히 객체이다.
prototype객체 내부에는 인스턴스가 사용할 메서드를 저장한다. 그러면 인스턴승도 숨겨진 프로퍼티인 __proto__를 통해 이 메서드들에 접근할 수 있다.
📍 __proto__ 는 dunder(double underscore) proto라고 읽는다.

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

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

이제 Person의 인스턴스는 __proto__프로퍼티를 통해 getName을 호출할 수 있다. 왜냐하면 인스턴스의 __proto__가 Constructir의 prototype 프로퍼티를 참조하므로 결국 둘은 같은 객체를 바라보기 때문이다.

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

Person.prototype === suzi.__proto__ //true

🤩메서드 호출결과로 undefined가 나온점에 주목!
'Suzi'라는 값이 나오지 않은 것보다는 에러가 발생하지 않았다는 점이 우선이다. 어떤 변수를 실행해 undefined가 나왔다는 것은 이 변수가 '호출할 수 있는 함수'에 해당한다는 것을 의미한다. 만약 실행할 수 없는, 즉 함수가 아닌 다른 데이터 타입이었다면 TypeError가 발생했을 것이다. 그런데 값이 에러가 아닌 다른 값이 나왔으니까 getName이 실제로 실행됐음을 알 수 있고, 이로부터 getName이 함수라는 것이 입증되었다.

다음으로 함수내부에서 어떤 값을 반환하는지를 살펴보자.
this.name 값을 리턴하는 내용이다. 그렇다면 this에 원래의 의도와는 다른 값이 할당된 것은 아닌가?라는 의심을 가지고 디버깅해보면 결론적으로 문제는 this에 바인딩된 대상이 잘못 지정됐다는 점이다.

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

그렇다면 만약 __proto__객체에 name프로퍼티가 있다면?

var chili = new Person('GOGO')
chili.__proto__.name = 'GOGO__proto__';
chili.__proto__.getName(); //GOGO__proto__

예상했던 대로 GOGO__proto__가 잘 출력된다. 따라서 관건은 this다. this를 인스턴스에서 바로 사용하고 싶다면 __proto__없이 인스턴스에서 곧바로 메소드를 사용하는 것이다.

var chili = new Person('고추', 30)
potato.getName() //고추
var onion = new Person('양파', 26)
onion.getName() //양파

이렇게 __proto__를 빼면 this가 가르키는 것이 인스턴스가 맞지만, 이대로 메소드가 호출되고 심지어 원하는 값이 나오는게 이상하다......하지만 이건 정상이다. 이유는 __proto__가 생략이 가능한 프로퍼티이기 때문이다. JS만든 브랜든아이크가 그렇게 만들었다.

따라서 __proto__를 생략하지 않으면 this는 potato.proto를 가르키지만 이를 생략하면 potato를 가르킨다. 즉 potato.proto에 있는 메소드인 getName을 실행하지만 this는 potato를 바라보게 할 수 있게 된것이다.

💡 new연산자로 Constructor를 호출하면 인스턴스가 만들어지는데, 이 인스턴스의 생략가능한 프로퍼티인 __proto__는 Constructor의 prototype을 참조한다.

var arr = [1,2,3]
console.dir(arr)
console.dir(Array)

왼쪽) arr변수를 출력한 결과
오른쪽) 생성자 함수인 Array를 출력한 결과

위의 결과를 도식으로 나타내면 아래와 같다

Array를 new연산자와 함께 호출해서 인스턴스를 생성하든, 그냥 배열 리터럴을 생성하든, 어쨌든 instance인 [1, 2]가 만들어진다.
이 인스턴스의 __proto__가 생략 가능하도록 설계돼 있기 때문에 인스턴스가 push,pop,forEach 등의 메서드를 마치 자신의 것처럼 호출할 수 있다. 한편 Array의 prototype 프로퍼티 내부에 있지 않은 from,isArray 등의 메서드들은 인스턴스가 직접 호출할 수 없을 것이다. 이들은 Array 생성자 함수에서 직접 접근해야 실행이 가능하다.

1.2 constructor 프로퍼티

생성자 함수의 프로퍼티인 Prototype 내부에는 consturctor라는 프로퍼티가 있다. 인스턴스의 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)이 부여된 예외적인 경우를 제외하고는 값을 바꿀 수 있다.

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 프로퍼티에 의존하는 것이 항상 안전하지는 않다는 것을 알 수 있다.

  • 다음 각 줄은 모두 동일한 대상을 가르킨다.
[Constructor]
[instance].__proto__.constructor
[instance].constructor
Object.getPrototypeOf([instance]).constructor
[Constructor].prototype.constructor
  • 다음 각 줄은 모두 동일한 객체에 접근할 수 있다.
[constructor].prototype
[instance].__proto__
[instance]
Object.getPrototypeOf([instance])

2. 프로토타입 체인

2.1 메서드 오버라이드

인스턴스가 동일한 이름의 프로퍼티나 메소드를 가지게 되면 메소드오버라이드가 일어난다.

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()); // 바로 지금

원본이 제거되고 다른 대상으로 교체되는 것이 아니라, 원본이 그대로 있는 상태에서 다른 대상을 그 위에 얹는다고 생각하면 쉽다. 자바스크립트 엔진이 getName 메소드를 찾는 방식은, 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 다음으로 가까운 대상인 proto를 검색하는 순서로 진행된다. 즉 순서가 밀리기 때문에 proto 의 메소드가 노출되지 않는 것이다.

💡만일 인스턴스를 바라보도록 바꿔주고 싶다면 call 이나 apply를 사용하면 된다.

console.log(iu.__proto__.getName.call(iu)); // 지금

2.2 프로토타입 체인

console.dir([1, 2]);

배열의 내부 구조를 보면 proto 안에 다시 proto가 있는 것을 확인할 수 있는데, 이는 prototype 객체가 객체이기 때문이다.

proto는 생략이 가능하기 때문에, 배열은 Array.prototype 내부의 메서드를 자신의 것처럼 사용할 수 있다. 즉, 객체 메서드로 실행이 가능한 것이다.

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

📍프로토타입 체인 : 어떤 데이터의 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

2.3 객체 전용 메소드의 예외사항

어떤 생성자 함수이든 prototype은 반드시 객체이기 때문에 Object.prototype이 언제나 프로토타입 체인의 최상단에 존재한다. 따라서 객체에서만 사용할 메소드는 다른 여느 데이터 타입처럼 프로토타입 객체 안에 정의할 수 없다. 객체에서만 사용할 메서드를 Objcet.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이 아닌 Objcet에 스태틱 메서드(static method)로 부여할 수 밖에 없다. 또한 생성자 함수인 Object와 인스턴스인 객체 리터럴 사이에는 this를 통한 연결이 불가능하기 때문에 여느 전용 메서드처럼 "메서드 명 앞의 대상이 곧 this"가 되는 방식 대신 this의 사용을 포기하고 대상 인스턴스를 인자로 직접 주입해야하는 방식으로 구현돼있다.

Object.freeze(instance) vs instance.freeze()

같은 이유에서 Object.prototype에는 어떤 데이터에서도 활용할 수 있는 범용적인 메서드들만 있다. toString, hasOwnProperty, valueOf, isPrototypeOf 등은 변수가 마치 자신의 메서드인 것처럼 호출할 수 있다.

Object.create
'프로토타입 체인상 가장 마지막에는 언제나 Object.prototype이 있다'고 했는데,
예외적으로 Object.create를 이용하면 Object.prototype에 접근할 수 없는 경우가 있다.
Object.create(null)__proto__가 없는 객체를 생성한다.

let _proto = Object.create(null);
_proto.getValue = function(key) {
  return this[key];
};
let obj = Object.create(_proto);
obj.a = 1;
console.log(obj.getValue('a')); // 1
console.dir(obj);

_proto에는 __proto__프로퍼티가 없는 객체를 할당했다.
다시 obj는 앞서 만든 _proto__proto__로 하는 객체를 할당했다.
obj를 출력해보면, __proto__에는 오직 getValue 메소드만 존재하며, __proto__ 및 constructor 프로퍼티 등은 보이지 않는다.
이 방식으로 만든 객체는 일반적인 데이터에서 반드시 존재하던 내장 메소드 및 프로퍼티들이 제거됨으로써 기본 기능 제약이 생긴 대신, 객체 자체의 무게가 가벼워짐으로써 성능상 이점을 가진다.

2.4 다중 프로토타입 체인

자바스크립트의 기본 내장 데이터 타입들은 모두 프로토타입 체인이 1-2 단계로 끝나는 경우만 있었지만, 사용자가 새롭게 만드는 경우 그 이상도 얼마든지 가능하다. 대각선의 __proto__를 연결해 나가기만 하면 무한대로 체인 관계를 만들 수 있는데, 이 방식으로 다른 언어의 클래스와 비슷하게 동작하는 구조를 만들 수 있다.

대각선의 __proto__를 연결하는 방법은 __proto__가 가리키는 대상, 즉 생성자 함수의 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;
};
let g = new Grade(100, 80);

변수 g는 Grade의 인스턴스를 바라본다. Grade의 인스턴스는 여러 개의 인자를 받아 각각 순서대로 인덱싱하여 저장하고 length 프로퍼티가 존재하는 등 배열의 형태를 지니지만 배열 메서드를 사용할 수 없는 유사배열객체이다. 이 인스턴스에서 배열 메소드를 직접 쓸 수 있게끔 하고 싶다면 g.__proto__, 즉 Grade.prototype이 배열의 인스턴스를 바라보게 하면 된다.

Grade.prototype = [];

이렇게 g 인스턴스는 프로토타입 체인에 따라 g 객체 자신이 지니는 멤버, Grade의 prototype에 있는 멤버, Array.prototype에 있는 멤버, 끝으로 Object.prototype에 있는 멤버에까지 모두 접근할 수 있게 된다.

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]

정리

  • 어떤 생성자 함수를 new 연산자와 함께 호출하면 Constructor에 정의된 내용을 바탕으로 새로운 인스턴스가 생성되는데, 이 인스턴스에는 __proto__라는 Constructor의 prototype 프로퍼티를 참조하는 프로퍼티가 자동으로 부여된다. __proto__는 생략 가능한 속성이라서, 인스턴스는 Constructor.prototype의 메서드를 마치 자신의 메서드인 것처럼 호출할 수 있다.

  • Constructor.prototype에는 Constructor라는 프로퍼티가 있는데, 이는 다시 생성 함수자신을 가리킨다. 이 프로퍼티는 인스턴스가 자신의 생성자 함수가 무엇인지를 알고자 할 때 필요한 수단이다.

  • 직각삼각형의 대각선 방향, 즉 __proto__방향을 계속 찾아가며 최종적으로는 Object.prototpe에 당도하게 된다. 이런식으로 __proto__안에 다시 __proto__를 찾아가는 과정을 프로토타입 체이닝이라고 하며, 이 프로토타입 체이닝을 통해 각 프로토타입 메서드를 자신의 것처럼 호출 할 수 있다. 이때 접근 방식은 자신으로부터 가장 가까운 대상부터 점차 먼 대상으로 나아가며, 원하는 값을 찾으면 검색을 중단한다.

  • Object.prototype에는 모든 데이터 타입에서 사용할 수 있는 범용적인 메서드만이 존재하며, 객체 전용 메서드는 여느 데이터 타입과 달리 Object생성자 함수에 스태틱하게 담겨있다.

  • 프로토타입 체인은 반드시 2단계로만 이뤄지는 것이 아니라 무한대의 단계를 생성할 수도 있다.

[참고한자료]
정재남, 『코어자바스크립트』, 위키북스(2019)
https://velog.io/@hyounglee/TIL-98
prototype | MDN

profile
해결문제에 대해 즐겁게 대화 할 수 있는 프론트엔드 개발자

0개의 댓글