javascript Deep Dive

[JavaScript] prototype

이경찬 :) 2023. 12. 23. 12:54

Javascript에서는 객체를 상속하기 위하여 프로토타입 이라는 방식을 사용한다.

프로토타입 체인이 동작하는 방식을 알아보고 이미 존재하는 생성자에 메소드를 추가하기 위해 프로토타입 속성을 사용하는 법을 알아보겠다.

 

프로토타입 기반 언어?

javascript는 흔히 프로토타입 기반 언어라 불린다.

모든 객체들이 메소드와 프로퍼티들을 상속 받기 위한 템플릿으로써 프로토타입 객체를 가진다는 의미다.

프로토타입 객체도 또 다시 상위 프로토타입 객체로부터 메소드와 속성을 상속 받을 수도 있고 그 상위 프로토타입 객체도 마찬가지 이다.

이를 프로토타입 체인이라 부르며 다른 객체에 정의된 메소드와 속성을 한 객체에서 사용할 수 있도록 하는 근간이다.

javascript에서는 객체 인스턴스와 프로토타입 간에 연결( 많은 브라우저들이 생성자의  prototype 속성에서 파생된 __proto__ 속성으로 객체 인스턴스에 구현하고 있다.)이 구성되며 이 연결을 따라 프로토타입 체인을 타고 올라가며 프로퍼티와 메서드를 탐색한다.

 

 

참고 : 객체의 prototype과 생성자의 prototype 속성의 차이를 인지하는 것이 중요하다. 전자는 개별 객체의 속성이며 후자는 생성자의 속성이다. 이 말은 object.getPrototypeOf(new Foobar())의 반환 값이 Foobar.prototype과 동일한 객체라는 의미이다.

 

prototype 구조

프로토타입의 구조를 도식화 해보았다.

Constructor(생성자)의 prototype을 Instance가 상속받는 것이라고 생각하면 된다.

 

프로토타입 객체 이해하기

생성자 함수를 정의해 보겠다.

function Person(first, last, age, gender, interests) {
  // 속성과 메소드 정의
  this.first = first;
  this.last = last;
  //...
}

그리고 인스턴스도 하나 만든다.

 

var person1 = new Person("Bob", "Smith", 32, "male", ["music", "skiing"]);

 

위에서 person1의 프로토타입 객체인 Person()에 정의된 멤버들 name, age,gender, interests,bio,greeting을 볼 수 있다.

또한 watch, valueOf처럼 Person()의 프로토타입 객체인 Object에 정의된 다른 멤버들도 볼 수 있다.

이는 프로토타입 체인이 동작했다는 증거이다.

 

그럼 실제로 Object에 정의되어 있는 메소드를 person1에서 호출하면 어떻게 될 것인가?

 

person1.valueOf();

 

이 메소드는 호출된 객체의 값을 단순 반환한다.

이때 일어나는 일은 아래와 같다.

 

- 브라우저는 우선 person1 객체가 valueOf() 메소드를 가지고 있는지 체크한다.

- 없으므로 person1의 프로토타입 객체(Person() 생성자의 프로토타입)에  valueOf() 메소드가 있는지 체크한다.

- 여전히 없으므로 Person() 생성자의 프로토타입 객체의 프로토타입 객체(Object() 생성자의 프로토타입)가 valueOf() 메소드를 가지고 있는지 체크한다. 여기에 있으니 호출이 끝난다.

 

참고: 프로토타입 체인에서 한 객체의 메소드와 속성들이 다른 객체로 복사되는 것이 아님을 재차 언급한다.

위에서 보다 시피 체인을 타고 올라가며 접근할 뿐이지 복사하는 것이 아니다!

 

프로토타입 속성: 상속 받은 멤버들이 정의된 곳

그럼 상속받은 속성과 메서드들은 어디에 정의되어 있을까?

Object를 보면 수 많은 속성과 메서드들이 나열되어 있는 것을 볼 수 있다.

 

person1이 상속받은 멤버들보다 훨씬 많이 존재한다. 일부는 상속되었지만 나머지는 아니다 이유는 무엇일까?

 

정답은 상속받는 멤버들은 prototype속성에 정의되어 있다.

즉 Object.로 시작하는게 아니라, Object.prototype.로 시작하는 것들이다.

prototype 속성도 하나의 객체이며 프로토타입 체인을 통해 상속하고자 하는 속성과 메서드를 담아두는 버킷으로 주로 사용되는 객체이다.

 

정리하면 object.prototype.watch(), object.prototype.valueOf() 등등은, 생성자를 통해 새로 생성되는 인스턴스는 물론 Object.prototype을 상속받는 객체라면 어떤 객체에서든지 접근할 수 있다.

