Javascript Prototype 이해하기

프로토타입을 알고난 후 모든 것이 달라졌다.

2019-06-20

거창한 부재만큼 자바스크립트를 얘기하면서 프로토타입을 빼놓을 순 없습니다. 그만큼 중요한 개념이고 자바스크립트를 제대로 그리고 재밋게 사용하려면 꼭 알아야 하는 개념중에 하나이기 때문입니다. 이번 포스트에서 prototype무엇인지, 언제 쓰이는지 그리고 프로토타입기반 언어인 자바스크립트에서 프로토타입을 이용한 상속은 어떻게 구현 하는지까지 알아보겠습니다.

프로토타입이 무엇인지 이해하기 위해서는 먼저 prototype objectprototype link에 대한 개념을 알아야 합니다. 프로토타입 객체부터 하나씩 알아보도록 하겠습니다.

프로토타입 객체

prototype object 는 자바스크립트에서 함수 선언시 생성되는 객체로 constructor__proto__ 를 기본 속성으로 가지는, 모든 함수가 가지고 있는 객체입니다. 기본 속성은 아래와 같은 속성값을 가집니다.

1
2
3
4
{
constructor: ƒ (), // 생성자 함수 즉, `prototype object` 가 속한 함수를 참조합니다.
__proto__: Object // 생성자 함수의 `prototype object` 를 참조합니다.
}

선언된 함수는 prototype 이라는 속성을 통해 생성된 prototype object 에 접근할 수 있으며 객체에 원하는 멤버를 추가, 삭제할 수 있습니다. 뒤에서 언급하겠지만 prototype object 에 추가된 멤버는 인스턴스 생서시 매번 인스턴스 멤버로 메모리에 올라가는 것이 아니라 프로토타입의 멤버로 하나의 참조값을 공유하는 특징을 가집니다.

프로토타입 링크

prototype link 자바스크립트 내부 속성인 [[prototpye]] 을 참조할 수 있도록 웹 브라우저 벤더사가 뚫어 놓은 __proto__ 라는 속성을 통해 접근할 수 있습니다. 함수만 가지는 prototype 속성과 달리 모든 객체가 가지고 있는 속성으로 생성자 함수prototype object 를 참조합니다.

__proto__ 속성은 표준 스펙이 아니기 때문에 개발시 사용하지 않도록 하며, ECMAScript 2015 를 사용 가능한 환경에서는 Object.getPrototypeOfprototype object 를 참조할 수 있습니다.

아래 생성자 함수, 객체 리터럴 두가지 방식의 객체 생성 코드를 통해 객체의 __proto__ 속성이 참조하는 값을 확인해 보겠습니다.

생성자 함수 사용

생성자 함수를 사용하여 생성된 객체의 __proto__ 속성값을 확인하기 위한 코드 입니다.

1
2
3
var Human = function(){}
var jaewon = new Human();
jaewon.__proto__ === Human.prototype; // true

Human 이라는 생성자 함수로 생성된 객체(jaewon 인스턴스)의 __proto__ 속성이 생성자 함수 Humanprototype 속성이 참조하는 prototype object 임을 확인할 수 있습니다.

객체 리터럴 사용

리터럴 방식으로 선언된 객체의 __proto__ 속성값을 확인하기 위한 코드 입니다.

1
2
var jaewon = {};
jaewon.__proto__ === Object.prototype; // true

위 코드를 보면 리터럴 방식으로 객체를 선언 했는데 선언된 객체의 __proto__ 속성은 생성자 함수 Objectprototype object 를 참조하고 있습니다. new Object() 를 사용하여 생성한게 아니라 리터럴 방식으로 선언했는데 어떻게 된 것 일까요?

자바스크립트는 리터럴 방식으로 객체를 선언하더라도 내부적으로 해당 타입에 대응하는 Wrapper Object(생성자 함수)를 사용하여 아래 코드와 같이 객체를 생성하기 때문에 리터럴 방식으로 생성된 객체의 __proto__ 속성도 Object.prototype 객체를 참조하게 되는 것 입니다.

1
2
var jaewon = new Object();
jaewon.__proto__ === Object.prototype; // true

아래는 Warpper Object 를 사용하는 예제 코드들 입니다.

