어서와, 개발은 처음이지?

자바스크립트 Prototype(프로토타입) 본문

Javascript

자바스크립트 Prototype(프로토타입)

오지고지리고알파고포켓몬고 2020. 2. 27. 18:03
반응형


간만에 기획 카테고리에서 일반 자바스크립트 글로 돌아왔습니다.


당분간 프로토타입(Prototype), 비동기 : 콜백(Callback)/프로미스(Promise), 제네레이터(Generator), async await에 대한 내용을 작성할 예정인데, 이들에 대해서 일반적으로 다루지 않는 지식들을 좀 더 깨우치면 기획 카테고리에 추가로 작성하겠습니다.



1. 프로토타입


명세에 따르면 자바스크립트 객체에는 [[Prototype]] 이라는 내부 프로퍼티(프로토타입 링크)가 있고, 다른 객체를 참조하는 레퍼런스로 사용합니다.


객체에서 프로퍼티를 탐색할 때 객체 내에 프로퍼티가 존재하지 않으면 undefined를 출력하고 끝나는게 아니라 [[Prototype]] 참조를 따라가면서 프로퍼티가 존재하는지 탐색합니다.(프로토타입 연쇄)


이러한 현상은 주변에서 쉽게 볼 수 있습니다.

var obj = { a: 1, b: 2 };
console.log(obj.a); // 1
console.log(obj.b); // 2
console.log(obj.c); // undefined
console.log(obj.valueOf()); // ???

특별할게 없는 위 코드에서 a는 1, b는 2가 출력되고 c는 존재하지 않으므로 undefined가 출력되는것을 볼 수 있습니다.


그런데 마지막 줄이 좀 이상합니다.


방금 살펴본 c처럼 valueOf가 obj에 존재하지 않는걸 머리론 알지만, valueOf는 undefined가 아닌 호출 가능한 메서드임에, 무의식적으로 이런 메서드들(forEach, toString 등)을 사용해본 기억이 있기 때문에 혼란스러워지기 시작합니다.


이런 현상이 바로 프로토타입 링크에 의한 탐색(연쇄)의 흔한 예 입니다.

obj의 프로토타입 링크는 어떤 다른 객체를 참조하고 있고, 그 객체에 valueOf가 정의되어있어서 참조가 가능한 것입니다.



2. 프로로타입 객체(.prototype)와 프로토타입 링크(.__proto__)


리터럴 형식으로 생성한 객체들은 javascript 엔진 입장에서 생성 함수로 생성한 것과 똑같이 해석됩니다.

즉 아래 코드는 같은 형태로 해석됩니다.

var obj = { a: 1, b: 2 };
// 리터럴로 생성한 obj는 실제로 아래와 같습니다.
var obj = new Object({ a: 1, b: 2 });

크롬 기준으로 자바스크립트의 객체에는 __proto__라는 프로퍼티가 존재하고 함수에는 prototype이라는 프로퍼티가 존재하는데,

객체의 __proto__을 프로토타입 링크함수의 prototype을 프로토타입 객체라고 할 수 있고, 객체 생성 시 생성된 객체의 프로토타입 링크가 생성 함수의 프로토타입 객체를 참조하는 형태를 하게 됩니다.


즉 obj 객체가 생성되면 프로토타입 링크(__proto__)가 객체의 생성 함수의 prototype이 가르키는 객체를 참조하게 되는 것입니다.


new + 함수 실행은 클래스 패턴의 객체 생성과 유사한데, 실질적으로 new키워드를 사용하여 함수를 실행하면, 생성된 객체의 프로토타입 링크(__proto__)와 함수의 prototype 객체가 연결된다고 언급한 적있습니다.(new 바인딩의 동작 원리)

var obj1 = { a: 1, b: 2 }; // new Object({ a: 1, b: 2})와 같음
var obj2 = new Object({ c: 3, d: 4 });

console.log(obj1.__proto__ === Object.prototype); // true
console.log(obj2.__proto__ === Object.prototype); // true
console.log(obj1.__proto__ === obj2.__proto__); // true

console.log(obj1.__proto__.valueOf === Object.prototype.valueOf); // true
console.log(obj2.__proto__.valueOf === Object.prototype.valueOf); // true
console.log(obj1.valueOf === Object.prototype.valueOf); // true
console.log(obj2.valueOf === Object.prototype.valueOf); // true

