Published on

프로토타입 체인의 동작 원리

Authors
  • avatar
    Name
    GW

개요

JavaScript를 공부하다 보면 한 번쯤 이런 코드를 보게 됩니다.

const person = {
  name: '철수',
};

console.log(person.toString());

person 객체에는 toString()을 정의한 적이 없습니다.

그런데도 에러가 발생하지 않고 정상적으로 실행됩니다.

그렇다면 toString()은 어디에서 온 걸까요?

또 배열에서는 map()이나 filter()를 사용할 수 있고, 문자열에서는 toUpperCase()를 사용할 수 있는 이유는 무엇일까요?

이 모든 것은 프로토타입(Prototype) 과 프로토타입 체인(Prototype Chain) 덕분입니다.

이번 글에서는 다음 내용을 살펴보겠습니다.

  • 프로토타입이 무엇인지
  • JavaScript는 프로퍼티를 어떤 순서로 탐색하는지
  • 프로토타입 체인은 어떻게 동작하는지

프로토타입은 왜 필요할까?

먼저 다음 코드를 살펴보겠습니다.

const person1 = {
  name: '철수',

  sayHello() {
    console.log(`안녕하세요. ${this.name}입니다.`);
  },
};

const person2 = {
  name: '영희',

  sayHello() {
    console.log(`안녕하세요. ${this.name}입니다.`);
  },
};

두 객체는 이름만 다를 뿐 sayHello()는 완전히 같은 함수입니다.

객체가 수백 개, 수천 개가 된다면 같은 함수를 계속 생성해야 할까요?

그렇게 되면

  • 메모리를 불필요하게 많이 사용하게 되고
  • 같은 함수를 계속 복사해야 하며
  • 유지보수도 어려워집니다.

그래서 JavaScript는 여러 객체가 하나의 메서드를 공유할 수 있는 구조를 만들었습니다.

바로 프로토타입입니다.

프로토타입이란?

프로토타입은 여러 객체가 공통으로 사용할 프로퍼티와 메서드를 저장하는 객체입니다.

예를 들어 생성자 함수를 이용하면 다음과 같이 작성할 수 있습니다.

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

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

const person1 = new Person('철수');
const person2 = new Person('영희');

person1.sayHello();
person2.sayHello();

겉으로 보기에는 두 객체 모두 sayHello()를 가지고 있는 것처럼 보입니다.

하지만 실제로는 그렇지 않습니다.

메모리 구조를 단순하게 표현하면 다음과 같습니다.

   person1               person2
      │                     │
      ▼                     ▼
{ name: '철수' }      { name: '영희' }
      │                     │
      └────────┐ ┌──────────┘
               ▼ ▼
         Person.prototype
          { sayHello() }

person1과 person2는 각각 별도의 객체이지만, 둘 다 같은 Person.prototype을 참조합니다.

따라서 sayHello() 함수는 하나만 생성되고, 여러 객체가 함께 사용할 수 있습니다.

즉, 같은 함수를 여러 번 생성하지 않아도 되므로 메모리를 효율적으로 사용할 수 있습니다.

객체는 프로토타입과 어떻게 연결될까?

앞에서 person1과 person2가 같은 Person.prototype을 참조하여 메서드를 공유한다는 것을 살펴봤습니다.

그렇다면 각각의 객체는 자신의 프로토타입과 어떻게 연결되어 있을까요?

JavaScript의 객체는 자신의 프로토타입을 가리키는 내부적인 연결을 가지고 있습니다.

이 연결은 [[Prototype]]이라는 내부 슬롯(Internal Slot) 을 통해 관리됩니다.

person
↓
[[Prototype]]
↓
person의 프로토타입 객체

[[Prototype]]은 일반적인 프로퍼티처럼 직접 사용하는 값이 아니라, JavaScript 엔진이 객체의 프로토타입을 연결하기 위해 내부적으로 사용하는 슬롯입니다.

