JS prototype

불꽃남자·2020년 9월 14일
1

이번 포스팅은 실행 컨텍스트에 대해 알아볼 생각이었다.
그렇지만 실행 컨텍스트를 배우기에 나는 아직 선행지식이 너무도 부족했다.
그래서 이번에 알아보게 될 것은 prototype이 되었다.

prototype

JS는 객체지향 언어이다. Java나 C++같은 객체지향 언어는 클래스라는 것을 기반으로 객체를 만들어내지만, JS는 클래스 대신 prototype에 기반하여 객체를 만들어낸다.
저번 포스팅에서 배운 스코프는 내부 함수가 외부 함수의 변수를 참조하게 해주는 것이었다. prototype은 자식 객체가 부모 객체의 메서드나 프로퍼티를 참조할 수 있게 해준다.
코드와 같이 살펴보자.

var cat = {
    name: "커비",
    age: "1"
}
console.log(cat.hasOwnProperty('name'));


코드에서 나는 cat이라는 객체에게 hasOwnProperty메서드를 선언해준 적이 없지만 위의 코드는 잘 동작하고 있다. 이런 일이 일어날 수 있는 이유는 prototype에 있다.

cat이라는 객체를 보면, __proto__라는 프로퍼티를 가지고 있다. 그 안에는 hasOwnProperty메서드를 비롯한 여러가지 메서드와 프로퍼티가 들어 있다.
cat 객체의 __proto__는 부모 객체인 Object의 Object.prototype을 참조하고 있는 것이다.
JAVASCRIPT.INFO의 프로토타입에 관한 설명에 의하면, 실제로 부모의 prototype을 참조하고 있는 것은 [[Prototype]]이며, __proto__는 [[Prototype]]의 링크역할을 하는 비표준 접근자라고 설명하고 있다.
그러니 이 밑으로는 __proto__ 대신 [[Prototype]]이라고 표현하겠다.

var cat = {
    name: "커비",
    age: "1"
}
console.log(cat.__proto__ === Object.prototype);


엄격한 비교 연산자를 사용했는데 true를 반환했다는 건, cat의 [[Prototype]]이 Object.prototype을 참조하고 있다는 의미가 된다. 그 말인 즉 Object.prototype에 메서드나 프로퍼티를 추가했을 때, cat의 [[Prototype]]도 추가된 메서드나 프로퍼티를 사용할 수 있게 된다는 것을 뜻한다.
이렇듯 JS는 prototype을 이용하여 부모 객체와 자식 객체간의 상속이라는 기능을 구현하고 있다.

누가 prototype을 가질 수 있나?

결론부터 이야기하자면, prototype을 가질 수 있는 것은 오직 함수이다.
왜 그런 것일까 생각해보면, prototype은 어떤 객체의 부모 역할을 하는 객체이다. 이 prototype을 가지기 위해서는 그 자신도 부모가 될 자격이 있어야 하는데, JS에서 그런 객체는 생성자 함수밖에 없다. 그리고 모든 함수는 생성자가 될 수 있으니, 모든 함수는 prototype을 가질 수 있고, 함수가 아닌 것은 생성자가 될 수 없으니 함수가 아니면 prototype을 가질 수 없는 것이다.

var MyFunction = function() {};
MyFunction.prototype.myPrototype = "Yes Im prototype";
var foo = new MyFunction();
MyFunction.prototype.myPrototype2 = "Im prototype, too";

console.dir(MyFunction);
console.dir(foo);


생성자 함수인 MyFunction은 prototype 프로퍼티가 있지만, 그 인스턴스인 foo는 prototype이 없다. 또한 foo의 [[prototype]]은 자신의 생성자인 MyFunction의 prototype을 참조하고 있다. 이는 foo가 생성된 이후 MyFunction.prototype에 추가한 프로퍼티가 foo의 [[prototype]]도 추가되었다는 점을 보아 알 수 있다.

prototype.constructor

prototype의 프로퍼티중 constructor라는 프로퍼티가 있다. 이것은 자식 객체의 관점에서 부모 객체를 나타내는 프로퍼티이다.

