안녕하세요. 동쪽별입니다.
지난 글에서 프로토타입이 대체 뭐하는 녀석인지, 그리고 관련 프로퍼티들과 프로토타입 체인에 대해 알아보았어요.
이번에는 프로토타입 생성 시점, 생성 방식 그리고 기타 관련 내용들에 대해 살펴보면서 정복해보려 해요.
사실 __proto__ 접근자 프로퍼티 사용은 권장되지 않는 것을 알고 계신가요?
좀있다가 알려드릴게요 😛 (궁금한 사람은 맨 밑으로!)
이번 내용을 이해하기 위해선 제 글이 아니더라도, 프로토타입에 대한 기본은 알고 계셔야 합니다..!
그럼 시작해볼까요?
목차
- 프토토타입 생성 시점
- 프로토타입 생성 방식
- 프로토타입 섀도잉
- 프로토타입의 교체
- 직접 상속 (feat. __proto__ 를 대신한 모던 메서드)
프로토타입 생성 시점
프로토타입은 생성자 함수가 생성되는 시점에 더불어 생성됩니다.
따라서, 프로토타입과 생성자 함수는 단독으로 존재할 수 없고 언제나 쌍으로 존재해요.
그럼 사용자 정의 생성자 함수와 자바스크립트가 기본으로 제공하는 빌트인 생성자 함수로 구분하여 알아볼게요.
사용자 정의 생성자 함수
먼저 예시를 볼까요?
console.log(Friend.prototype); // {}
function Friend(name) {
this.name = name;
}
함수 선언문은 런타임 이전에 자바스크립트 엔진에 의해 먼저 실행됩니다.
(이를 호이스팅이라 해요. 나중에 따로 다루도록 할게요.)
따라서 위 예시의 Friend 생성자 함수는 런타임 전에 먼저 평가되어 함수 객체가 됩니다.
이때 프로토타입도 생성! 위 예시에서 { } 가 출력된 것을 볼 수 있죠.
즉, 생성자 함수로서 호출할 수 있는 함수, constructor는 함수 정의가 평가되어 함수 객체가 생성되는 시점에 프로토타입도 더불어 생성됩니다.
빌트인 생성자 함수
Object , String , Number , Function , Array , RegExp , Date , Promise 등과 같은 빌트인 생성자 함수도 일반 함수와 마찬가지로 빌트인 생성자 함수가 생성되는 시점에 프로토타입이 생성됩니다.
그리고 모든 빌트인 생성자 함수는 전역 객체가 생성되는 시점에 생성돼요!
전역 객체는 코드가 실행되기 이전 단계에 자바스크립트 엔진에 의해 생성되는 특수한 객체입니다.
이처럼 객체가 생성되기 이전에 생성자 함수와 프로토타입은 이미 객체화 되어 존재하게 됩니다.
이후 생성자 함수로 객체를 생성하면 프로토타입은 생성된 객체의 [[Prototype]] 내부 슬롯에 할당되는 것입니다.
프로토타입 생성 방식
이번엔 프로토타입의 생성 방식에 대해 알아볼게요.
객체는 다음과 같이 다양한 생성 방법이 있어요.
- 객체 리터럴 ({ })
- Object 생성자 함수
- 사용자 정의 생성자 함수
- Object.create 메서드
- 클래스
이처럼 다양한 방식으로 생성되는 모든 객체는 세부적인 방식의 차이는 있으나, 추상 연산 OrdinaryObjectCreate 에 의해 생성된다는 공통점이 있습니다.
추상 연산 OrdinaryObjectCreate 는 필수적으로 자신이 생성할 객체의 프로토타입을 인수로 전달 받아요.
그리고 추가 옵션으로 생성할 객체에 추가할 프로퍼티 목록도 전달 받을 수 있답니다.
이러한 OrdinaryObjectCreate 는
1. 빈 객체를 생성한 후,
2. 인수로 전달된 프로퍼티가 있으면 이를 객체에 추가하고
3. 인수로 전달받은 프로토타입을 생성한 객체의 [[Prototype]] 내부 슬롯에 할당한 다음,
- 사용자 정의 생성자 함수일 경우 → prototype 프로퍼티에 바인딩 되어있는 객체 할당
- 객체 리터럴, Object 생성자 함수 → Object.prototype 할당
4. 객체를 반환합니다.
즉, 프로토타입은 추상 연산 OrdinaryObjectCreate 에 의해 전달되는 인수에 의해 결정되는 것!
❓여기서 한가지 의문점!
객체 리터럴로 생성한 객체는 생성자 함수 안쓰는데..? 프로토타입이 어떻게 있죠..
분명 생성자 함수와 프로토타입은 쌍으로 있어야 한다고 했는데..
// 생성자 함수로 객체 생성
const obj = new Object();
const fuc = new Function('x', 'y', 'return x + y');
console.log(obj.constructor === Object); // true
console.log(fuc.constructor === Function); // true
// 리터럴 표기법으로 객체 생성
const obj2 = {};
function func() {};
console.log(obj2.constructor === Object); // true
console.log(func2.constructor === Function); // true
리터럴 표기법으로 생성한 객체도 각각 Function , Object 생성자 함수가 있는 것을 볼 수 있습니다!
그렇다면 리터럴에 의해 생성된 객체는 사실 Function , Object 생성자 함수로 생성되는 것은 아닐까요?
생성자 함수 호출과 객체 리터럴의 평가는 추상 연산 OrdinaryObjectCreate 를 호출하여 빈 객체를 생성하는 점에서 동일하나, new.target 의 확인이나 프로퍼티를 추가하는 처리 등 세부 내용은 다릅니다.
new.target 프로퍼티를 통해 함수 또는 생성자가 new 연산자를 사용하여 호출됐는지를 감지할 수 있습니다.
따라서 객체 리터럴에 의해 생성된 객체는 생성자 함수가 생성한 객체가 아닙니다!!
리터럴 표기법에 의해 생성된 객체도 상속을 위해 프로토타입이 필요합니다.
그렇기에 가상적인 생성자 함수를 가지게 됩니다.
그럼 생성자 함수의 짝궁인 프로토타입 또한 더불어 생성되겠죠.
(prototype, constructor 프로퍼티에 의해 연결되어 있기 때문)
프로토타입 생성 시점과 생성 방식에 대해 알아보았어요.
이제 추가 관련 내용들에 대해 소개해 드리도록 하겠습니다!
프로퍼티 섀도잉
프로토타입이 소유한 메서드와 동일한 이름의 메서드를 인스턴스에 추가하면 어떻게 될까요?
프로토타입 프로퍼티를 덮어쓰는 것이 아니라 인스턴스 프로퍼티로 추가합니다! ⇒ 오버라이딩
오버라이딩(overriding): 상위 클래스가 가지고 있는 메서드를 하위 클래스가 재정의하여 사용하는 방식
예시를 봅시다.
const Friend = (function () {
function Friend(name) {
this.name = name;
}
Friend.prototype.introduce = () => {
console.log(`안녕하세요. ${this.name}입니다!`);
};
return Friend;
}());
const friend1 = new Friend('짱구');
friend1.introduce = () = {
console.log(`안녕. ${this.name}라고 해!`);
}
friend1.introduce(); // 안녕. 짱구라고 해!
인스턴스 메서드 introduce 는 프로토타입 메서드 introduce 를 오버라이딩했고, 프로토타입 메서드 introduce 는 가려집니다.
이처럼 상속 관계에 의해 프로퍼티가 가려지는 현상을 프로퍼티 섀도잉(property shadowing)이라고 합니다.
프로퍼티를 삭제하는 경우도 마찬가지에요.
introduce 를 삭제하게 되면, 당연히 프로토타입 메서드가 아닌 인스턴스 메서드가 삭제됩니다.
만약 인스턴스 메서드 삭제 후, 또 다시 introduce 메서드를 삭제하면?
delete friend1.introduce;
friend1.introduce(); // 안녕하세요. 짱구입니다!
삭제되지 않았네요. 에러도 없이 아~무 일도 벌어지지 않았습니다.
그 이유는 프토토타입 프로퍼티를 변경/삭제하려면 하위 객체를 통해 접근해선 안되고, 프로토타입에 직접 접근해야 하기 때문입니다.
프로토타입의 교체
1. 생성자 함수에 의한 프로토타입 교체
const Friend = (function () {
function Friend(name) {
this.name = name;
}
Friend.prototype = {
introduce() {
console.log(`안녕하세요. ${this.name}입니다!`);
}
}
return Friend;
}());
const friend1 = new Friend('짱구');
Friend.prototype 에 객체 리터럴을 할당했습니다.
즉, 프로토타입을 객체 리터럴로 교체한 것이에요.
이를 이미지로 표현하면 아래와 같습니다.
프로토타입으로 교체한 객체 리터럴에 constructor 프로퍼티가 없네요..?
constructor 프로퍼티는 자바스크립트 엔진이 프로토타입을 생성할 때 암묵적으로 추가한 프로퍼티에요.
따라서 프토토타입을 다른 객체로 교체하면 덮어쓰게 되어 constructor 프로퍼티가 사라집니다.
⇒ 따라서, constructor 프로퍼티와 생성자 함수 간의 연결이 파괴됩니다.
이로인해 friend1 객체의 생성자 함수를 검색하면 Friend 가 아닌 Object 가 나오게 돼요. 아래 코드처럼!
console.log(friend1.constructor === Friend); // false
console.log(friend1.constructor === Object); // true
파괴된 연결을 되살리기 위해선 수동으로 constructor 프로퍼티를 추가해야 합니다..
...
Friend.prototype = {
constructor: Friend,
introduce() {
console.log(`안녕하세요. ${this.name}입니다!`);
}
}
...
2. 인스턴스에 의한 프로토타입 교체
function Friend(name) {
this.name = name;
}
const friend1 = new Friend('짱구');
const obj = {
introduce() {
console.log(`안녕하세요. ${this.name}입니다!`);
}
}
friend1.__proto__ = obj;
friend1.introduce(); // 안녕하세요. 짱구입니다!
friend1 객체의 프로토타입을 obj 객체로 교체했습니다.
이를 이미지로 표현하면 아래와 같습니다.
'생성자 함수에 의한 프토토타입 교체’ 와 마찬가지로 constructor 프로퍼티가 없으므로 constructor 프로퍼티와 생성자 함수 간의 연결이 파괴됩니다.
따라서 friend1 객체의 생성자 함수를 검색하면 역시나 Friend 가 아닌 Object 가 나오게 되겠죠.
하지만! 유일한 차이로, Friend 생성자 함수의 prototype 프로퍼티가 교체된 프로토타입을 가리키지 않습니다!
따라서, 파괴된 생성자 함수와 프로토타입 간의 연결을 되살리기 위해서
- 교체한 객체 리터럴에 constructor 프로퍼티를 추가하고
- 생성자 함수의 prototype 프로퍼티를 재설정해야 합니다..
아래처럼요..
...
const obj = {
constructor: Friend,
introduce() {
console.log(`안녕하세요. ${this.name}입니다!`);
},
};
Friend.prototype = obj;
...
이처럼 프로토타입 교체를 통해 객체 간의 상속 관계를 동적으로 변경하는 것은 꽤나 번거롭습니다.
따라서 프로토타입은 직접 교체하지 않는 것이 좋습니다.
상속 관계를 인위적으로 설정하려면 직접 상속이 더 편리하고 안전합니다.
직접 상속이 뭐냐구요? 바로 알아보러 가시죠.
빌트인 객체 프로토타입(ex. Object.prototype)을 조작하지 맙시다. 빌트인 객체 프로토타입은 전역으로 영향을 미치기 때문에 프로토타입을 조작하면 기존 코드와 충돌날 가능성이 큽니다.
직접 상속 (feat. __proto__ 를 대신한 모던 메서드)
__proto__ 접근자 프로퍼티를 코드 내에서 직접 사용하는 것은 권장되지 않습니다.
__proto__ 접근자 프로퍼티를 대신한 모던한 메서드들이 있어요.
- Object.getPrototypeOf(obj) : obj의 [[Prototype]]을 반환한다.
- Object.setPrototypeOf(obj, proto) : obj의 [[Prototype]]이 proto가 되도록 설정한다.
const child = {};
const parent = { x: 1 };
Object.setPrototypeOf(child); // child.__proto__;
Object.getPrototypeOf(child, parent); // child.__proto__ = parent;
console.log(child.x); // 1
이 메서드들은 각각 __proto__ 접근자 프로퍼티를 사용한 처리 내용과 정확히 일치합니다.
대신하는 쿨한 메서드들이 있는건 알겠는데.. 왜 __proto__ 쓰지말라는 거여..?
그 이유는! 모든 객체가 __proto__ 접근자 프로퍼티를 사용할 수 있는 것은 아니기 때문!
직접 상속(Object.create)을 통해 Object.prototype을 상속받지 않는 객체를 생성할 수도 있어요.
Object.create 메서드는 명시적으로 프로토타입을 지정하여 새로운 객체를 생성해요.
다른 객체 생성 방식과 마찬가지로 추상 연산 OrdinaryObjectCreate 또한 호출합니다.
Object.create(proto, [descriptors]) :
- [[Prototype]]이 proto를 참조하는 빈 객체를 만듭니다.
- 이때 프로퍼티 설명자(value, wriable, enumerable, configurable - 프로퍼티 플래그에 대해 모르면 넘어갑시다)를 추가로 넘길 수 있습니다.
let obj = Object.create(null);
console.log(Object.getPrototypeOf(obj) === null); // true
// ---------------------------
obj = Object.create(Object.prototype);
console.log(Object.getPrototypeOf(obj) === Object.prototype); // true
// ---------------------------
obj = Object.create(Object.prototype, {
x: { value: 1 },
});
console.log(obj.x); // 1
// ---------------------------
const myProto = { x: 10 };
obj = Object.create(myProto);
console.log(obj.x); // 10
// ---------------------------
function Friend(name) {
this.name = name;
}
obj = Object.create(Friend.prototype);
obj.name = '짱구';
console.log(obj.name); // 짱구
console.log(Object.getPrototypeOf(obj) === Friend.prototype); // true
위 코드의 다양한 예시처럼 Object.create 메서드는 첫 번째 매개변수에 전달한 객체의 프로토타입 체인에 속하는 객체를 생성합니다.
즉, 객체를 생성하면서 직접적으로 상속을 구현하는 것이에요.
장점은 다음과 같아요.
- new 연산자가 없이도 객체를 생성할 수 있어요.
- 프로토타입을 지정하면서 객체를 생성할 수 있어요.
- 객체 리터럴에 의해 생성된 객체를 생성할 수 있어요.
.
.
.
이렇게 프로토타입에 대한 내용을 마치도록 할게요.
딱! 한가지만 더 말하고..!
❗️ 속도가 중요하다면 기존 객체의 프로토타입을 변경하지 맙시다!
대게는 객체를 생성할 때만 프로토타입을 설정하고 이후엔 수정하지 않아요.
Object.setPrototypeOf 나 obj.__proto__= 을 써서 프로토타입을 그때그때 바꾸는 연산은 객체 프로퍼티 접근 관련 최적화를 망쳐, 성능에 나쁜 영향을 미치기 때문이에요.
[[Prototype]] 에서는 단일 숨겨진 프로퍼티를 수정하는 것으로 변경을 고려하지만 실제 구현은 훨씬 더 복잡합니다.
그리고 많은 프로퍼티 검사 작업이 변경되지 않는 [[Prototype]] 체인에 암묵적으로 의존하기 때문에, 엔진이 교체된 프로토타입을 관찰할 때 교체된 [[Prototype]] 이 있는 객체는 객체가 통과하는 모든 코드를 "오염"시킵니다.
이 오염은 변경된 [[Prototype]] 객체를 관찰하는 모든 코드를 통해 흐르게 됩니다 😰
프로로타입 관련 내용은 아직 더 남아있어요.
하지만 저는 프로토타입에 관한 글을 이번 글로 마치려고 해요.
(이 정도면 거의 다루긴 한 듯..)
아! 그리고 자바스크립트는 프로토타입 뿐만 아니라 ES6에 도입된 클래스 문법의 클래스 상속을 지원해요.
두 가지의 방법의 장단점이 있답니다.
그래서 조만간 클래스 상속에 대한 글로 찾아뵙도록 할게요.
감사합니다 😁
※ 참조
📖 모던 자바스크립트 Deep Dive (이웅모 지음)