Prototype 톺아보기

원민관·2025년 8월 7일

[TIL]

목록 보기
189/201
post-thumbnail

0. Overview ✍️

배포 전, 프로젝트의 주요 feature를 다이어그램으로 도식화하고 있었고, 그 과정에서 Passport.js의 소스 코드를 확인했습니다.

사실 모든 코드를 완벽하게 이해한 것은 아니었습니다. prototype, this, closure 등 자바스크립트의 근간을 이루는 개념들에 대한 이해가 다소 부족한 상태라고 판단했습니다.

이론적 배경에 집중할 좋은 기회를 얻었다고 생각하고, 오랜만에 '모던 자바스크립트 Deep Dive'를 꺼냈습니다. 오늘은 Prototype에 대한 이야기를 풀어볼까 합니다.

1. 객체지향 프로그래밍 ✍️

자바스크립트는 프로토타입 기반의 객체지향 프로그래밍 언어입니다. 프로토타입은 차치하더라도 우선 객체지향 프로그래밍이 무엇인지 알아야겠죠. 객체를 "지향한다"라고 하니, 객체가 무엇인지 살펴봐야겠습니다.

우리 선조들이 즐겨 하던 게임 '리그 오브 레전드'를 아시나요? 저는 게임을 그다지 좋아하지는 않습니다만, 사회생활을 위해 블리츠크랭크라는 챔피언(=캐릭터) 정도는 연습을 해놨습니다. 이제 이 깡통 녀석을 객체로 생성해 보겠습니다.

const blitz = {
  name: "블리츠크랭크",
  hp: 600,
  mana: 267,
  // Q - 로켓 손
  q(target) {
    this.mana -= 100;
    console.log(`Q! ${target}을(를) 끌어옵니다. (남은 마나: ${this.mana})`);
  },
  // W - 폭주
  w() {
    this.mana -= 75;
    console.log(`W! 폭주! 빨라집니다. (남은 마나: ${this.mana})`);
  },
  // E - 강철 주먹
  e(target) {
    this.mana -= 25;
    console.log(`E! ${target}을(를) 띄웁니다. (남은 마나: ${this.mana})`);
  },
  // R - 정전기장
  r() {
    this.mana -= 100;
    console.log(
      `R! 정전기장! 주변에 광역 피해와 침묵. (남은 마나: ${this.mana})`
    );
  },
};

블리츠크랭크의 기본적인 상태(hp, mana)와 동작(q, w, e, r)을 구현했습니다. 이처럼 상태(프로퍼티)와 동작(메서드)을 하나의 논리적인 단위로 묶어놓은 자료구조를 객체라고 합니다.

따라서 객체지향 프로그래밍은, 객체를 활용하는 방식으로 코딩을 하겠다는 것을 의미합니다.

2. 상속과 프로토타입 ✍️

객체를 활용한다는 것이 다소 추상적으로 느껴집니다.

상속(inheritance)은 객체 활용의 핵심입니다. 어떤 객체의 상태(프로퍼티)와 동작(메서드)을 다른 객체가 상속받아 그대로 사용할 수 있는 것을 말합니다. 이번에는 블리츠크랭크가 아니라 원을 만든다고 생각해 보죠.

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);

반지름을 전달하면 원을 생성할 수 있는 객체입니다. 그런데 위 코드는 문제가 있습니다.

원을 생성할 때마다 반지름은 다를 수 있지만, 반지름을 통해 원의 넓이를 구하는 공식은 어떤 원이든 동일하죠. 현재의 코드대로라면, 원을 생성할 때마다 getArea라는 메서드를 중복 생성하고 모든 원이 중복 소유하게 됩니다.

프로토타입(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);

프로토타입이라는 별도의 공간에 getArea를 분리해서 공용 로직처럼 사용하면, circle1이나 circle2 같은 인스턴스들이 getArea를 들고 있을 필요가 없어지므로, 불필요한 메모리 낭비를 막을 수 있습니다.