반면에, Object.is(), Object.keys()등 prototype 버킷에 정의되지 않은 멤버들은 상속되지 않는다.

이것들은 Object() 생성자에서만 사용할 수 있는 멤버들이다.

 

create()

새 인스턴스를 생성하기 위해서 Object.create() 메서드를 사용한다.

 

아래 코드를 Javascript console.에서 실행했었다면

var person2 = Object.create(person1);

 

create() 메서드가 실제로 하는 일은 주어진 객체를 프로토타입 객체로 삼아 새로운 객체를 생성한다. 여기서 person2는 person1을 프로토타입 객체로 삼는다. 아래 코드를 실행하여 이를 확인할 수 있다.

 

person2.__proto__;

 

콘솔상에는 person1이 출력된다.

 

Constructor 속성

모든 생성자 함수는 constructor 속성을 지닌 객체를 프로토타입 객체로 가지고 있다.

이 constructor 속성은 원본 생성자 함수 자신을 가리키고 있다.

Person.prototype속성 ( 또는 위 절에서 언급된 아무 생성자 함수의 prototype 속성)에 정의된 속성들은 Person() 생성자로 생성된 모든 인스턴스에서 사용할 수 있다. 그러므로 person1과 person2에서도 constructor 속성에 접근할 수 있다.

 

예를들어 아래 코드를 콘솔에서 실행하면

person1.constructor;
person2.constructor;

 

두 구문 모두 Person() 생성자 함수를 반환할 것이다(어디에서 참조되었는지 알 수 있다).

constructor 속성에 괄호를 붙이고 실행하여(인자가 필요하면 전달한다) 새 인스턴스를 생성하는 트릭이 있다.

생성자도 함수의 일종이므로 괄호를 붙이면 실행할 수 있다. new 키워드를 통해 실행하면 함수를 인스턴스를 생성하기 위한 생성자로 사용할 수 있다.

 

아래 코드를 실행해보자

var person3 = new person1.constructor("Karen", "Stephenson", 26, "female", [
  "playing drums",
  "mountain climbing",
]);

 

새로 생성된 객체를 테스트

 

person3.name.first;
person3.age;
person3.bio();

 

잘 동작함을 알 수 있다. 이런 방식을 자주 사용할 필요는 없지만 실행 도중 명시적인 생성자 함수를 예측할 수 없는 상황에서 인스턴스를 생성해야 하거나 하는 경우 유용하게 사용할 수 있는 방법이다.

 

constructor 속성은 여러 사용법이 있다. 예를 들어 인스턴스의 생성자 이름이 필요한 경우 아래의 코드로 알 수 있다.

 

instanceName.constructor.name;

 

person1에 적용

person1.constructor.name;

 

참고 : constructor.name은 병경이 가능하므로 (상속이나 바인딩, 전처리, 트랜스 파일러 등에 의해) 복잡한 로직에 적용하기 위해서는 instanceof 연산자를 사용하는게 좋다.

 

instanceof 연산자

 

더보기

instanceof 연산자는 생성자의 prototype 속성이 객체의 프로토타입 체인 어딘가 존재하는지 판별한다.

 

구문

object instanceof constructor;

 

object - 판별할 객체

constructor - 판별 목표 함수

 

instanceof 연산자는 object의 프로토타입 체인에 constructor.prototype이 존재하는지 판별한다

 

// 생성자 정의
function C() {}
function D() {}

var o = new C();

// true, 왜냐하면 Object.getPrototypeOf(o) === C.prototype
o instanceof C;

// false, 왜냐하면 D.prototype이 o 객체의 프로토타입 체인에 없음
o instanceof D;

o instanceof Object; // true, 왜냐하면
C.prototype instanceof Object; // true

C.prototype = {};
var o2 = new C();

o2 instanceof C; // true

// false, 왜냐하면 C.prototype이
// 더 이상 o의 프로토타입 체인에 없음
o instanceof C;

D.prototype = new C(); // C를 D의 [[Prototype]] 링크로 추가
var o3 = new D();
o3 instanceof D; // true
o3 instanceof C; // true, 왜냐하면 이제 C.prototype이 o3의 프로토타입 체인에 존재

 

instanceof 의 값은 생성자 prototype 프로퍼티의 변화에 따라 바뀔수 있으며, Object.setPrototypeOf의 사용함에 따라서도 바뀔 수 있음에 주의하세요. 또한 non-standard의 __proto__ 슈도-프로퍼티도 사용할 수 있도록 만들어 준.

 

프로토타입 수정하기

