본문으로 건너뛰기

프론트엔드

프로토타입과 프로토타입 체인

2026. 06. 16. 화요일 오후 9시 18분

정의한 적 없는 메서드 호출

const arr = [3, 1, 2];
 
arr.sort(); // [1, 2, 3]
arr.map((n) => n * 2); // [6, 2, 4]

arr은 대괄호로 만든 배열일 뿐이고, sortmap을 따로 정의한 적은 없습니다. 하지만 두 메서드는 정상적으로 동작합니다.

조금 더 분명한 예를 보겠습니다.

function Animal(name) {
  this.name = name;
}
 
const dog = new Animal('멍멍이');
 
console.log(dog.hasOwnProperty('name')); // true

Animal 생성자 안에는 name만 정의했습니다. hasOwnProperty라는 메서드는 어디에도 정의하지 않았지만, dog는 이 메서드를 호출할 수 있습니다.

이 동작을 이해하려면 프로토타입을 알아야 합니다. 이 글에서는 프로토타입과 프로토타입 체인을 코드로 따라가며, class 같은 문법 뒤에서 객체가 어떻게 동작하는지 살펴봅니다.

프로토타입(Prototype)의 정의

프로토타입이란, 자바스크립트 객체가 속성과 메서드를 다른 객체와 공유하기 위한 메커니즘입니다.

거의 모든 객체는 자신과 연결된 또 다른 객체를 하나 가리키고 있습니다. 이 "가리키는 링크"가 프로토타입입니다. (단, 체인의 맨 끝인 Object.prototype이나 Object.create(null)로 만든 객체는 이 링크가 null입니다.) 객체에서 어떤 속성을 읽을 때 그 객체에 속성이 없으면, 자바스크립트 엔진은 이 링크를 따라가 연결된 객체에서 다시 찾습니다. 거기에 있으면 그 값을 돌려주고, 없으면 그 객체의 프로토타입에서 또 찾습니다.

dog는 이 과정을 거쳐 hasOwnProperty를 호출합니다. dog 자신은 그 메서드를 갖고 있지 않지만, dog의 프로토타입(정확히는 Animal.prototype)을 거쳐 그 위의 Object.prototype까지 올라가면 hasOwnProperty가 있습니다.

헷갈리는 세 가지 이름: [[Prototype]], __proto__, prototype

프로토타입과 관련된 이름은 세 가지이고 서로 비슷합니다. prototype__proto__를 같은 것으로 착각하기 쉽지만, 셋은 서로 다릅니다.

  • [[Prototype]]: 모든 객체가 내부적으로 가진 "숨은 링크" 그 자체입니다. 명세상의 표기이고, 코드에서 직접 이 이름으로 접근하지는 않습니다.
  • __proto__: 그 숨은 링크에 접근하기 위한 접근자(getter/setter)입니다. 정확히는 Object.prototype에 정의된 접근자라서, Object.prototype을 상속하지 않는 객체(예: Object.create(null))에서는 동작하지 않습니다. 다만 지금은 웹 호환성을 위해 남겨둔 레거시 기능으로 분류되고, 프로토타입 오염 위험도 있어서, 새 코드에서는 Object.getPrototypeOf/Object.setPrototypeOf를 쓰는 편이 안전합니다.
  • prototype: 오직 함수만 가지는 속성입니다. 그 함수를 new로 호출해 만든 인스턴스의 [[Prototype]]이 가리키게 될 객체입니다.

세 이름의 관계를 코드로 정리하면 이렇습니다.

function Animal(name) {
  this.name = name;
}
const dog = new Animal('멍멍이');
 
// dog의 숨은 링크([[Prototype]])는 Animal.prototype을 가리킨다
Object.getPrototypeOf(dog) === Animal.prototype; // true
 
// __proto__는 같은 링크에 접근하는 다른 통로일 뿐이다
dog.__proto__ === Animal.prototype; // true
  • prototype은 함수가 미리 들고 있는 "이 생성자로 만든 인스턴스들이 공유할 객체"입니다.
  • new로 인스턴스를 만들면, 그 인스턴스의 [[Prototype]]이 생성자의 prototype을 가리키도록 자동으로 연결됩니다.
  • 인스턴스 쪽에서 그 링크를 읽을 때 쓰는 것이 __proto__(또는 Object.getPrototypeOf)입니다.

여기서 new가 무슨 일을 하는지 설명하겠습니다. new Animal('멍멍이')는 (1) 빈 객체를 하나 만들고, (2) 그 객체의 [[Prototype]]Animal.prototype에 연결한 뒤, (3) 그 객체를 this로 삼아 생성자 본문을 실행하고 돌려줍니다. 위에서 말한 "자동으로 연결됨"이 바로 이 (2) 단계입니다.

프로토타입 체인

링크는 하나로 끝나지 않습니다. 프로토타입이 가리키는 객체에도 또 프로토타입이 있고, 그 프로토타입에도 프로토타입이 있습니다. 속성을 찾을 때 엔진은 이 링크를 한 칸씩 거슬러 올라가며 같은 방식으로 찾습니다. 그러다 null을 만나면 멈춥니다. 이렇게 이어진 경로를 프로토타입 체인이라고 부릅니다.