그렇다면, 프로토타입은 바뀌지 않는 공통된 특질을 저장한다는 점에서 유전자로 비유할 수 있겠네요. 이 비유가 상당히 중요합니다. 앞으로 전개될 내용을 전부 유전자로 빗대어 설명할 예정입니다.

3. 프로토타입 객체 ✍️

프로토타입의 개념을 이해했으니, 이제 구체적인 동작 방식을 살펴보겠습니다. 자바스크립트의 모든 객체는 프로토타입과 연결되어 있으며, 이 연결고리를 통해 상속을 구현합니다. 마치 모든 생명체가 유전자를 통해 부모의 특성을 물려받는 것처럼 말이죠.

3-1. 접근자 프로퍼티 __proto__ 🎯

모든 객체는 __proto__ 접근자 프로퍼티를 통해 자신의 프로토타입에 간접적으로 접근할 수 있습니다. 유전자라는 중요한 정보에 직접 접근할 수는 없고, __proto__라는 접근자 프로퍼티를 통해 간접적으로나마 접근할 수 있는 것이죠.

const person = { name: 'Lee' };

// person 객체는 __proto__ 프로퍼티를 소유합니다.
console.log(person.__proto__ === Object.prototype); // true

__proto__는 접근자 프로퍼티입니다. 접근자 프로퍼티란 getter/setter 함수로 구성된 프로퍼티로, 값을 가지지 않고 다른 데이터 프로퍼티의 값을 읽거나 저장할 때 사용합니다.

obj.__proto__ = parent;는 obj의 유전자에 parent의 값을 저장하는 코드입니다.

const obj = {};
const parent = { x: 1 };

// getter 함수인 get __proto__가 호출되어 obj 객체의 프로토타입을 취득
console.log(obj.__proto__);

// setter 함수인 set __proto__가 호출되어 obj 객체의 프로토타입을 교체
obj.__proto__ = parent;

console.log(obj.x); // 1

__proto__ 접근자 프로퍼티는 상호 참조에 의한 프로토타입 체인이 생성되는 것을 방지하기 위해 존재합니다.

유전자는 위에서 아래로, 단방향으로 전달되어야 합니다. 조금 멋지게 표현하면, 프로토타입 체인은 단방향 링크드 리스트로 구현되어야 한다고 표현할 수 있죠.

__proto__는 순환 참조가 발생하면 TypeError를 반환해 줍니다.

const parent = {};
const child = {};

// child의 프로토타입을 parent로 설정
child.__proto__ = parent;

// parent의 프로토타입을 child로 설정 (순환 참조)
parent.__proto__ = child; // TypeError: Cyclic __proto__ value

3-2. 함수 객체의 prototype 프로퍼티 🎯

함수 객체만이 소유하는 prototype 프로퍼티는 생성자 함수가 생성할 인스턴스의 프로토타입을 가리킵니다.

즉, 함수 객체의 prototype 프로퍼티는 자식이 갖고 있는 유전자를 가리킵니다.

// 함수 객체는 prototype 프로퍼티를 소유합니다.
(function () {}).hasOwnProperty('prototype'); // true

// 일반 객체는 prototype 프로퍼티를 소유하지 않습니다.
({}).hasOwnProperty('prototype'); // false

prototype 프로퍼티는 생성자 함수가 생성할 객체(인스턴스)의 프로토타입을 가리킵니다. me라는 자식의 유전자가 부모인 Person의 prototype과 일치한다는 점을 확인할 수 있습니다.

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

const me = new Person('Lee');

// me의 프로토타입은 Person.prototype입니다.
console.log(me.__proto__ === Person.prototype); // true

화살표 함수와 ES6 메서드 축약 표현으로 정의한 메서드는 non-constructor이므로 prototype 프로퍼티를 소유하지 않으며 프로토타입도 생성하지 않습니다. 마치 생식 능력이 없어 자손에게 유전자를 전달할 수 없는 개체와 같습니다.

// 화살표 함수는 non-constructor입니다.
const Person = name => {
  this.name = name;
};

// non-constructor는 prototype 프로퍼티를 소유하지 않습니다.
console.log(Person.hasOwnProperty('prototype')); // false