var MyFunction = function() {};
var foo = new MyFunction();

console.dir(MyFunction);
console.dir(foo);

이 코드에서 foo의 부모 객체는 MyFunction생성자 함수이다. 그래서 foo.constructor는 MyFunction이다. foo의 [[Prototype]]의 constructor가 MyFunction을 가리키고 있기 때문이다.
foo의 [[Prototype]]은 MyFunction의 prototype을 가리키고 있기 때문에 MyFunction.prototype.constructor 또한 MyFunction을 가리키고 있다는 사실을 유추할 수 있다.
정확히는 MyFunction.prototype.constructor가 MyFunction을 가리키기 때문에 그 인스턴스인 foo의 [[Prototype]]의 constructor도 MyFunction을 가리키는 것이다. 객체의 [[Prototype]]은 부모 객체의 prototype을 가리킨다고 했으니까 말이다.

MyFunction또한 [[Prototype]]을 가지고 있다. 바로 Fucntion 생성자 함수의 prototype이다. 함수는 어떻게 생성하든 Function 생성자 함수로 인해 생성되기 때문이다.
그리고 Function 생성자 함수도 [[Prototype]]이 있으니 Object 생성자 함수의 prototype이다. (JS에서 함수는 객체이다. 이는 JS의 함수가 일급객체의 조건을 모두 만족하기 때문이다.) JS에서 함수는 객체이기 때문이다.

Object가 Function.prototype의 메소드를, Function이 Object.prototype의 메소드를 사용하는 모습

그럼 Object의 [[Prototype]]도 있지 않을까 하는 생각도 들었지만, Object의 [[Prototype]]은 null이다.
다시 MyFunction.prototype으로 돌아가보자. MyFunction.prototype도 [[Prototype]]을 가지고 있다. MyFunction.prototype은 객체이기 때문에 Object 생성자의 prototype을 [[Prototype]]으로 가지고 있다.

개념을 이해하다가도 자칫 헷갈리게 되면 금방 헤매기 쉽상이다. 정말 제대로 이해해야 할 필요가 있다.

prototype chain

prototype chain은 scope chain과 유사한 개념이다.
객체가 메서드나 프로퍼티에 접근하려고 할 때, 객체 자신에게 메서드나 프로퍼티가 존재하지 않으면 자신의 [[Prototype]]을 탐색한다. 자신의 [[Prototype]]에 없다면 [[Prototype]]의 [[Prototype]](부모 객체의 prototype의 [[Prototype]])에서 탐색, 없으면 [[Prototype]]의 [[Prototype]]의 [[Prototype]]에서 ... 이런식으로 탐색하다가 결국 [[Prototype]]이 null이 될 때 까지 탐색하고, 그래도 찾던 메서드나 프로퍼티를 탐색하지 못 하면 undefined를 반환한다.
[[Prototype]]이 null이라면 더 이상 부모 prototype이 없다는 뜻이기 때문에, 해당 [[Prototype]]이 prototype chain의 최상위 prototype이라는 의미이다.

prototype chain의 형태

JS에서 prototype chain의 형태는 크게 두 가지로 나뉜다.
객체 리터럴로 생성된 객체가 가진 prototype chain, 사용자 생성자 함수로 생성된 객체가 가진 prototype chain이 그것이다.

객체 리터럴로 생성된 객체의 prototype chain

먼저 객체 리터럴로 생성된 객체를 확인해보자.

var cat = {
    name: "커비",
    color: "gray",
    age: 1
};
console.dir(cat);

console.dir(cat.__proto__); //Object.prototype
console.dir(cat.__proto__.__proto__); //null. cat의 prototype chain 종료.
console.dir(cat.__proto__.constructor); //Object 생성자 함수
console.dir(cat.__proto__.constructor.__proto__); //Function.prototype
console.dir(cat.__proto__.constructor.__proto__.__proto__); //Object.prototype
console.dir(cat.__proto__.constructor.__proto__.__proto__.__proto__); //null

