프로그래밍-학습기록/Javascript

[Javascript] 프로토타입 찬찬히 이해해보기

leesche 2021. 2. 16. 15:13

자바스크립트의 근본, 프로토타입... 바~rrㄹ로 시작!

프로토타입의 개념 이해

constructor, prototype, instance

var instance = new Constructor();

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

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

  • __proto__[[prototype]]

    ES5.1 명세에는 __proto__ 가 아니라 [[prototype]]이라는 명칭으로 정의되어 있습니다. __proto__ 는 브라우저들이 [[prototype]]을 구현한 대상에 지나지 않았습니다. 명세는 또 instance.__proto__와 같은 방식으로 직접 접근하는 것은 허용하지 않는다고 말합니다. 명세는 오직, Object.getPrototypeOf(instance) 또는 Reflect.getPrototypeOf(instance)를 통해서만 접근할 수 있도록 정의했습니다. 하지만 대부분 브라우저들이 직접 접근하는 방식을 포기하지 않았습니다. 결국 ES6에서는 이를 브라우저에서 동작하는 레거시 코드 호환성 유지 차원에서 정식으로 인정하게 됐습니다. 그러나 직접 접근 방식은 권장되는 방식은 아니고, 브라우저가 아닌 다른 환경에서는 얼마든지 이 방식이 지원되지 않을 가능성이 열려있습니다.

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

위 코드에서 Person이라는 생성자 함수의 prototypegetName 이라는 메서드(프로퍼티)를 지정했습니다. Person의 인스턴스는 __proto__프로퍼티를 통해 getName 메서드를 호출할 수 있습니다. Person으로 생성한 인스턴스의 __proto__Personprototype을 참조하기 때문입니다.

var adolf = new Person("Adolf");
adolf.__proto__.getName();

하지만 adolf.__proto__.getName();의 반환 값은 undefined입니다. 이는 this 바인딩이 우리가 의도한 대로 되지 않았기 때문입니다. getName()은 메서드로서 호출되어 this가 adolf.__proto__가 되어버렸습니다. this._name을 찾아 반환하는데, 실제로 동작하는 것은 adolf.__proto__._name 입니다. _name이 없는 경우(객체에서 찾는 속성이 없는 경우) 자바스크립트는 undefined를 반환합니다.

어떻게 this가 인스턴스를 가리키도록 할 수 있을까요? 방법은 쉽습니다. __proto__없이 바로 메서드를 호출하면 됩니다. 없으면 문제가 생기는 것이 아닐까 싶습니다. 그럴 것 같지만 의도된 것입니다. __proto__는 생략 가능한 프로퍼티입니다(그렇게 자바스크립트를 만들 때 정의되어 있습니다).

... 원래부터 생략 가능하도록 정의되어 있습니다. 그리고 이 정의를 바탕으로 자바스크립트의 전체 구조가 구성됐다고 해도 과언이 아닙니다 그러니까 '생략 가능한 프로퍼티'라는 개념은 언어를 창시하고 전체 구조를 설계된 브랜든 아이크의 머리에서 나온 아이디어로, 이해의 영역이 아니므로 '그냥 그런가보다'하는 수밖에 없습니다.
<코어 자바스크립트> 151쪽

프로토타입의 개념을 좀 더 상세히 설명하자면 다음과 같습니다.

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

constructor 프로퍼티

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

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

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

인스턴스의 __proto__ 가 생성자의 prototype을 참조하니, constructor에도 접근할 수 있게 되어 인스턴스 변수를 사용하여 새로운 배열을 생성할 수 있었습니다.

한편, constructor읽기 전용 속성이 부여된 예외적인 경우(기본형 리터럴 변수: number, string, boolean)를 제외하고는 값을 바꿀 수 습니다. 다시 말해, constructor가 참조하는 대상을 변경할 수 있습니다. 따라서 어떤 인스턴스의 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는 것이 항상 안전하지는 않습니다.

프로토타입 체인

메서드 오버라이드

var Person = function (name) {
  this.name = name;
};
Person.prototype.getName = function () {
  return `저는 ${this.name}입니다. 홀리쉿!! 사랑해요!`;
};
var jason = new Person("제이슨");
jason.getName = function () {
  return `나의 이름은 ${this.name}이다. 인간들, 복수한다.`;
};
console.log(jason.getName());
/* 출력 결과
나의 이름은 제이슨이다. 인간들, 복수한다.
*/

만약 인스턴스가 동일한 이름의 프로퍼티 또는 메서드를 가진다면, '메서드 오버라이드'가 일어나 인스턴스의 메서드(또는 프로퍼티)가 우선적으로 실행됩니다. 기존 생성자의 메서드(또는 프로퍼티)는 건재합니다. 생성자는 어뜨케 된겨?! 어디 사라진 것이 아니라, 자바스크립트 엔진이 메서드를 가장 가까운 곳에서 찾아 바로 실행했을 뿐입니다.

그렇다면 특별히 생성자의 메서드(프로퍼티)에 접근하고 싶다면 어떻게 할까요? __proto__를 붙여 호출하면 됩니다. 하지만 __proto__를 붙여 메서드를 호출하면 메서드의 thisprototype 객체를 가리키게 되니 원하는 객체를 this가 가리키도록 하려면 별도의 메서드를 사용해야 합니다. 그 메서드는 call 또는 apply 입니다.