1
2
3
4
5
6
7
8
9
10
11
var str = "blabla";
// var str = new String("blabla");
str.__proto__ === String.prototype; // true

var arr = [];
// var arr = new Array();
arr.__proto__ === Array.prototype; // true

var is = true;
// var is = new Boolean();
is.__proto__ === Boolean.prototype; // true

지금까지 prototype objectprototype link에 대해 알아봤습니다. prototype 이 뭔지 감이 좀 오시나요?
이제 언제 사용하는지 알아보면서 prototype 에 대해 좀 더 알아보도록 하겠습니다.

언제 사용하는 걸까요?

자바스크립트는 prototype 기반 언어로 객체의 뼈대가 될 class 가 없는 언어입니다. 대신 앞에서 살펴본 prototype 을 가지며 이것을 사용해 class 를 사용한 것 처럼 객체를 생성할 수 있습니다. 정확히 말하면 prototype 을 가지는 생성자 함수 를 사용하여 class 를 사용한 것 처럼 객체를 생성할 수 있습니다. 즉, prototype효율적으로 객체생성할 필요가 있을 때 사용하게 됩니다.

효율적인 객체 생성의 이해를 돕기위해 아래 prototype 을 활용하지 않은 생성자 함수, prototype 을 활용한 생성자 함수 두개의 코드를 준비하였습니다.

프로토타입을 활용하지 않은 생성자 함수

요구사항을 처리하기 위한 모든 멤버를 생성자 함수에 선언합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var 붕어빵틀 = function( initParam ){
this.만든사람 = initParam.만든사람;
this.앙금 = initParam.앙금;

this.가열 = function(){
console.log("반죽 가열합니다.");
};

this.뒤집기 = function(){
console.log("붕어빵 뒤집습니다.");
};

this.유통기한 = function(){
var today = new Date(),
expirationDate = 2;

today.setDate( today.getDate() + expirationDate );

this.유통기한 = today.toLocaleDateString();
};

this.만들기 = function(){
this.가열();
this.뒤집기();
this.가열();
this.유통기한();
console.log(this.앙금 + " 붕어빵이 완성되었습니다.")
console.log(this.유통기한 + " 까지 드실 수 있습니다.")
};

this.만든사람 && this.앙금 && this.만들기();
};

프로토타입을 활용한 생성자 함수

인스턴스마다 독립적으로 가져야하는 요구사항은 생성자 함수의 멤버(인스턴스 멤버)로, 공유되어야 하는 부분들은 prototype object 의 멤버로 선언합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var 붕어빵틀 = function( initParam ){
this.만든사람 = initParam.만든사람;
this.앙금 = initParam.앙금;
this.만든사람 && this.앙금 && this.만들기();
};

붕어빵틀.prototype.만들기 = function(){
this.가열();
this.뒤집기();
this.가열();
this.유통기한();
console.log(this.앙금 + " 붕어빵이 완성되었습니다.")
console.log(this.유통기한 + " 까지 드실 수 있습니다.")
};

붕어빵틀.prototype.가열 = function(){
console.log("반죽 가열합니다.")
};

붕어빵틀.prototype.뒤집기 = function(){
console.log("붕어빵 뒤집습니다.")
};

붕어빵틀.prototype.유통기한 = function(){
var today = new Date(),
expirationDate = 2;

today.setDate( today.getDate() + expirationDate );

this.유통기한 = today.toLocaleDateString();
};

인스턴스 생성

prototype 을 사용한 생성자 함수나 사용하지 않은 생성자 함수 모두 아래 코드로 인스턴스를 생성할 수 있습니다.

1
2
3
4
var 팥붕어빵1 = new 붕어빵틀({ 앙금: "팥" });
// {만든사람: "시장상인 A", 앙금: "팥", 유통기한: "2019. 6. 20."}
var 슈크림붕어빵1 = new 붕어빵틀({ 앙금: "슈크림" });
// {만든사람: "시장상인 A", 앙금: "슈크림", 유통기한: "2019. 6. 20."}

하지만 인스턴스 생성 시점에서 아래와 같이 두 방식의 차이점이 발생합니다.

