프로토타입과 상속
객체의 프로토타입 체인이 속성과 메서드를 찾는 과정을 추적하고 생성자 상속과 class 문법의 내부 원리를 연결합니다.
앞 글 This 키워드와 바인딩에서는 생성자 함수를 new로 호출하면 this가 새 인스턴스를 가리킨다는 것을 살펴봤습니다. 이번에는 그렇게 만든 인스턴스가 메서드를 어디서 찾는지, 그리고 한 객체가 다른 객체의 기능을 어떻게 물려받는지를 다룹니다.
많은 객체 지향 언어는 클래스(class)를 기반으로 상속을 구현하지만, 자바스크립트는 ES2015 이전까지 클래스 없이 프로토타입(prototype)으로 상속을 구현했습니다. 이를 프로토타입 기반 상속(prototype-based inheritance)이라고 합니다. class 키워드가 생긴 지금도 상속은 내부적으로 프로토타입으로 동작하므로, 프로토타입을 알면 class 문법과 라이브러리 코드가 어떻게 동작하는지 읽기 쉬워집니다.
프로토타입과 프로토타입 체인
자바스크립트의 객체는 다른 객체 하나와 연결될 수 있고, 이 연결된 객체를 그 객체의 프로토타입이라고 합니다. 객체는 프로토타입이 가진 속성과 메서드를 자기 것처럼 쓸 수 있습니다.
객체에서 속성을 읽으면 엔진은 먼저 그 객체 자신에게 그 속성이 있는지 확인합니다. 없으면 프로토타입으로 이동해 찾고, 거기에도 없으면 프로토타입의 프로토타입으로 이동하기를 반복합니다. 이렇게 이어진 연결을 프로토타입 체인(prototype chain)이라고 하며, 프로토타입이 null인 객체에 도달하면 체인이 끝납니다.
프로토타입을 확인하는 방법
일반 객체는 [[Prototype]]이라는 내부 슬롯(internal slot)에 프로토타입 객체나 null을 담고 있습니다. 내부 슬롯은 코드에서 직접 읽을 수 없으므로 Object.getPrototypeOf()로 확인하고, 바꿀 때는 Object.setPrototypeOf()를 씁니다.
오래된 코드에서는 obj.__proto__로 프로토타입을 읽는 모습도 볼 수 있습니다. __proto__는 Object.prototype에 있는 접근자 속성이라, 객체가 같은 이름의 자체 속성을 가지거나 프로토타입이 null인 객체에서는 기대한 대로 동작하지 않습니다. 이 글의 예제는 모두 표준 메서드로 확인합니다.
const user = {
name: "김민준",
age: 28
};
// 객체 리터럴로 만든 객체의 프로토타입은 Object.prototype입니다.
console.log(Object.getPrototypeOf(user) === Object.prototype); // true
// user는 Object.prototype의 toString 메서드를 상속받아 사용합니다.
console.log(user.toString()); // [object Object]
// user 자신에게는 toString이 없지만, 프로토타입 체인을 따라 Object.prototype.toString을 찾습니다.생성자 함수의 prototype 속성
function으로 선언한 일반 함수는 prototype이라는 속성을 가집니다. 이 함수를 생성자로 new와 함께 호출하면, 만들어지는 인스턴스의 프로토타입이 이 prototype 객체가 됩니다. 화살표 함수나 메서드 단축 문법으로 만든 메서드처럼 생성자로 쓸 수 없는 함수에는 prototype 속성이 없습니다.
함수의 prototype 속성과 객체의 [[Prototype]]은 이름이 비슷하지만 다릅니다. Person.prototype은 Person이 만들 인스턴스들의 프로토타입이 될 객체이고, Person 함수 자신의 프로토타입은 Function.prototype입니다.
function Person(name, age) {
this.name = name;
this.age = age;
}
// Person 함수의 prototype 객체에 sayHello 메서드를 추가합니다.
Person.prototype.sayHello = function() {
console.log(`안녕하세요, 제 이름은 ${this.name}입니다.`);
};
const person1 = new Person("이수민", 25);
const person2 = new Person("박지훈", 30);
// 두 인스턴스의 [[Prototype]]은 Person.prototype입니다.
console.log(Object.getPrototypeOf(person1) === Person.prototype); // true
console.log(Object.getPrototypeOf(person2) === Person.prototype); // true
// 인스턴스 자신에게는 sayHello가 없지만, 프로토타입 체인을 따라 찾습니다.
person1.sayHello(); // 안녕하세요, 제 이름은 이수민입니다.
person2.sayHello(); // 안녕하세요, 제 이름은 박지훈입니다.
// 두 인스턴스는 메서드를 복사하지 않고 프로토타입의 같은 함수를 참조합니다.
console.log(person1.sayHello === person2.sayHello); // true메서드를 프로토타입에 정의하면 모든 인스턴스가 같은 함수 하나를 공유하므로, 인스턴스를 많이 만들어도 함수가 늘어나지 않습니다. 반면 생성자 안에서 this.sayHello = function() { ... }처럼 만들면 인스턴스마다 새 함수가 생깁니다. 같은 동작을 공유한다면 프로토타입 메서드가 알맞고, 인스턴스마다 다른 클로저를 가져야 한다면 생성자 안에서 만드는 방법을 씁니다.
constructor 속성
생성자 함수가 처음 가지고 있는 prototype 객체에는 그 생성자를 가리키는 constructor 속성이 들어 있습니다. 즉 함수는 prototype 속성으로 자기 prototype 객체를, 그 객체는 constructor 속성으로 다시 함수를 가리킵니다. 다음 코드는 이 관계를 생성자 쪽과 인스턴스 쪽에서 각각 확인합니다.
function Animal(name) {
this.name = name;
}
console.log(Animal.prototype.constructor === Animal); // 결과: true
const dog = new Animal("바둑이");
console.log(dog.constructor === Animal); // 결과: truedog 자신에게는 constructor가 없으므로 프로토타입인 Animal.prototype에서 찾아 Animal을 얻습니다. 다만 constructor는 일반 속성이라 다른 값으로 바꾸거나 prototype 객체를 통째로 교체하면 사라질 수 있습니다. 그래서 객체를 어떤 생성자로 만들었는지 판별할 때 constructor만 믿지는 않습니다.
프로토타입 체인으로 상속하기
자바스크립트의 상속은 프로토타입 체인으로 이루어집니다. 한 객체가 다른 객체의 프로토타입이 되고, 그 객체가 또 다른 객체의 프로토타입이 되는 식으로 사슬처럼 이어집니다.
프로토타입 속성 탐색 사슬 — 상태 추적
자체→첫/다음 prototype에서 존재 검사 후 처음 발견한 곳에서 멈춘다. 같은 key/current object/검색 결과를 추적하면 own undefined와 없는 key를 구분한다.
LOOKUP · ORDINARY OBJECT
프로토타입 탐색: 찾은 지점에서 멈춘다
일반 객체의 속성 읽기는 자신의 속성을 먼저 검사한다. 없을 때만 [[Prototype]]을 따라가며, 어느 단계에서든 발견하면 탐색을 끝낸다.
- Instance own property
요청한 key가 있으면 즉시 읽는다. data property는 저장된 값, accessor는 getter 결과다.
- Type.prototype
없을 때만 [[Prototype]]으로 이동한다. 여기서 발견해도 즉시 읽고 끝난다.
- Object.prototype
이 예시의 다음 프로토타입이다. 여기서도 없을 때만 다음 연결로 이동한다.
- 끝까지 없고 다음이 null
undefined를 반환한다. null 자체는 검사할 속성 노드가 아니다.
- 없으면 다음 [[Prototype]]
- 발견하면 조기 종료
Shadowing은 앞에서 발견한 같은 key가 가리는 것
own property의 값이 undefined여도 그 속성은 존재하므로 다음 프로토타입으로 넘어가지 않는다. Type.prototype → Object.prototype → null은 예시 체인이다. 사용자 정의 상속이나 null prototype 객체는 연결이 다르다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 자체 속성, 첫 프로토타입, 다음 프로토타입을 차례로 검사한다. 각 질문의 있음 가지는 값을 읽고 끝나며 없음만 다음 객체로 간다. 마지막 연결이 null이면 없는 속성의 결과는 undefined이다. 값이 undefined인 자체 속성도 있음 가지로 끝난다. 실제 체인 길이는 객체에 따라 다르다. |
| 원도표 조건·주석 | Instance own property: 요청한 key가 있으면 즉시 읽는다. data property는 저장된 값, accessor는 getter 결과다. |
| 원도표 조건·주석 | Type.prototype: 없을 때만 [[Prototype]]으로 이동한다. 여기서 발견해도 즉시 읽고 끝난다. |
| 원도표 조건·주석 | Object.prototype: 이 예시의 다음 프로토타입이다. 여기서도 없을 때만 다음 연결로 이동한다. |
| 원도표 조건·주석 | 끝까지 없고 다음이 null: undefined를 반환한다. null 자체는 검사할 속성 노드가 아니다. |
| 원도표 설명 | Shadowing은 앞에서 발견한 속성이 뒤의 같은 key를 가리는 것 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| own key 검사 | own key: 있음/없음 / 값: undefined도 존재 가능 | 찾는 key가 객체 자신의 속성인지 확인한다 | 값 undefined와 key 부재는 다르다 |
| own이면 종료 | 결과: own undefined / prototype: 검색 안 함 | 자신의 undefined를 찾았어도 탐색을 끝낸다 | 부모의 같은 이름 값으로 대체하지 않는다 |
| 없으면 prototype | 현재: 다음 prototype / 종료: 찾음 또는 null | own이 없을 때만 chain에서 같은 key를 찾는다 | prototype lookup은 부재 조건에서만 이어진다 |
프로토타입 속성 탐색 사슬 — 상태 추적
자체→첫/다음 prototype에서 존재 검사 후 처음 발견한 곳에서 멈춘다. 같은 key/current object/검색 결과를 추적하면 own undefined와 없는 key를 구분한다.
LOOKUP · ORDINARY OBJECT
프로토타입 탐색: 찾은 지점에서 멈춘다
일반 객체의 속성 읽기는 자신의 속성을 먼저 검사한다. 없을 때만 [[Prototype]]을 따라가며, 어느 단계에서든 발견하면 탐색을 끝낸다.
- Instance own property
요청한 key가 있으면 즉시 읽는다. data property는 저장된 값, accessor는 getter 결과다.
- Type.prototype
없을 때만 [[Prototype]]으로 이동한다. 여기서 발견해도 즉시 읽고 끝난다.
- Object.prototype
이 예시의 다음 프로토타입이다. 여기서도 없을 때만 다음 연결로 이동한다.
- 끝까지 없고 다음이 null
undefined를 반환한다. null 자체는 검사할 속성 노드가 아니다.
- 없으면 다음 [[Prototype]]
- 발견하면 조기 종료
Shadowing은 앞에서 발견한 같은 key가 가리는 것
own property의 값이 undefined여도 그 속성은 존재하므로 다음 프로토타입으로 넘어가지 않는다. Type.prototype → Object.prototype → null은 예시 체인이다. 사용자 정의 상속이나 null prototype 객체는 연결이 다르다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 자체 속성, 첫 프로토타입, 다음 프로토타입을 차례로 검사한다. 각 질문의 있음 가지는 값을 읽고 끝나며 없음만 다음 객체로 간다. 마지막 연결이 null이면 없는 속성의 결과는 undefined이다. 값이 undefined인 자체 속성도 있음 가지로 끝난다. 실제 체인 길이는 객체에 따라 다르다. |
| 원도표 조건·주석 | Instance own property: 요청한 key가 있으면 즉시 읽는다. data property는 저장된 값, accessor는 getter 결과다. |
| 원도표 조건·주석 | Type.prototype: 없을 때만 [[Prototype]]으로 이동한다. 여기서 발견해도 즉시 읽고 끝난다. |
| 원도표 조건·주석 | Object.prototype: 이 예시의 다음 프로토타입이다. 여기서도 없을 때만 다음 연결로 이동한다. |
| 원도표 조건·주석 | 끝까지 없고 다음이 null: undefined를 반환한다. null 자체는 검사할 속성 노드가 아니다. |
| 원도표 설명 | Shadowing은 앞에서 발견한 속성이 뒤의 같은 key를 가리는 것 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| own key 검사 | own key: 있음/없음 / 값: undefined도 존재 가능 | 찾는 key가 객체 자신의 속성인지 확인한다 | 값 undefined와 key 부재는 다르다 |
| own이면 종료 | 결과: own undefined / prototype: 검색 안 함 | 자신의 undefined를 찾았어도 탐색을 끝낸다 | 부모의 같은 이름 값으로 대체하지 않는다 |
| 없으면 prototype | 현재: 다음 prototype / 종료: 찾음 또는 null | own이 없을 때만 chain에서 같은 key를 찾는다 | prototype lookup은 부재 조건에서만 이어진다 |
위 그림은 속성을 읽을 때 체인을 따라가는 순서를 질문으로 나타낸 것입니다. 먼저 인스턴스 자신에게 요청한 이름의 속성이 있는지 묻고, 있으면 그 값을 읽고 바로 끝납니다. 없을 때만 첫 프로토타입인 Type.prototype으로, 거기에도 없으면 다음 프로토타입인 Object.prototype으로 이동합니다. 다음 연결이 null이 될 때까지 찾지 못하면 결과는 undefined입니다.
찾은 속성이 일반 데이터 속성이면 저장된 값을, getter가 있는 접근자 속성이면 getter의 결과를 읽습니다. 앞 단계에서 같은 이름을 찾으면 뒤쪽 프로토타입의 속성은 가려지며, 이를 속성 가리기(shadowing)라고 합니다. 그림 아래의 설명처럼 자체 속성의 값이 undefined여도 속성은 있는 것이므로 다음 프로토타입으로 넘어가지 않습니다. 그림의 Type.prototype에서 Object.prototype, null로 이어지는 체인은 한 가지 예이며, 실제 체인의 길이는 객체마다 다릅니다.
다음 코드는 Object.setPrototypeOf()로 child의 프로토타입을 parent로 정해 체인을 직접 만듭니다.
const parent = {
parentProp: "Parent Value",
parentMethod: function() {
console.log("Parent Method Called!");
}
};
const child = {
childProp: "Child Value"
};
// child의 프로토타입을 parent로 설정합니다.
// Object.setPrototypeOf()는 객체의 [[Prototype]]을 설정하는 표준적인 방법입니다.
Object.setPrototypeOf(child, parent);
console.log(child.childProp); // 결과: Child Value (child 객체 자신의 속성)
console.log(child.parentProp); // 결과: Parent Value (프로토타입 체인을 통해 parent에서 찾음)
child.parentMethod(); // 결과: Parent Method Called! (프로토타입 체인을 통해 parent에서 찾음)
console.log(child.nonExistent); // 결과: undefined (체인 끝까지 찾았으나 없음)child.childProp은 child 자신에게서 바로 찾습니다. parentProp과 parentMethod는 child에 없으므로 프로토타입인 parent에서 찾습니다. nonExistent는 parent와 그 프로토타입인 Object.prototype에도 없어 체인 끝에서 undefined가 됩니다. 실무에서는 이미 만든 객체의 프로토타입을 바꾸는 것보다 Object.create(parent)로 처음부터 프로토타입을 정해 객체를 만드는 편이 좋습니다. 만들어진 객체의 프로토타입을 바꾸면 엔진이 해 둔 최적화가 무효가 되어 느려질 수 있기 때문입니다.
더 깊이: 엔진은 프로토타입 체인을 매번 따라갈까
명세대로라면 속성을 읽을 때마다 체인을 한 단계씩 따라가야 하지만, 엔진은 같은 일을 반복하지 않으려고 객체의 모양을 기록해 둡니다. V8은 같은 이름의 속성을 같은 순서로 가진 객체들에 히든 클래스(hidden class)라는 공통의 모양 식별자를 붙이고, 이 히든 클래스에 그 객체의 프로토타입에 대한 참조도 함께 담습니다. 코드의 한 지점이 늘 같은 모양의 객체를 읽는다면, 인라인 캐시(inline cache)가 지난번에 찾은 위치를 기억해 두었다가 다음에는 찾는 과정을 건너뜁니다.
Object.setPrototypeOf()로 이미 쓰고 있던 객체의 프로토타입을 바꾸면 이 가정이 깨집니다. MDN은 이 작업이 현대 엔진이 속성 접근을 최적화하는 방식 때문에 모든 브라우저와 엔진에서 아주 느리다고 경고하며, 그 영향이 바꾸는 문장 한 줄에 그치지 않고 그 객체에 접근하는 모든 코드로 번질 수 있다고 설명합니다. 바꾸는 순간뿐 아니라 그 뒤에 그 객체를 쓰는 코드까지 느려질 수 있다는 뜻입니다.
그래서 상속 관계는 객체를 만들 때 정하는 것이 원칙입니다. Object.create(), 생성자 함수의 prototype, 뒤에 볼 class의 extends는 모두 객체가 처음 만들어질 때 프로토타입을 정합니다. 히든 클래스의 동작은 V8 블로그의 빠른 속성 설명에서 더 자세히 볼 수 있습니다.
생성자 함수로 상속하기
ES2015의 class가 생기기 전에는 생성자 함수와 프로토타입을 조합해 상속을 구현했습니다. 다음 코드는 Dog가 Animal을 물려받도록 두 곳에서 연결하므로, 생성자 안의 Animal.call(this, name)과 생성자 밖의 Object.create() 줄을 먼저 찾아보세요. 마지막 네 줄은 만들어진 프로토타입 체인을 myDog에서 시작해 한 단계씩 위로 올라가며 확인합니다.
// 부모 생성자 함수
function Animal(name) {
this.name = name;
this.species = "동물";
}
Animal.prototype.sound = function() {
console.log("동물 소리!");
};
// 자식 생성자 함수
function Dog(name, breed) {
// Animal 생성자를 this를 Dog 인스턴스로 지정해 호출합니다.
Animal.call(this, name); // 부모 생성자로 name과 species 초기화
this.breed = breed;
}
// Animal.prototype을 프로토타입으로 하는 새 객체를 Dog.prototype으로 정합니다.
Dog.prototype = Object.create(Animal.prototype);
// 새 prototype에는 자체 constructor가 없어 Animal로 보이므로 Dog로 되돌립니다.
Dog.prototype.constructor = Dog;
// Dog.prototype에 Dog만의 메서드를 추가합니다.
Dog.prototype.bark = function() {
console.log("멍멍!");
};
const myDog = new Dog("댕댕이", "시바견");
console.log(myDog.name); // 댕댕이 (Animal 생성자가 만든 자체 속성)
console.log(myDog.species); // 동물 (Animal 생성자가 만든 자체 속성)
console.log(myDog.breed); // 시바견 (Dog 생성자가 만든 자체 속성)
myDog.sound(); // 동물 소리! (Animal.prototype에서 찾은 메서드)
myDog.bark(); // 멍멍! (Dog.prototype에서 찾은 메서드)
console.log(Object.getPrototypeOf(myDog) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(Object.getPrototypeOf(Animal.prototype) === Object.prototype); // true
console.log(Object.getPrototypeOf(Object.prototype) === null); // true (체인의 끝)이 코드에서 상속은 두 가지 일로 나뉩니다. Animal.call(this, name)은 부모 생성자의 초기화 코드를 다시 쓰는 것으로, 지금 만드는 Dog 인스턴스에 name과 species를 자체 속성으로 만듭니다. 그래서 myDog.name은 부모의 프로토타입에서 읽는 값이 아니라 myDog 자신의 속성입니다.
Object.create(Animal.prototype)은 메서드를 찾을 연결을 만듭니다. 이 연결 덕분에 myDog에서 sound를 찾으면 myDog, Dog.prototype을 지나 Animal.prototype에서 찾습니다. constructor를 Dog로 되돌리는 줄은 체인이 동작하는 데 꼭 필요하지는 않지만, myDog.constructor가 Animal로 보이는 혼란을 막아 줍니다.
class 문법
ES2015부터 자바스크립트에 class 키워드가 추가되었습니다. class는 프로토타입 기반 상속을 다른 객체 지향 언어의 클래스처럼 읽히게 해 주는 문법으로, 흔히 문법적 설탕(syntactic sugar)이라고 부릅니다. class로 만들어도 상속은 여전히 프로토타입 체인으로 이루어집니다.
// 부모 클래스
class Animal {
constructor(name) {
this.name = name;
this.species = "동물";
}
sound() { // 이 메서드는 Animal.prototype에 추가됩니다.
console.log("동물 소리!");
}
}
// 자식 클래스 (extends 키워드를 사용하여 상속)
class Dog extends Animal {
constructor(name, breed) {
super(name); // 부모 클래스의 constructor를 호출합니다. (this보다 먼저 필수)
this.breed = breed;
}
bark() { // 이 메서드는 Dog.prototype에 추가됩니다.
console.log("멍멍!");
}
// 부모 메서드 오버라이딩 (재정의)
sound() {
console.log(`${this.name}는 멍멍 짖습니다!`);
}
}
const myCat = new Animal("나비");
myCat.sound(); // 동물 소리!
const myNewDog = new Dog("초코", "푸들");
console.log(myNewDog.name); // 초코
console.log(myNewDog.species); // 동물
console.log(myNewDog.breed); // 푸들
myNewDog.sound(); // 초코는 멍멍 짖습니다! (오버라이딩된 메서드)
myNewDog.bark(); // 멍멍!
// 내부적으로는 여전히 프로토타입 체인으로 연결되어 있습니다.
console.log(Object.getPrototypeOf(myNewDog) === Dog.prototype); // true
console.log(Object.getPrototypeOf(Dog.prototype) === Animal.prototype); // true
console.log(Object.getPrototypeOf(Animal.prototype) === Object.prototype); // trueclass 클래스명 { ... }으로 클래스를 선언하고, constructor()는 new로 인스턴스를 만들 때 호출되어 인스턴스의 속성을 초기화합니다. constructor 밖에 적은 메서드는 클래스의 prototype 객체에 추가되므로, 앞의 생성자 함수 예제에서 Animal.prototype.sound에 직접 넣던 일을 대신합니다. extends로 상속할 부모를 정하면 Dog.prototype의 프로토타입이 Animal.prototype으로 연결됩니다.
자식 클래스의 constructor에서는 super()로 부모의 constructor를 호출해야 하며, super()를 호출하기 전에는 this를 쓸 수 없습니다. 앞 예제의 Animal.call(this, name)이 하던 부모 초기화를 super(name)이 맡는 셈입니다. Dog에 같은 이름의 sound()를 다시 정의하면 Dog.prototype의 sound가 체인에서 먼저 발견되어 Animal.prototype의 sound를 가립니다.
상속 모델은 같지만, class와 생성자 함수가 모든 면에서 같지는 않습니다. 클래스 본문은 항상 엄격 모드이고, 클래스는 new 없이 호출하면 TypeError가 나며, 클래스 메서드는 for...in으로 열거되지 않습니다. 이 글에서 문법적 설탕이라고 부르는 것은 상속이 프로토타입으로 이루어진다는 점에서입니다.
읽기와 쓰기의 차이
속성을 읽을 때는 프로토타입 체인을 따라가지만, 속성에 값을 대입할 때는 다릅니다. 다음 예제는 일반 데이터 속성만으로 이 차이를 보여 줍니다. getter와 setter가 있는 접근자 속성이나 Proxy는 대입할 때 다르게 동작하므로 여기서는 다루지 않습니다.
const parentForLookup = { score: 10 };
const childForLookup = Object.create(parentForLookup);
console.log(childForLookup.score, Object.hasOwn(childForLookup, "score"));
childForLookup.score = 20;
console.log(childForLookup.score, parentForLookup.score);
delete childForLookup.score;
console.log(childForLookup.score);첫 출력은 10 false입니다. childForLookup 자신에게는 score가 없으므로 Object.hasOwn()은 false이고, 읽을 때는 프로토타입인 parentForLookup에서 10을 찾습니다. 다음 출력은 20 10입니다. 대입은 체인을 따라가 부모의 값을 바꾸지 않고 childForLookup에 자체 속성을 새로 만들며, 부모의 score는 그대로 10입니다.
delete로 자식의 자체 속성을 지우면 다시 부모에서 찾으므로 마지막 출력은 10입니다. 이처럼 프로토타입의 속성은 자식에게 복사되는 것이 아니라 읽을 때마다 체인을 따라 찾는 것입니다.
정리
- 객체는
[[Prototype]]으로 다른 객체나null과 연결되고, 속성을 읽을 때는 자기 자신부터 체인을 따라 찾다가 처음 찾은 곳에서 멈춥니다. - 체인 끝까지 없으면
undefined이며, 자체 속성의 값이undefined인 것과 속성이 없는 것은 다릅니다. - 프로토타입은
Object.getPrototypeOf()로 확인하고, 처음부터 정할 때는Object.create()를 씁니다.__proto__는 오래된 방식입니다. - 생성자 함수의
prototype속성은 인스턴스들의 프로토타입이 되며, 여기에 둔 메서드는 모든 인스턴스가 함께 씁니다. - 생성자 함수로 상속할 때는 부모 생성자 호출로 자체 속성을 만들고,
Object.create()로 메서드를 찾을 체인을 연결합니다. class와extends,super()는 같은 프로토타입 상속을 더 읽기 쉽게 쓰는 문법이지만, 엄격 모드와new필수처럼 다른 점도 있습니다.- 대입은 체인을 따라가지 않고 그 객체에 자체 속성을 만들어 프로토타입의 같은 이름 속성을 가립니다.
연습 문제
1. undefined를 대입하면
앞 절의 마지막 예제에서 childForLookup.score = 20;만 childForLookup.score = undefined;로 바꿉니다. 세 console.log의 출력과, 대입 직후 Object.hasOwn(childForLookup, "score")의 값을 예상해 보세요. 값이 undefined인 것과 속성이 없는 것은 같을까요?
해설 보기
출력은 10 false, undefined 10, 10입니다. 대입하면 값이 undefined인 자체 속성이 생기므로 대입 직후 Object.hasOwn()은 true이고, 읽기는 그 자체 속성에서 멈춰 undefined를 돌려줍니다. 부모의 10으로 대신 채워지지 않습니다.
delete를 한 뒤에야 자체 속성이 없어져 다시 부모를 찾고 10이 나옵니다. 그래서 값이 undefined인 것과 속성이 없는 것은 다릅니다.
2. 생성자 안의 메서드
Person 예제에서 Person.prototype.sayHello = ...를 지우고, 대신 생성자 안에 this.sayHello = function() { ... };를 넣었습니다. person1.sayHello === person2.sayHello와 Object.hasOwn(person1, "sayHello")의 결과는 각각 무엇일까요?
해설 보기
person1.sayHello === person2.sayHello는 false이고, Object.hasOwn(person1, "sayHello")는 true입니다. 생성자를 호출할 때마다 새 함수를 만들어 인스턴스 자신의 속성으로 넣으므로, 두 인스턴스는 각자 다른 함수를 가집니다.
프로토타입에 정의했을 때는 sayHello가 Person.prototype에만 있어 Object.hasOwn(person1, "sayHello")가 false였고, 두 인스턴스가 같은 함수를 공유했습니다.
3. 오버라이딩된 메서드는 어디에 있을까
class 예제의 myNewDog에 대해 다음 세 값은 각각 true일까요, false일까요? 그리고 myNewDog.sound()가 Animal의 sound가 아니라 Dog의 sound를 호출하는 이유를 체인으로 설명해 보세요.
Object.hasOwn(myNewDog, "sound");
Object.hasOwn(Dog.prototype, "sound");
Object.hasOwn(Animal.prototype, "sound");해설 보기
차례로 false, true, true입니다. 클래스 메서드는 인스턴스가 아니라 각 클래스의 prototype에 들어가므로, sound는 Dog.prototype과 Animal.prototype 두 곳에 있고 myNewDog 자신에게는 없습니다.
myNewDog.sound()를 호출하면 myNewDog 자신에게 없으므로 다음 프로토타입인 Dog.prototype에서 sound를 찾고, 거기서 멈춥니다. 그래서 체인의 더 뒤에 있는 Animal.prototype.sound는 가려져 호출되지 않습니다.
프로토타입 탐색과 속성 가리기는 MDN의 상속과 프로토타입 체인 문서에서, 클래스의 엄격 모드와 선언 규칙은 MDN의 클래스 문서에서 확인할 수 있습니다.
다음 글 객체 지향과 함수형 프로그래밍에서는 상태를 객체의 메서드로 관리하는 방식과 순수 함수로 새 값을 만드는 방식을 비교합니다.