// non-constructor는 프로토타입을 생성하지 않습니다.
console.log(Person.prototype); // undefined

3-3. constructor 프로퍼티와 생성자 함수 🎯

모든 프로토타입은 constructor 프로퍼티를 갖습니다. constructor 프로퍼티는 prototype 프로퍼티로 자신을 참조하고 있는 생성자 함수를 가리킵니다.

모든 유전자에는 constructor라는 내용이 기록되어 있습니다. constructor는 자신의 유전자를 제공해 준 대상을 표현합니다. 한마디로 생성자와 생성자가 만든 객체를 연결하는 요소가 constructor 프로퍼티입니다.

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

const me = new Person('Lee');

// me 객체의 생성자 함수는 Person입니다.
console.log(me.constructor === Person); // true

이 연결은 함수 객체가 생성될 때 이뤄집니다. Person 생성자 함수는 me 객체를 생성했고, me 객체는 프로토타입의 constructor 프로퍼티를 통해 생성자 함수와 연결됩니다.

4. 리터럴 표기법에 의해 생성된 객체의 생성자 함수와 프로토타입 ✍️

생성자 함수에 의해 생성된 인스턴스는 프로토타입의 constructor 프로퍼티에 의해 생성자 함수와 연결됩니다. 그런데 리터럴 표기법에 의해 생성된 객체는 어떨까요? 리터럴로 만들어진 객체들은 누구의 유전자를 물려받을까요?

// 객체 리터럴
const obj = {};

// 함수 리터럴
const add = function (a, b) { return a + b; };

// 배열 리터럴
const arr = [1, 2, 3];

// 정규표현식 리터럴
const regexp = /is/ig;

리터럴 표기법에 의해 생성된 객체도 프로토타입이 존재합니다. 하지만 constructor 프로퍼티가 가리키는 생성자 함수가 반드시 객체를 생성한 생성자 함수라고 단정할 수는 없습니다. 마치 입양된 아이가 양부모의 유전자를 가지고 있다고 표시되는 것과 같죠.

// obj 객체는 Object 생성자 함수로 생성한 객체가 아니라 객체 리터럴로 생성했습니다.
const obj = {};

// 하지만 obj 객체의 생성자 함수는 Object 생성자 함수입니다.
console.log(obj.constructor === Object); // true

이는 Object 생성자 함수에 인수를 전달하지 않거나 undefined나 null을 인수로 전달하면 내부적으로 추상 연산 OrdinaryObjectCreate를 호출하여 Object.prototype을 프로토타입으로 갖는 빈 객체를 생성하기 때문입니다.

실제로는 다음과 같은 대응 관계가 성립합니다. 각 리터럴 표기법으로 만들어진 객체들은 해당하는 "종족"의 유전자를 물려받습니다:

리터럴 표기법생성자 함수프로토타입
객체 리터럴ObjectObject.prototype
함수 리터럴FunctionFunction.prototype
배열 리터럴ArrayArray.prototype
정규 표현식 리터럴RegExpRegExp.prototype

5. 프로토타입의 생성 시점 ✍️

프로토타입은 생성자 함수가 생성되는 시점에 더불어 생성됩니다. 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재하기 때문입니다. 유전자가 없는 사람은 존재할 수 없겠죠.

5-1. 사용자 정의 생성자 함수와 프로토타입 생성 시점 🎯

생성자 함수로서 호출할 수 있는 함수, 즉 constructor는 함수 정의가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성됩니다. 마치 새로운 종족이 탄생하는 순간 그 종족만의 유전자도 함께 만들어지는 것과 같습니다.

// 함수 정의(constructor)가 평가되어 함수 객체를 생성하는 시점에 프로토타입도 더불어 생성됩니다.
console.log(Person.prototype); // {constructor: ƒ}

// 생성자 함수
function Person(name) {
  this.name = name;
}

반면 non-constructor는 프로토타입이 생성되지 않습니다. 생식 능력이 없는 개체는 후손을 위한 유전자를 만들 필요가 없기 때문입니다.