그렇다면 new Person()으로 생성한 person 객체의 [[Prototype]]은 구체적으로 어떤 객체를 가리킬까요?

[[Prototype]]은 어디를 가리킬까?

그렇다면 person 객체의 부모는 누구일까요?

답은 바로 Person.prototype 입니다.

즉, 객체가 생성되면 JavaScript는 다음과 같은 관계를 자동으로 만들어 줍니다.

person
↓
[[Prototype]]
↓
Person.prototype

그래서 person 객체에서 원하는 프로퍼티나 메서드를 찾지 못하면 JavaScript는 [[Prototype]]을 따라 Person.prototype을 탐색하게 됩니다.

이것이 바로 프로토타입 체인이 시작되는 지점입니다.

prototype과 [[Prototype]]은 무엇이 다를까?

여기까지 이해했다면 이런 궁금증이 생길 수 있습니다.

Person.prototype은 어디에서 온 걸까?

prototype은 생성자 함수가 가지고 있는 프로퍼티입니다.

const Person = function () {};

console.log(Person.prototype);

Person 함수는 prototype이라는 프로퍼티를 가지고 있으며, 이 프로퍼티는 프로토타입으로 사용될 객체를 가리킵니다.

Person 함수
└─ prototype → 프로토타입 객체

따라서 Person.prototype은 Person 함수의 prototype 프로퍼티가 가리키는 객체를 의미합니다.

그리고 new Person()으로 객체를 생성하면, 생성된 객체의 [[Prototype]]이 이 Person.prototype을 가리키게 됩니다.

const person = new Person();
Person 함수
└─ prototype → Person.prototype
                   ↑
                   │ [[Prototype]]
                   │
                 person

즉, prototype과 [[Prototype]]은 모두 Person.prototype과 관련되어 있지만 서로 다른 역할을 합니다.

정리하면 prototype과 [[Prototype]]은 이름은 비슷하지만 역할이 다릅니다.

구분prototype[[Prototype]]
형태생성자 함수가 가진 프로퍼티객체가 가진 내부 슬롯
역할인스턴스의 프로토타입으로 사용될 객체를 가리킴객체의 프로토타입을 가리킴
예시Person.prototypeperson의 [[Prototype]]

Person 함수의 prototype 프로퍼티는 Person.prototype 객체를 가리키고, person 객체의 [[Prototype]] 역시 같은 Person.prototype 객체를 가리킵니다.

따라서 각각의 역할은 다음과 같이 정리할 수 있습니다.

  • Person.prototype: Person 함수의 prototype 프로퍼티가 가리키는 프로토타입 객체
  • person의 [[Prototype]]: person이 자신의 프로토타입 객체를 참조하기 위한 내부 슬롯

즉, prototype과 [[Prototype]]은 서로 다른 개념이지만 Person.prototype이라는 같은 객체를 기준으로 연결됩니다.

참고로 객체의 프로토타입을 설명할 때 __proto__라는 표현도 자주 볼 수 있습니다.

const person = new Person();

console.log(person.__proto__);

__proto__와 [[Prototype]]은 완전히 별개의 프로토타입을 의미하는 것이 아닙니다.

실제로 객체가 가지고 있는 것은 [[Prototype]]이라는 내부 슬롯이며, __proto__는 이 [[Prototype]]에 접근할 수 있도록 제공되는 접근자 프로퍼티입니다.

관계를 단순하게 표현하면 다음과 같습니다.

person
│
├─ [[Prototype]] → Person.prototype
│
└─ __proto__ → [[Prototype]]에 접근하기 위한 접근자

따라서 프로토타입의 내부 동작을 이해할 때는 __proto__보다는 [[Prototype]]을 기준으로 이해하는 것이 더 정확합니다.

프로토타입 체인은 어떻게 동작할까?

이제 처음에 봤던 궁금증으로 돌아가 보겠습니다.