그림으로 나타내면 다음과 같다.

  1. cat.__proto__는 Object.prototype이다. 이것은 객체 리터럴로 생성된 객체도 내부적으로는 Object 생성자로 생성된 것과 같다는 것을 의미한다.
    또한 cat.__proto__.__proto__는 null이기 때문에 cat.__proto__에서 cat의 prototype chain이 끝난다.
  2. Object.prototype.constructor는 Object 생성자 함수이다.
  3. Object 생성자 함수는 함수이므로 Object의 [[Prototype]]은 Function.prototype이다.
  4. JS에서 함수는 객체이므로 Function의 [[Prototype]]은 Object.prototype이다.
  5. Object.prototype의 [[Prototype]]은 null이다.

사용자 생성자 함수로 생성된 객체의 prototype chain

사용자 생성자 함수라고 표현한 이유는, JS의 표준 내장 객체의 생성자 함수와 구분하기 위해서이다.

var Cat = function(name, age) {
     this.name = name;
     this.age = age;
}

var myCat = new Cat("커비", 1);

console.dir(myCat.__proto__); //Cat.prototype
console.dir(myCat.__proto__.__proto__); //Object.prototype
console.dir(myCat.__proto__.__proto__.__proto__); //null. myCat의 prototype chain 종료
console.dir(myCat.__proto__.constructor); //Cat 생성자 함수
console.dir(myCat.__proto__.constructor.__proto__); //Function.prototype
console.dir(myCat.__proto__.constructor.__proto__.__proto__); //Object.prototype
console.dir(myCat.__proto__.constructor.__proto__.__proto__.__proto__); //null


1. myCat.__proto__는 Cat.prototype이다.
2. myCat.__proto__.__proto__는 Object.prototype이다. 또한 myCat.__proto__.__proto__.__proto__는 null이기 때문에 myCat의 prototype chain은 myCat.__proto__.__proto__에서 종료된다.
3. Cat.prototype의 constructor는 Cat 생성자 함수이다.
4. Cat은 생성자 함수이기 때문에 Cat의 [[Prototype]]은 Function.prototype이다.
5. JS에서 함수는 객체이므로 Function의 [[Prototype]]은 Object.prototype이다.
6. Object.prototype의 [[Prototype]]은 null이다.

원시 타입의 [[Prototype]]

[[Prototype]]은 객체이다. 객체를 자신의 프로퍼티로 소유하려면 자신은 객체여야한다.
그렇다면 윈시 타입(primitive type)은 객체가 아니므로 [[Prototype]]을 프로퍼티로 소유할 수 없어야 하고, 이는 원시 타입은 prototype의 메서드나 프로퍼티를 사용할 수 없다는 뜻이다.

var num = 1; //원시타입 number
var string = "string"; //원시타입 string
var boolean = true; //원시타입 boolean

console.log(num.toString());
console.log(string.length);
console.log(boolean.toString());


그렇지만 코드를 살펴보면 원시 타입도 내장 객체의 프로토타입의 메서드나 프로퍼티를 사용하고 있다.
이는 JS엔진이 연산을 할 때에 필요에 따라 암시적 형변환을 사용하기 때문이다.
JAVASCRIPT INFO의 원시값에 대한 설명에서는 암시적 형변환에 대해 이렇게 설명하고 있다.

문자열과 숫자 불린값은 객체가 아닙니다. 그런데 이런 원시값들의 프로퍼티에 접근하려고 하면 내장 생성자 String, Number, Boolean을 사용하는 임시 래퍼(wrapper) 객체가 생성됩니다. 임시 래퍼 객체는 이런 메서드를 제공하고 난 후에 사라집니다.

래퍼 객체는 보이지 않는 곳에서 만들어집니다. 최적화는 엔진이 담당하죠. 그런데 명세서에선 각 자료형에 해당하는 래퍼 객체의 메서드를 프로토타입 안에 구현해 놓고 String.prototype, Number.prototype, Boolean.prototype을 사용해 쓸 수 있도록 규정합니다.

즉 원시 타입의 프로퍼티에 접근하려고 할 때에, JS엔진은 원시 타입의 값을 지닌 임시 래퍼 객체를 생성하고, 임시 래퍼 객체는 해당 원시 타입과 관련된 생성자의 prototype을 상속받는 것이다.