1
2
3
4
// 프로토타입을 활용하지 않은 생성자 함수의 인스턴스
팥붕어빵1.가열 === 슈크림붕어빵1.가열 // false
// 프로토타입을 활용한 생성자 함수의 인스턴스
팥붕어빵1.가열 === 슈크림붕어빵1.가열 // true
  • prototype 을 활용하지 않은 생성자 함수의 인스턴스: 생성자 함수의 모든 멤버를 받기 때문에 굳이 필요치 않은 부분까지 메모리에 올라가 인스턴스를 많이 만들수록 부담이 됩니다.
  • prototype 을 활용한 생성자 함수의 인스턴스: prototype object에 선언된 멤버들은 매 인스턴스 마다 메모리에 올라가지 않고 동일한 참조값을 가집니다. ( 원시타입의 경우 인스턴스 멤버, prototype 멤버 여부와 관계 없이 메모리에 올라갑니다. )

기타 프로토타입 특징

  • prototype 멤버의 내용을 동적으로 변경하면 변경 이전에 생성된 객체라도 적용이 됩니다.
  • 인스턴스에서는 prototype 의 내용을 읽을수는 있지만 쓸수는 없습니다.
  • prototype 체인의 마지막은 Always Object.prototype 입니다.

지금까지 prototype 이 무엇이고 언제 사용하게 되는지 알아보았습니다. 여기까지만 이해하셔도 이전과는 다른 방식으로 코드를 좀 더 재밋게 작성할 수 있으실거라 생각합니다.

하지만 조금 더 재밋게 사용하기 위해 prototype 상속에 대해서도 조금 알아 보겠습니다.

프로토타입을 사용한 상속 구현

자바스크립트 상속은 객체만으로 가능하지만 복잡한 어플리케이션의 요구사항을 만족하기엔 무리가 있습니다. 그래서 클래스와 같은 생성자 함수를 상속함하여 코드를 좀 더 짜임세 있게 설계할 필요가 있습니다. 실제로는 프로토타입 체인을 연결하고 필요에 따라 메소드를 오버라이드 해주는 것이 전부입니다.

지금은 ECMAScript 2015 (ES6) 스펙에 class, extends 키워드가 추가되어 쉽게 클래스(생서자 함수)와 상속을 사용할 수 있습니다. 하지만 저처럼 아직 class, extends 키워드를 사용하지 못하는 안타까운 환경에 놓인 개발자들도 있습니다.

저같은 개발자를 위해 old school 방식으로 상속을 구현하는 방법에 대해 알아보겠습니다.
구현하려는 상속은 다음과 같은 요구사항을 가집니다.

  • 부모 생성자가 만드는 인스턴스별 고유해야할 속성 참조가능
  • 부모 생성자의 prototype 멤버에 접근가능

이해를 돕기위해 제가 좋아하는 자동차로 예제 코드를 작성해 보겠습니다.

폭스바겐/아우디 사에서는 비용절감을 위한 차세대 자동차 플랫폼을 연구 개발하였습니다. 개발된 플랫폼의 이름은 MQB 로 앞으로 폭스바겐, 아우디에서 생산되는 많은 차종에 공통으로 사용될 플랫폼 입니다.

MQB : 폭스바겐, 아우디사의 비용절감 전략 플랫폼으로 Golf, A3 등 여러 차종에 범용적으로 사용되는 자동차 프레임

MQB

폭스바겐, 아우디(생성자 함수)가 상속받을 차세대 자동차 플랫폼 생성자 함수입니다.
플랫폼이 버틸 수 있는 최대 마력수, 사용 가능한 구동타입, 휠 베이스 등의 정보를 가지고 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
var MQB = function(){
this.wheelbase = "4.18";
this.engineLayout = ["FF", "FR"];
this.maxHorsePower = 350;
}

MQB.prototype.getMaxHorsePower = function(){
return this.maxHorsePower;
}

MQB.prototype.getEngineLayout = function(){
return this.engineLayout;
}

FF: 엔진이 차체 앞에 위치하면 앞바퀴 굴림의 구동방식 (전륜)
FR: 엔진이 차체 앞에 위치하면 뒤바퀴 굴림의 구동방식 (후륜)

Volkswagen

MQB 플랫폼을 상속받아 자동차를 생산할 Volkswagen 생성자 함수입니다.

1
2
3
4
var Volkswagen = function(){
this.horsePower = 0; // 마력
this.fuel; // 연료타입
}