인스턴스에서 시작해 Object.prototype을 거쳐 null로 끝나는 프로토타입 체인 다이어그램"인스턴스에서 시작해 Object.prototype을 거쳐 null로 끝나는 프로토타입 체인 다이어그램"

위 그림처럼, dog에 아직 없는 speak 같은 속성을 읽으면 엔진은 dogAnimal.prototypeObject.prototype 순서로 speak를 찾습니다. 어느 단계에서 찾으면 거기서 멈추고, 끝까지 못 찾으면 그 값은 undefined입니다. (그 상태로 dog.speak()처럼 호출하려 하면, 그 값이 함수가 아니므로 dog.speak is not a function 형태의 TypeError가 납니다.) 프로토타입 체인은 변수를 못 찾으면 바깥 함수로, 또 그 바깥으로 올라가는 스코프 체인과 구조가 닮았습니다. 스코프 체인은 스코프를 거슬러 오르고, 프로토타입 체인은 객체 링크를 거슬러 오른다는 차이가 있습니다.

체인을 따라 올라가며 각 단계를 출력해 보겠습니다.

prototype 객체에는 자신을 만든 생성자를 가리키는 constructor 속성이 들어 있어서, current.constructor.name으로 단계 이름을 알아낼 수 있습니다.

function chainOf(obj) {
  const names = [];
  let current = Object.getPrototypeOf(obj); // 첫 프로토타입으로 이동
  while (current !== null) {
    const ctor = current.constructor; // 이 단계를 만든 생성자
    names.push((ctor && ctor.name ? ctor.name : '(anonymous)') + '.prototype'); // 단계 이름 기록
    current = Object.getPrototypeOf(current); // 한 칸 위로
  }
  names.push('null'); // 체인의 끝은 항상 null
  return names.join(' -> ');
}
 
const dog = new Animal('멍멍이');
console.log(chainOf(dog));
// Animal.prototype -> Object.prototype -> null

constructor는 평범한 속성이라 보정되지 않았거나 위조되면 실제 단계와 다르게 찍힐 수 있습니다. 그래서 이 함수는 체인을 눈으로 확인하기 위한 용도일 뿐, 단계를 정확히 식별하는 신뢰할 만한 방법은 아닙니다.

  • 객체 리터럴 {}로 만든 객체는 곧장 Object.prototype으로 이어집니다.
  • 생성자 함수나 class로 만든 인스턴스는 그 사이에 생성자.prototype이 한 칸 더 끼어듭니다.
  • 대부분의 객체는 종착지가 Object.prototype이고, 그 위는 null입니다. (예외로 Object.create(null)로 만든 객체는 처음부터 프로토타입이 null이라 Object.prototype을 거치지 않습니다.)

자기 속성과 상속 속성 구분하기

체인을 타고 올라가다 보면, 어떤 속성이 객체 "자신의 것"인지 "상속받은 것"인지 헷갈릴 때가 있습니다. hasOwnProperty는 이를 구분해 줍니다.

Animal.prototype도 평범한 객체이므로, 아래처럼 속성을 직접 추가할 수 있습니다.

Animal.prototype.legs = 4; // 프로토타입에 공유 속성을 둔다
const cat = new Animal('야옹이'); // name은 인스턴스 고유, legs는 상속
 
cat.hasOwnProperty('name'); // true, 자기 속성
cat.hasOwnProperty('legs'); // false, 프로토타입에서 상속
'legs' in cat; // true, in 연산자는 상속 속성까지 포함해 검사
  • hasOwnProperty는 체인을 보지 않고, 오직 객체 자신이 직접 가진 속성만 true로 칩니다.
  • 반면 in 연산자는 체인 전체를 훑기 때문에, 상속받은 속성에도 true를 돌려줍니다.
  • 그래서 객체를 순회하다 상속 속성을 걸러내고 싶을 때 hasOwnProperty를 자주 씁니다.

프로토타입에 둔 메서드의 공유

프로토타입의 실용적인 쓸모는 메모리 절약입니다. 메서드를 생성자 안에서 this에 직접 붙이면, 인스턴스를 만들 때마다 똑같은 함수가 매번 새로 생깁니다. 반면 프로토타입에 한 번만 정의해두면 모든 인스턴스가 같은 함수 하나를 공유합니다.

인스턴스는 함수를 복사해 가지지 않고 프로토타입을 "참조"만 합니다. 그래서 인스턴스를 다 만든 뒤에 프로토타입에 메서드를 추가해도 기존 인스턴스가 곧바로 그 메서드를 쓸 수 있습니다.

const before = new Animal('이미-만든-객체');
typeof before.speak; // 'undefined', 아직 speak가 없다
 
// 인스턴스를 만든 뒤에 프로토타입에 메서드를 추가한다
Animal.prototype.speak = function () {
  return `${this.name}: 멍!`;
};
 