prototype 객체 변경

prototype 객체는 사용자가 임의로 변경할 수 있다.

var Cat = function(name, age) {
     this.name = name;
     this.age = age;
}

var myCat = new Cat("커비", 1);
console.dir(myCat.constructor); // Cat 생성자 함수

Cat.prototype = { catIs: "cute" };
var yourCat = new Cat("두야", 1);
console.dir(yourCat.__proto__); // { catIs: "cute" }
console.dir(yourCat.constructor); // Object 생성자 함수

Cat 생성자 함수를 선언하고, myCat 인스턴스를 생성했다.
myCat.constructor를 확인해보면 Cat 생성자 함수를 반환한다. Cat.prototype.constructor가 Cat 생성자 함수이기 때문이다.
그 다음 Cat.prototype을 { catIs: "cute" }으로 변경한 뒤 yourCat 인스턴스를 생성했다.
yourCat의 [[Prototype]]는 { catIs: "cute" }이다. Cat.prototype을 { catIs: "cute" }로 바꾸었기 때문이다.
yourCat.constructor를 확인해보면 Object 생성자 함수이다. 이유는 다음과 같다.

  1. yourCat.constructor 를 확인한다. yourCat.constructor는 없다.
  2. yourCat.__proto__.constructor를 확인한다. Cat.prototype.constructor는 없다.
  3. yourCat.__proto__.__proto__constructor를 확인해본다. Object.constructor는 존재한다.
  4. Object.constructor를 반환한다. Object.constructor는 Object 생성자 함수이다.

prototype chain 동작

접근하려는 프로퍼티가 해당 객체의 프로퍼티에 존재하지 않을 경우, prototype chain은 동작한다.
이는 객체에 프로퍼티를 할당할 때에 헷갈리기 쉽다.

var Cat = function(name) {
     this.name = name;
}

Cat.prototype.age = 1;

var myCat = new Cat("커비");
var yourCat = new Cat("두야");
console.log(myCat.age, yourCat.age); // 1 1

myCat.age = 2;
console.log(myCat.age, yourCat.age); // 2 1

첫 번째 console.log 명령어에서, myCat.age은 존재하지 않기 때문에 myCat.__proto__.age를 반환한다.
그 후 myCat.age에 2를 할당했다. 언뜻 보기에 myCat.__proto__.age를 2로 변경한듯 보이나 그렇지 않다.

myCat.age = 2;를 실행하면 myCat.age 프로퍼티를 생성하고 그곳에 2를 할당한다. myCat.__proto__.age의 값은 변하지 않는다.

마치며

사실 JS의 prototype에 대해서는 몇 번이고 찾아본 적이 있다. 그 땐 대충 알아낸 뒤 prototype에 대해서 안다고 생각하고 있었다. 참으로 오만한 생각이었다...
앞서 말했듯이 원래는 실행 컨텍스트에 대해 알아보려고 했지만, 그 전에 this를 알아야했고, 그 전에 인스턴스를 알아야 했고, 그 전에 prototype에 대해 알아야 했다.
지식의 문을 열려고 했지만 맞는 열쇠가 없어 찾으러 온 것이다.

요전에는 남을 가르친다고 생각하며 글을 쓰고 있었는데 이 글을 쓰며 느낀 것이, 남을 가르치듯이 글을 쓸 게 아니라 나 자신을 가르치며 글을 써야한다.
이 블로그에 포스팅을 하는 주 목적이 남을 가르치는 것이 아닌 나 자신이 배우기 위함이기 때문이다.

그리고 중간 중간에 코드가 동작하는 순서와 코드간의 관계를 나타낸 그림이 있는데, 이는 이웅모님의 프로토타입에 관한 글에 나오는 그림을 참고한 것이다.
따라 만들고 있으니 확실히 이해가 쏙쏙 잘 되었다.

참고

MDN - Object prototypes
이웅모님의 프로토타입에 관한 글

profile
프론트엔드 꿈나무, 탐구자.

0개의 댓글