console.log(jason.__proto__.getName.call(jason));
/*출력결과
저는 제이슨입니다. 홀리쉿!! 사랑해요!
*/

이로써 생성자의 prototype에 접근하여 getName메서드에 call메서드로 getNamethisjason을 가리키도록 한다음 getName() 함수를 반환하고 호출했습니다. 이런 식...으로 가장 가까운 메서드에만 접근하지 않고 원본 생성자의 메서드가 접근할 수 있습니다.

프로토타입 체인

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

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

  • 책 <코어자바스크립트> 165p

객체 전용 메서드의 주의사항

어떤 생성자 함수이든, prototype은 객체입니다. 객체이기 때문에 Object.prototype이 프로토타입 체인의 최상단에 존재하게 됩니다. 따라서 객체에서만 사용할 메서드는 Object.prototype.methodForObject() 이런 식으로 정의할 수 없습니다. Object.prototype 내부에 정의한다면 다른 데이터 타입도 프로토타입 체인을 통해 그 메서드를 사용할 수 있게 되기 때문입니다.

이런 이유로 객체만을 대상으로 동작하는 객체 전용 메서드들은 Object스태틱 메서드(static method)로 부여해야 합니다. 메서드가 static으로 지정되면(스태틱 메서드화되면), 해당 객체가 인스턴스화 되었을 때 그 스태틱 메서드를 사용할 수 없습니다. 따라서 생성자 함수인 Object와 인스턴스인 객체 리터럴 사이에는 this를 통한 연결이 불가능합니다. 메서드 이름 앞의 대상이 곧 this가 될 수 없기 때문입니다. 이에 객체의 전용 메서드는 this의 사용을 포기하고 대상 인스턴스를 인자로 직접 주입해야 하는 방식으로 구현되어 있습니다.

  • 예외 → Object.create(null)__proto__가 없는 객체를 생성합니다. 이 방식으로 만든 객체는 일반적인 데이터에서 반드시 존재하던 내장 메서드 및 프로퍼티들이 제거됨으로써 기능 수행에 제약이 따릅니다. 하지만 객체 자체의 무게가 가벼워지고 성능상 이점을 가집니다.

다중 프로토타입 체인

자바스크립트의 기본 내장 데이터 타입들은 대부분 프로토타입 체인이 1단계이거나 2단계로 끝납니다. 하지만 사용자가 새롭게 만드는 경우, 그 이상도 얼마든지 가능합니다.

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

var Grade = function () {
  var args = Array.prototype.slice.call(arguments);
  for (var i = 0; i < args.length; i++) {
    this[i] = args[i];
  }
  this.length = args.length;
};
var g = new Grade(100, 80);
g.pop(); // 오류! --> g.pop is not a function --> Array의 메서드를 사용할 수 없다.

변수 gGrade의 인스턴스를 가리킵니다. Grade는 인자(들)를 받아 각 순서대로 색인을 만들어 저장하고 length 프로퍼티로 길이도 저장하는 유사 배열의 형태를 띱니다.

이 유사 배열 객체가 배열 메서드를 직접 쓸 수 있도록 만들려면 어떻게 해야 할까요?

g의 __proto__가 배열의 인스턴스를 바라보게 하면 됩니다. 즉, Grade.prototype이 배열의 인스턴스를 바라보게 하면 됩니다.

var Grade = function () {
  var args = Array.prototype.slice.call(arguments);
  for (var i = 0; i < args.length; i++) {
    this[i] = args[i];
  }
  this.length = args.length;
};
Grade.prototype = []; // g의 __proto__가 Array 인스턴스를 가리킴
var g = new Grade(100, 80);
console.log(g.pop()); // 출력 결과: 80 --> Array의 메서드를 사용할 수 있다!

정리

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

Constructor.prototype에는 constructor라는 프로퍼티가 있습니다. 이는 다시 생성자 함수 자신을 가리킵니다. 이 프로퍼티는 인스턴스가 자신의 생성자 함수가 무엇인지를 알고자 할 때 필요한 수단입니다. 하지만 참조 대상이 변경될 수 있기 때문에 완전히 보장된 것은 아닙니다.

__proto__ 방향으로 끝까지 파고들면 최종적으로 Object.__proto__에 당도합니다. 이 과정을 프로토타입 체이닝이라고 합니다. 이 방식으로 각 프로토타입 메서드를 자신의 것처럼 호출할 수 있습니다. 이 접근 방식은 자신으로부터 가장 가까운 대상부터 먼 대상으로 나아갑니다. 원하는 값을 찾으면 검색을 중단합니다. 이 원칙으로 오버라이딩이 가능해집니다.

Object.prototype에는 모든 데이터 타입에서 사용할 수 있는 범용적인 메서드만이 존재합니다. 객체 전용 메서드는 프로토타입에 정의하면 모든 객체에서 그 메서드를 사용할 수 있기 때문에, 다른 데이터타입과 달리 Object 생성자 함수에 스태틱(static)하게 담겨 있습니다.

출처 및 참고 문헌

  • 코어 자바스크립트, 정재남 저, 위키북스, 2019년 09월 10일
  • instanceof → instanceof 연산자는 생성자의 prototype 속성이 객체의 프로토타입 체인 어딘가 존재하는지 판별합니다.
  • static