JavaScript 의 Prototype 에 대해서

💡 JavaScript 특징 중 하나인 Prototype 에 대한 지식을 정리합니다.

1. 객체와 Prototype

Javascript에서는 객체를 상속하기 위하여 프로토타입이라는 방식을 사용합니다 -MDN 설명 중-

Java나 Python과 같은 프로그래밍 언어에서 ‘객체’를 ‘상속’하기 위해서 Class를 사용합니다. 일반적으로 Class에 대해서 배우게 되면 필드와 메서드로 구성된다고 이야기합니다. 여기서 필드와 메서드는 각각 Class가 가질 수 있는 값(변수)과 행동(함수)에 해당합니다.


사람을 클래스로 나타낸다고 했을 때 필드에는 이름, 나이가 있고 메서드에는 먹다, 자다가 있음 사람을 간단하게 클래스로 나타낸다면

하지만 Javascript는 객체를 상속하기 위해 Prototype을 사용합니다.

… 상속되는 속성과 메서드들은 각 객체가 아니라 객체의 생성자의 prototype 이라는 속성에 정의되어 있습니다.

그렇다면 객체 생성자는 무엇일까요?

객체 생성자는 객체를 생성할 때 사용하는 함수로 객체 내부의 필드를 초기화할 때 호출합니다.

일단 JavaScript에서 객체를 생성하는 방법부터 천천히 알아보겠습니다.

1) Literal 방식

위의 예시에서 들었던 사람 클래스를 Literal 방식으로 생성한다면 다음과 같습니다.

const 사람 = {};
사람.나이 = 25;
사람.이름 = '홍길동';
사람.먹다 = () => console.log("먹다");
사람.자다 = () => console.log("자다");

{} 를 사용하여 객체를 생성하고 나서 내부에 필요한 속성을 하나씩 선언해줍니다. 가장 간단하게 객체를 생성할 수 있는 방법입니다.

2) 생성자 함수를 사용하는 방식

같은 내용에 대해서 new Object()로 생성해보겠습니다.

const 사람 = new Object();
사람.나이 = 25;
사람.이름 = '홍길동';
사람.먹다 = () => console.log("먹다");
사람.자다 = () => console.log("자다");

{}로 객체를 선언했던 부분을 new Object()로 변경만 했습니다. 정확하게 동일하게 작동합니다.

여기서 Object는 무엇일까요?


Object를 console.log()로 확인해본 결과 Object()가 출력됨

Object는 함수입니다. Object라는 함수와 new라는 키워드가 만나서 객체를 생성하고 있습니다.

따라서 여기서 Object는 객체를 생성하는 생성자 함수로 사용된 것입니다.

JavaScript에서는 다음과 같이 일반 함수를 사용하여 객체를 생성하는 방법도 있습니다.

function 사람 () {
  this.나이 = 25;
  this.이름 = '홍길동';
  this.먹다 = () => console.log("먹다");
  this.자다 = () => console.log("자다");
}

const 나 = new 사람();


'사람'이라는 함수로 '나'라는 객체를 생성하는 경우를 나타낸 그림

앞서 Object가 함수이고 new 키워드와 함께 쓰였던 것처럼 사람이라는 함수를 정의하여 객체를 생성할 수 있습니다.

즉, 위의 사람함수와 같이 객체를 생성할 때 사용된 함수를 객체 생성자가 됩니다.

이제 객체 생성자를 파악했으니 MDN에서 말하고 있는 것처럼 생성자의 프로토타입을 확인해보겠습니다.

프로토타입에 접근하기 위해서 getPrototypeOf 메서드를 사용하겠습니다.


'나'라는 객체의 프로토타입을 확인해보면 constructor에 '사람' 함수가 있음

라는 인스턴스를 생성할 때 사용했던 사람이라는 함수가 prototype으로 나옵니다.

따라서 가 상속받는 속성과 메서드들은 사람이라는 함수로부터 왔다는 것을 알 수 있습니다.


2. Prototype Chain으로

위에서 사람 함수를 생성하자마자 콘솔을 찍어보면 바로 내부에 Prototype이 생성되어있는 것을 확인할 수 있습니다.


console.dir()로 '사람' 함수의 프로토타입을 확인

앞서 그림에서 함수를 선언하고 바로 객체를 생성하는 것이 아니라 표현되지 않은 과정이 하나 숨어있습니다.


'사람' 함수를 선언하면 프로토타입이 자동으로 생성되는 것을 표현한 그림

함수는 선언되자마자 해당 함수의 Prototype을 생성합니다. 또한 해당 함수를 생성자 함수로 하여 객체를 생성하게 되면 Prototype을 참조하여 속성들을 이어받게 됩니다.

이처럼 함수와 함께 생성된 프로토타입을 Prototype Property 라고 부르고 다음과 같이 접근합니다.

EX) 사람.prototype

한편, 인스턴스에서 확인할 수 있는 원본 객체의 프로토타입을 Prototype Link 라고 부르고 __proto__ 로 접근할 수 있습니다.

EX) 나.__proto__

