
JavaScript는 객체 지향 프로그래밍(OOP)을 지원하는 멀티 패러다임 언어다.
그러나 Java, C++과 같이 클래스 기반 상속을 사용하는 언어와 달리 JavaScript는 프로토타입 기반 상속을 사용한다.
ES6+에서 클래스 사용이 가능해졌지만, 내부적으로는 프로토타입 기반 패턴의 새로운 객체 생성 메커니즘을 사용하므로 Prototype에 대한 이해가 반드시 필요하다.
또한, 프로토타입 기반 상속은 기존 코드를 재사용하여 불필요한 중복을 제거하는 데 큰 역할을 한다.
아래는 생성자 함수 내부에 직접 메서드를 정의한 경우다.
이 방식은 모든 인스턴스가 동일한 내용을 가진 메서드를 중복 생성하게 되어 비효율적이다.
function Human(name) {
this.name = name;
this.hi = function() {
return `Hi ${this.name}`;
}
}
이와 달리, 프로토타입에 메서드를 추가하면 단 한 개의 함수가 모든 인스턴스에 공유되어 메모리 효율이 높아진다.
function Human(name) {
this.name = name;
}
Human.prototype.hi = function () {
return `Hi ${this.name}`;
}
Human 생성자 함수가 생성하는 모든 인스턴스는 자신이 소유한 상태(name)와 함께, 부모 객체 역할을 하는 Human.prototype의 모든 프로퍼티와 메서드를 공유한다.
모든 객체는 내부 슬롯인 [[Prototype]]을 가지며, 이 값은 객체가 상속받을 프로토타입의 참조다. 프로토타입은 객체 생성 방식에 따라 결정된다.
Object.prototype을 프로토타입으로 갖는다.prototype 프로퍼티에 바인딩된 객체를 프로토타입으로 갖는다.__proto__ 접근자 프로퍼티를 통해 객체는 자신의 내부 슬롯 [[Prototype]]이 가리키는 프로토타입에 간접적으로 접근할 수 있다.
내부 슬롯은 직접 접근이 불가능하기 때문에 이를 위해 __proto__를 제공하지만, 직접 사용하기보다는 Object.getPrototypeOf와 Object.setPrototypeOf 메서드를 사용하는 것이 권장된다.
모든 함수 객체는 prototype 프로퍼티를 가지며, 이는 생성자 함수가 생성할 객체의 프로토타입을 지정한다.
단, 생성자 함수가 아닌 일반 함수(화살표 함수 등)는 prototype 프로퍼티의 의미가 없다.
또한, __proto__ 접근자 프로퍼티는 모든 객체가 상속을 통해 사용할 수 있고, 프로토타입 체인을 따라 부모 객체의 프로퍼티를 검색한다.
프로토타입 체인의 종점은 Object.prototype.__proto__이며, 순환 참조가 발생하면 Cyclic __proto__ value 에러가 발생한다.
아래 코드는 프로토타입 체인의 종점을 확인할 수 있다.
const obj = Object.create(null); // 이 경우, obj.__proto__는 undefined
객체 생성 시 인수를 전달하지 않거나 undefined 또는 null을 인수로 전달하면, 내부적으로 추상 연산 OrdinaryObjectCreate가 호출되어 Object.prototype을 프로토타입으로 갖는 빈 객체가 생성된다.
런타임 문법(객체 리터럴 등) 역시 내부적으로 OrdinaryObjectCreate를 호출하여 객체를 생성한 후, 프로퍼티 정의 목록(Property Definition Evaluation)을 수행한다.


위에서 볼 수 있듯, MakeBasicObject는 기본 객체를 생성한 후 호출자가 프로토타입을 재정의하지 않은 경우 기본 프로토타입(Object.prototype)을 포함하도록 한다.
이로 인해 객체 리터럴로 생성된 객체도 상속을 위해 가상적인 생성자 함수와 프로토타입이 할당된다.
객체 리터럴, Object 생성자 함수, 사용자 정의 생성자 함수의 프로토타입 관계는 다음과 같다:
Object 생성 → 프로토타입: Object.prototypeFunction 생성 → 프로토타입: Function.prototypeArray 생성 → 프로토타입: Array.prototypeRegExp 생성 → 프로토타입: RegExp.prototypenon-constructor 함수(화살표 함수 등)는 프로토타입이 생성되지 않는다.
일반 함수로 선언된 생성자 함수는 함수 호이스팅에 의해 먼저 평가되어 함수 객체가 되고, 동시에 prototype 프로퍼티가 바인딩된다.
이때 프로토타입은 기본적으로 constructor만 갖는 객체로 생성되며, 그 프로토타입의 내부 슬롯은 Object.prototype을 가리킨다.
따라서 객체가 생성되기 이전에 생성자 함수와 프로토타입은 이미 객체화되어 존재하며, 런타임 시 생성된 객체는 해당 생성자 함수의 prototype을 내부 슬롯인 [[Prototype]]에 할당받아 상속 관계를 형성한다.
사용자 정의 생성자 함수로 생성된 객체는 해당 함수의 prototype 객체에 연결되어 있다.
다음과 같이 생성된 객체들은 모두 Human.prototype의 메서드를 상속받아 단 하나의 메서드를 공유한다.
function Human(name) {
this.name = name;
}
Human.prototype.hi = function () {
return `Hi ${this.name}`;
} // Human에 의해 생성된 모든 하위 객체에 즉각 반영된다.
이처럼 각 인스턴스는 자신만의 상태(name)를 소유하고, 메서드는 프로토타입 체인을 통해 공유된다.
JavaScript는 객체의 프로퍼티에 접근할 때, 해당 객체에 프로퍼티가 없으면 내부 슬롯인 [[Prototype]]을 따라 상위 프로토타입에서 프로퍼티를 순차적으로 검색한다. 이를 프로토타입 체인이라고 한다.
const a = new Human("Kim");
a.hasOwnProperty('name'); // true
a 객체에서 hasOwnProperty 메서드를 찾지 못하면, 먼저 Human.prototype에서 찾고, 그래도 없으면 최종적으로 Object.prototype에서 해당 메서드를 검색하여 실행된다. 스코프 체인과 프로토타입 체인은 협력하여 식별자와 프로퍼티를 검색한다.
상속에서 오버라이딩은 중요한 개념이다.
만약 인스턴스에서 프로토타입과 동일한 이름의 메서드를 정의하면, 인스턴스는 해당 메서드를 자신의 프로퍼티로 추가하여 프로토타입의 메서드를 가린다.
이를 프로퍼티 섀도잉이라 한다.
function Human(name) {
this.name = name;
}
Human.prototype.hi = function () {
return `Hi ${this.name}`;
};
const person = new Human("Lee");
// 인스턴스에 동일한 이름의 메서드를 정의하면 프로토타입의 메서드를 덮어쓴다.
person.hi = function() {
return `Hello ${this.name}`;
};
console.log(person.hi()); // "Hello Lee"
또한, 하위 객체에서는 프로토타입의 프로퍼티를 직접 변경하거나 삭제할 수 없으며, 프로토타입 자체는 동적으로 다른 객체로 교체할 수도 있다. 단, 이 경우 순환 참조가 발생하지 않도록 주의해야 한다.