MQB 와 Volkswagen 두개의 생성자 함수가 준비되었습니다. Volkswagen 은 MQB 플랫폼을 상속받아 자동차를 만들어야 하기 때문에 생성자 빌려쓰기프로토타입 링크 참조값 변경 을 통하여 두 클래스간 상속 관계를 맺어 주도록 하겠습니다.

생성자 빌려쓰기

prototype link 의 참조값 변경으로 부모 prototype object 는 상속 받을 수 있지만 부모 생성자가 만드는 인스턴스 멤버는 상속 받을 수 없기 때문에 부모 생성자가 만든 인스턴스의 멤버를 참조할 수 있도록 MQB.apply( this, arguments ) 코드를 호출해 줍니다.

1
2
3
4
5
var Volkswagen = function(){
MQB.apply( this, arguments ); // 생성자 빌려쓰기
this.horsePower = 0;
this.fuel;
};

생성자 빌려쓰기 후 Volkswagen 인스턴스를 생성하면 인스턴스 멤버로 부모 생성자인 MQB 의 인스턴스 멤버도 참조할 수 있습니다. 다음으로 prototype link 의 참조값 변경을 통하여 프로토타입 상속을 구현을 완성해 보겠습니다.

프로토타입 링크 참조값 변경

임의의 객체 F 를 사용하여 부모 생성자의 prototype 을 상속받고, 해당 객체의 인스턴스를 자식 객체 prototype link 의 참조값으로 사용합니다.

1
2
3
var F = function(){};
F.prototype = MQB.prototype;
Volkswagen.prototype = new F();

상속 후 Volkswagen.prototype.__proto__ 찍어보면 MQB.prototype 를 참조하고 있음을 알 수 있습니다.

1
2
3
Volkswagen.prototpye = {
__proto__ : MQB.prototype
}

이렇게 하면 상속 자체는 모두 끝이 납니다. 하지만 상속을 통하여 코드를 잘 작성하기 위해서 주의해야할 점이 있습니다.

반드시 prototype 상속을 위해 앞에서 보여드린 상속 코드 이후 아래와 같이 자식 생성자 함수의 prototype object 에 멤버를 추가해야 한다는 점 입니다. 역순으로 할 경우 자식 생성자 함수의 prototype object 에 선언된 모든 멤버는 가비지 컬렉터에 의해서 날아가게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Volkswagen.prototype.setFuel = function( fuelType ){
this.fuel = fuelType;
};

Volkswagen.prototype.setEngine = function( engineType ){
var _supportEngines = this.getEngineLayout();

for(var i=-0,l=_supportEngines.length; i<l; i++){
if( engineType === _supportEngines[i] ){
this.engineType = _supportEngines[i];
break;
}
}
};

Volkswagen.prototype.setHorsePower = function( horsePower ){
if( horsePower > this.getMaxHorsePower() ){
console.error("can not set the horse power : " + horsePower);
return;
}

this.horsePower = horsePower;
};

MQB 를 상속받은 Volkswagen 를 통하여 GTI, GTD 같은 차들을 만들 수 있으며 마력이나 구동방식 셋팅시 MQB 가 제공하는 함수를 통하여 미리 설계된 범위 내에서 안전한 차량을 생산할 수 있습니다.

1
2
var golf_gti = new Volkswagen();
var golf_gtd = new Volkswagen();

마지막으로 좀 더 깔끔하게 상속을 처리를 위해 앞에서 살펴본 prototype link 참조값 변경 코드를 아래와 같이 inherit 함수로 만들어 줍니다.

1
2
3
4
5
6
7
var inherit = function( Parent, Child ){
var F = function(){};
F.prototype = Parent.prototype;
Child.prototype = new F();
Child.prototype.constructor = Child;
Child.super = Parent.prototype;
};

개선된 함수는 아래와 같은 기능을 합니다.

  • 파라미터로 부모, 자식 생성자 함수를 받습니다.
  • 임의의 생성자 함수 F 를 통해 자식 생성자 함수의 prototype link 를 변경합니다.
  • constructor 속성을 추가하여 생성자가 누구인지 알려줍니다.
    • 명시하지 않을 경우 체인을 타고 부모 prototype objectconstructor 를 참조하기 때문에 부모 생성자를 참조하게 됩니다.
  • 마지막으로 prototype 체인을 타지 않고 부모 생성자의 prototype 멤버를 바로 호출하기 위한 super 키워드를 추가합니다.