위 코드는 obj1과 obj2가 프로토타입 링크에 의해 동일한 프로토타입 객체(Object.prototype)를 참조하고 있음을 나타내고 있습니다.


이러한 관계들을 대략적으로 도식화하면 아래와 같습니다.

다른 자료형 역시 동일합니다.(number, string, boolean 같은 원시 자료형들은 auto boxing이 일어납니다)

var a = [1, 2, 3];
var b = new Array([4, 5, 6]);

console.log(a.__proto__ === Array.prototype); // true
console.log(b.__proto__ === Array.prototype); // true
console.log(
  a.forEach === b.__proto__.forEach &&
    b.__proto__.forEach === Array.prototype.forEach
); // true
var a = "yuddomack";
console.log(a.replace === String.prototype.replace); // true


직접 정의한 함수도 프로토타입 객체가 있고, new 키워드를 사용해서 객체를 생성하면 동일한 현상이 발생합니다.

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

Person.prototype.sayName = function() {
  console.log("my name is", this.name);
};

var p1 = new Person("yuddomack");
console.log(p1); // {name: "yuddomack"}
p1.sayName(); // my name is yuddomack



3. 프로토타입 연쇄


1번 항목에서 잠시 언급했지만, 객체에서 프로퍼티를 호출하면 프로토타입 연쇄에 의해 아래와 같은 과정이 일어납니다.


1. 객체 내에 해당 프로퍼티를 찾는다.

2. 객체 내에 프로퍼티가 존재하지 않으면 객체가 참조(__proto__)하고 있는 prototype 객체에서 프로퍼티를 찾는다.

3. 최상위 prototype까지 탐색하며 2번 항목을 반복한다.(__proto__.__proto__.__proto__ ... )

4. 존재하지 않으면 undefined를 반환한다.

 

이렇게 상위 prototype 참조를 타고 올라가는 과정을 prototype 연쇄라고 하며, 자바스크립트 명세에서는 [[prototype]]이라고 표현한다고 합니다.

(아래 코드처럼 __proto__를 직접 명시하지 않아도 프로토타입 연쇄가 발생합니다. __proto__는 그저 프로토타입 링크를 볼 수 있는 구현체입니다. 어떤 브라우저에서는 __proto__라는 프로퍼티가 존재하지 않습니다.)


한번 코드를 보겠습니다.

function Test() {
  this.a = 10;
}

Test.prototype.b = 20;
Object.prototype.c = 30;

var obj = new Test();
console.log(obj); // Test { a: 10 }
console.log(obj.a); // 10
console.log(obj.b); // 20
console.log(obj.c); // 30
console.log(obj.d); // undefined

obj를 콘솔에 찍어보면 new 바인딩에 의해서 객체 obj에 a라는 프로퍼티가 등록되어있음을 확인할 수 있습니다.

하지만 b, c라는 프로퍼티는 객체 obj에 존재하지 않음에도 호출됨을 볼 수 있습니다.


valueOf 메서드를 찾아냈 듯, 프로토타입 연쇄 과정을 통해 프로퍼티를 찾아냈기 때문입니다.


연쇄의 과정은 아래 코드처럼 __proto__를 통해 탐색하는 것과 같습니다.

function Test() {
  this.a = 10;
}

Test.prototype.b = 20;
Object.prototype.c = 30;

var obj = new Test();

console.log(obj.__proto__ === Test.prototype); // true
console.log(obj.__proto__.b); // 20
console.log(obj.__proto__.__proto__ === Object.prototype); // true
console.log(obj.__proto__.__proto__.c); // 20

obj의 [[prototype]] 링크(__proto__)가 Test 함수의 prototype과 같음을 볼 수 있고, 이를 통해 b를 참조할 수 있음을 볼 수 있습니다.

또한 obj의 [[prototype]] 링크의 [[prototype]] 링크가, Object 함수의 prototype와 같고, c를 참조할 수 있음을 볼 수 있습니다.


결국 obj.c를 호출하면, 프로토타입 연쇄에 따라 obj.__proto__.__proto__에 이르러야 비로소 프로퍼티 c를 찾아내서 출력하게 되는 것입니다.