// 화살표 함수는 non-constructor입니다.
const Person = name => {
  this.name = name;
};

// non-constructor는 프로토타입이 생성되지 않습니다.
console.log(Person.prototype); // undefined

5-2. 빌트인 생성자 함수와 프로토타입 생성 시점 🎯

Object, String, Number, Function, Array, RegExp, Date, Promise 등의 빌트인 생성자 함수도 일반 함수와 마찬가지로 빌트인 생성자 함수가 생성되는 시점에 프로토타입이 생성됩니다.

모든 빌트인 생성자 함수는 전역 객체가 생성되는 시점에 생성됩니다. 생성된 프로토타입은 빌트인 생성자 함수의 prototype 프로퍼티에 바인딩됩니다. 이는 마치 세상이 시작될 때 기본적인 종족들의 유전자가 미리 준비되는 것과 같습니다.

// 전역 객체 window가 생성되는 시점에 모든 빌트인 생성자 함수는 생성됩니다.
// 빌트인 생성자 함수인 Object도 전역 객체의 프로퍼티입니다.
console.log(window.Object === Object); // true

6. 객체 생성 방식과 프로토타입의 결정 ✍️

객체는 다양한 생성 방법이 있습니다. 객체 리터럴, Object 생성자 함수, 생성자 함수, Object.create 메서드, 클래스(ES6) 등이 있죠. 각각의 생성 방식은 서로 다른 "탄생 방법"이지만, 결국 모든 객체는 어떤 종족의 유전자를 물려받게 됩니다.

이렇게 다양한 방식으로 생성된 모든 객체는 추상 연산 OrdinaryObjectCreate에 의해 생성된다는 공통점이 있습니다. OrdinaryObjectCreate는 자신이 생성할 객체의 프로토타입을 인수로 전달받고, 자신이 생성할 객체에 추가할 프로퍼티 목록을 옵션으로 전달받습니다.

6-1. 객체 리터럴에 의해 생성된 객체의 프로토타입 🎯

자바스크립트 엔진은 객체 리터럴을 평가하여 객체를 생성할 때 OrdinaryObjectCreate를 호출합니다. 이때 Object.prototype을 프로토타입으로 전달합니다. 즉, 리터럴로 만들어진 모든 객체는 Object라는 원시 종족의 유전자를 물려받습니다.

const obj = { x: 1 };

// 객체 리터럴에 의해 생성된 obj 객체는 Object.prototype을 상속받습니다.
console.log(obj.constructor === Object); // true
console.log(obj.hasOwnProperty('x')); // true

객체 리터럴에 의해 생성된 obj 객체는 Object.prototype을 프로토타입으로 갖게 되므로, Object.prototype의 constructor 프로퍼티와 hasOwnProperty 메서드 등을 상속받아 사용할 수 있습니다.

6-2. Object 생성자 함수에 의해 생성된 객체의 프로토타입 🎯

Object 생성자 함수를 인수 없이 호출하면 빈 객체가 생성됩니다. Object 생성자 함수를 호출하면 객체 리터럴과 마찬가지로 OrdinaryObjectCreate가 호출됩니다. 이 방식도 마찬가지로 Object 종족의 유전자를 그대로 물려받습니다.

const obj = new Object();
obj.x = 1;

// Object 생성자 함수에 의해 생성된 obj 객체는 Object.prototype을 상속받습니다.
console.log(obj.constructor === Object); // true
console.log(obj.hasOwnProperty('x')); // true

객체 리터럴과 Object 생성자 함수에 의한 객체 생성 방식의 차이는 프로퍼티를 추가하는 방식에 있습니다. 객체 리터럴 방식은 객체 리터럴 내부에 프로퍼티를 추가하지만 Object 생성자 함수 방식은 일단 빈 객체를 생성한 이후 프로퍼티를 추가해야 합니다. 마치 태어날 때부터 특성을 가지고 태어나는 것과 태어난 후에 특성을 습득하는 것의 차이입니다.

6-3. 생성자 함수에 의해 생성된 객체의 프로토타입 🎯

