본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.
객체 지향 프로그래밍 방식에서는 흔히 상속이라는 개념을 사용한다. 대표적으로 JAVA의 경우는 클래스를 사용하여 상속을 구현한다. 상속은 기존에 있는 기능을 가져와 확장하는 경우 유용하게 사용할 수 있다. 자바스크립트에서의 객체(형)은 프로토타입
속성을 이용하여 상속을 구현할 수 있다.
ES6(ES2015)에서
class
문법이 추가되었다. 따라서 이를 사용해서도프로토타입
을 사용하는 것과 동일한 기능을 구현할 수 있다. 다만class
는 문법적인 양념(Syntax Sugar)의 기능일뿐(내부적으로프로토타입
을 이용) 자바스크립트는 여전히 프로토타입 기반 언어이다.class
에 대해서는 다음 챕터에서 자세히 살펴보자.
자바스크립트의 객체는 명세서에 기재되어 있는 [[Prototype]]
이라는 숨김 프로퍼티를 가지고 있다. 이 프로퍼티의 값(value
)은 오직 null
또는 다른 객체만을 참조할 수 있다. 이때 해당 프로퍼티가 객체를 참조하는 경우에 이 참조 대상을 프로토타입(prototype)
이라고 부른다.
프로토타입의 동작 방식은 앞서 다룬 렉시컬 환경
과 유사한 점이 많다. 해당 객체에서 특정 프로퍼티를 읽으려고 할 때, 객체 내부에 해당 프로퍼티가 없는 경우엔 자동으로 프로토타입으로 올라가 프로퍼티를 탐색하기 때문이다. 이러한 동작 방식을 프로토타입 상속
이라고 칭한다.
[[Prototype]]
프로퍼티는 내부 프로퍼티이면서 숨김 프로퍼티이지만 다양한 방법을 통해 개발자가 값을 설정할 수 있다. 이전부터 사용하던 방식은 주로 __proto__
라는 키워드를 사용해 값을 설정했다.
사실
__proto__
는 이전 챕터에서 다룬[[Prototype]]
용 접근자 프로퍼티(getter/setter
)이다. 따라서 엄연히[[Prototype]]
프로퍼티와__proto__
는 서로 다른 프로퍼티이다. 하위 호환성 때문에 여전히__proto__
를 사용할 수는 있지만 모던JS 에서는__proto__
대신 함수Object.getPrototypeOf
또는Object.setPrototypeOf
를 지원한다. 이에 대한 이슈는 조금 있다 다시 살펴보자.
let animal = {
eats: true
};
let rabbit = {
jumps: tre
};
rabbit.__proto__ = aminal;
console.log( rabbit.eats ); // true
console.log( rabbit.jumps ); // true
위에서 객체 rabbit
의 내부 프로퍼티는 jumps: true
만 존재한다. 하지만 콘솔에서 rabbit.eats
역시 정상적으로 출력되는 것을 확인할 수 있다. 이는 앞서 말했듯이 해당 프로퍼티가 내부에 없고, 연결된 프로토타입이 있는 경우 해당 프로토타입에서 프로퍼티에 접근하기 때문이다.
객체의 프로퍼티는 함수도 지정 가능하다. 따라서 프로토타입 상속을 이용해서 객체 메서드 역시 상속받아 사용할 수 있다. 이처럼 프로토타입에서 상속받은 프로퍼티를 상속 프로퍼티
라고 부른다. 또한 프로토타입 상속은 마치 체인처럼 연결되어 있다고 하여 프로토타입 체인
이라는 용어로도 부른다.
let animal = {
eats: true,
walk() {
console.log('walking...');
}
}
let rabbit = {
jumps: true,
__proto__: animal, // animal 객체 상속
}
let longEar = {
earLength: 10,
__proto__: rabbit, // rabbit 객체 상속
}
rabbit.walk(); // walking...
longEar.walk(); // walking...
console.log( longEar.jumps ); // true
rabbit
은 animal
객체를 상속받아 walk()
메서드를 사용할 수 있다. 또한 longEar
는 rabbit
객체를 상속받았기 때문에 가장 상위에 있는 walk()
메서드는 물론 jumps
프로퍼티 역시 사용할 수 있다. 이를 그림으로 나타내면 다음과 같다.
이때 프로토타입 체이닝에는 다음과 같은 제약사항이 있다.
__proto__
의 값은 항상 객체 또는 null
만 가능하다. 다른 자료형은 무시된다.[[Prototype]]
만 있을 수 있다. 두 개의 객체를 동시에 상속받을 수 없다.기본적으로 프로토타입은 프로퍼티를 읽을 때만 사용한다. 즉 프로퍼티를 추가, 수정 및 제거하는 연산은 해당 객체에 직접 수행해야 한다. 이는 객체 지향형 프로그래밍에서의 메소드 오버라이딩
과 일맥상통한다.
let animal = {
eats: true,
walk() {
console.log("뚜벅뚜벅");
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function () {
console.log("껑충껑충");
};
rabbit.walk(); // 껑충껑충
위와 같이 rabbit
객체에 직접 walk()
메서드를 할당하는 경우엔 rabbit
객체가 참조하고 있는 프로토타입 animal
에서 메서드를 빌려오지 않고 자신의 메서드를 호출한다.
그러나 접근자 프로퍼티는 setter
함수를 통해서 프로퍼티에 값을 할당하기 때문에 이 규칙이 적용되지 않는다. 왜냐하면 접근자 프로퍼티에 값을 할당하는 것은 결국 함수를 호출하여 값을 변경하는 것과 동일하기 때문이다.
let user = {
name: "KG",
surname: "LEE",
set fullName(value) {
[this.name, this.surname] = value.split(' ');
},
get fullName() {
return `${this.name} ${this.surname}`;
}
};
let admin = {
__proto__: user,
isAdmin: true,
};
console.log(admin.fullName); // KG LEE
admin.fullName = "SJ WOO";
console.log(admin.fullName); // SJ WOO
console.log(user.fullName); // KG LEE
처음 admin
에서 getter(fullName
)을 호출했을 때는 해당 객체에서 getter 프로퍼티가 없으므로 상위 프로토타입에서 이를 빌려온다. 또한 첫 호출시에는 admin
객체 내에 name
과 surname
프로퍼티가 없기에 역시 상위 프로퍼티의 값을 빌려오게 된다.
그러나 admin
에서 setter를 호출하는 경우에는 admin
객체 내부에 name
과 surname
프로퍼티를 생성하게 된다. 이는 앞서 살펴보았듯이 this
는 런타임에 결정되기 때문이다. 즉 this
는 프로토타입에도 영향을 받지 않는다. 점(.
) 앞에 사용된 객체가 admin
이기 때문에 setter가 호출된 시점에서의 this
는 admin
이 되며, 따라서 admin
객체 내부에서 name
과 surname
프로퍼티를 설정하게 된다. 메서드를 객체에서 호출했든 프로토타입에서 호출했든 상관없이 this
는 언제나 .
앞에 있는 객체가 된다는 점을 유의하자.
따라서 객체를 하나 만들고 해당 객체에 여러 메서드를 많이 구현해 놓은 다음, 여러 객체에서 이 커다란 객체를 상속받게 하는 경우로 활용할 수 있다. 이와 같이 사용하면 메모리 사용률을 조금 더 아낄 수 있는 장점이 있다. 프로토타입은 모든 객체가 공유하고 있어서 한 번만 만들어지기 때문이다.
let worker = {
work() {
console.log('일 하는 중...');
},
sleep() {
console.log('자는 중...');
},
walk() {
console.log('걷는 중...');
}
}
let workerA = {
name: 'KG',
__proto__: worker
};
let workerB = {
name: 'SJ',
__proto__: worker
};
workerA.work(); // 일 하는 중...
workerB.work(); // 일 하는 중...
이처럼 여러개의 workerX
객체가 메서드의 집합체인 worker
객체를 상속받게 한다면 각각의 workerX
객체에서는 일일히 메서드를 구현하지 않고 상위의 메서드를 사용할 수 있기때문에 메모리 낭비를 막을 수 있다.
for...in
반복문은 상속 프로퍼티 역시 순회대상에 포함시킨다. 이때 obj.hasOwnProperty(key)
를 사용하면 상속 프로퍼티는 순회 대상에서 제외시킬 수 있다. 이 내장 메서드는 key
에 대응하는 프로퍼티가 상속 프로퍼티가 아닌 직접 구현된 프로퍼티일 때 true
를 반환하기 때문이다. 또한 키-값
을 순회하는 내장 메서드 대부분은 상속 프로퍼티를 제외하고 동작한다. 따라서 Object.keys()
또는 Object.values()
와 같은 메서드는 별다른 설정없이도 상속 프로퍼티는 제외하고 동작한다.
let animal = {
eats: true,
};
let rabbit = {
jumps: true,
__proto__: animal,
};
for(let key of rabbit)
console.log(key); // jumps, eats
for(let key of rabbit) {
if(rabbit.hasOwnProperty(key))
console.log('myOwn :', key); // jumps
else
console.log('상속 : ', key); // eats
}
console.log(Object.keys(rabbit)); // jumps
이때 rabbit.hasOwnProperty(key)
와 같은 메서드는 어디서 오는 것일까? 이 역시 프로토타입 체인과 관련이 있다. 객체 리터럴 방식 또는 생성자 방식(생성자 방식은 객체 리터럴 방식과 살짝 다른 체이닝이 생기지만 자세한 내부 로직은 다음 챕터에서 다루어보자)으로 객체를 생성하게 되면, 이들은 별도로 프로토타입 지정을 하지 않는 이상 기본으로 Object
를 상위의 프로토타입으로 참조하게 된다. 내장 객체인 Object
에는 이미 구현된 여러 메서드가 존재하는데 그 중에 하나가 hasOwnProperty
이다. 또한 모든 객체가 내장 메서드로 toString
을 가지는 이유 또한 내장 객체 Object
에 toString
이 구현되어 있고 이를 각 객체가 상위 프로토타입으로 참조하기 때문이다.
실제로 객체 리터럴 방식으로 객체를 생성하고 이를 콘솔창에 출력하면 __proto__
프로퍼티에 Object
가 프로토타입으로 지정되어 있는 것을 확인할 수 있다.
그런데 이들이 for...in
순회에 등장하지 않는 이유는 간단하다. 내장 객체 Object
에 포함된 모든 메서드들의 enumerable
플래그 값이 false
로 설정되어 있기 때문이다.
객체를 생성하는 방식은 객체 리터럴 외에도 생성자 함수를 이용하는 방식이 있다. new Function()
과 같이 객체를 생성할 때, 만약 Function.prototype
이 객체라면 new
연산자는 Function.prototype
을 사용해 새롭게 생성된 객체의 [[Prototype]]
을 설정한다.
옛 버전 자바스크립트에서는 앞서 살펴본바와 같이 프로토타입에 직접 접근할 방법이 없었다. 그나마 사용할 수 있는 방법이 지금 다루는 생성자 함수의
prototype
을 이용하는 방법이었기 때문에, 많은 스크립트가 해당 방법을 이용해 상속을 구현하는 것을 오늘도 살펴볼 수 있다.
이때 Function.prototype
에서 prototype
은 그저 Function
에 정의된 일반 프로퍼티라는 점에 주의해야 한다. 앞서 다룬 프로토타입 객체와 동일한 용어를 사용하지만 생성자 함수에서 prototype
은 그저 이름만 같은 일반 프로퍼티이다. 함수도 객체형이기 때문에 이와 같이 프로퍼티를 지정해줄 수 있고, 이는 그저 일반 프로퍼티로 인식되는 것이다. Function.prototype
의 값은 객체 또는 null
만 가능하다는 것은 동일하다.
let animal = {
eats: true,
};
function Rabbit (name) {
this.name = name;
}
Rabbit.prototype = animal;
let rabbit = new Rabbit("White Rabbit");
// 이 경우 rabbit.__proto__ == animal 이 된다.
console.log( rabbit.eats ); // true
따라서 Rabbit.prototype = animal
은 new Rabbit
을 호출해 만든 새로운 객체의 [[Prototype]]
을 animal
로 설정하라는 것과 동일한 의미이다.
이때 Function.prototype
에서 prototype
은 일반 프로퍼티이지만 new
연산자를 사용해 Function
을 호출하는 경우, 즉 생성자 함수로 사용하는 경우에만 사용된다. new Function
을 호출해 새롭게 만든 객체의 [[Prototype]]
을 설정해주는 역할을 한다. 생성자 함수를 호출하는 경우 자바스크립트는 실제로 다음 작업을 수행한다.
let o = new Foo();
// 이는 다음의 과정으로 분해되어 실행된다.
let o = new Object(); // 먼저 객체를 생성
o.[[Prototype]] = Foo.prototype; // 함수의 prototype 연결
Foo.call(o); // 함수에 생성된 객체를 this로 적용
만약 새로운 객체가 만들어진 후에 Function.prototype
이 새로운 객체로 할당되고, 그 이후에 new Function
으로 새 객체를 생성하는 경우, 새로 생성된 객체는 새롭게 할당된 객체를 [[Prototype]]
으로 가지게 된다. 기존에 생성된 객체의 [[Prototype]]
은 유지된다.
이때 새로운 객체로 대체되는 시점은 Function.prototype
에 새로운 객체를 할당할 때만 해당된다. 즉 기존 메모리에 있는 참조를 끊고 새로운 값을 할당하는 경우이다. 만약 Function.prototype
의 값을 수정만 하는 경우엔 기존에 생성된 객체들이 여전히 같은 참조를 바라보기 때문에 수정된 프로토타입을 그대로 가리키게 된다. 즉 수정과 재할당을 구분하여 생각해야 한다.
개발자가 특별히 할당하지 않더라도 모든 함수는 prototype
프로퍼티를 갖는다. 이때 기본 프로퍼티인 prototype
은 constructor
프로퍼티만 가지고 있는 객체를 가리키게 되는데, constructor
프로퍼티는 함수 자기 자신을 가리키고 있다.
function Rabbit () {};
/* 기본 prototype
Rabbit.prototype = { constructor: Rabbit }
*/
따라서 특별한 조작을 가하지 않았다면 Rabbit
을 구현한 모든 객체에서 [[Prototype]]
을 거쳐 constructor
프로퍼티를 사용할 수 있다.
function Rabbit () {};
let rabbit = new Rabbit();
console.log( rabbit.constructor === Rabbit ); // true
// 상위 프로토타입의 constructor를 빌려와 객체 생성
// new Rabbit()과 동일
let rabbit2 = new rabbit.constructor();
객체가 서드 파티 라이브러리에서 온 경우 등 객체를 만들때 어떤 생성자가 사용되었는지 명확히 파악할 수 없는 경우에 이와 같은 방식으로 유용하게 동일한 타입의 객체를 생성할 수 있다.
그러나 앞서 말했듯 prototype
은 일반 프로퍼티이고, 따라서 자바스크립트는 알맞은 constructor
값을 보장해주지 않는다. 함수에 기본적으로 prototype
값이 설정되는 것만 보장할 뿐, constructor
에 벌어지는 모든 일은 전적으로 개발자에게 달려있다. 따라서 다음과 같이 prototype
의 값을 다른 객체로 바꾼다면 이 객체는 constructor
프로퍼티가 존재하지 않으며 바꾸는 과정에서 에러 또한 발생하지 않는다.
function Rabbit () {}
Rabbit.prototype = {
jumps: true
};
let rabbit = new Rabbit();
console.log( rabbit.constructor === Rabbit ); // false
이런 상황을 방지하고 알맞은 constructor
를 유지하려먼 prototype
전체를 덮어쓰지 말고, 기본 prototype
에 원하는 프로퍼티를 추가/제거 하는 방식으로 설정해야 한다. 또는 constructor
프로퍼티를 수동으로 다시 만들어주는 것 역시 대안이 될 수 있다.
function Rabbit () {}
Rabbit.prototype.jumps = true;
// 이 경우엔 Rabbit.prototype.constructor가 유지된다.
// 또는 수동으로 유지시킬 수 있다.
Rabbit.prototype = {
constructor: Rabbit,
jumps: true,
};
프로토타입 체인은 재귀탐색을 수행하여 모든 프로토타입 체인 전체를 탐색할 수 있다. 따라서 성능적인 이슈가 발생할 수 있는데
hasOwnProperty
메서드 등을 이용해 최적화 할 필요가 있다.