이를 도식화하면 아래와 같습니다.

[프로토타입 간 관계도]


obj 객체로부터 시작해서 프로토타입 연쇄, 즉 [[prototype]] 라고 명시된 부분들을 따라올라가면 a,b,c를 참조할 수 있음을 짐작 가능합니다.


한가지 주목할 점은 Test.prototype 객체의 [[prototype]] 링크가 Object.prototype을 참조하고있는데, Test.prototype도 '객체'이기 때문입니다.

(Test.prototype에 정의된 객체가 아니라 메모리 어딘가에 생성된 Test의 prototype객체를 Test.prototype 프로퍼티가 참조하고, obj.__proto__가 참조한다고 봐야합니다.)


이러한 현상은 new 키워드를 사용하여 '특별히 발생'하는 것이 아니고 javascript가 프로토타입 기반으로 설계되어있기 때문입니다.

프로토타입을 굳이 의식하여 바라보지 마시고 모든 객체는 어디서부턴가로 이어져 나온다 라고 개념만 가져가시면 좋겠습니다. 




4. 프로토타입 체인의 장점


이렇게 프로토타입 체인을 연결하면 메모리 관점에서 장점이 있습니다.


이를 설명하기 위해 클래스 패턴 관점에서 Person이라는 함수를 만들어보겠습니다.

function Person(name) {
  this.name = name;
  this.sayName = function() {
    console.log(`안녕하세요 ${this.name}입니다.`);
  };
}

var p1 = new Person('yuddomack');
var p2 = new Person('darr');

console.log(p1); // Person { name: 'yuddomack', sayName: [Function] }
p1.sayName(); // 안녕하세요 yuddomack입니다.
console.log(p2); // Person { name: 'darr', sayName: [Function] }
p2.sayName(); // 안녕하세요 darr입니다.

console.log(p1.sayName === p2.sayName); // false

클래스의 역할을 하는 Person 함수는 멤버변수 name과, name을 출력하는 sayName이라는 메소드를 정의하는 행위를 합니다.


new Person으로 생성된 객체 p1, p2는 각각 name과 sayName 함수를 가지고 있는 모습을 볼 수 있습니다.


여기서 우리는 sayName이라는 메소드가 공통적으로 사용됨을 볼 수 있습니다.

이를 prototype을 사용할 수 있도록 바꿔보겠습니다.

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

Person.prototype.sayName = function() {
  console.log(`안녕하세요 ${this.name}입니다.`);
};

var p1 = new Person('yuddomack');
var p2 = new Person('darr');

console.log(p1); // Person { name: 'yuddomack' }
p1.sayName(); // 안녕하세요 yuddomack입니다.
console.log(p2); // Person { name: 'darr' }
p2.sayName(); // 안녕하세요 darr입니다.

console.log(p1.sayName === p2.sayName); // true

공통 메소드인 sayName를 Person 함수의 prototype에 정의하고, 이외에는 마찬가지로 new Person을 사용하여 객체를 생성했습니다.


헌데 p1, p2에서 sayName이 호출되고 있는데 각 객체 안에 sayName 프로퍼티가 보이지 않습니다.

그리고 맨 하단 console.log를 주목해보시면 첫번째 예제와는 다르게 true를 나타내고있습니다.


왜 이런걸까요? 위에서 설명한 프로토타입 체인으로

p1과 p2의 __proto__는 같은 prototype, 즉 Person의 prototype을 참조하고 있기 때문입니다.


<prototype에 sayName정의하지 않았을 때>


<prototype에 sayName을 정의했을 때>


그렇기 때문에 결국 위 도식처럼 sayName 함수의 메모리 참조에 관한 차이가 생깁니다.

당연히 각 객체에 sayName 함수를 할당하는 것 보다 prototype에 정의된 sayName 함수를 참조하는 것이 메모리 측면에서 이득이겠지요.


또한 Number, String, Array, Object 같은 네이티브 함수의 prototype에 나만의 공통 함수를 정의하여 편하게 사용할 수 있습니다.

Array.prototype.findOneIndex = function(value) {
  for (var i = 0, length = this.length; i < length; i++) {
    if (this[i] === value) {
      return i;
    }
  }
  return;
};