new 연산자와 함께 생성자 함수를 호출하여 인스턴스를 생성하면 다른 객체 생성 방식과 마찬가지로 OrdinaryObjectCreate가 호출됩니다. 이때 생성자 함수의 prototype 프로퍼티에 바인딩되어 있는 객체가 프로토타입으로 전달됩니다. 즉, 해당 생성자 함수 종족의 고유한 유전자를 물려받게 됩니다.

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

const me = new Person('Lee');

// 생성자 함수에 의해 생성된 me 객체는 Person.prototype을 상속받습니다.
console.log(me.constructor === Person); // true

표준 빌트인 객체인 Object 생성자 함수와 더불어 생성되는 프로토타입 Object.prototype은 다양한 빌트인 메서드를 갖고 있습니다. 하지만 사용자 정의 생성자 함수 Person과 더불어 생성된 프로토타입 Person.prototype의 프로퍼티는 constructor뿐입니다. 마치 원시 종족 Object는 풍부한 유전 정보를 가지고 있지만, 새로 만든 Person 종족은 기본적인 유전 정보만 가지고 있는 것과 같습니다.

프로토타입은 객체이므로 일반 객체와 같이 프로토타입에도 프로퍼티를 추가/삭제할 수 있습니다. 그리고 이렇게 추가/삭제된 프로퍼티는 즉시 프로토타입 체인에 반영됩니다. 이는 종족의 유전자에 새로운 특성을 추가하거나 제거하면, 그 종족의 모든 후손들이 즉시 그 특성을 물려받거나 잃게 되는 것과 같습니다.

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

// 프로토타입 메서드
Person.prototype.sayHello = function () {
  console.log(`Hi! My name is ${this.name}`);
};

const me = new Person('Lee');
const you = new Person('Kim');

me.sayHello(); // Hi! My name is Lee
you.sayHello(); // Hi! My name is Kim

7. 프로토타입 체인 ✍️

프로토타입의 핵심인 프로토타입 체인에 대해 알아보겠습니다. 프로토타입 체인은 자바스크립트가 상속을 구현하는 메커니즘입니다. 마치 유전자의 계보를 따라 올라가며 조상의 특성을 찾는 과정과 같습니다.

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

// 프로토타입 메서드
Person.prototype.sayHello = function () {
  console.log(`Hi! My name is ${this.name}`);
};

const me = new Person('Lee');

// hasOwnProperty는 Object.prototype의 메서드입니다.
console.log(me.hasOwnProperty('name')); // true

Person 생성자 함수에 의해 생성된 me 객체는 Object.prototype의 메서드인 hasOwnProperty를 호출할 수 있습니다. 이는 me 객체가 Person.prototype뿐만 아니라 Object.prototype도 상속받았다는 것을 의미합니다. me는 Person 종족의 유전자를 가지고 있지만, 동시에 모든 종족의 원시 조상인 Object의 유전자도 물려받고 있는 것입니다.

me 객체의 프로토타입은 Person.prototype입니다.

console.log(me.__proto__ === Person.prototype); // true

Person.prototype의 프로토타입은 Object.prototype입니다. 프로토타입의 프로토타입은 언제나 Object.prototype입니다. 모든 종족의 유전자는 결국 Object라는 원시 종족으로 거슬러 올라갑니다.

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

이처럼 자바스크립트는 객체의 프로퍼티(메서드 포함)에 접근하려고 할 때 해당 객체에 접근하려는 프로퍼티가 없다면 [[Prototype]] 내부 슬롯의 참조를 따라 자신의 부모 역할을 하는 프로토타입의 프로퍼티를 순차적으로 검색합니다. 이를 프로토타입 체인이라 합니다.

프로토타입 체인의 동작 과정

me.hasOwnProperty('name');