JavaScript는 속성값을 읽을 때 해당 값이 존재하지 않을 경우, 상위 Prototype에서 동일한 이름의 속성을 찾는 방식으로 동작합니다. 이러한 참조 관계를 Prototype Chain이라고 합니다. 만약 라는 객체에서 완전 엉뚱한 메서드를 호출하면 어떻게 될까요? 나.놀기를 호출한 결과는 다음과 같이 undefined 로 나오게 됩니다.


객체 '나'에서 '놀기'라는 메서드에 접근하면 undefined를 출력

왜냐하면 놀기라는 필드나 메서드가 에 존재하지 않기 때문입니다. 다음에는 hasOwnProperty 를 호출해본 결과입니다.


객체 '나'에서 'hasOwnProperty'라는 메서드에 접근하면 함수가 출력

콘솔창에 나온 것처럼 함수가 존재하는 것을 확인할 수 있습니다. 이는 hasOwnProperty라는 값이 Object 객체에 정의되어 있기 때문입니다. 메서드의 호출은 나라는 객체에서 진행되었지만 해당 속성이 객체 내부에 존재하지 않았기 때문에 상위 Prototype에 동일한 속성에 접근하게 됩니다. 하지만 사람이라는 함수에도 해당 부분이 존재하지 않기 떄문에 그의 상위 Prototype인 Object에 있는 값을 사용하게 됩니다.

다시 놀기 라는 키값으로 접근했을 때를 살펴보면, 해당 값은 상위로 계속 올라가도 존재하지 않기 때문에 Object의 상위 Prototype인 null 값에 이르러서 undefined 를 내보냅니다. 두 상황을 그림으로 간단하게 표현해보면 다음과 같습니다.


Prototype Chain을 나타낸 그림 상위 Prototype으로 계속 올라가면서 값이 존재하는지 확인합니다

이러한 Prototype Chain 덕분에 객체들이 다른 객체의 값을 상속받을 수 있습니다.


3. Prototype 과 Class 의 상속

ES6 에서 추가된 Class 문법으로 객체를 생성하면 다음과 같습니다.

class 사람 {

  constructor (이름, 나이) {
    this.이름 = 이름;
    this.나이 = 나이;
  }

  먹다 = () => console.log("먹다");
  자다 = () => console.log("자다");
}

이제 새로운 하위 Class를 만들어서 사람 Class를 상속해보겠습니다.

class 학생 extends 사람 {
  놀기 = () => console.log("놀기");
}

const 나 = new 학생('홍길동', 25);
console.log(나); 
// 학생 {이름: '홍길동', 나이: 25, 먹다: ƒ, 자다: ƒ, 놀기: ƒ}

사람을 extends 한 학생 클래스를 사용하면 위와 같이 두 클래스 내부에 있는 모든 필드와 메서드를 가진 객체를 생성할 수 있습니다.

흔히들 Class 문법을 Synthetic Sugar 라고 부르는 이유는 이러한 동작을 Prototype 과 Object 내장 함수인 create를 사용하여 동일하게 수행할 수 있기 때문입니다.

function 사람 (이름, 나이) {
  this.이름 = 이름;
  this.나이 = 나이;

  this.먹다 = () => console.log("먹다");
  this.자다 = () => console.log("자다");
}

function 학생 (이름, 나이) {
  사람.call(this, 이름, 나이); // 1
}

학생.prototype = Object.create(사람.prototype); // 2
학생.prototype.constructor = 학생; // 3
학생.prototype.놀기 = function () { // 4
  console.log("놀기");
}

한 줄 씩 설명해보면,

  1. Function의 메서드인 call를 통해 실행 컨텍스트를 학생 함수로 변경하고 사람 함수를 호출합니다. 즉, 학생 객체를 생성하기 위해 상속하고자 하는 부모 클래스(혹은 함수)의 생성자 함수를 호출합니다.
  2. 사람의 prototype을 갖는 새로운 객체를 학생 prototype에 저장합니다. 학생 클래스가 사람 클래스의 필드와 메서드 정보를 알 수 있게 합니다.
  3. 2에서 prototype 을 몽땅 복제했기 때문에 생성자 함수까지 사람 함수를 가리키는 상태이므로 학생 함수를 다시 생성자 함수로 지정합니다.
  4. 학생 함수만의 새로운 메서드를 prototype에 저장합니다.

다시 그림으로 살펴보겠습니다.


'사람'을 extends한 '학생'이 객체를 생성할 때 내부적으로 일어나는 동작을 표현한 그림


4. 맺으며

Class 키워드를 사용하지 않을 경우 같은 기능을 만들기 위해서 꽤 많은 코드를 작성해야 하는 것을 알 수 있습니다. 객체 상속 시 불필요하게 반복되는 코드가 많이 발생할 수 있기 때문에 Class 문법을 사용하는 것이 좋은 선택이 될 것이라 생각합니다. 다만, 내부적으로 Prototype으로 인해 동일하게 작동한다는 것과 prototype 간의 참조가 어떻게 이루어지고 있는지 아는 것도 JavaScript를 이해하는 데에 도움이 된다고 생각합니다.

한편, 이러한 개념들을 이해하기 위해서는 단편적으로 Prototype에 대해서만 학습하기보다는 실행 컨텍스트와 this와 같은 주변 지식도 함께 필요하다는 것을 느꼈습니다. 물론 각각의 내용들이 방대하기 때문에 추후에 이어서 정리해보도록 하겠습니다.


[참고]