JavaScript의 상속과 프로토타입
객체지향 프로그래밍에서는 하나의 객체가 다른 객체의 속성과 기능을 물려받는 상속이 자주 사용됩니다.
JavaScript에서는 클래스가 등장하기 전부터 프로토타입(Prototype)을 이용해 상속을 구현했습니다.
그렇다면 JavaScript는 왜 프로토타입을 사용하고, prototype과 constructor는 어떤 관계이며, 오늘날 사용하는 class 문법은 프로토타입과 어떤 관계가 있을까요?
1. JavaScript에서 상속은 어떻게 구현할까?
먼저 객체가 다른 객체의 기능을 사용할 수 있도록 만들어보겠습니다.
const animal = {
eat() {
console.log('먹는다');
}
};
const dog = Object.create(animal);
dog.bark = function () {
console.log('짖는다');
};
이제 dog에는 eat이라는 메서드가 직접 존재하지 않지만 호출할 수 있습니다.
dog.eat(); // 먹는다
그 이유는 dog의 프로토타입이 animal로 설정되어 있기 때문입니다.
dog
├─ bark()
│
└─ [[Prototype]]
↓
animal
└─ eat()
JavaScript는 dog.eat()을 실행할 때 먼저 dog에서 eat을 찾습니다.
없다면 dog의 [[Prototype]]으로 이동해 animal에서 다시 찾습니다.
이처럼 객체에서 원하는 프로퍼티를 찾지 못했을 때 프로토타입을 따라가며 탐색하는 구조를 프로토타입 체인(Prototype Chain)이라고 합니다.
즉, JavaScript의 상속은 프로퍼티를 복사하는 방식이 아니라 객체와 객체를 연결하고, 필요할 때 프로토타입을 따라가며 프로퍼티를 찾는 방식으로 이루어집니다.
2. 그렇다면 왜 프로토타입이 필요한가?
프로토타입의 중요한 역할 중 하나는 공통으로 사용하는 메서드를 공유하는 것입니다.
예를 들어 여러 명의 Person 객체를 만든다고 생각해봅시다.
function Person(name) {
this.name = name;
this.sayHello = function () {
console.log(`Hello, ${this.name}`);
};
}
const kim = new Person('Kim');
const lee = new Person('Lee');
이렇게 작성하면 kim과 lee가 각각 sayHello 함수를 하나씩 가지고 있게 됩니다.
kim
└─ sayHello → 함수 A
lee
└─ sayHello → 함수 B
하지만 메서드의 내용은 동일합니다.
이때 메서드를 프로토타입에 두면 하나의 함수를 여러 객체가 공유할 수 있습니다.
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {
console.log(`Hello, ${this.name}`);
};
이제 구조는 다음과 같습니다.
kim ──────┐
│
lee ──────┼──→ Person.prototype
│ └─ sayHello()
other ────┘
kim.sayHello()를 호출하면 kim에 sayHello가 없기 때문에 프로토타입인 Person.prototype에서 찾아 실행합니다.
따라서 프로토타입은 객체들이 공통으로 사용할 기능을 공유하고, 객체 간 상속 관계를 구성하기 위한 기반이라고 볼 수 있습니다.
3. 함수에는 prototype 프로퍼티가 생성된다
여기서 prototype이라는 개념이 등장합니다.
function Person(name) {
this.name = name;
}
일반적으로 생성자로 사용할 수 있는 함수에는 prototype이라는 프로퍼티가 만들어집니다.
console.log(Person.prototype);
이 prototype은 Person으로 생성되는 객체가 자신의 프로토타입으로 사용할 객체입니다.
const kim = new Person('Kim');
Object.getPrototypeOf(kim) === Person.prototype;
// true
관계를 그려보면 다음과 같습니다.
Person
│
│ prototype
↓
Person.prototype
↑
│ [[Prototype]]
│
kim
여기서 반드시 구분해야 할 것이 있습니다.
prototype과 [[Prototype]]은 서로 다른 개념입니다.
prototype
함수가 가지고 있는 프로퍼티입니다.
Person.prototype
[[Prototype]]
객체가 자신의 프로토타입을 가리키는 내부 슬롯입니다.
Object.getPrototypeOf(kim)
따라서 다음 두 문장은 서로 다른 관계를 의미합니다.
Person ── prototype ──→ Person.prototype
kim ── [[Prototype]] ──→ Person.prototype
prototype은 함수가 가지고 있는 프로퍼티이고, [[Prototype]]은 객체가 프로토타입을 가리키는 내부 연결입니다.
참고로 일반 객체에 prototype 프로퍼티가 있는 것은 아닙니다.
const kim = {};
console.log(kim.prototype);
// undefined
하지만 일반 객체도 [[Prototype]]은 가지고 있습니다.
Object.getPrototypeOf(kim);
// Object.prototype
4. constructor는 무엇일까?
그렇다면 Person.prototype에는 무엇이 들어 있을까요?
기본적으로 constructor라는 프로퍼티가 존재합니다.
function Person(name) {
this.name = name;
}
console.log(Person.prototype.constructor === Person);
// true
즉 다음과 같은 연결이 만들어집니다.
Person
│
│ prototype
↓
Person.prototype
│
│ constructor
↓
Person
constructor는 해당 프로토타입 객체를 기본적으로 만든 함수 객체를 가리키는 프로퍼티입니다.
그래서:
Person.prototype.constructor === Person
// true
가 됩니다.
5. 프로토타입의 전체 구조 이해하기
new Person()으로 만들어진 객체는 Person.prototype을 자신의 프로토타입으로 가집니다.
function Person(name) {
this.name = name;
}
Person.prototype.sayHi = function () {
console.log(`Hi, ${this.name}`);
};
const kim = new Person('Kim');
console.log(Object.getPrototypeOf(kim) === Person.prototype);
// true
여기서 kim과 Person.prototype의 관계는 다음과 같습니다.
kim
↓ [[Prototype]]
Person.prototype
그런데 Person도 하나의 함수 객체이기 때문에 자신의 [[Prototype]]을 가지고 있습니다.
Object.getPrototypeOf(Person) === Function.prototype;
// true
그리고 Function.prototype 역시 객체이므로 자신의 [[Prototype]]을 가지고 있습니다.
Object.getPrototypeOf(Function.prototype) === Object.prototype;
// true
결국 Person의 프로토타입 체인은 다음과 같이 이어집니다.
Person
↓ [[Prototype]]
Function.prototype
↓ [[Prototype]]
Object.prototype
↓ [[Prototype]]
null
kim 역시 Person.prototype에서 끝나는 것이 아니라, 그 위의 프로토타입까지 연결됩니다.
kim
↓ [[Prototype]]
Person.prototype
↓ [[Prototype]]
Object.prototype
↓ [[Prototype]]
null
즉, 지금까지 살펴본 kim, Person, Person.prototype의 관계는 서로 독립적인 것이 아니라 Function.prototype과 Object.prototype까지 연결된 하나의 객체 구조를 이루고 있습니다.
앞에서 작성한 코드를 이러한 전체 관계로 표현하면 다음과 같습니다.
6. class는 왜 등장했을까?
프로토타입을 이용해 상속을 구현하는 방식은 강력하지만 코드가 다소 복잡하게 느껴질 수 있습니다.
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {
console.log(`Hello, ${this.name}`);
};
const kim = new Person('Kim');
JavaScript에서는 이를 보다 익숙한 클래스 형태로 작성할 수 있습니다.
class Person {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(`Hello, ${this.name}`);
}
}
const kim = new Person('Kim');
그렇다고 class가 프로토타입을 없앤 것은 아닙니다.
const kim = new Person('Kim');
Object.getPrototypeOf(kim) === Person.prototype;
// true
또한 클래스의 메서드는 기본적으로 인스턴스에 직접 만들어지는 것이 아니라 Person.prototype에 존재합니다.
Person.prototype.hasOwnProperty('sayHello');
// true
즉 class를 사용해도 내부적으로는 프로토타입 기반의 객체 모델이 사용됩니다.
따라서 다음과 같이 이해하는 것이 가장 정확합니다.
JavaScript의 객체 상속 모델은 여전히 프로토타입 기반이며,
class는 이 모델을 클래스 문법으로 표현할 수 있도록 제공되는 문법입니다.
7. 프로퍼티에는 Descriptor가 존재한다
프로토타입을 공부하다 보면 객체의 프로퍼티를 조금 더 세밀하게 제어해야 하는 경우가 있습니다.
JavaScript의 프로퍼티는 단순히 key와 value만 가지고 있는 것이 아닙니다.
const user = {
name: 'Kim'
};
name이라는 프로퍼티에는 다음과 같은 정보가 함께 존재합니다.
name
├─ value: "Kim"
├─ writable: true
├─ enumerable: true
└─ configurable: true
이러한 프로퍼티의 속성을 설명하는 객체를 Property Descriptor라고 합니다.
Object.getOwnPropertyDescriptor()를 사용하면 확인할 수 있습니다.
Object.getOwnPropertyDescriptor(user, 'name');
결과:
{
value: 'Kim',
writable: true,
enumerable: true,
configurable: true
}
각 속성은 다음을 의미합니다.
value: 프로퍼티의 값writable: 값을 변경할 수 있는지enumerable: 열거 가능한지configurable: 프로퍼티를 삭제하거나 Descriptor를 변경할 수 있는지
8. Descriptor를 직접 정의할 때 주의할 점
Object.defineProperty()를 사용하면 프로퍼티의 Descriptor를 직접 정의할 수 있습니다.
const user = {};
Object.defineProperty(user, 'name', {
value: 'Kim'
});
여기서 주의할 점은 생략한 Descriptor의 기본값입니다.
Object.defineProperty()로 프로퍼티를 정의할 때 writable, enumerable, configurable을 생략하면 기본적으로 false가 됩니다.
따라서:
user.name = 'Lee';
console.log(user.name);
// Kim
처럼 값이 변경되지 않습니다.
Descriptor를 확인하면:
Object.getOwnPropertyDescriptor(user, 'name');
다음과 같습니다.
{
value: 'Kim',
writable: false,
enumerable: false,
configurable: false
}
따라서 일반적인 객체 리터럴의 프로퍼티와 동일한 동작을 원한다면 필요한 속성을 명시해야 합니다.
Object.defineProperty(user, 'name', {
value: 'Kim',
writable: true,
enumerable: true,
configurable: true
});
9. 데이터 프로퍼티와 접근자 프로퍼티
Descriptor에는 크게 두 종류가 있습니다.
데이터 프로퍼티
값을 직접 저장하는 프로퍼티입니다.
{
value: 'Kim',
writable: true,
enumerable: true,
configurable: true
}
접근자 프로퍼티
getter와 setter를 사용하는 프로퍼티입니다.
const user = {
firstName: 'Kim',
get name() {
return this.firstName;
}
};
이 경우 name에 대한 Descriptor는 다음과 같은 형태를 가집니다.
{
get: function,
set: undefined,
enumerable: true,
configurable: true
}
여기서 중요한 규칙이 하나 있습니다.
하나의 프로퍼티에서 데이터 프로퍼티와 접근자 프로퍼티의 속성을 섞어 사용할 수 없습니다.
즉 다음처럼 작성할 수 없습니다.
Object.defineProperty(user, 'name', {
value: 'Kim',
get() {
return 'Lee';
}
});
value, writable은 데이터 프로퍼티의 속성이고, get, set은 접근자 프로퍼티의 속성이기 때문입니다.
마무리
JavaScript의 상속은 각각의 문법을 따로 외우기보다 객체와 프로토타입이 어떻게 연결되고, 프로퍼티를 어떻게 탐색하는지를 하나의 흐름으로 이해하는 것이 중요합니다.
특히 다음 세 가지는 반드시 구분해야 합니다.
Person ── prototype ──→ Person.prototype
kim ── [[Prototype]] ──→ Person.prototype
Person.prototype ── constructor ──→ Person
prototype: 함수가 가지고 있는 프로퍼티[[Prototype]]: 객체가 자신의 프로토타입을 가리키는 내부 슬롯constructor: 프로토타입 객체에서 생성자 함수를 가리키는 프로퍼티
결국 JavaScript의 상속은 객체를 복사하는 것이 아니라 프로토타입을 연결하고, 프로토타입 체인을 따라 필요한 프로퍼티를 찾아가는 방식입니다.
그리고 class를 사용하더라도 이 프로토타입 기반 객체 모델은 그대로 유지됩니다.
면접 대비 질문
Q1. JavaScript의 프로토타입 상속은 어떻게 동작하나요?
객체에 원하는 프로퍼티가 없으면 자신의
[[Prototype]]이 가리키는 객체로 이동해 프로퍼티를 탐색합니다. 이러한 연결을 따라가며 프로퍼티를 찾는 구조를 프로토타입 체인이라고 합니다.
Q2. prototype과 [[Prototype]]의 차이는 무엇인가요?
prototype은 함수가 가지고 있는 프로퍼티로,new를 통해 생성된 인스턴스의 프로토타입으로 사용될 객체를 가리킵니다. 반면[[Prototype]]은 객체가 자신의 프로토타입을 가리키는 내부 슬롯입니다.
Q3. Person.prototype.constructor === Person인 이유는 무엇인가요?
생성자 함수가 생성될 때 기본적으로 만들어지는
prototype객체에constructor프로퍼티가 생성자 함수 자신을 가리키도록 설정되기 때문입니다.
Q4. class를 사용하면 프로토타입을 사용하지 않나요?
아닙니다.
class도 내부적으로 프로토타입 기반의 객체 모델을 사용합니다. 클래스에서 정의한 인스턴스 메서드는 해당 클래스의prototype객체에 존재하고, 인스턴스는 이를 자신의 프로토타입으로 참조합니다.
Q5. 프로토타입을 사용하는 이유는 무엇인가요?
여러 객체가 동일한 메서드를 공유할 수 있기 때문입니다. 메서드를 각각의 객체에 생성하는 대신 프로토타입에 하나만 만들어두면 여러 인스턴스가 같은 함수를 공유할 수 있습니다.
복습 퀴즈
1. 다음 중 prototype과 [[Prototype]]에 대한 설명으로 올바른 것은?
function Person() {}
const kim = new Person();
① kim.prototype은 Person.prototype을 가리킨다.
② Person.[[Prototype]]은 kim을 가리킨다.
③ kim의 [[Prototype]]은 Person.prototype을 가리킨다.
④ Person.prototype과 Person은 같은 객체이다.
정답
③
new Person()으로 생성된 kim의 [[Prototype]]은 Person.prototype을 가리킵니다.
Object.getPrototypeOf(kim) === Person.prototype;
// true
2. 다음 코드의 결과는?
const animal = {
eat() {
console.log('먹는다');
}
};
const dog = Object.create(animal);
dog.eat();
① 에러가 발생한다.
② undefined
③ 먹는다
④ animal.eat의 함수 코드가 dog에 복사된다.
정답
③
dog에 eat이 없기 때문에 [[Prototype]]이 가리키는 animal에서 eat을 찾아 실행합니다.
dog
↓ [[Prototype]]
animal
↓
eat()
메서드가 dog에 복사되는 것은 아닙니다.
3. 다음 중 일반 객체와 함수의 차이를 가장 잘 설명한 것은?
const obj = {};
function Person() {}
① obj와 Person 모두 기본적으로 prototype 프로퍼티를 가진다.
② Person은 prototype 프로퍼티를 가지지만 obj는 일반적으로 가지지 않는다.
③ obj는 [[Prototype]]을 가지지만 Person은 가지지 않는다.
④ prototype과 [[Prototype]]은 같은 개념이다.
정답
②
일반 객체는 [[Prototype]]을 가지지만 일반적으로 prototype 프로퍼티는 갖지 않습니다.
반면 생성자 함수로 사용되는 함수에는 prototype 프로퍼티가 존재합니다.
4. 다음 코드의 결과는?
class Person {
sayHello() {}
}
const kim = new Person();
console.log(kim.hasOwnProperty('sayHello'));
console.log(Person.prototype.hasOwnProperty('sayHello'));
① true, true
② true, false
③ false, true
④ false, false
정답
③
class에서 정의한 메서드는 인스턴스에 직접 생성되지 않고 Person.prototype에 존재합니다.
따라서:
kim.hasOwnProperty('sayHello');
// false
Person.prototype.hasOwnProperty('sayHello');
// true
5. 다음 코드에서 kim.sayHello()가 실행될 때 어떤 순서로 탐색할까요?
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function () {
console.log(`Hello ${this.name}`);
};
const kim = new Person('Kim');
kim.sayHello();
① Person → kim → Person.prototype
② kim → Person.prototype
③ Person.prototype → kim
④ kim → Person
정답
②
먼저 kim 자신의 프로퍼티에서 sayHello를 찾습니다.
없으면 kim의 [[Prototype]]인 Person.prototype으로 이동해 찾습니다.
kim
↓ [[Prototype]]
Person.prototype
└─ sayHello