재사용 가능한 상속 함수를 만듦으로써 prototype 을 사용한 상속도 이제 손쉽게 할 수 있게 되었습니다. 이제 자바스크립트에서 넘어야할 많은 산들중 큰 산을 하나 넘었습니다.

기왕 산을넘은 김에 old school 방식이 아닌 모던한 방법도 알아볼까요?
조금 허무할 수 도 있습니다. 너무 심플하거든요.

모던하게 자바스크립트 상속 구현하기

ECMAScript 2015 문법을 사용한 상속 구현이지만 내부적으로는 old school 방식과 동일하게 상속을 처리합니다. 아래 두가지 방법을 소개해 드립니다.

  1. ECMAScript 2015 (ES6) 문법인 Object.create 메소드 사용

    구글링 하다가 Classical inheritance with Object.create() 이라는 페이지를 찾았습니다. 모던한 방법이 아닌 클래시컬한 방법이라고 하네요…ㅠ

  2. ECMAScript 2015 (ES6) 문법인 class, extends 키워드 사용

Object.create

Object.create 메서드는 지정된 prototype object 및 속성을 갖는 새 객체를 만듭니다.

1
Volkswagen.prototype = Object.create(MQB.prototype);

위 코드를 사용하면 임의의 생성자 함수를 직접 만들 필요가 없이 내부적으로 아래와 같이 prototype link 참조를 변경해 줍니다.

1
2
3
Volkswagen.prototpye = {
__proto__ : MQB.prototype
}

prototype 상속은 잘 되었지만 앞서 만든 inherit 함수에 추가한 constructorsuper 같은 내용은 처리해 주지 않습니다. 따라서 아래와 같이 기존에 만든 상속 함수에 prototype link 변경 부분만 수정하여 좀 더 간결한 상속 함수로 개선해 줍니다.

1
2
3
4
5
var inherit = function( Parent, Child ){
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
Child.super = Parent.prototype;
};

상속 함수 자체는 많이 심플해 졌지만 부모 생성자의 인스턴스 멤버에 접근하기 위해서는 여전히 자식 함수에서 부모 생성자 함수 빌려쓰기를 해줘야 합니다.

이제 class, extends 키워드를 사용해 더 심플해질 차례입니다.

class, extends

class 키워드를 사용하여 부모 클래스를 MQB 를 선언합니다.

1
2
3
4
5
6
7
8
9
10
class MQB {
constructor(title) {
this.complete = false;
this.title = title;
}

getTitle() {
return this.title;
}
}

부모 클래스 MQB 를 상속받을 Volkswagen 클래스는 extends 키워드를 사용하여 선언합니다.

1
2
3
4
5
6
7
8
9
10
class Volkswagen extends MQB {
constructor(title) {
super(title); // old school 방식에서 생성자 빌려쓰기 부분
this.complete = false;
}

getSuper(){
return super.getTitle(); // 상속시 따로 super 속성을 추가하지 않아도 됩니다.
}
}

상속이 완료되었습니다. old school 방식처럼 생성자 빌려쓰기, constructor, super 를 처리하기 위한 어떠한 작업도 필요 없습니다. 코드에서 보듯이 super 키워드로 부모 생성자를 초기화 할 수 있으며, super.getTitle 과 같이 부모 클래스의 prototype object 를 바로 참조할 수 도 있습니다. 인스턴스.constructor 를 찍어보면 아래와 같이 인스턴스의 생성자 함수인 Volkswagen 가 잘 찍히는 것도 확인해 볼 수 있습니다.

1
2
3
const golf = new Volkswagen();
console.log(golf.constructor);
// class Volkswagen extends MQB {...}

마무리

prototype 이 무엇이며 언제 사용되는지와 prototype 을 사용하여 상속을 구현하는 몇가지 방법들도 살펴보았습니다. 부족한 내용이지만 조금이나마 도움이 되었기를 바라며 es6 이상의 개발 환경에 있든 아니든 prototype 을 활용하여 좀 더 즐겁게 개발하실 수 있기를 바랍니다.

잘 못된 내용이나 개선점이 있으면 피드백을 남겨주시면 반영하도록 하겠습니다.
읽어주셔서 감사합니다.