본 내용은 책 코어 자바스크립트(2019, 정재남)를 세 번 정독 후, 글쓴이가 이해한 내용과 실제 업무에서 겪은 경험을 기술하였습니다. 다소 형식적이지 못합니다. 2019년 6월 NHN OOP 교육에서 배운 프로토타입 내용 또한 첨가하였습니다.
2022-02-20 추가
이전에는 JS는 객체지향프로그래밍의 상속을 구현하기 위해 프로토타입을 지원한다고 생각했는데, 지금 읽어보니 전혀 아니다..!!
프로토타입, 즉 원형이 되는 객체를 기반으로 하되, 문맥(context)에 따라 의미를 다르게 가져갈 수 있음을 구현한 것이다.
임성묵 개발자님의 자바스크립트는 왜 프로토타입을 선택했을까 참고
복기하기 전 주절주절...
이 책을 세 번 읽으면서, 또 OOP 문제를 내·외부에서 설계해보면서도 프로토타입이란 개념은 아직도 자신있게 알고 있다고 확신할 수 없지만, 작년보다 더 그리고 어제보다는 더 확실하게 이해하자는 마음으로 계속계속 반복해야 겠다.
1. prototype 이해하기
자바스크립트는 프로토타입 기반 언어로서, 클래스 기반 언어들이 상속을 통해 다형성을 구현하는 것을 자바스크립트에서는 객체의 원형이 되는 프로토타입을 복제하여 이를 체인으로 연결하여 탐사해나가는 것으로 구현하였다.
먼저, 프로토타입이라는 개념이 왜 필요한지에 대해 알아본다.
이전의 03. this와 객체에서 객체 생성 방법 중 생성자 함수에서, 프로토타입에 대한 내용을 살짝 언급했던 적이 있다.
나중에 프로토타입을 복기할 때 나오겠지만 생성자 함수로 만든 인스턴스의 공통 함수는 모두 다른 주소값을 참조하고 있기 때문에 결국 새 함수를 생성하는 것이 된다. 따라서 프로토타입을 이용하는 것이 비용 소모가 적다.
function Burger (title, price, topping = []) {
this.title = title;
this.price = `${price}원`;
this.isSale = false;
this.discountPrice = null;
this.topping = topping;
this.discount = function (percentage) {
this.isSale = true;
this.discountPrice *= (100-percentage) / 100;
}
this.getPrice = function () {
return this.isSale
? this.discountPrice
: this.price
}
}
위와 같이 this의 프로퍼티로 discount와 getPrice라는 어느 버거에서나 사용할법한 공통 메소드를 할당하면, new 키워드로 인스턴스를 생성할 때마다 공통 메소드 또한 계속 새로 생성하여 불필요한 비용소모가 발생한다.
따라서 자바스크립트에서는 생성자 함수에서 쓰이는 공통 메소드는 prototype이라는 공통 객체에 저장해두고, 생성자 함수에서는 객체마다 고유한 속성들만을 저장하고 있도록 구현하였다.
function Burger (title, price, topping = []) {
this.title = title;
this.price = `${price}원`;
this.isSale = false;
this.discountPrice = null;
this.topping = topping;
}
Burger.prototype.discount = function (percentage) {
this.isSale = true;
this.discountPrice *= (100-percentage) / 100;
}
Burger.prototype.getPrice = function () {
return this.isSale
? this.discountPrice
: this.price
}
이제 이 프로토타입에 저장된 공용 메소드들이 어떻게 각 인스턴스에서 사용할 수 있는지 아래의 인스턴스 생성 과정을 통해 보자.
var instance = new Constructor();
이 한줄로 얻는 인스턴스가 생성되는 과정을 풀어쓰면 다음과 같다.
- 어떤 생성자 함수(Constructor())를 new 키워드와 함께 호출하면,
- 생성자 함수에서 정의된 내용을 바탕으로 새 인스턴스(instance)가 생성됩니다.
- 그 때, 인스턴스에서는 __proto__라는 프로퍼티가 부여되는데
- __proto__는 Contructor.prototype을 참조합니다.
위의 과정을 책에서는 다음 그림처럼 표현하였다.
생성자 함수의 prototype 객체를 인스턴스는 __proto__라는 프로퍼티에서 이를 참조함으로써 인스턴스는 생성자 함수의 공용 메소드들을 사용할 수 있었던 것이다!
이제 위의 Burger 생성자 함수 예제를 기반으로 이 공용메소드를 호출해보자.
var cheeseBurger = new Burger("치즈버거", "4000", ["치즈", "토마토"]);
cheeseBurger.__proto__ === Burger.prototype // true
// 음, 위에서 true인 걸 확인했으니까
// __proto__에 메소드가 등록되어있으니 이렇게 호출해야지!
cheeseBurger.__proto__.getPrice(); // (1)
cheeseBurger.__proto__가 곧 Burger.prototype임을 확인하였으니, getPrice() 메소드를 (1)과 같이 호출하였다.
과연 결과는 원하는대로 4000원이 나올까?
답은 undefined가 출력된다.
우선 에러가 아니므로 정상적으로 호출하였음을 알았으니 왜 undefined인지를 추적하면 된다.
긴 말않고 03장에서 배웠던 대로 this의 값이 곧 cheeseBurger가 아니라 cheeseBurger.__proto__를 가리키는 것을 알 수 있다.
즉, cheeseBurger.__proto__에 찾고자 하는 식별자가 없으므로, undefined가 반환되었다.
그렇다면 올바르게 호출하는 방법은 무엇일까?
바로 __proto__를 생략하고 바로 cheeseBurger.getPrice()를 호출하면 된다.
cheeseBurger.getPrice()
자바스크립트에서 __proto__는 생략 가능한 점만 알고 있으면 된다!
이제 그림 1.과 __proto__가 생략가능하다는 점을 합쳐서 prototype을 물어보면 이렇게 답하면 된다!
자바 스크립트는 함수에 prototype이라는 값을 자동으로 생성하는데, 이 함수를 생성자 함수로 사용하고자 new 키워드로 호출하면 새 인스턴스가 생성되는데, 이 인스턴스의 생략 가능한 __proto__라는 프로퍼티는 생성자 함수의 prototype을 참조한다. __proto__는 생략가능하기 때문에 각 인스턴스는 생성자 함수의 prototype에 정의된 데이터를 마치 자신의 데이터처럼 사용할 수 있다.
1.1 프로토 타입과 정적 메소드
콘솔창에 다음을 실행해보자.
var arr = [1, 2];
console.log(arr); // (1)
console.dir(Array); // (2)
(1)을 실행한 결과값은 다음과 같다.
arr.__proto__가 Array임을 확인할 수 있고, Array.prototype에 저장된 메소드들을 arr에서도 마찬가지로 사용할 수 있다.
(2)를 보면,
Array.prototype과 arr.__proto__가 동일함을 확인할 수 있다. 또한, Array.from, Array.isArray와 같이 정적 메서드는 Array 원형에서만 사용이 가능한데, Array 객체의 prototype을 참조하고 있는 모든 데이터 타입들이 from, isArray와 같은 값을 사용하게 하고 싶지 않아서 정적 메소드로 두었다.
이는 Array 생성자 함수 뿐만 아니라, 항상 프로토타입 체인에서 가장 최상단에 위치한 Object 생성자 함수에서도 정적 메소드를 사용하는 이유이기도 하다.
사실 항상 프로토타입 체인의 마지막은 Object이라는 말은 평소에는 맞지만, Object.create(null)처럼 __proto__가 없는 객체를 생성하면 Object.prototype의 메소드에 접근할 수 없다.
1.2 constructor 속성
위의 arr.__proto__에 constructor 속성이 Array() 인 것을 볼 수 있다. 이는 즉, 원형 함수의 prototype에 Array() 원형이 저장되어있다는건데 왜 굳이 자기 자신의 함수를 또 저장하고 있을까?
constructor 속성은 생성자 함수보다는 인스턴스에서 인스턴스의 원형이 무엇인지를 확인할 때 유용하다.
즉, 인스턴스의 타입 체크를 위해 활용할 수 있다.
var shrimpBurger = new Burger("새우버거", 3000, ["새우", "피클"]);
console.log(shrimpBurger.constructor); // ƒ Burger (title, price, topping = []) { ...
shrimpBurger.constructor === Burger // true
shrimpBurger instanceof Burger // true
여기서 instanceof는 해당 변수가 사용하고 있는 prototype의 chain을 2번째 인자와 비교해서 true/false 값을 리턴한다.
이 constructor 속성은 사실 변경가능한 값이지만, 이를 변경한다고 해서 이미 만들어둔 인스턴스의 원형이 바뀌거나 하지는 않는다.
shrimpBurger.constructor = Array
shrimpBurger.constructor === Burger // false
shrimpBurger instanceof Burger // true, 원형 변경 없음
어떤 생성자 정보를 알아내기 위해 constructor 프로퍼티에 의존하는 게 항상 안전하지는 않은 것이죠.
constructor는 비록 항상 값을 보장하지는 않지만, 이러한 특성 덕분에 클래스 기반 언어의 "상속"을 비슷하게 구현할 수 있게 되었다. 프로토타입 체인에서 자세히 적을 예정이다.
2. prototype 체인
아까 arr 예제를 다시 보자.
var arr = [1, 2];
console.log(arr); // (1)
arr.__proto__안에 또 다시 __proto__: Object로 존재하는 게 보인다.
이는 Array.prototype 또한 객체이기 때문에 __proto__가 존재하기 때문이다.
아까 위에서 __proto__는 생략가능하기 때문에 arr는 Object.prototype 메소드까지 자신의 메소드처럼 사용할 수 있다.
arr.toString();
어떤 데이터의 __proto__ 속성 내부에 다시 __proto__ 속성이 연쇄적으로 이어진 것이 곧 프로토타입 체인이고, 이 체인을 탐사하면서 메소드를 검색하는 것을 프로토타입 체이닝이라고 한다.
프로토타입 체인은 클래스 기반 언어에서의 메소드 오버라이드와 그 맥락을 같이 하는데, 메소드 A를 호출하면 자바스크립트 엔진은 자신의 프로퍼티 중 A가 있는지 검색하고, 없으면 __proto__를 검색하고, 또 없으면 __proto__의 __proto__를 검색해나간다.
이제 이 프로토타입 체인으로 자바스크립트에서 상속을 구현해보자.
그전에 상속의 의미를 다시 짚고 넘어가도록 해본다.
상속이란, 객체 간의 관계를 표현하는 방법으로, 부모의 메소드와 속성을 자식에게 상속하여, 자식은 부모의 데이터를 사용할 수 있으면서, 자기 자신의 고유한 메소드와 속성을 지닐 수 있는 특징이다.
자바 스크립트에서는 이 상속을 프로토타입 체인으로 비슷하게 구현하고 있다.
아래 더보기는 자바스크립트에서 과연 상속이란 말이 타당?한가에 대한 의견을 정리해보았다.
사실 자바 스크립트에서는 생성자 함수가 부모-자식 관계에 있기보다는 그저 prototype에 접근할 수 있는 권한을 주는 것이고, 이에 접근해서 해당 메소드를 사용하는 것이다. 따라서 꽤 많은 사람들이 프로토타입 상속이라기 보다는 프로토타입 위임이라는 말을 사용하기도 한다.
[출처] 2019년 6월 NHN 교육
2.1 생성자 기반 프로토타입 체인 구현
체인을 형성하는 것은 간단하게 인스턴스의 __proto__에 생성자의 prototype을 할당해주면 된다.
var a = {
name: "juj"
}
var b = {
__proto__: a,
age: "27"
}
console.log(b.__proto__) // { name: "juj" }
b.__proto__ === a // true
하지만 객체 리터럴로 __proto__를 위임하는 것은 표준은 아니다.
표준 방법으로, Object.create()를 사용한다.
Object.create()는 객체 생성 방법들에서도 소개했듯이 첫번째 인자를 prototype으로 하여 새 객체를 생성한다.
var a = {
name: "juj"
}
var b = Object.create(a);
b.age = "27";
하지만, 위처럼 객체 간의 관계로써 상속을 표현하기에는 Application을 모두 설계하는 데에는 한계가 있다.
따라서 자바의 클래스와 비슷한 생성자함수를 사용하여 원하는 프로토타입 체인 구조를 만들어보자.
앞에서 버거를 예로 들어서 다음의 상황을 구현해보자.
- 버거를 파는 패스트푸드점으로 롯데리아가 있다.
- 해당 버거를 판매하는 곳을 prefix로 붙여 버거 이름을 리턴하는 getStoreBurger()를 구현한다.
function Burger (title, price, topping = []) {
this.title = title;
this.price = `${price}원`;
this.isSale = false;
this.discountPrice = null;
this.topping = topping;
}
Burger.prototype.discount = function (percentage) {
this.isSale = true;
this.discountPrice *= (100-percentage) / 100;
}
Burger.prototype.getPrice = function () {
return this.isSale
? this.discountPrice
: this.price
}
function StoreBurger (title, price, topping = [], store) {
Burger.call(this, title, price, topping = []); // (1)
this.store = store;
}
StoreBurger.prototype = Object.create(Burger.prototype); // (2)
StoreBurger.prototype.constructor = StoreBurger; // (3)
StoreBurger.prototype.getStoreBurger = function () {
return `[${this.store}] ${this.title}`
}
var lotteriaCheeseBurger = new StoreBurger("치즈버거", 3000, [], "롯데리아");
lotteriaCheeseBurger.getStoreBurger(); // "[롯데리아] 치즈버거"
lotteriaCheeseBurger.getPrice(); // "3000원"
(1)에서 Burger 생성자 함수에 thisBinding을 하여 해당 인스턴스를 생성한다. 이는 곧 클래스의 super 연산자와 동일한다.
(2)에서 이제 Object.create로 StoreBurger의 프로토타입으로 Burger.prototype을 지정해준다.
다만 이렇게 지정하면, 원래의 constructor까지 사라지기 때문에 (3)처럼 별도로 원형 생성자 함수를 constructor 속성에 할당해준다.
'Books > 코어 자바스크립트' 카테고리의 다른 글
07. 클래스 (0) | 2020.05.22 |
---|---|
05. 클로저 (0) | 2020.04.12 |
04. 콜백 함수 (0) | 2020.04.04 |
03. this와 객체 (0) | 2020.03.05 |
02. 실행 컨텍스트 (0) | 2019.12.31 |