const person = new Person('철수');

person.sayHello();

person 객체에는 sayHello가 존재하지 않습니다.

그런데도 정상적으로 실행되는 이유는 JavaScript가 프로토타입 체인을 따라 프로퍼티를 탐색하기 때문입니다.

탐색 순서는 다음과 같습니다.

person
↓
Person.prototype
↓
Object.prototype
↓
null

먼저 person 객체에서 sayHello를 찾습니다.

없다면 Person.prototype으로 이동합니다.

여기에서 sayHello를 발견하면 탐색을 멈추고 해당 함수를 실행합니다.

person
↓
sayHello 탐색
↓
찾지 못함
↓
[[Prototype]]을 따라 이동
↓
Person.prototype
↓
sayHello 발견
↓
실행

이처럼 JavaScript는 필요한 프로퍼티를 찾을 때 자신의 객체만 확인하는 것이 아니라,

프로토타입을 따라 차례대로 탐색하는 구조를 가지고 있습니다.

Object.prototype까지 탐색하는 이유

앞에서 JavaScript는 객체에서 원하는 프로퍼티를 찾지 못하면 프로토타입 체인을 따라 탐색한다고 설명했습니다.

그렇다면 프로토타입 체인의 마지막에 등장하는 Object.prototype은 무엇일까요?

다음 코드를 살펴보겠습니다.

const person = {
  name: '철수',
};

console.log(person.toString());

person 객체에는 toString()이 정의되어 있지 않습니다.

하지만 객체 리터럴 {}로 만든 객체의 [[Prototype]]은 기본적으로 Object.prototype을 가리킵니다.

person
↓ [[Prototype]]
Object.prototype
↓ [[Prototype]]
null

따라서 person에서 toString()을 찾지 못하면 [[Prototype]]을 따라 Object.prototype에서 다시 탐색합니다.

person
└─ toString 없음
      ↓
[[Prototype]]을 따라 이동
      ↓
Object.prototype
└─ toString 발견
      ↓
     실행

Object.prototype에는 toString()이 정의되어 있기 때문에 person에서도 이 메서드를 사용할 수 있습니다.

참고로 객체를 생성하는 방법에 따라 중간의 프로토타입 체인은 달라질 수 있습니다.

객체 리터럴로 생성하면 프로토타입 체인은 아래와 같이 연결됩니다.

person
↓
Object.prototype
↓
null

반면 new Person()으로 생성하면 프로토타입 체인은 다음과 같이 연결됩니다.

person
↓
Person.prototype
↓
Object.prototype
↓
null

new Person()으로 만든 객체는 중간에 Person.prototype을 거치지만, 결국 일반적인 프로토타입 체인은 Object.prototype까지 이어집니다.

그래서 객체에 직접 정의하지 않아도 Object.prototype에 정의된 toString(), valueOf() 등등의 메서드를 사용할 수 있습니다.

그리고 Object.prototype의 [[Prototype]]은 null이므로 여기서 프로토타입 체인이 끝납니다.

프로토타입 체인은 언제 탐색을 멈출까?

프로토타입 체인은 필요한 프로퍼티를 찾을 때까지 계속 위로 올라갑니다.

하지만 무한히 탐색하는 것은 아닙니다.

예를 들어 다음 코드를 살펴보겠습니다.

const person = {
  name: '철수',
};

console.log(person.age);

탐색 과정은 다음과 같습니다.

person
└─ age 없음
      ↓
[[Prototype]]을 따라 이동
      ↓
Object.prototype
└─ age 없음
      ↓
[[Prototype]] → null
      ↓
   탐색 종료

프로토타입 체인의 끝은 항상 null입니다.

즉, 더 이상 탐색할 프로토타입이 없으면 탐색을 종료하고, 프로퍼티 접근 결과는 undefined가 됩니다.

이 때문에 프로토타입 체인이 무한 루프에 빠지는 일은 없습니다.

