JS - prototype
prototype
자바스크립트를 사용하면서 prototype이라는 존재는 종종 본적이 있을 것이다.
코드를 작성하다 어떤 값을 콘솔로 확인할때 [[Prototype]]이라는 프로퍼티를 확인할 수 있다. 내가 부여한 적 없는 내용들이 왜 들어가게 되었는지, 이 내용들은 어디서 왔고 용도는 뭔지 알아보겠다.
javascript의 특성
프로토타입에 대해 알아보기 전에 자바스크립트의 특성을 먼저 이해하고 진행하겠다.
일급 객체
자바스크립트를 공부하면서 한번은 들어봤을 단어이다.
- 무명의 리터럴로 생성 가능하다.
- 변수나 자료구조 등에 저장할 수 있다.
- 함수의 매개변수에 전달 가능하다.
- 함수의 반환값으로 사용할 수 있다.
위와 같은 특징을 가진 객체를 일급 객체라고 한다. 추가적으로 자바스크립트에서는 함수도 객체이기 때문에 일급 객체의 특징을 함수도 가지고 있다. 그래서 함수를 확인해보면
arguments, caller, length, name, prototype과 같은 프로퍼티를 확인할 수 있는 것이다.
객체 지향 프로그래밍
우리가 익히 자바스크립트는 객체 지향 프로그래밍이라고 많이 얘기한다. 하지만 자바스크립트는 객체 지향 언어가 아니다. 그럼에도 왜 객체 지향이라는 수식어가 붙었을까?
객체 지향 언어의 4가지 특징
- 추상화 : 객체의 공통적인 속성과 기능을 추출하여 정의하는것
- 상속 : 한 번만 정의해두고 간편하게 재사용할 수 있어 반복적인 코드를 최소화하고 공유하는 속성과 기능에 간편하게 접근
- 다형성 : 어떤 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있는 성질
- 캡슐화 : 서로 연관있는 속성과 기능들을 하나의 캡슐(capsule)로 만들어 데이터를 외부로부터 보호하는 것
객체 지향 언어는 위와같은 4가지 특징이 있다. class를 기반으로 객체를 생성하고 class와 객체에서 나타나는 특징이다. 하지만 우리는 자바스크립트를 사용하면서 class를 생성해서 object를 만들어본 경험이 대부분 없을 것이다. 자바스크립트가 만들어지는 당시에 class가 없는 언어로 설계가 되었다. 프로토타입이라는 원형 객체를 기반으로 객체의 속성을 참조함으로서 상속과 비슷한 효과를 구현해냈다. 그래서 자바스크립트는 프로토타입 기반 객체 지향 언어라고 불린다.
이런 두가지 특성으로 자바스크립트는 일급 객체이기 때문에 함수도 객체로 변환해 사용하고, 프로토타입을 활용해 class없이 상속과 캡슐화와 같은 객체 지향 언어의 특성을 가질수 있게 되었다. 지금은 너무 포괄적으로 설명하고 있지만 천천히 설명해보겠다.
프로토타입이란?
프로토타입은 뭘까?
var instance = new Constructor();
new와 함께 생성자 함수인 Constructor를 호출하면, 새로운 instance가 생성된다. 이때 instance에는 __proto__라는 프로퍼티가 생성되는데 __proto__는 생성자 함수의 prototype을 참조한다. 말이 어려운데 도식화를 해보면 좀 간단하다.
여기서 나오는 prototype과 __proto__가 프로토타입의 핵심이다. prototype와 __proto__는 객체이며 prototype객체 내부에는 인스턴스가 사용할 메서드를 저장하고, 인스턴스에서는 __proto__를 통해 메서드에 접근이 가능하다. 그럼 prototype에 메서드를 지정해보자.
var Person = function(name){ this._name = name; }; Person.prototype.getName = function(){ return this._name; };
이제 생성자 함수 Person을 통해 생성된 instance는 __proto__를 통해 getName에 접근할 수 있다.
var alex = new Person('alex'); alex.__proto__.getName(); // undefined
위에서 얘기했듯이 instance의 __protp__는 Constructor의 prototype을 참조하기 때문에 같은 원형 객체를 바라보고 있기 때문이다.
Person.prototype === alex.__proto__ // true
그런데 Contructor를 통해 instance의 name프로퍼티를 지정해줫는데 결과가 undefined가 나왔을까? 제대로 메서드 지정이 안되었다면 undefined가 아니라 Reference error를 발견했을것이다. 이건 this에서도 알아봤듯이 메서드의 특징이다. 메서드로 호출한 함수는 호출한 메서드 앞에 어떤 객체가 바인딩 되었는지 여부이다.
getName메서드 앞에 __proto__를 붙임으로서 바인딩되었고, __proto__를 확인해보면 getName메서드는 잘 들어있지만 _name프로퍼티는 없기 때문에 undeinfed로 나온것이다.
alex.__proto__._name = 'alex__proto__'; alex.__proto__.getName(); // alex__proto__
그래서 __proto__에 직접 프로퍼티를 만들어주면 문제없이 잘 동작한다. 그럼 매번 이렇게 해줘야하는가? 그건 아니다. 우리는 한번도 객체에서 메서드를 사용할때 __proto__에 값을 할당해본 경험이 없다.
alex.getName(); // alex
우리가 아는데로 메서드 앞의 객체를 바인딩하기 때문에 생성한 instance에서 바로 메서드를 실행하는 것이다. 조금 이상한 것은 __proto__는 Constructor의 prototype을 참조해 생성되는데 __proto__를 생략했는데 어떻게 __proto__에 있는 메서드가 실행되고 원하는 값이 나올까싶다. 그 이유는 __proto__는 생략 가능한 프로퍼티이기 때문이다.
갑자기 뜬금없이 생략가능한 프로퍼티라니 혼란스럽지만 이해가능한 범주가 아니다. 자바스크립트를 설계한 개발자의 생각과 아이디어로 이해할 필요없이 받아들이면 된다...
그럼 위에서 만든 도식을 수정해보자.
__proto__는 생략 가능하기 때문에 Constructor의 prototype에 어떤 메서드나 프로퍼티가 있다면 instance에서도 자신의 값인 것처럼 접근할 수 있게 된다.
var Constructor = function(name){ this.name = name; }; Constructor.prototype.method1 = function(){}; Constructor.prototype.property1 = 'prototype property'; var instance = new Constructor('Instance'); console.dir(Constructor); console.dir(instance);
생성자 함수의 프로토타입에 메서드와 프로퍼티를 생성해줬다. 그리고 해당 생성자 함수를 통해 인스턴스를 생성해 각각 내부 값을 확인해 보면 다음과 같다.
생성자 함수의 prototype과 인스턴스의 [[Prototype]](__proto__)가 같은 내용으로 구성되어 있음을 알수 있다.
생성자 함수와 리터럴 변수 선언
대표적인 생성자 함수인 Array와 리터럴 변수 선언을 통해 prototype을 알아보겠다.
var arr = [1,2]; console.dir(Array); console.dir(arr);
배열 생성자 함수인 Array의 결과이다. 함수의 기본 프로퍼티와 Array 함수의 정적 메서드, prototype의 메서드를 확인할 수 있다.
리터럴 변수 선언으로 만든 배열을 출력한 결과이다. 리터럴 변수 선언도 Array 생성자 함수를 원형으로 생성되었다는 것을 확인할 수 있다. 추가적으로 __proto__를 확인해보면 Array 생성자 함수의 prototype과 동일한 메서드들을 확인할 수 있다.
우리가 배열을 만들어 사용하면서 배열 메서드를 사용할 수 있었던 이유가 이것이다. Array 생성자를 원형으로 배열이 생성되기 때문에 우리가 배열에 별도의 작업을 안해줘도 배열 메서드를 공통적으로 사용할 수 있었던 것이다.
prototype에 없는 기본 Array 생성자 메서드(from, isArray)는 당연히 배열 메서드로 사용할 수 없다. 만약 isArray를 사용해서 배열인지 확인하고 싶다면
Array.isArray(data)이런 식으로 Array 생성자를 호출해야한다.
constructor 프로퍼티
prototype과 __proto__는 constructor 프로퍼티가 존재한다. 이름에서 알수 있듯이 생성자 함수(자기 자신)를 참조한다. 굳이 자기 자신을 참조하는 이유는 인스턴스와의 관계에 있어서 인스턴스로부터 그 원형이 뭔지 알 수 있는 수단이기 때문이다.
var arr = [1,2]; Array.prototype.constructor === Array arr.__proto__.constructor === Array arr.constructor === Array // __proto__는 생략가능
그래서 생성자 함수는 자기 자신을 constructor로 가지고, 배열의 constructor는 인스턴스의 원형이 Array를 가진다.
추가적으로 constructor는 읽기 전용 속성(기본형 리터럴 변수)을 제외한 값은 변경이 가능하다.
var NewConstructor = function(){ console.log('new constructor'); } var dataType = [ 1, // Number & false 'test', // String & false {}, // NewConstructor & false [], // NewConstructor & false new String() // NewConstructor & false ]; dataType.forEach(function(data){ data.constructor = NewConstructor; console.log(data.constructor.name, '&', data instanceof NewConstructor); });
기본형 데이터는 변동이 없고, 그 외에 객체나 배열, 생성자 함수는 constructor가 변경되었다. 하지만 instanceof의 결과는 false이다. 이 의미는 constructor가 변경될 수는 있지만 참조되는 대상이 바뀔뿐, 인스턴스의 원형이 바뀌거나 데이터 타입이 바뀌는 것은 아니다.
console.log(dataType[3].constructor) // NewConstructor dataType[3] instanceof NewConstructor // false dataType[3] instanceof Object // true
그래서 인스턴스의 원형을 알아내기 위해 constructor 프로퍼티에 의존하는 것은 항상 옳은 방법은 아닐수 있다는 점을 유의하자.
메서드 덮어 쓰기
지금까지 prototype을 참조하는 __proto__를 생략하면 prototype에 정의된 프로퍼티나 메서드에 접근 가능하다고 했다. 그렇다면 만약 인스턴스에 prototype과 동일한 프로퍼티나 메서드가 있다면 어떻게 될까?
var Person = function(name){ this.name = name; }; Person.prototype.getName = function(){ return this.name; }; var alex = new Person('alex'); alex.getName = function(){ return '나는 ' + this.name + ' 입니다.'; }; console.log(alex.getName()); // 나는 alex 입니다.
지금 생성자 함수의 prototype에 메서드를 추가하고 인스턴스에도 동일한 이름의 메서드를 추가해서 호출하니 인스턴스의 메서드가 호출됬다. 이런 현상을 메서드 오버라이드라고 하며, 메서드 위에 메서드를 덮어씌웠다는 의미이다. 다른 대상으로 교체하는 것이 아닌 덮어 씌웠다는 것이 핵심이다.
자바스크립트 엔진이 메서드를 찾는 방식은 가장 가까운 대상인 자신의 프로퍼티를 검색하고, 없으면 그다음 대상인 __proto__를 검색한다. 즉, __proto__에 도달하기 전에 이미 자기 자신에게 해당 메서드를 찾았으니 인스턴스에 있는 메서드가 호출된 것이다. 그럼 prototype에 접근하는 법은 없을까?
alex.__proto__.getName(); // undefined
직접 __proto__의 메서드를 호출하면 된다. 하지만 결과는 undefined이다. 왜냐하면 alex.__proto__에는 name이 없기 때문이다.
Person.prototype.name = '알렉스';
이렇게 직접 prototype에 name 프로퍼티를 추가함으로서 해결 가능하지만 이제 Person 생성자를 사용한 객체에는 name의 값이 고정될 것이다.
alex.__proto__.getName.call(alex); // 'alex'
그래서 직접 this를 바인딩해줌으로서 그 문제를 해결할 수 있다. 즉, 메서드 오버라이드가 된 경우, 자신에게 가장 가까운 메서드에 접근하지만 __proto__에 직접 메서드로 접근하는 방법이 존재한다.
프로토타입 체이닝
메서드 오버라이드와 직접적으로 연관된 개념이 프로토타입 체인이다. 프로토타입 체인에 대해서 알아보기 전에 내부 구조를 한번더 보겠다.
var arr = [1,2]; console.dir(arr);
배열을 하나 생성하고 내부 구조를 확인해보면
익숙하게 배열 데이터와 __proto__를 확인할 수 있다. 그런데 __proto__의 내용을 확인해보면
__proto__내부에 또다른 __proto__가 존재한다. 내부 __proto__는 Object생성자로 되어 있고, Object.prototype과 동일한 내용으로 구성되어있다. 그 이유는 prototype도 결국 객체이기 때문이다. 그래서 모든 객체의 __proto__는 Object.prototype과 연결되어있다.
그래서 해당 내용을 도식화해보면 이렇게 나온다. 그래서 배열 데이터를 생성해도 __proto__를 따라 올라가 Object 생성자의 메서드를 사용할수 있는 것이다.
var arr = [1,2]; arr.__proto__.__proto__.hasOwnProperty(1); // true arr.hasOwnProperty(1); // true // __proto__는 생략 가능
이렇게 어떤 데이터의 __proto__프로퍼티 내부에 다시 __protp__프로퍼티가 연쇄적으로 이어진 것을 **프로토타입 체인(prototype chain)**이라고 하고, 체인을 따라가며 검색하는 것을 프로토타입 체이닝이라고 한다.
그럼 처음에 얘기했던 메서드 오버라이드와 연관이 있다는 말이 이해가 될것이다. 메서드를 호출하면 자바스크립트 엔진은 자신의 프로퍼티부터 검색해서, 해당 메서드를 발견할때 까지 __proto__를 검색한다.
var arr = [1,2]; Array.prototype.toString.call(arr); // '1,2' Object.prototype.toString.call(arr); // '[object Array]' arr.toString(); // '1,2' arr.toString = function(){ return this.join('-'); }; arr.toString(); // '1-2'
배열은 Array.prototype을 참조하고, Array.prototype.__proto__는 Object.prototype을 참조할 것이다. 위에서 사용한 toString은 배열과 객체 둘다 메서드로 가지고 있다. 그래서 어떤 메서드가 호출되는지 볼수 있다. 각 생성자의 메서드로 실행하면 해당 생성자 메서드로 동작하고, 인스턴스의 메서드로 실행하면 가장 가까운 prototype에 해당하는 Array.prototype의 toString을 실행한다. 그래서 추가로 인스턴스의 메서드로 동일하게 toString을 부여하면 인스턴스의 메서드로 실행된다.
프로토타입 체이닝에 대해서 질문할 수 있다.
- 프로토타입의 원형은 무조건 Object인가?
- 프로토타입은 두개만 연결되는가?
우선 첫번째 질문은 맞고 두번째 질문은 틀리다. 질문은 위의 도표를 다시 보면 된다.
prototype도 결국 객체이기 때문에 원형은 Object 생성자일수 밖에 없다.
두번째 질문도 이미지로 확인해보겠다.
배열을 생성하는 Array도 함수이기 때문에 결국 Constructor는 Function이다. 그리고 이 Function도 함수이기 때문에 동일한 Constructor를 갖는다. 위에 질문과 같이 두개의 프로토타입만 연결되는 것이 아니라 Constructor와 prototype이 끝없이 이어진다.
실제 메모리에 무한정에 해당하는 구조를 가지고 있는 것은 다행히 아니다. 결국 같은 Function 생성자 함수를 가리키기 때문에 메모리가 낭비되지는 않는다.
객체 전용 메서드 예외사항
프로토타입 체이닝으로 결국 Object 생성자의 prototype을 참조한다는 것을 알았다. 그렇다면 Object 생성자의 prototype에 메서드를 추가하면 모든 곳에서 해당 메서드를 사용할수 있을까?
Object.prototype.getEntries = function(){ var res = []; for(var prop in this){ if(this.hasOwnProperty(prop)){ res.push([prop, this[prop]]) } } return res; }; var data = [ ['object', {a : 1, b : 2, c : 3}], // [[a, 1], [b, 2], [c, 3]] ['number', 123], // [] ['string', 'abc'], // [[0, a], [1, b], [2, c]] ['boolean', true], // [] ['function', function(){}], // [] ['arr', [1, 2, 3]] // [[0, 1], [1, 1], [2, 2]] ]; data.forEach(function(item){ console.log(item[1].getEntries()); });
객체에서만 사용할 메서드를 Object prototype에 추가했다. 목적은 객체에서만 사용하는 것이지만, 타입에러 없이 모든 곳에서 에러 없이 함수가 모두 동작한다. 위에서 예상한데로 결국 모두 Object prototype을 참조하기 때문에 프로토타입 체이닝을 통해 Object prototype에 정의한 메서드가 동작한다.
그래서 객체에서만 동작하는 객체 전용 메서드는 Object static method로 지정할 수 밖에 없다. 그래서 다른 메서드들처럼 메서드 앞에 오는 데이터를 this로 갖는 방법이 아닌 객체를 직접 받는 함수로 지정되어 있다.
var obj = {a:1}; Object.freeze(obj);
반면에 데이터 타입에 상관없이 사용 가능한 메서드들은 Object.prototype에 담겨 상속된다.
프로토타입 체이닝을 통해 결국 원형은 Object prototype이라고 얘기를 했지만 예외가 있다.
Object.create(null)을 하면 __proto__가 없는 객체를 생성한다. 이렇게 만들어진 객체는 프로토타입 체이닝이 적용되지 않음으로 내장 메서드와 프로퍼티를 갖지 않게 되고, 무게가 가벼운 객체가 생성된다.
다중 프로토타입 체인
프로토타입은 사용자가 새롭게 만드는 경우에 따라서 여러가지 프로토타입을 연결해 사용할 수 있다.
var Grade = function(){ var args = Array.prototype.slice.call(arguments); for(var i = 0; i < args.length; i++){ this[i] = args[i]; } this.length = args.length; }; var g = new Grade(100, 80);
위의 Grade 생성자는 유사 배열 객체를 만드는 생성자이다. 유사 배열 객체는 배열의 형태를 지니지만 배열이 아닌 관계로 배열 메서드를 사용할 수 없다. 앞에서 this에서 얘기했듯이 call, apply, bind등을 통해 this를 직접 배열메서드에 주입함으로써 사용하는 방법이 있지만 프로토타입을 통해서 구현해보겠다.
Grade.prototype = [];
방법은 간단하다. 현재 g의 __proto__가 Grade.prototype을 바라보고 있는데 Object에서 Array로 변경해주면 된다.
g.pop();
이렇게 했을때 인스턴스에서 해당 메서드를 검색하고 없으면 __proto__를 거슬러 메서드를 찾는다. 결국 우리가 변경해준 Grade.prototype에 접근하고, Grade.prototype은 배열로 변경했기 때문에 Array.prototype에 접근해 배열 메서드를 사용할수 있는 것이다.
마무리
맨 처음에 얘기했듯이 자바스크립트는 프로토타입 기반 객체 지향 언어라고 한다. 자바스크립트는 프로토타입을 활용해 객체 지향 언어의 특징을 구현해냈다.
- Clouser를 통해 데이터를 캡슐화(은닉)
- 생성자 함수와 prototype을 통해 프로퍼티와 메서드를 추상화
- prototype 통해 메서드를 인스턴스에 넘겨주면서 메서드를 상속
- 생성된 인스턴스에 직접 메서드를 주입해 다형성
이렇게 객체 지향 언어가 아님에도 객체 지향 언어의 특징을 구현하였기때문에 객체 지향이라는 수식어가 붙게 되었으며, 객체 지향이 prototype을 중심으로 이뤄지고 있기 때문에 prototype기반 객체 지향 언어불린다.
프로토타입이 우선 끝이 났다. 매번 포스팅을 하면서 느끼는 것은 앞의 내용이 선행되지 않으면 이해하기 어렵겠다라는 생각이 든다. 이 다음은 class이다. 결국 지금까지 했던 포스팅은 모두 마지막 장을 위한 도약인 것이다. 마지막이 좀 걱정되긴 한다.
확실하게 이해하고 활용가능한가에 대한 물음은 아직 잘 모르겠다. 내가 아직 경험이 좁아서 그런지 프로토타입까지 생각하면서 메서드를 삽입하고 사용해본 경험이 전혀 없기 때문이다. 하지만 왜 코드 에디터로 배열에 .만 찍으면 메서드가 확인되는지 이유를 알았기 때문에 유의미한 시간이였다고 생각한다. 계속해서 공부해보자.
개의 댓글
1
prototype
javascript의 특성
일급 객체
객체 지향 프로그래밍
프로토타입이란?
생성자 함수와 리터럴 변수 선언
constructor 프로퍼티
메서드 덮어 쓰기
프로토타입 체이닝
객체 전용 메서드 예외사항
다중 프로토타입 체인
마무리