위 코드가 실행될 때 자바스크립트 엔진은 다음과 같은 과정을 거칩니다. 마치 가족의 계보를 따라 올라가며 특정 특성이나 능력을 찾는 과정과 같습니다:

  1. me 객체에서 hasOwnProperty 메서드를 검색합니다. me 객체에는 hasOwnProperty 메서드가 없으므로 프로토타입 체인을 따라 [[Prototype]] 내부 슬롯에 바인딩되어 있는 프로토타입으로 이동하여 hasOwnProperty 메서드를 검색합니다. 첫 번째 단계에서는 자신의 직계 부모인 Person 종족의 유전자를 확인합니다.

  2. Person.prototype에서 hasOwnProperty 메서드를 검색합니다. Person.prototype에도 hasOwnProperty 메서드가 없으므로 프로토타입 체인을 따라 [[Prototype]] 내부 슬롯에 바인딩되어 있는 프로토타입으로 이동하여 hasOwnProperty 메서드를 검색합니다. Person 종족에서도 찾지 못했으니, 더 상위 조상인 Object 종족의 유전자로 올라갑니다.

  3. Object.prototype에서 hasOwnProperty 메서드를 검색합니다. Object.prototype에는 hasOwnProperty 메서드가 존재합니다. 자바스크립트 엔진은 Object.prototype.hasOwnProperty 메서드를 호출합니다. 이때 Object.prototype.hasOwnProperty 메서드의 this에는 me 객체가 바인딩됩니다. 드디어 원시 조상 Object의 유전자에서 찾고 있던 특성을 발견했습니다.

// call 메서드를 사용해 명시적으로 this를 바인딩해도 결과는 동일합니다.
Object.prototype.hasOwnProperty.call(me, 'name'); // true

프로토타입 체인의 최상위 객체

프로토타입 체인의 최상위에 위치하는 객체는 언제나 Object.prototype입니다. 따라서 모든 객체는 Object.prototype을 상속받습니다. Object.prototype을 프로토타입 체인의 종점(end of prototype chain)이라 합니다. 마치 모든 생명체의 원시 조상과 같은 존재입니다.

Object.prototype의 프로토타입, 즉 [[Prototype]] 내부 슬롯의 값은 null입니다. 모든 유전자의 최종 종점이며, 더 이상 올라갈 조상이 없음을 의미합니다.

console.log(Object.prototype.__proto__); // null

Object.prototype에서도 프로퍼티를 검색할 수 없는 경우 undefined를 반환합니다. 이때 에러가 발생하지 않는다는 것에 주의하세요. 모든 조상의 유전자를 뒤져봐도 해당 특성이 없다면, 그냥 "없다"고 조용히 알려주는 것입니다.

console.log(me.foo); // undefined

마무리 ✍️

프로토타입은 자바스크립트의 핵심 개념 중 하나입니다. 클래스 기반 언어에 익숙한 개발자들에게는 다소 생소할 수 있지만, 프로토타입을 이해하면 자바스크립트의 상속 메커니즘과 내부 동작을 깊이 이해할 수 있습니다.

프로토타입을 유전자로 비유하면, 모든 객체는 어떤 종족의 유전자를 물려받아 태어나고, 필요한 특성을 찾을 때는 자신의 유전자 계보를 따라 조상들의 특성까지 검색할 수 있습니다. 프로토타입 체인을 통해 모든 객체는 Object.prototype의 메서드를 사용할 수 있으며, 이는 자바스크립트의 강력함과 유연성을 보여주는 대표적인 예시입니다.

ES6의 클래스 문법이 도입되었지만, 이는 프로토타입 기반 상속의 syntactic sugar일 뿐이므로 프로토타입에 대한 이해는 여전히 중요합니다. 클래스 문법은 프로토타입이라는 유전자 시스템을 보다 친숙한 문법으로 포장한 것일 뿐, 내부적으로는 여전히 유전자 기반의 상속 시스템이 동작합니다.

다음에는 오버라이딩과 프로퍼티 섀도잉 / 프로토타입의 교체 / instanceof 연산자 / 직접 상속 / 정적 프로퍼티와 메서드 / 프로퍼티 존재 확인 / 프로퍼티 열거 등 프로토타입에 관련된 추가적인 주제에 관해 다루도록 하겠습니다.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글