왜 JavaScript는 이렇게 설계했을까?

여기까지 이해했다면 이런 생각이 들 수 있습니다.

"메서드를 객체마다 하나씩 넣으면 더 단순하지 않을까?"

가능은 합니다.

하지만 객체가 많아질수록 같은 메서드를 계속 생성해야 합니다.

예를 들어 사용자 객체가 10,000개 있다고 가정해 보겠습니다.

만약 모든 객체가 같은 메서드를 직접 가지고 있다면

person1
sayHello()

person2
sayHello()

person3
sayHello()

...

person10000
sayHello()

처럼 동일한 함수가 10,000번 생성될 수도 있습니다.

  Person.prototype
  ┌──────────────┐
  │ sayHello()   │
  └──────────────┘
    ▲     ▲     ▲
    │     │     │
person1 person2 person3

반면 프로토타입을 사용하면 person1, person2, person3는 각각 별도의 객체이지만, 모두 같은 Person.prototype을 참조합니다.

따라서 sayHello() 함수는 하나만 생성되고, 여러 객체가 함께 공유하게 됩니다.

즉,

  • 메모리를 절약할 수 있고
  • 동일한 메서드를 공유할 수 있으며
  • 유지보수도 쉬워집니다.

JavaScript는 이러한 이유로 상속과 메서드 공유를 프로토타입 기반으로 설계했습니다.

class도 결국 프로토타입일까?

ES6에서 class가 등장하면서 많은 사람들이 JavaScript에도 클래스 기반 상속이 추가되었다고 생각합니다.

하지만 실제로는 그렇지 않습니다.

다음 코드를 살펴보겠습니다.

class Person {
  constructor(name) {
    this.name = name;
  }

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

겉으로 보기에는 Java나 C++의 클래스와 비슷해 보입니다.

하지만 JavaScript의 class도 내부적으로는 프로토타입 기반의 객체 구조와 상속 방식을 사용합니다.

class 문법
├─ constructor → 인스턴스 생성 및 초기화
└─ 메서드 → prototype에 정의되어 공유
                 ↓
          프로토타입 체인

즉, class는 프로토타입 기반의 동작을 클래스 형태로 더 편리하게 사용할 수 있도록 제공하는 문법입니다.

문법은 달라졌지만 JavaScript의 객체가 프로토타입을 통해 연결되고 상속하는 기본 구조는 그대로 사용됩니다.

정리

JavaScript의 객체는 필요한 프로퍼티를 자신의 객체에서 먼저 찾고, 없다면 프로토타입 체인을 따라 상위 프로토타입을 탐색합니다.

이 구조 덕분에 여러 객체가 같은 메서드를 공유할 수 있으며, 불필요한 메모리 사용을 줄일 수 있습니다.

핵심 내용을 정리하면 다음과 같습니다.

  • 프로토타입은 여러 객체가 공통으로 사용할 프로퍼티와 메서드를 저장하는 객체입니다.
  • 객체는 [[Prototype]]을 통해 자신의 프로토타입을 참조합니다.
  • 프로퍼티를 찾지 못하면 프로토타입 체인을 따라 계속 탐색합니다.
  • 대부분의 객체는 마지막에 Object.prototype까지 탐색합니다.
  • 탐색은 null을 만나면 종료됩니다.
  • class 역시 내부적으로는 생성자 함수와 프로토타입을 기반으로 동작합니다.

프로토타입 체인은 JavaScript 객체 모델의 핵심 개념입니다.

처음에는 다소 어렵게 느껴질 수 있지만, "내게 없으면 프로토타입에서 찾아본다." 라는 관점으로 이해하면 훨씬 이해하기 쉽습니다.

이 개념을 이해하면 객체 상속, 클래스, 메서드 공유 등 JavaScript의 객체 동작 방식을 이해하는 데 큰 도움이 될 수 있습니다.

참고 자료