console.log([5, 4, 3, 2, 1].findOneIndex(4)); // 1

Number.prototype.toDollor = function() {
  return this.valueOf() + '$';
};

var salary = 100;
console.log(salary.toDollor()); // 100$

물론 처음에는 네이티브 함수를 조작해서 사용하는 것이 스웩 넘쳐보지만, 다른 사람들과 일하다보면 prototype에 뭘 붙여놓는 것은 아주 골치아픈 일이 될 수 있습니다.

필요하다면 개인적으로 모듈을 만들어서 사용하는 것을 권장합니다.


(이 절에서 사용되는 this가 혼란스러우실 수 있는데, 암시적 바인딩 형태로 동작하게 됩니다. 예를들어서 p1.sayName()은 p1을 this 컨텍스트로 두게되는 것이지요. 자세한 내용은 this 바인딩에 관한 글을 읽어보시기 바랍니다.)



5. prototype을 사용하여 상속 패턴 구현


prototype에 공용 메소드를 정의하고, 생성된 객체가 눈에 보이지 않는 메소드를 참조하는 모습은 java에서 클래스를 상속하여 사용하는 모습과 비슷한 느낌을 줍니다.


prototype을 조작하여 상속 패턴을 만들어보겠습니다.

function Parent(name) {
  this.name = name;
}

Parent.prototype.sayName = function() {
  console.log(`my name is ${this.name}`);
};

function Children(name, age) {
  Parent.call(this, name);
  this.age = age;
}

Children.prototype = Object.create(Parent.prototype); // Parent.prototype을 [[prototype]]으로 갖는 객체를 Children.prototype에 연결

Children.prototype.sayAge = function() {
  console.log(`my age is ${this.age}`);
};

var p1 = new Children('yuddomack', 20);
console.log(p1); // { name: 'yuddomack', age: 20 }
p1.sayName(); // my name is yuddomack
p1.sayAge(); // my age is 20

한눈에 봐도 꽤 복잡해졌습니다.


핵심적인 부분이 두가지 있는데, 먼저 Children 함수 내에서 this를 Parent 함수에 넘겨서 호출하는 것으로 부모의 생성자를 호출하는 부분을 구현합니다.

다음으로 Children의 prototype을 Parent의 Prototype과 관계 맺어줌으로써 상속 형태를 구현했습니다.


이렇게 prototype을 사용하여 상속을 구현하면 java의 상속과 크게 다른점이 생깁니다.


java에서는 상속된 클래스의 객체를 생성할 경우, 각 객체(인스턴스)마다 부모 영역이 메모리 공간에 잡힌다고 합니다.


<스프링 입문을 위한 자바 객체 지향의 원리와 이해 - 김태영 저>


위 개념도를 인용하여 prototype을 통해 구현된 상속 패턴과 구분하면 아래와 같은 구조를 생각할 수 있겠습니다.


<메모리 입장에서 본 Javascript와 Java의 상속된 객체 비교>


Java의 p객체들은 모두 Parent 영역까지 가지고있는 반면에, Javascript의 p객체들은 [[prototype]] 을 통해서 하나의 Parent를 참조하고 있는 모습이 보입니다. 


물론 이렇게만 보면 prototype을 사용한 구현이 좋아보이지만, 만약 상속 관계가 깊어질수록 [[prototype]] 과정이 많이 일어나야 하기 때문에 참조에 대한 이슈가 있을 수 있습니다.




6. 좀 더 javascript스럽게 해보자


지금까지 함수 객체의 prototype을 사용하여 객체와 객체 간에 관계를 맺어주는 내용을 살펴봤습니다.


하지만 클래스 역할을 수행하는 각 함수를 정의하고, 그들의 prototype을 묶어서 사용해야 하는 등, 코드도 번거롭고 각종 prototype들의 관계를 구조적으로 떠올리기 복잡한 느낌을 지울 수 없습니다.


(하으.. 이게 다 뭔소리래..)


사실 이 모든 것은 클래스 패턴의 방식(클래스 역할을 하는 함수와, new 키워드)으로 접근했기 때문입니다. 


javascript에서는 객체간 관계를 작동 위임이라는 디자인 방식으로 쉽게 구현할 수 있습니다.

