[모자딥 스터디] 19~21장

Seungrok Yoon (Lethe)·2023년 12월 13일
0

19장 - 프로토타입

프로토타입은 자바스크립트 언어의 근간을 다루는 챕터라 분량도 많고, 어렵기도 하다. 하지만, 프로토타입을 제대로 이해만 하고 있다면, 자바스크립트를 사용하면서 떠오르는 궁금증을 명쾌하게 해소해줄 것이다.

프로토타입...왜 사용하나?

function Circle(radius) {
  this.radius = radius;
  this.getArea = function () {
    return Math.PI * this.radius ** 2;
  };
}

const circle1 = new Circle(1);
const circle2 = new Circle(2);

console.log(circle1.getArea === circle2.getArea); //false

위 코드를 살펴보자. 두 개의 Circle 인스턴스가 생성이 되었고, 각 인스턴스의 메서드는 다른 함수객체임을 확인하는 코드이다.

생성자함수로 new 연산자를 통해 인스턴스를 생성한 상황을 도식화하면 다음과 같다.

도식을 살펴보면, 두 인스턴스 객체는 radius 프로퍼티와 getArea메서드를 가지고 있다. 그런데 getArea 메서드는 인스턴스 간에 로직이 동일하다. 인스턴스가 생성될 때마다, getArea 메서드도 매번 다시 생성될 것이다.

하지만 이는 동일한 로직을 지닌 함수를 인스턴스 생성마다 중복으로 소유하는 것을 의미한다. 중복된 메서드들은 메모리를 불필요하게 점유하게 된다.

그렇다면 어떻게 인스턴스의 기능을 그대로 유지하면서 인스턴스가 차지하는 메모리는 줄일 수 있을까?

자바스크립트는 프로토타입 기반의 객체지향 프로그래밍 언어이다. 객체지향에서의 상속 또한 프로토타입(정확하게는 프로토타입체인)메커니즘으로 구현되어 있다. 그렇다면 프로토타입 상속을 통해 위 문제를 해결해보자.

코드를 조금 바꿔보도록 하겠다.

function Circle(radius) {
  this.radius = radius;
}

Circle.prototype.getArea = function () {
  return Math.PI * this.radius ** 2;
};

const circle1 = new Circle(1);
const circle2 = new Circle(2);

console.log(circle1.getArea === circle2.getArea); //true

어라, 이전과는 다르게 두 인스턴스의 메서드가 동일한 참조값을 가짐에 주목하자. getArea함수는 인스턴스에 귀속된 메서드가 아니라, 어디선가 다른 객체에 소속된 메서드가 되었다. 이를 도식으로 살펴보면 다음과 같다.

Circle 생성자함수가 생성한 인스턴스들은 생성자 함수 내부의 prototype객체를 상속받게 되어 prototype의 프로퍼티나 메서드를 공통적으로 사용할 수 있게 된다.

프로토타입 객체

프로토타입은 어떤 객체의 상위(부모)객체의 역할을 하는 객체이다.

  • 모든 객체는 [[Prototype]]이라는 내부 슬롯을 가지고 있다. (null인 경우도 있음)
  • 모든 프로토타입은 생성자 함수와 연결되어 있다.
  • 내부 슬롯 [[Prototype]]에 저장되는 프로토타입은 객체 생성방식에 의해 결정된다!!!!!!!!!!!
function Circle(radius) {
  this.radius = radius;
}

Circle.prototype.getArea = function () {
  return Math.PI * this.radius ** 2;
};

const circle1 = new Circle(1);
const circle2 = new Circle(2);

console.log(circle1.getArea === circle2.getArea); //true
console.log(Object.getPrototypeOf(circle1)); //{ getArea: [Function (anonymous)] }
console.log(circle1.__proto__); //{ getArea: [Function (anonymous)] }

어떤 객체의 프로토타입은, 해당 객체 인스턴스를 생성한 생성자함수의 .prototype 프로퍼티가 가리키는 객체와 동일하다. 객체 기준에서는 __proto__ 라는 접근자 프로퍼티(get, set)으로 프로토타입 객체에 접근이 가능하다.

그렇다면 위 코드를 프로토타입 개념이 잘 드러나도록 맞게 재구성해보자. 사실은 아래 도식 모양으로 객체 생성과 상속이 이루어지고 있었던 것이다.

정리하자면, 생성자함수의 prototype 프로퍼티에는 프로토타입 객체(생성자함수.prototype)가 바인딩이 되어있고, 인스턴스는 이 프로토타입객체를 상위 객체(부모)로 삼으며, __proto__접근자로 간접 접근이 가능하다.(참고로, 모든 객체는 종국적으로는 Object.prototype을 상속하고 있으니, Object.prototype.__proto__를 사용 가능한것이다.)

프로토타입객체(생성자함수.prototype에 바인딩된 객체)는 constructor프로퍼티를 통해서 생성자함수에 접근할 수 있다.

중요하니 다시 언급한다. 생성자함수는 .prototype프로퍼티를 통해 프로토타입객체에 접근할 수 있고, 또 프로토타입 객체는 constructor 프로퍼티를 통해 생성자함수에 접근할 수 있다! => 이 부분이 내가 프로토타입을 이해하는 데 있어서 가장 헷갈리는 부분이었다ㅜㅜ.

직전의 도식과 같긴한데, 내가 조금 더 잘 이해할 수 있는 방식으로 도식을 재구성해보면 아래와 같을 것이다.

그래서 아래 코드도 제대로 동작한다.

function Circle(radius) {
  this.radius = radius;
}

Circle.prototype.getArea = function () {
  return Math.PI * this.radius ** 2;
};