before.speak(); // '이미-만든-객체: 멍!', 이미 만든 객체에도 즉시 반영된다
  • beforespeak가 추가되기 전에 만들어졌지만, 추가 직후 바로 호출할 수 있습니다.
  • 인스턴스가 프로토타입을 실시간으로 참조하기 때문입니다.
  • 편리한 만큼 위험하기도 합니다. Object.prototype 같은 공용 프로토타입을 함부로 건드리면 모든 객체에 영향을 줍니다. 이른바 프로토타입 오염이고, 남의 코드와 충돌하기 쉬워 실무에선 피해야 합니다.

class extends와 프로토타입 연결

ES6의 classextends를 보면 자바와 비슷한 클래스 상속처럼 느껴집니다. 하지만 자바스크립트에는 클래스 상속 메커니즘이 따로 없습니다. class extends가 만들어 내는 인스턴스의 프로토타입 체인은 Object.create로 직접 연결한 체인과 같습니다.

class가 실제로 무엇을 하는지 보려면, 같은 결과를 프로토타입으로 직접 연결해 보는 것이 가장 빠릅니다. 같은 상속 구조를 두 방식으로 나란히 만들어 보겠습니다.

직접 연결할 때 쓰는 함수는 Object.create입니다. Object.create(proto)[[Prototype]]proto인 빈 객체를 만들어줍니다. 그래서 ManualChild.prototype = Object.create(ManualBase.prototype) 한 줄로, 자식의 프로토타입이 부모의 프로토타입을 거슬러 올라가도록 체인이 엮입니다.

// (A) class 문법
class Base {
  hello() {
    return 'hello from Base';
  }
}
class Child extends Base {
  hi() {
    return 'hi from Child';
  }
}
const a = new Child();
 
// (B) Object.create로 직접 연결한 같은 구조
function ManualBase() {}
ManualBase.prototype.hello = function () {
  return 'hello from Base';
};
function ManualChild() {}
// ManualChild.prototype의 프로토타입을 ManualBase.prototype으로 연결
ManualChild.prototype = Object.create(ManualBase.prototype);
ManualChild.prototype.constructor = ManualChild; // 이 줄이 없으면 b.constructor가 ManualBase를 가리킨다
ManualChild.prototype.hi = function () {
  return 'hi from Child';
};
const b = new ManualChild();
 
a.hi() === b.hi(); // true
a.hello() === b.hello(); // true
  • (A)와 (B)는 인스턴스의 프로토타입 체인이 같습니다. class는 이 연결과 constructor 보정 같은 번거로운 작업을 알아서 처리해줍니다.
  • 다만 class는 단순한 '문법적 설탕' 이상의 동작을 처리합니다. 정적 메서드 상속(Child 클래스 자체도 Base를 프로토타입으로 가짐: Object.getPrototypeOf(Child) === Base), super 호출, new 없이 부르면 에러, 메서드를 for-in 같은 순회에 끼지 않게 하는 비열거(non-enumerable) 처리 같은 동작은 (B)처럼 직접 연결하면 재현되지 않아 따로 구현해야 합니다. (super의 내부 바인딩처럼 사실상 동일하게 재현할 수 없는 동작도 있습니다.)
  • 그래도 그 밑바탕이 프로토타입 연결이라는 점을 알아두면, extends가 동작하지 않거나 메서드가 엉뚱하게 상속될 때 원인을 찾기 쉽습니다.

직접 실행해본 결과

위 내용을 한 파일(playground/prototype-chain/src/index.mjs)에 모아 실제로 돌려봤습니다. 별도 의존성 없이 node src/index.mjs 한 줄로 실행되며, 콘솔 출력은 다음과 같습니다.

프로토타입 예제를 실행한 콘솔 출력 화면"프로토타입 예제를 실행한 콘솔 출력 화면"

class로 만든 인스턴스와 Object.create로 직접 연결한 인스턴스의 결과가 같게 나오고, 모든 체인이 Object.prototypenull로 끝나는 것을 직접 확인할 수 있습니다.

정리

  • 프로토타입은 객체가 속성과 메서드를 공유하도록 잇는 "숨은 링크"입니다.
  • 객체에 없는 속성은 이 링크를 따라 거슬러 올라가며 찾고, null에서 멈춥니다. 이 경로가 프로토타입 체인입니다.
  • hasOwnProperty는 자기 속성만, in 연산자는 상속 속성까지 검사합니다.
  • 메서드를 프로토타입에 두면 인스턴스끼리 공유되어 메모리를 아낄 수 있지만, 공용 프로토타입 오염은 조심해야 합니다.
  • class extendsObject.create는 같은 프로토타입 연결을 만드는 두 가지 표기일 뿐입니다.

마치며

이번 포스트에선 프로토타입과 프로토타입 체인을 살펴봤습니다. class 문법 뒤에는 객체끼리 이어진 링크가 있고, 엔진은 이 링크를 따라 속성을 찾아 올라갑니다. 이 구조를 알면 자바스크립트 객체의 동작을 예측할 수 있습니다. 긴 글 읽어주셔서 감사합니다.