작동 위임 방식은 객체의 [[prototype]]이 되는 객체를 직접 지정하는 방법입니다.


Object.create(prototypeObj) 혹은 Object.setPrototypeOf(obj, prototypeObj) 메소드를 사용하여 쉽게 prototype 관계를 지정해줄 수 있습니다.


- Object.create는 인자로 받은 객체를 [[prototype]] 으로 사용하는 빈 객체{}를 생성해줍니다.

- Object.setPrototypeOf는 첫번째 인자의 [[prototype]] 을 두번째 인자에 연결해줍니다.

var Person = {
  sayName: function() {
    console.log(`my name is ${this.name}`);
  },
};

var p1 = Object.create(Person);
p1.name = 'yuddomack';
p1.sayName(); // yuddomack
console.log(p1.__proto__ === Person);

var p2 = {name: 'darr'};
Object.setPrototypeOf(p2, Person);
p2.sayName(); // darr
console.log(p2.__proto__ === Person); // true

클래스 역할을 하는 function도 없고, prototype을 조작할 일도 없습니다.


클래스 패턴에서는 기능을 정의한 class를 만들=> class간 관계를 만들고 => 객체를 생성했었지만,

작동 위임에서는 기능을 정의한 객체를 만들고 => 객체를 이어줌으로써 동일한 동작을 구현할 수 있습니다.


작동 위임 방식을 사용하면 구조적으로 아래와 같은 차이가 생겨납니다.



VS



prototype과 함수와의 관계가 빠지면서 훨씬 깔끔해짐을 볼 수 있습니다.


이번에는 작동 위임 방식으로 4번 항목에서 사용한 코드를 수정해보겠습니다.

var parent = {
  sayName: function() {
    console.log(`my name is ${this.name}`);
  },
};

var children = {
  sayAge: function() {
    console.log(`my age is ${this.age}`);
  },
};

Object.setPrototypeOf(children, parent);

function makePerson(name, age) {
  var p = {
    name: name,
    age: age,
  };

  Object.setPrototypeOf(p, children);
  return p;
}

var p1 = makePerson('yuddomack', 20);
p1.sayName(); // my name is yuddomack
p1.sayAge(); // my age is 20

함수와 함수의 prototype이라는 낯선 개념 없이, 우리가 아는 객체(object)만으로 객체 - 객체의 관계를 지어줄 수 있음에 마음이 평화로워집니다.



7. 주의할 점


물론 이 prototype과 __proto__라는 녀석들에 깊이 들어가면 알아야 할 내용이 한 없이 많지만,

충분히 실수할 수 있는 한 가지 사례만 우선 소개하겠습니다.

var parent = {
  age: 20,
};

var p1 = Object.create(parent);
console.log(p1); // {}
console.log(p1.age); // 20
console.log(p1.__proto__.age); // 20

p1.age++;
console.log(p1); // { age: 21 }
console.log(p1.age); // 21
console.log(p1.__proto__.age); // 20

p1과 [[prototype]] 으로 관계가 맺어진 parent 객체가 있는데, 처음엔 p1.age가 parent의 age를 참조하고 있지만 증감연산자를 사용하면

참조되고 있는 age가 아닌 p1 객체에 생성되는 모습을 볼 수 있습니다.


이는 [[getter]] [[setter]] 등의 동작과 연관이 되어있는데 이 글에서는 따로 다루지 않겠습니다.

언젠가 관련 내용을 작성하겠지만 지금 궁금하신 분들은 [[getter]], [[setter]], shadowing현상 키워드로 검색해보세요 :)



7. 마치며


이번 시간에는 간단(?)하게 자바스크립트의 프로토타입에 대해서 알아봤습니다.

중급 개념이 나올수록 점점 쉽게 설명하기 어려워지는데, 제가 완벽하게 이해하지 못하고 있기 때문이겠습니다.


하지만 좀 더 짬이 쌓이다보면 이 글을 리뉴얼 할 날이 오겠지요!


글로 정리할 js에 관한 기본 지식 주제들의 끝이 점점 보이기 시작하네요.

다음 시간에는 비동기에 관련된 내용을 작성하겠습니다.


코로나 조심하시고 즐거운 개발 되시길 바라겠습니다!

Comments