const circle3 = new Circle.prototype.constructor(4); // 제대로 작동한다.
console.log(circle3.getArea());

왜 접근자프로퍼티를 사용해서 프로토타입에 접근해야할까?

객체들의 프로토타입은 단방향 링크드 리스트로 구현이 되어야 한다. 하지만 프로토타입을 아무런 체크없이 설정하게 되면 상호참조같은 단방향리스트가 깨지는 불상사가 발생할수도 있다. __proto__접근자는 임의의 상호참조를 만나면 에러를 발생시킴으로써, 객체 간 프로토타입체인을 정상적으로 유지하는 데 도움을 준다.

그렇지만 접근자프로퍼티를 사용하는 것은 권장하지 않는다.

모든 객체가 __proto__ 접근자 프로퍼티를 사용할 수 있는 것이 아니기 때문이다.

const obj = Object.create(null)
console.log(obj.__proto__) //undefined
console.log(Object.getPrototypeOf(obj)) //null

위 코드처럼 프로토타입체인의 종점에 있는 객체는 Object.__proto__를 상속받을 수 없다. 만약 객체의 프로토타입에 접근하고싶다면, Object.getPrototypeOf 메서드를 사용하고, 프로토타입 교체를 원한다면, Object.setPrototypeOf 메서드를 통해 프로토타입을 교체한다.

함수객체의 prototype 프로퍼티

오로지 함수객체만이 .protytpe 프로퍼티를 소유하고 있다. 함수의 prototype프로퍼티는 생성자함수가 생성할 인스턴스의 프로토타입을 가리킨다.

function Circle(radius) {
  this.radius = radius;
}

Circle.prototype.getArea = function () {
  return Math.PI * this.radius ** 2;
};

const circle1 = new Circle(1);
const circle2 = new Circle(2);

console.log(Circle.prototype === Object.getPrototypeOf(circle1)); //true
console.log(Circle.prototype === circle1.__proto__); //true

리터럴 표기법에 의해 생성된 객체의 생성자함수

앞에서 설명한 프로토타입 도식은 모두 생성자 함수를 활용하여 객체를 생성할 때와 관련된 도식들이었다. 그렇다면 new 연산자를 사용하지 않고, 리터럴로 객체를 생성하는 경우에는 어떨까? 객체 리터럴은 Object 생성자함수가 new연산자를 통해 생성한 객체와 같은 녀석일까?

답부터 말하자면, 아니다.

Object생성자 함수 호출과 객체 리터럴의 평가는 추상연산OrdinaryObjectCreate 호출로 빈 객체를 생성하는 점에서는 동일하지만, 세부 구현이 다르다. 즉 다른 녀석이다!

그치만 리터럴 표기법에 의해 생성된 객체도 상속은 해야한다. 그러니 프로토타입이 필요하긴 하다. 리터럴 객체도 가상적인 생성자 함수를 가진다. 프로토타입은 생성자 함수와 더불어 생성되고, 생성자함수의 prototype프로퍼티, 프로토타입의 constructor 프로퍼티에 의해 연결되어 있는 모양새를 가지고 있기 때문이다. 프로토타입과 생성자함수는 언제나 쌍으로 존재한다.

엄밀히 이야기해서 생성자함수를 통해 생성된 객체와 리터럴 표기법으로 생성된 객체는 세부적으로 보면 다른 종류의 객체이지만, 본질적으로는 큰 차이가 없다. 그래서 (1)프로토타입의 constructor 프로퍼티를 통해 연결되어있는 생성자 함수와 (2) 리터럴 객체를 생성한 생성자함수를 동일하게 간주해도 무관하다.

프로토타입의 생성 시점

내부적으로 [[Constructor]]내부 메서드를 가지고 있는 함수 객체(일반 함수 선언문, 함수 표현식)는 new연산자와 함께 생성자 함수로서 호출할 수 있다.

함수 정의가 평가되어 함수 객체를 생성하는 시점에, 프로토타입도 더불어 생성된다.
함수 선언문은 런타임 이전에 자바스크립트 엔진에 의해 먼저 실행된다. 따라서 함수가 실행되기 이전에 프로토타입은 이미 생성이 되어 있는 상태이다.

console.log(Person.prototype);

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

사용자 정의 생성자함수의 프로토타입

아까 예시로 들었던 Circle은 빌트인 생성자함수가 아니라, 사용자 정의 생성자 함수이다. 사용자 정의 생성자 함수의 프로토타입의 프로토타입은 언제나 Object.prototype이다.

빌트인 생성자함수의 프로토타입

Object, String, Number, Function, Array, RegExp 등등의 빌트인 생성자함수는 전역객체가 생성되는 시점에 생성된다.

전역객체는 코드가 실행되기 이전 단계에 자바스크립트 엔진에 의해 생성되는 특수한 객체이다.

빌트인 객체가 생성되기 이전에 빌트인 생성자함수와 빌트인 생성자함수의 프로토타입은 이미 객체화되어 존재한다

프로토타입 체인

자바스크립트는 객체의 프로퍼티(메서드)에 접근하려고 할 때, 해당 객체에 접근하려는 프로퍼티가 없다면 [[Prototype]]내부 슬롯의 참조를 따라 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 거슬러 올라가 순차적으로 검색한다. 이를 프로토타입체인이라 한다. 프로토타입 체인은 자바스크립트가 객체지향 프로그래밍의 상속을 구현하는 메커니즘이댜.

프로토타입의 프로토타입은 언제나 Object.prototype이다.

참고: Inheritance and Inheritance and the prototype chain - MDN

20장

21장

profile
안녕하세요 개발자 윤승록입니다. 내 성장을 가시적으로 기록하기 위해 블로그를 운영중입니다.

0개의 댓글