생성자의 prototype 속성을 수정하는 법을 알아보겠다(프로토타입에 메서드를 추가하면 해당 생성자로 생성된 모든 객체에서 사용 가능하다).

 

예시코드

 

Person.prototype.farewell = function () {
  alert(this.name.first + " has left the building. Bye for now!");
};

 

Person.prototype.farewell에 함수를 할당하였다.

이렇게 되면 Person을 상속받은 객체들은 모든 .(__proto__).farewell에 접근할 수 있다.

 

person1.farewell();

 

생성자에서 지정했던 person의 name이 alert창으로 출력 될 것이다.

매우 유용한 기능이지만 중요한 점은 prototype에 새 메서드를 추가하는 순간 동일한 생성자로 모든 객체에서 추가된 메서드를 바로 사용할 수 있다는 점이다.

 

정리해보면. 예제에서 생성자르 정의하고, 객체를 생성하였으며, 그 이후에 프로토타입에 새 메서드를 추가하였다.

 

function Person(first, last, age, gender, interests) {
  // 속성과 메소드 정의
}

var person1 = new Person("Tammi", "Smith", 32, "neutral", [
  "music",
  "skiing",
  "kickboxing",
]);

Person.prototype.farewell = function () {
  alert(this.name.first + " has left the building. Bye for now!");
};

 

그런데도 person1에서 바로 farewell() 메서드를 사용할 수 있다. - 자동으로 업데이트 되기 때문이다(실제로는 프로토타입 객체는 모든 인스턴스에서 공유하기 때문에 정의하는 즉시 별도의 갱신 과정 없이 접근이 가능하다).

 

그런데 prototype에 속성을 정의하는 경우는 별로 본 적이 없다. 왜냐하면 별로 좋은 방법이 아니다.

프로퍼티를 추가할 때 아래와 같이 할 수 있다.

 

Person.prototype.fullName = "Bob Smith";

 

사람들이 항상 밥 스미스로 부르지 않을 수 있으니 이런 정적 대입은 별로 좋은 방법 같지는 않다.

fullname을 name.first와 name.last로 나누어 보겠다.

 

Person.prototype.fullName = this.name.first + " " + this.name.last;

 

이 경우 this는 함수 범위가 아닌 전역 범위를 가리키므로 코드가 의도대로 동작하지 않는다.

이대로 실행해도 undefined undefined만 볼 수 있다. 윗 절에서 프로토타입에 정의한 메소드 내에서는 정상적으로 동작하는 데 이는 코드가 함수 범위 내에 있으며 객체의 멤버 함수로써 동작하기에 객체 범위로 전환되었기 때문이다.

따라서 프로토타입에 상수를 정의하는 것은 가능하지만 일반적으로 생성자에서 정의하는것이 좋다.

 

일반적으로 프로퍼티는 생성자에서, 메서드는 프로토타입에서 정의한다.

생성자에는 프로퍼티에 대한 정의만 있으며 메서드는 별도의 블럭으로 구분할 수 있으니 코드를 읽기가 훨씬 쉬워진다.

 

// 생성자에서 속성 정의

function Test(a, b, c, d) {
  // 속성 정의
}

// 첫 메소드 정의

Test.prototype.x = function() { ... };

// 두번째 메소드 정의

Test.prototype.y = function() { ... };

// 그 외.

 

이렇게 프로토타입의 정의를 알아봤다.

 

그래서 왜 자바스크립트에서 프로토타입을 사용할까?

1. 생성자 함수를 통해 새로운 객체가 생성되면 객체 내의 property와 method가 매번 새롭게 생성된다. 이는 메모리 낭비로 이어진다. 반면 프로토타입을 사용하면 부모 객체 생성 시 한 번만 생성되게 되어 메모리를 절약할 수 있다.

 

2. 생성자 함수 내에 프로퍼티나 메서드를 함수 밖에서 수정할 수 없다. 하지만 프로토타입을 이용하면 외부에서 수정이 가능하다.

 

3.바로 특정 프로퍼티를 부모 객체에만 갖게 할 수 있기 때문이다.

 

자식은 그 프로퍼티를 갖고 있지 않지만, 여전히 부모 객체가 갖고 있는 프로토타입의 프로퍼티는 쓸 수 있다.

반면, 객체 내 프로퍼티를 직접 정의하면 부모뿐만 아니라 자식 객체에서도 같은 프로퍼티를 갖게된다.

 

배열 a에 대한 프로토타입을 한번 구상해 보겠다.

 

 

배열 a에 Array.prototype이 연결되고 Array.__proto__에 Object.prototype이 연결되는 프로토타입 체인이 이루어 진다.