객체의 프로퍼티는 값 뿐만 아니라 플래그(flag)
라 불리는 특별한 속성 세 가지를 갖는다.
writable
: true
이면 프로퍼티의 값을 수정할 수 있다.enumerable
: true
이면 반복문을 사용해서 프로퍼티를 나열할 수 있다.configurable
: true
이면 프로퍼티를 삭제하거나 플래그 수정이 가능하다.지금까지 해왔던 방식으로 객체에 프로퍼티를 만들면 해당 프로퍼티의 플래그는 모두 true
가 된다.
먼저 플래그를 얻는 방법을 알아보자. Object.getOwnPropertyDescriptor
메서드를 사용하면 특정 프로퍼티에 대한 정보를 모두 얻을 수 있다.
let descriptor = Object.getOwnPropertyDescriptor(obj, propertyName);
위 메서드를 호출하면 프로퍼티 설명자(descriptor)
라 불리는 객체가 반환되는데, 여기에 프로퍼티 값과 세 가지 플래그에 대한 정보가 모두 담겨져있다.
let user = {
name: "John"
};
let descriptor = Object.getOwnPropertyDescriptor(user, 'name');
alert( JSON.stringify(descriptor, null, 2 ) );
/* property descriptor:
{
"value": "John",
"writable": true,
"enumerable": true,
"configurable": true
}
*/
Object.defineProperty
메서드를 사용하면 프로퍼티의 플래그를 변경할 수 있다. 객체에 해당 프로퍼티가 있으면 인수로 넘겨받은 정보대로 플래그를 변경해준다. 만약 프로퍼티가 없으면 인수로 넘겨받은 정보를 이용해서 새로운 프로퍼티를 만든다. 이 때, 플래그 정보가 없으면 플래그값은 자동으로 false
가 된다.
Object.defineProperty(obj, propertyName, descriptor)
아래 예시를 보면 새로운 프로퍼티 name
이 만들어지고, 모든 플래그 값이 false
가 된 것을 확인할 수 있다.
let user = {};
Object.defineProperty(user, "name", {
value: "John"
});
let descriptor = Object.getOwnPropertyDescriptor(user, 'name');
alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
"value": "John",
"writable": false,
"enumerable": false,
"configurable": false
}
*/
writable
플래그를 사용해서 프로퍼티에 값을 쓰거나, 못쓰게 만들 수 있다.
// 이미 존재하는 프로퍼티 변경
let user = {
name: "John"
};
Object.defineProperty(user, "name", {
writable: false
});
user.name = "Pete"; // Error: Cannot assign to read only property 'name'
// 새로운 프로퍼티에 적용
let user = { };
Object.defineProperty(user, "name", {
value: "John",
// defineProperty를 사용해 새로운 프로퍼티를 만들 땐, 어떤 플래그를 true로 할지 명시해주어야 한다.
enumerable: true,
configurable: true
});
alert(user.name); // John
user.name = "Pete"; // Error
enumerable
플래그 값을 이용해서 프로퍼티가 반복문에 나타나게(열거 가능) 혹은 나타나지 않게(열거 불가능) 만들 수 있다.
객체 내장 메서드 toString
은 열거가 불가능하지만, 커스텀으로 만든 toString
메서드는 반복문에 나타난다.
let user = {
name: "John",
toString() {
return this.name;
}
};
//커스텀 toString은 for...in을 사용해 열거할 수 있습니다.
for (let key in user) alert(key); // name, toString
위에서 설명한 enumerable
플래그 값을 false
로 설정해서 for in
반복문에 나타나지 않게 할 수 있다.
let user = {
name: "John",
toString() {
return this.name;
}
};
Object.defineProperty(user, "toString", {
enumerable: false
});
// 이제 for...in을 사용해 toString을 열거할 수 없게 되었습니다.
for (let key in user) alert(key); // name
configurable
플래그를 통해 해당 프로퍼티를 객체에서 지우거나 지울 수 없게, 플래그 설정을 가능하게하거나 가능하지 않게 만들 수 있다.
기본적으로 false
로 설정된 몇몇 내장 객체의 프로퍼티가 있다. 예를 들어 내장 객체Math
의 PI
프로퍼티가 대표적인 예이다. 이 프로퍼티는 쓰기와 열거, 구성이 불가능하다.
let descriptor = Object.getOwnPropertyDescriptor(Math, 'PI');
alert( JSON.stringify(descriptor, null, 2 ) );
/*
{
"value": 3.141592653589793,
"writable": false,
"enumerable": false,
"configurable": false
}
*/
Math.PI = 3; // Error
// 수정도 불가능하지만 지우는 것 역시 불가능하다
configurable
플래그를 false
로 설정하면 돌이킬 방법이 없다. defineProperty
를 써도 값을 true
로 돌릴 수 없다.
configurable
플래그를 false
로 설정했을 때 만들어내는 구체적인 제약사항은 아래와 같다.
configurable
플래그를 수정할 수 없다.enumerable
플래그를 수정할 수 없다.writable
플래그의 값을 false
에서 true
로 바꿀 수 없다. 단, true
에서 false
로의 변경은 가능하다.get / set
을 변경할 수 없다. 단, 새롭게 만드는 것은 가능하다.let user = { };
Object.defineProperty(user, "name", {
value: "John",
writable: false,
configurable: false
});
// user.name 프로퍼티의 값이나 플래그를 변경할 수 없습니다.
// 아래와 같이 변경하려고 하면 에러가 발생합니다.
// user.name = "Pete"
// delete user.name
// Object.defineProperty(user, "name", { value: "Pete" })
Object.defineProperty(user, "name", {writable: true}); // Error
Object.defineProperties(obj, descriptors)
메서드를 사용하면 프로퍼티 여러 개를 한 번에 정의할 수 있다.
Object.defineProperties(obj, {
propName1: descriptor1,
propName2: descriptor2
// ...
});
Object.defineProperties(user, {
name: { value: "John", writable: false },
surname: { value: "Smith", writable: false },
// ...
});
Object.getOwnPropertyDescriptors(obj)
메서드를 사용하면 모든 프로퍼티 설명자를 한 번에 가져올 수 있다.
해당 메서드와 Object.definedProperties
메서드를 함께 사용하면 객체 복제 시 플래그도 함께 복사할 수 있다.
let clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(obj));
위 방법을 사용하면 프로퍼티 뿐만 아니라 for in
반복문 방식으로는 복제 못하는 플래그 정보, 심볼형 프로퍼티도 복제할 수 있다.
객체의 프로퍼티는 두 종류로 나뉜다.
데이터 프로퍼티(data property)
: 지금까지 사용한 모든 일반적인 프로퍼티이다.접근자 프로퍼티(accessor property)
: 본질은 함수이며, 값을 get하거나 set하는 역할을 담당한다. 외부 코드에서는 함수가 아닌 일반적인 프로퍼티처럼 동작한다.접근자 프로퍼티는 getter
와 setter
메서드로 표현된다. 객체 리터럴 안에서 get
과 set
으로 나타낼 수 있다.
let obj = {
get propName() {
// getter, obj.propName을 실행할 때 실행되는 코드
},
set propName(value) {
// setter, obj.propName = value를 실행할 때 실행되는 코드
}
};
getter
메서드는 obj.propName
을 사용해서 프로퍼티를 읽으려고 할 때 실행되고, setter
메서드는 obj.propName = value
으로 프로퍼티에 값을 할당하려 할 때 실행된다.
프로퍼티 name
의 값이 "John"
이고 surname
의 값이 "Smith"
인 객체 user
를 만들어보자.
이 객체에 fullName
이라는 프로퍼티를 추가할 때, 기존 값을 복사-붙여넣기 하지 않고 fullName
이 John Smith
가 되도록 접근자 프로퍼티를 구현하자.
let user = {
name: "John",
surname: "Smith",
get fullName() {
return `${this.name} ${this.surname}`;
}
};
alert(user.fullName); // John Smith
객체 외부에서 프로퍼티를 호출할 때, 접근자 프로퍼티를 사용하면 함수처럼 호출하지 않고 일반 프로퍼티에서 값에 접근하는 것처럼 프로퍼티를 사용할 수 있다. 나머지 작업은 getter / setter
메서드 뒷단에서 처리해준다.
이어서, 위 예시의 fullName
은 getter
메서드만 가지고 있기 때문에 user.fullName = value
처럼 사용해서 값을 할당하려고 하면 에러가 발생한다.
let user = {
get fullName() {
return `...`;
}
};
user.fullName = "Test"; // Error (프로퍼티에 getter 메서드만 있어서 에러가 발생한다.)
user.fullName
에 setter
메서드를 추가해서 에러가 발생하지 않도록 고쳐보자.
let user = {
name: "John",
surname: "Smith",
get fullName() {
return `${this.name} ${this.surname}`;
},
set fullName(value) {
[this.name, this.surname] = value.split(" ");
}
};
// 주어진 값을 사용해 set fullName이 실행된다.
user.fullName = "Alice Cooper";
alert(user.name); // Alice
alert(user.surname); // Cooper
이렇게 getter
와 setter
메서드를 구현하면 객체에는 fullName
이라는 가상의 프로퍼티가 생긴다. 가상의 프로퍼티는 읽고 쓸 수는 있지만, 실제로 존재하지 않는다.
데이터 프로퍼티의 설명자와 접근자 프로퍼티의 설명자는 다르다. 접근자 프로퍼티에는 설명자 value
와 writable
이 없는 대신에 get
과 set
이라는 함수가 있다.
get
: 인수가 없는 함수로, 프로퍼티를 읽을 때 동작한다.set
: 인수가 하나인 함수로, 프로퍼티에 값을 쓸 때 호출된다.enumerable
: 데이터 프로퍼티와 동일하다.configurable
: 데이터 프로퍼티와 동일하다.데이터 프로퍼티를 정의할 때 처럼 접근자 프로퍼티도 아래와 같이 defineProperty
에 설명자 get
과 set
을 전달하면 접근자 프로퍼티를 설정할 수 있다.
let user = {
name: "John",
surname: "Smith"
};
Object.defineProperty(user, 'fullName', {
get() {
return `${this.name} ${this.surname}`;
},
set(value) {
[this.name, this.surname] = value.split(" ");
}
});
alert(user.fullName); // John Smith
for(let key in user) alert(key); // name, surname
객체의 프로퍼티는 접근자 프로퍼티나 데이터 프로퍼티 중 한 종류에만 속할 수 있다. 즉, get / set
메서드만 가지거나 value
만 가질 수 있다. 한 프로퍼티에 get / set
과 value
를 동시에 설정하면 에러가 발생한다.
// Error: Invalid property descriptor.
Object.defineProperty({}, 'prop', {
get() {
return 1
},
value: 2
});
getter
와 setter
를 실제 프로퍼티 값을 감싸는 래퍼처럼 사용하면 실제 프로퍼티 값을 원하는 대로 통제할 수 있다.
아래 예시에서는 name
프로퍼티를 위한 setter
를 만들어서 이름이 짧아지는 것을 방지하고 있다. 실제 값은 별도의 프로퍼티 _name
에 저장된다.
let user = {
get name() {
return this._name;
},
set name(value) {
if (value.length < 4) {
alert("입력하신 값이 너무 짧습니다. 네 글자 이상으로 구성된 이름을 입력하세요.");
return;
}
this._name = value;
}
};
user.name = "Pete";
alert(user.name); // Pete
user.name = ""; // 너무 짧은 이름을 할당하려 함
객체 user
의 이름은 _name
에 저장되고, 프로퍼티에 접근하는 것은 getter
와 setter
를 통해 이루어진다. 기술적으로 외부 코드에서 user._name
을 사용해서 바로 접근할 수 있다. 하지만 _
로 시작하는 프로퍼티는 객체 내부에서만 활용하고 외부에서는 건드리지 않는 것이 관습이다.
접근자 프로퍼티는 어느 때나 getter
와 setter
를 사용해서 데이터 프로퍼티의 행동과 값을 원하는 대로 조정할 수 있다는 점에서 유용하다.
데이터 프로퍼티 name
과 age
를 사용해서 사용자를 나타내는 객체를 구현한다고 가정해보자.
function User(name, age) {
this.name = name;
this.age = age;
}
let john = new User("John", 25);
alert( john.age ); // 25
그런데, 요구사항이 바뀌어서 age
대신에 birthday
를 저장해야 한다고 하자.
function User(name, birthday) {
this.name = name;
this.birthday = birthday;
}
let john = new User("John", new Date(1992, 6, 1));
위 방식처럼 생성자 함수를 수정하면 기존 코드 중 프로퍼티 age
를 사용하고 있는 코드를 모두 수정해야 한다. age
가 사용되는 부분을 모두 찾아서 수정하는 것도 가능하지만, 시간이 오래 걸린다. 게다가 여러 사람이 age
를 사용하고 있다면 모두 찾아 수정하는 것 자체가 힘들다.
age
프로퍼티는 그대로 있어도 나쁠 것이 없다. 그래서 기존 코드들은 그대로 두고 코드를 수정해보자. 대신 age
프로퍼티를 위한 getter
를 추가해서 문제를 해결해 보자.
function User(name, birthday) {
this.name = name;
this.birthday = birthday;
// age는 현재 날짜와 생일을 기준으로 계산된다.
Object.defineProperty(this, "age", {
get() {
let todayYear = new Date().getFullYear();
return todayYear - this.birthday.getFullYear();
}
});
}
let john = new User("John", new Date(1992, 6, 1));
alert( john.birthday ); // birthday를 사용할 수 있다.
alert( john.age ); // age 역시 사용할 수 있습니다다.
기존에 있는 기능을 가져와 확장해야 하는 경우에는 어떻게 할까? 예시를 살펴보자.
사람에 관한 프로퍼티와 메서드를 가진 user
라는 객체가 있는데, user
와 유사하지만 약간의 차이가 있는 admin
과 guest
라는 객체를 만들어야 한다고 가정하자.
이 때 프로토타입 상속을 이용하면 user
의 메서드를 복사/붙여넣거나 다시 구현하지 않고, user
에 약간의 기능을 얹어 admin
과 guest
객체를 만들 수 있다.
[[Prototype]]
과 __proto__
자바스크립트의 객체는 [[Prototype]]
이라는 숨김 프로퍼티를 갖는다. 이 숨김 프로퍼티 값은 null
이거나 다른 객체를 참조하는데, 참조 대상을 프로토타입
이라 부른다.
어떤 객체에서 프로퍼티를 읽으려고 하는데 해당 프로퍼티가 객체 내에 없으면 자바스크립트는 자동으로 프로토타입에서 프로퍼티를 찾는다. 이러한 방식을 통해 프로토타입 상속
을 구현할 수 있다.
[[Prototype]]
프로퍼티는 숨김 프로퍼티이지만 __proto__
를 사용해서 개발자가 값을 설정할 수 있다.
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal;
__proto___
는 [[Prototype]]
과 다르다. __proto__
는 [[Prototype]]
의 getter이자 setter이다. 하위 호환성 때문에 여전히 __proto__
를 사용할 수는 있지만, 최신 스크립트에서는 __proto__
대신 함수 Object.getPrototypeOf
나 Object.setPrototypeOf
를 써서 프로토타입을 get하거나 set한다.
다시 예시로 돌아가면 객체 rabbit
에서 프로퍼티를 얻고싶은데 해당 프로퍼티가 없다면, 자바스크립트는 자동으로 프로토타입인 animal
객체에서 프로퍼티를 얻는다.
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // (*)
// 프로퍼티 eats과 jumps를 rabbit에서도 사용할 수 있게 되었습니다.
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true
이제 rabit
의 프로토타입은 animal
이다. 그리고, rabbit
은 animal
을 상속받는다. 이렇게 프로토타입에서 상속받은 프로퍼티를 상속 프로퍼티
라고 한다.
상속 프로퍼티를 사용해서 animal
에 정의된 메서드를 rabbit
에서 호출해 보자.
let animal = {
eats: true,
walk() {
alert("동물이 걷습니다.");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
// 메서드 walk는 rabbit의 프로토타입인 animal에서 상속받았습니다.
rabbit.walk(); // 동물이 걷습니다.
프로토타입 체인은 계속 연결될 수 있다.
let animal = {
eats: true,
walk() {
alert("동물이 걷습니다.");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
let longEar = {
earLength: 10,
__proto__: rabbit
};
// 메서드 walk는 프로토타입 체인을 통해 상속받았습니다.
longEar.walk(); // 동물이 걷습니다.
alert(longEar.jumps); // true (rabbit에서 상속받음)
프로토타입 체이닝에는 두 가지 제약사항이 있다.
__proto__
를 이용해서 닫힌 형태로 다른 객체를 참조하면 에러가 발생한다.__proto__
의 값은 객체
나 null
만 가능하다. 다른 자료형은 무시된다.프로토타입은 프로퍼티를 읽을 때만 사용한다. 프로퍼티를 추가, 수정하거나 지우는 연산은 객체에 직접 해야 한다.
객체 rabbit
에 메서드 walk
를 직접 할당해보자. rabbit.walk()
를 호출하면 프로토타입에 있는 메서드가 실행되지 않고, rabbit
에 직접 추가한 메서드가 실행된다.
let animal = {
eats: true,
walk() {
/* rabbit은 이제 이 메서드를 사용하지 않습니다. */
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("토끼가 깡충깡충 뜁니다.");
};
rabbit.walk(); // 토끼가 깡충깡충 뜁니다.
메서드를 객체에서 호출하던 프로토타입에서 호출하던지 상관없이 this
는 언제나 .
앞에 있는 객체이다.
상속받은 메서드를 사용하더라도 객체는 프로토타입이 아닌 자신의 상태를 수정한다. 예시를 살펴보자. 메서드 저장소 역할을 하는 객체 animal
을 rabbit
이 상속받았다고 하자.
// animal엔 다양한 메서드가 있습니다.
let animal = {
walk() {
if (!this.isSleeping) {
alert(`동물이 걸어갑니다.`);
}
},
sleep() {
this.isSleeping = true;
}
};
let rabbit = {
name: "하얀 토끼",
__proto__: animal
};
// rabbit에 새로운 프로퍼티 isSleeping을 추가하고 그 값을 true로 변경합니다.
rabbit.sleep();
alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (프로토타입에는 isSleeping이라는 프로퍼티가 없습니다.)
rabbit.sleep()
을 호출하면 객체 rabbit
에 isSleeping
프로퍼티가 추가된다. 이 때 상속받은 메서드의 this
는 animal
이 아닌 실제 메서드가 호출되는 시점의 .
앞에 있는 객체가 된다. 따라서 this
에 데이터를 쓰면 animal
이 아닌 해당 객체의 상태가 변화한다. 즉, 메서드는 공유되지만, 객체의 상태는 공유되지 않는다!
for in
은 객체 자신의 프로퍼티 키 뿐만 아니라 상속받은 프로퍼티들의 키도 순회대상에 포함시킨다.
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
// Object.keys는 객체 자신의 키만 반환합니다.
alert(Object.keys(rabbit)); // jumps
// for..in은 객체 자신의 키와 상속 프로퍼티의 키 모두를 순회합니다.
for(let prop in rabbit) alert(prop); // jumps, eats
Object.hasOwnProperty(key)
를 이용하면 상속 프로퍼티를 순회 대상에서 제외할 수 있다. 이 메서드는 key
에 대응하는 프로퍼티가 상속 프로퍼티가 아니고 객체에 직접 구현되어있는 프로퍼티라면 true
를 반환한다.
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
for(let prop in rabbit) {
let isOwn = rabbit.hasOwnProperty(prop);
if (isOwn) {
alert(`객체 자신의 프로퍼티: ${prop}`); // 객체 자신의 프로퍼티: jumps
} else {
alert(`상속 프로퍼티: ${prop}`); // 상속 프로퍼티: eats
}
}
위 예시의 상속 관계를 그림으로 나타내면 아래와 같다. 그림을 보면 for in
안에서 rabbit
에 사용한 메서드 hasOwnProperty
가 상속받은 메서드인 것을 확인할 수 있다.
근데 상속 프로퍼티인 eats
는 출력되지만, hasOwnProperty
는 출력되지 않았다. 왜일까? 이유는 간단하다. Object.prototype
에 있는 모든 메서드의 enumerable
플래그는 false
이고, for in
은 열거 가능한 프로퍼티만 순회 대상에 포함하기 때문이다.
생성자 함수에 new
연산자를 사용해 만든 객체는 생성자 함수의 프로토타입 정보를 사용한다.
생성자 함수 F
의 프로토타입을 의미하는 F.prototype
에서 prototype
은 F
에 정의된 일반 프로퍼티이다. 다시 말하면 F.prototype
에서 prototype
은 앞 서 배운 숨김 프로퍼티인 [[Prototype]]
과 다른 일반적인 프로퍼티이다.
F.prototype
프로퍼티는 new F
를 호출할 때만 사용된다. new F
를 호출할 때 만들어지는 새로운 객체의 [[Prototype]]
을 할당해 준다.
아래 예시를 살펴보자.
let animal = {
eats: true
};
function Rabbit(name) {
this.name = name;
}
Rabbit.prototype = animal;
let rabbit = new Rabbit("흰 토끼"); // rabbit.__proto__ == animal
alert( rabbit.eats ); // true
Rabbit.protoype = animal
은 new Rabbit
을 호출해서 만든 새로운 객체의 [[Prototype]]
을 animal
로 설정하라는 것을 의미한다.
위 그림에서 세로 화살표가 객체 rabbit
이 animal
을 상속받았다는 것을 의미한다.
모든 함수는 특별히 할당하지 않더라도 기본적으로 prototype
을 갖는다.
prototype
프로퍼티는 constructor
프로퍼티 하나만 가지고 있는 객체를 가리키는데, 여기서 constructor
프로퍼티는 함수 자신을 가리킨다.
// 함수를 만들기만 해도 디폴트 프로퍼티인 prototype이 설정된다.
function Rabbit() {} // Rabbit.prototype = { constructor: Rabbit };
alert( Rabbit.prototype.constructor == Rabbit ); // true
특별한 조작을 가하지 않았다면 new Rabbit
을 실행해서 만든 모든 객체는 constructor
프로퍼티를 사용할 수 있는데, 이 때 [[Prototype]]
을 거쳐간다.
function Rabbit() {} // Rabbit.prototype = { constructor: Rabbit }
let rabbit = new Rabbit(); // {constructor: Rabbit}을 상속받음
alert(rabbit.constructor == Rabbit); // true ([[Prototype]]을 거쳐 접근함)
생성자 함수로 만들어진 객체의 constructor
프로퍼티를 이용하면 새로운 객체를 만들 때 사용할 수 있다. 이런 방법은 객체를 만들 때 어떤 생성자가 사용되었는지 알 수 없을 때 유용하게 쓸 수 있다.
function Rabbit(name) {
this.name = name;
alert(name);
}
let rabbit = new Rabbit("흰 토끼");
let rabbit2 = new rabbit.constructor("검정 토끼");
constructor
에 가장 중요한 점은 constructor
와 관련해서 벌어지는 모든 일은 전적으로 개발자에게 달려있다는 점이다.
함수에 기본으로 설정되는 prototype
프로퍼티 값을 다른 객체로 바꾸면 무슨일이 일어나는지 살펴보자.
function Rabbit() {}
Rabbit.prototype = {
jumps: true
};
let rabbit = new Rabbit();
alert(rabbit.constructor === Rabbit); // false
이런 상황을 방지하고 constructor
의 기본 성질을 제대로 활용하려면 prototype
에 뭔가를 하고 싶을 때 prototype
전체를 덮어쓰지 말고, prototype
에 원하는 프로퍼티만 추가 및 제거 해야 한다.
function Rabbit() {}
// Rabbit.prototype 전체를 덮어쓰지 말고
// 원하는 프로퍼티가 있으면 그냥 추가하자
Rabbit.prototype.jumps = true
// 이렇게 하면 디폴트 프로퍼티 Rabbit.prototype.constructor가 유지된다.
만약 실수로 prototype
을 덮어썼다 하더라도 constructor
프로퍼티를 수동으로 다시 만들어주면 constructor
를 다시 사용할 수 있다.
Rabbit.prototype = {
jumps: true,
constructor: Rabbit
};
prototype
프로퍼티는 자바스크립트 내부에서도 광범위하게 사용된다. 모든 내장 생성자 함수에서 prototype
프로퍼티를 사용한다.
빈 객체가 있다고 가정하자.
let obj = {};
alert( obj ); // "[object Object]"
obj
는 비어있는데, "[object Object]"
문자열을 생성하는 코드는 어디에 있을까? obj = {}
는 obj = new Object()
와 같다. 여기서 Object
는 내장 객체 생성자 함수인데, 이 생성자 함수의 prototype
은 toString
을 비롯한 다양한 메서드가 구현되어있는 객체를 참조한다.
new Object()
를 호출하거나 리터럴 문법을 사용해서 객체를 만들 때, 새롭게 생성된 객체의 [[Prototype]]
은 Object.prototype
을 참조한다.
따라서, obj.toString()
이 호출되면 Object.prototype
에서 해당 메서드를 가져오게 된다.
let obj = {};
alert(obj.__proto__ === Object.prototype); // true
alert(obj.toString === obj.__proto__.toString); //true
alert(obj.toString === Object.prototype.toString); //true
Array
, Date
, Function
을 비롯한 내장 객체들 역시 프로토타입에 메서드를 저장해 놓는다.
예를 들어, 배열 [1, 2, 3]
을 만들면 new Array()
의 내부 동작에 의하여 Array.prototype
이 배열 [1, 2, 3]
의 프로토타입이 되고, Array.prototype
을 통해 배열 메서드를 사용할 수 있다. 이러한 내부 동작은 메모리 효율을 높여주는 장점이 있다.
모든 내장 프로토타입의 상속 트리 꼭대기에는 Object.prototype
이 있다.
let arr = [1, 2, 3];
// arr은 Array.prototype을 상속받았나요?
alert( arr.__proto__ === Array.prototype ); // true
// arr은 Object.prototype을 상속받았나요?
alert( arr.__proto__.__proto__ === Object.prototype ); // true
// 체인 맨 위엔 null이 있습니다.
alert( arr.__proto__.__proto__.__proto__ ); // null
프로토타입 체인 상의 중복 메서드가 있을 때는 체인에 가장 가까운 곳에 있는 메서드가 사용된다. 예를 들어, Object.prototype
에도 toString
이 있고, Array.prototype
에도 toString
이 있는데, 가장 가까운 Array.prototype
의 toString
메서드가 사용된다.
let arr = [1, 2, 3]
alert(arr); // 1,2,3 <-- Array.prototype.toString의 결과
배열뿐만 아니라 함수 같은 다른 내장 객체들 또한 같은 방식으로 동작한다.
function f() {}
alert(f.__proto__ == Function.prototype); // true
alert(f.__proto__.__proto__ == Object.prototype); // true, 객체에서 상속받음
문자열과 숫자, 불린은 객체가 아니기에 다루기 까다롭다. 이런 원시 타입 값의 프로퍼티에 접근하려고 하면 내장 생성자 String
, Number
, Boolean
을 사용하는 임시 래퍼(wrapper) 객체가 생성된다. 임시 래퍼 객체는 메서드를 제공한 후 사라진다.
명세서에는 각 자료형에 해당하는 래퍼 객체의 메서드를 프로토타입 안에 구현해 놓고 String.prototype
, Number.prototype
, Boolean.prototype
을 사용해서 쓰도록 규정한다.
null
과 undefined
에 대응하는 래퍼 객체는 없다. 따라서, 메서드와 프로퍼티뿐만 아니라 프로토타입도 사용할 수 없다.
내장 프로토타입은 수정할 수 있다. 하지만, 프로토타입은 전역으로 영향을 미치기 때문에 프로토타입을 조작하면 기존 코드와 충돌이 날 가능성이 크다. 따라서, 내장 프로토타입을 수정하는 것은 최대한 지양한다. 모던 자바스크립트에서 내장 프로토타입의 변경을 허용하는 경우는 폴리필을 만들 때 딱 하나뿐이다.
개발을 하다 보면 내장 프로토타입에 구현된 메서드를 빌려야 하는 경우가 생긴다.
유사 배열 객체를 만들고 Array.prototype
의 메서드를 복사해보자. join
의 내부 알고리즘은 인덱스와 length
프로퍼티만 확인하기 때문에 아래 코드는 에러없이 동작한다.
let obj = {
0: "Hello",
1: "world!",
length: 2,
};
obj.join = Array.prototype.join;
alert( obj.join(',') ); // Hello,world!
이처럼 메서드 빌리기는 여러 객체에서 필요한 기능을 가져와 섞는 것을 가능하게 해주기 때문에 유연한 개발을 가능하게 한다.
__proto__
가 없는 객체__proto___
는 브라우저를 대상으로 개발하고 있다면 다소 구식이기 때문에 더는 사용하지 않는 것이 좋다. 대신 아래와 같은 모던한 메서드들을 사용하는 것이 좋다.
Object.create(proto, [descriptors])
: [[Prototype]]
이 매개변수 proto
를 참조하는 빈 객체를 만든다. 프로퍼티 설명자를 추가로 넘길 수 있다.Object.getPrototypeOf(obj)
: obj
의 [[Prototype]]
을 반환한다.Object.setPrototypeOf(obj, proto)
: obj
의 [[Prototype]]
이 매개변수 proto
가 되도록 설정한다.let animal = {
eats: true
};
// 프로토타입이 animal인 새로운 객체를 생성합니다.
let rabbit = Object.create(animal);
alert(rabbit.eats); // true
alert(Object.getPrototypeOf(rabbit) === animal); // true
Object.setPrototypeOf(rabbit, {}); // rabbit의 프로토타입을 {}으로 바꿉니다.
프로퍼티 설명자를 이용해 새 객체에 프로퍼티를 추가할 수도 있다.
let animal = {
eats: true
};
let rabbit = Object.create(animal, {
jumps: {
value: true
}
});
alert(rabbit.jumps); // true
Object.create
를 사용하면 for in
을 사용해서 프로퍼티를 복사하는 것보다 더 효과적으로 객체를 복제할 수 있다. 객체의 모든 프로퍼티를 포함한 완벽한 사본이 만들어진다. 사본에는 열거 가능한 프로퍼티와 불가능한 프로퍼티, 데이터 프로퍼티, getter, setter, [[Prototype]]
등 모든 프로퍼티가 복제된다. 단, 복제된 객체는 얕은 복사본이다.
let clone = Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));
참고로, 원한다면 언제나 [[Prototype]]
에 접근하거나 설정할 수 있다. 하지만 대개는 객체를 생성할 때만 설정하고 이후에는 수정하지 않는다. 자바스크립트 엔진은 이런 시나리오를 토대로 최적화되어 있다. 프로토타입을 그때그때 바꾸는 연산은 객체 프로퍼티 접근 관련 최적화를 망치기 때문에 성능에 나쁜 영향을 미친다. 따라서, [[Prototype]]
을 바꾸는 것이 어떤 결과를 초래할지 확실히 알거나 속도가 전혀 중요하지 않은 경우라면 프로토타입을 바꾸지 말자.
아래 예시를 살펴보자. 프롬프트 창에 __proto__
를 입력하면 값이 제대로 할당되지 않는다. __proto__
프로퍼티는 특별한 프로퍼티라는 것을 이미 알고 있기 때문이다. 참고로 __proto__
는 항상 객체이거나 null
이어야 한다.
let obj = {};
let key = prompt("입력하고자 하는 key는 무엇인가요?", "__proto__");
obj[key] = "...값...";
alert(obj[key]); // "...값..."이 아닌 [object Object]가 출력됩니다.
개발자가 위 예시와 같은 코드를 작성할 때는 이런 결과를 의도하면서 구현하지는 않는다. 다만 키가 무엇이 되었든, 키를 저장하는데 키가 __proto__
일 때 값이 제대로 저장되지 않는 건 명백한 버그이다.
위 예시에서는 이 버그가 그리 치명적이지는 않다. 그런데 할당 값(obj[key]
의 right side)이 객체 일 때는 프로토타입이 바뀔 수 있다는 치명적인 버그가 발생할 수 있다. 프로토타입이 바뀌면 예상치 못한 일이 발생할 수 있기 때문이다.
개발자들은 대개 프로토타입이 중간에 바뀌는 시나리오는 배제한 채 개발을 진행한다. 이런 고정관념 때문에 프로토타입이 중간에 바뀌면서 발생한 버그는 그 원인을 쉽게 찾지 못한다. 서버 사이드에서 자바스크립트를 사용할 때는 이런 버그가 취약점이 되기도 한다.
이러한 문제를 어떻게 예방할 수 있을까? 객체 대신 맵을 사용하면 된다. 또, 간단하게 그냥 객체를 사용할 수도 있다.
앞 서 봤듯이 __proto__
는 객체의 프로퍼티가 아니라 Object.prototype
의 접근자 프로퍼티이다.
그렇기 떄문에 obj.__proto__
를 읽거나 쓸때는 이에 대응하는 getter, setter가 프로토타입에서 호출되고 obj
는 [[Prototype]]
을 통해 getter와 setter에 접근한다. 다시말해, __proto__
는 [[Prototype]]
에 접근하기 위한 수단이지 [[Prototype]]
그 자체가 아니다.
Object.create(null)
을 사용해서 프로토타입이 없는 빈 객체를 만들면 해결할 수 있다.
let obj = Object.create(null);
let key = prompt("입력하고자 하는 key는 무엇인가요?", "__proto__");
obj[key] = "...값...";
alert(obj[key]); // "...값..."이 제대로 출력됩니다.
위 방법을 사용하면 객체는 더 이상 __proto___
의 getter와 setter를 상속받지 않는다. 이제 __proto__
는 평범함 데이터 프로퍼티처럼 처리되므로 버그 없이 예시가 잘 동작하게 된다.
이렇게 프로토타입이 없는 빈 객체는 아주 단순한 혹은 순수 사전식 객체라고 부른다. 이런 객체는 toString
같은 내장 메서드가 없다는 단점이 있다.
https://ko.javascript.info/object-properties
https://ko.javascript.info/prototypes