프로토타입
프로토타입을 이용하면 객체와 객체를 연결하고 한쪽 방향으로 상속을 받는 형태를 만들 수가 있다. 자바스크립트에서 객체와 객체를 연결해서 상속 받는다는 것은 다른 말로 객체와 객체를 연결해 멤버함수나 멤버변수를 공유 한다는 뜻이다. 이런 점을 이용해 자바스크립트에서는 상속과 비슷한 효과를 얻는 것이다.
var a = {
attr1: 1
}
var b = {
attr2: 2
}
a 하고 b 라는 객체가 있다. a 에는 attr1
이라는 멤버변수가 있고 b 에는 attr2
라는 멤버변수가 있다. 지금은 b 객체를 통해 a의 attr1
속성에 접근할 방법이 없다. b 객체에서도 a의 attr1
을 마치 b가 소유한 것처럼 사용하길 원한다면 __proto__
라는 특수한 속성을 이용한다.
var a = {
attr1: 'a1'
}
var b = {
attr2: 'a2'
}
b.__proto__ = a;
b.attr1
자바스크립트 엔진은 객체를 일종의 키와 값을 가진 해쉬맵처럼 다룬다. 값에는 데이터와 함수가 들어갈 수 있고 엔진 내부에 필요한 데이터를 임의로 만들어 넣기도 한다. 물론 그게 자바스크립트단으로 노출이 될 수도 있고 안될 수도 있다. 프로토타입 체인의 핵심은 엔진이 사용하는 __proto__
라는 속성이다. __proto__
속성은 ECMAScript의 스펙 [[Prototype]]
이 자바스크립트로 노출된 것인데 예전 스펙이 legacy 처럼 남아있는 것이다. 모던 브라우저 개발자 도구에서도 디버깅 편의상 노출하고 있지만 개발 코드에서 직접적으로 접근하는 것은 피해야 한다. 여기서도 프로토타입의 이해를 돕기 위해 사용한다. __proto__
이 참조한는 객체를 확인해야 하는 상황(예를 들면 프레임웍 개발)이라면 __proto__
속성을 직접 사용하지 말고 Object.getPrototypeOf()
를 이용해서 참조하면 된다. b 의 __proto__
속성이 a 객체를 가리키고(참조)있다. 이 말은 a의 멤버 변수나 메서드를 몇 가지 제약은 있지만 마치 b가 소유한 것처럼 사용할 수 있다는 것이다. 이 과정에서 상속과 비슷한 효과를 얻을 수 있게 된다. 다른 클래스를 통한 상속의 경우 클래스의 상속 정보를 이용해 상속 구조의 모습을 가진 새로운 객체를 찍어내는 반면 프로토타입을 통한 상속 구조는 존재하는 객체와 존재하는 객체의 동적인 연결로 풀어낸다. 그렇다 보니 이미 객체가 만들어진 상태라도 상속된 내용이 변경되거나 혹은 추가되기도 하고 아예 상속 구조를 바꿀 수도 있게 된다. 물론 대부분 안티 패턴이다.
var a = {
attr1: 'a1'
}
var b = {
attr2: 'a2'
}
b.__proto__ = a;
b.attr1
a.attr1 = 'a000';
b.attr1
a.attr3 = 'a3'
b.attr3
delete a.attr1
b.attr1
일반적인 클래스개념에서는 상상도할 수 없는 일이다. 이런 방식이 좋다 나쁘다를 떠나서 자바스크립트에서는 이렇게 밖에 못한다. 사실 이런 성질을 이용해 클래스 메커니즘을 흉내내는것은 비교적 쉬운 반면 클래스 기반의 언어가 프로토타입 메커니즘을 흉내내기는 매우 어려울것이다. 아뭏든 객체와 객체의 연결을 통한 단방향 공유 관계를 프로토타입 체인이라고 한다. 자바스크립트의 프로토타입 상속은 사실 이 내용만 제대로 이해하면 90%이상 이해했다고 봐도 된다. 이런 연결을 직접적으로 코드에서 __proto__
에 접근하지 않고 만들어야 하는데 그 방법은 몇가지가 있다. 하지만 그 전에 프로토타입을 통해 식별자를 찾는 과정을 조금 더 살펴보자.
프로토타입 식별자 룩업
자바스크립트에서 식별자를 룩업하는 방법은 두 종류의 메커니즘이 있다. 프로토타입 룩업과 스코프 룩업인데 여기서는 프로토타입 체인을 통해 식별자를 찾아가는 프로토타입 룩업을 살펴본다. 프로토타입 룩업이라는 과정은 클래스의 상속과 비교되는 특징 중 하나이다. 쉽게 이야기하면 클래스 상속은 객체를 만든 시점에 이미 이 객체가 상속구조를 통해 어떤 멤버들을 보유하고 있는지 결정된 반면 프로토타입 체인을 통한 상속의 경우 실행을 해봐야 객체가 해당 멤버를 가지고 있는지 알 수 있다. 물론 개발자는 알고 있겠지만 자바스크립트 엔진의 관점에서는 메서드를 실행할 때 동적으로 해당 메서드를 찾아서 실행한다는 의미다. 그래서 이미 만들어진 객체의 상속된 내용이 변경될 수 있는 것이다. 객체가 만들어 질 때가 아닌 실행할 때의 내용이 중요하니까 말이다. 이렇게 프로토타입 체인을 통해 객체의 메서스나 속성을 찾아가는 과정을 프로토타입 룩업이라고 한다.
var a = {
attr1: 'a'
};
var b = {
__proto__: a,
attr2: 'b'
};
var c = {
__proto__: b,
attr3: 'c'
};
c.attr1
위의 코드에서는 객체를 세개 만들고 각 객체의 __proto__
속성을 이용해 c -> b-> a 로 연결했다. c.attr1
로 c 객체에는 없는 attr1이라는 속성에 접근하면 자바스크립트 엔진은 아래와 같은 작업을 수행한다.(물론 엔진의 최적화에 따라 단계는 축소된다.)
- c객체 내부에
attr1
속성을 찾는다. -> 없다.
- c객체에
__proto__
속성이 존재하는지 확인한다. -> 있다.
- c객체의
__proto__
속성이 참조하는 객체로 이동한다. -> b객체로 이동
- b객체 내부에
attr1
속성을 찾는다. -> 없다.
- b객체에
__proto__
속성이 존재하는지 확인한다. -> 있다.
- b객체의
__proto__
속성이 참조하는 객체로 이동한다. -> a객체로 이동
- a객체 내부에
attr1
속성을 찾는다. -> 있다.
- 찾은 속성의 값을 리턴한다.
단순히 표현하면 __proto__
의 연결을 따라 링크드리스트를 탐색하듯 탐색해서 원하는 키값을 찾는것이다. 여기서 어떤 객체에도 존재하지 않는 속성인 attr0
을 찾게 되면 7번부터 다른 과정을 거치게 된다.
(7번 부터)
- a객체 내부에
attr0
속성을 찾는다. -> 없다.
- a객체에
__proto__
속성이 존재하는지 확인한다. -> 있다.
- a객체의
__proto__
속성이 참조하는 객체로 이동한다. -> Object.prototype
로 이동
Object.prototype
에서 attr0
속성을 찾는다. -> 없다.
Object.prototype
에서 __proto__
속성을 찾는다. -> 없다.
- undefined 리턴
모든 프로토타입 체인의 끝은 항상 Object.prototype
이다. 그래서 Object.prototype
은 __proto__
속성이 없다. attr0
라는 속성은 프로토타입의 마지막 단계인 Object.prototype
에 존재하지 않고 Object.prototype
에는 __proto__
속성이 존재하지 않으니 탐색을 종료하고 undefined를 리턴한다. 크롬과 Node.js의 자바스크립트 엔진인 V8은 이 과정을 최적화해 탐색 비용을 줄여 퍼포먼스를 향상시켰다. 단일 링크드리스트 형태로 한쪽 방향의 연결이다 보니 상속이란 개념을 얼추 적용할 수 있다. c에서 a의 속성은 접근할 수 있지만 a에서 c의 속성은 접근할 수 없다.
var a = {
attr1: 'a'
};
var b = {
__proto__: a,
attr2: 'b'
};
var c = {
__proto__: b,
attr3: 'c'
};
a.attr3
이런 점을 이용해 메서드 오버라이드를 구현할 수 있다.
var a = {
method1: function() { return 'a1' }
};
var b = {
__proto__: a,
method1: function() { return 'b1' }
};
var c = {
__proto__: b,
method3: function() { return 'c3' }
};
a.method1()
c.method1()
c 객체에서 method1()
메서드를 실행하면 프로토타입 룩업을 통해 c 에서 b 로 이동하고 b 에 이미 해당 메서드가 있기 때문에 a 까지 안올라가고 b 의 method1()
이 실행된다. 자바스크립트에서는 이런 상황을 a 의 method1()
메서드를 b 가 오버라이드 했다고 한다. 이제 이런 프로토타입을 이용해 객체를 만드는 방법을 살펴보자.
생성자
생성자를 이용해 객체를 생성하면 생성된 객체는 생성자의 프로토타입 객체와 프로토타입 체인으로 연결된다. 기본적인 생성자 사용 방법에 대한 이해는 있다는 가정하에 이 글을 쓰고 있기 때문에 생성자의 기초적인 내용은 다루진 않고 프로토타입 체인을 설명하기 위한 부분만 다룬다.
function Parent(name) {
this.name = name;
}
Parent.prototype.getName = function() {
return this.name;
};
var p = new Parent('myName');
매우 간단한 생성자 예제이다. Parent
라는 생성자가 만들어내는 객체는 p 는 name 이라는 속성을 가지고 있고 getName 이라는 프로토타입 메서드를 사용할 수 있다. p 객체가 Parent
의 프로토타입 메서드에 접근할 수 있는 이유는 p 객체의 __proto__
속성이 Parent.prototype
을 가리키고 있기 때문이다. 이 과정은 생성자를 new
키워드와 함께 사용할 때 엔진 내부에서 연결해준다. 실제로 엔진은 아래와 같은 행동을 한다. (이해를 돕기 위한 코드이다)
var p = new Parent('myName');
p = {};
Parent.call(p, 'myName');
p.__proto__ = Parent.prototype;
p.getName();
위 코드는 생성자가 만들어 내는 객체가 어떻게 생성자의 =prototype=과 연결되는지 보여주고 있다. 위 과정을 통해서 프로토타입 룩업시 p -> Parent.prototype 의 탐색 과정을 만들어 낼 수 있다. 현재의 코드에선 Object 타입을 제외한다면 의도적으로 구현된 상속이란 개념은 아직 들어가있지 않다. Parent 타입을 상속받는 Child 타입라는 타입을 만들려 한다면 어떻게 해야할까? 프로토타입 체인의 연결 구조는 Child 의 인스턴스 -> Child.prototype -> Parent.prorotype 이런 연결 구조를 만들어야 한다. Child의 인스턴스 -> Child.prototype 으로의 연결은 바로 위 코드의 Parent의 경우와 다르지 않다. 여기서 중요한건 어떻게 Child.prototype과 Parent.prototype을 연결하는가이다. 이 과정이 프로토타입을 이해하는데 제일 애매한 부분이지만 이 과정만 이해한다면 몇 단계의 상속 구조도 쉽게 만들수 있다.
Object.create();
자식의 프로토타입과 부모의 프로토타입의 연결은 결국 객체와 객체를 연결하는 것을 말한다. 방법으로는 두가지가 있는데 첫 번째 방식은 꼼수에 지나지 않는 옛 방식이고 두 번째는 표준 API를 이용한 새로운 방식이다. 표준 API가 있는데 왜 꼼수를 이용하는가 궁금할 수 있는데 표준 API가 나온 시점이 늦은편이고 늦었다는건 브라우저의 지원이 좋지 않았기 때문이다(IE9이상부터 지원). 현재도 한국 환경에서는 IE8을 버리기가 힘들 수 있어 우선 API를 이용하는 방법으로 시작하고 후에 꼼수도 설명한다. 여기서 말하는 표준 API는 Object.create()
다. Object.create()
는 객체를 인자로 받아 그 객체와 프로토타입 체인으로 연결되어 있는 새로운 객체를 리턴해준다. 초반에 설명한 __proto__
예제로 다시 설명한다.
var a = {
attr1: 'a'
};
var b = {
__proto__: a,
attr2: 'b'
};
위 코드는 __proto__
를 통해 프로토타입이 연결되는 과정을 설명하려고 작업한 실제로는 사용해선 안될 코드다. __proto__
속성에 개발자가 직접 접근한 코드가 문제인데 Object.create()
을 이용해 __proto__
속성에 직접 접근하지 않고 프로토타입 체인을 연결할 수 있다.
var a = {
attr1: 'a'
};
var b = Object.create(a);
b.attr2 = 'b';
Object.create()
으로 인해 __proto__
가 a를 참조하는 새로운 빈객체를 리턴하게되고 b 에 참조되어 b.attr1
이런식으로 a 객체의 맴버에도 접근할 수 있다. 위의 코드의 구현 내용은 사실 Object.create()
이 없어도 구현할 수 있다. 이제 꼼수가 나오게 된다. Object.create()
과 같은 API들이 생겨나는 것도 이런 꼼수들을 착안한것이라고 생각한다.
var a = {
attr1: 'a'
};
function Ghost() {}
Ghost.prototype = a;
var b = new Ghost();
b.attr2 = 'b';
Object.create()
이 해주는 작업은 위 코드와 다를 게 없다. Ghost
라는 대리(혹은 임시) 생성자를 만들고 prototype
이 a 를 참조하게 한 뒤 객체를 만들게 되면 __proto__
가 a 를 참조하고 있는 Ghost
의 인스턴스가 만들어진다. b 가 Ghost
의 인스턴스라는 것 빼고는 Object.create()
이 만들어낸 객체와 큰 차이가 없다. Ghost
의 인스턴스라는 것도 b.constructor
의 참조를 Object로 바꿔 숨길 수 있지만 이런 장황한 과정보다는 Object.create()
을 사용하는 것이 옳은 선택이다. 아마 지금까지의 내용만으로는 자식 프로토타입과 부모 프로토타입이 연결되는 것으로 바로 생각이 이어지지 않을 것이다. 이제 프로토타입끼리 연결해보자.
function Parent(name) {
this.name = name;
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name) {
Parent.call(this, name);
this.age = 0;
}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
Child.prototype.getAge = function() {
return this.age;
};
var c = new Child();
(1) 에서 Object.create()
을 이용해 Child의 prototype
객체를 교체했다. (1) 에서 만들어진 새 객체 즉 Child.prototype
는 __proto__
속성이 Parent.prototype
을 가르키게 되고 (2) 에서 Child의 인스턴스 c의 __proto__
가 Child.prototype
을 참조하게 된다. 이렇게해서 c -> Child.prototype -> Parent.prototype 으로 연결되는 프로토타입이 만들어졌고 프로토타입 룩업시 우리가 의도한 탐색 경로로 식별자를 찾게 된다. 추가로 Child 생성자에서 Parent 생성자를 빌려써 객체를 확장한것은 생성자 빌려쓰기라는 오래된 기법으로 자바스크립트 상속에서 부모의 생성자를 실행하는 유일한 방법이다. 이렇게 해야 부모타입의 생성자의 내용도 상속 받는다.
Object.create()
을 사용하지 못하는 환경에서는 아까 다뤘던 꼼수를 이용해야 한다.
function Parent(name) {
this.name = name;
}
Parent.prototype.getName = function() {
return this.name;
};
function Child(name) {
Parent.call(this, name);
this.age = 0;
}
function Ghost() {};
Ghost.prototype = Parent.prototype;
Child.prototype = new Ghost();
Child.prototype.constructor = Child;
Child.prototype.getAge = function() {
return this.age;
};
var c = new Child();
바뀐 코드의 범위는 주석으로 표현했다. 코드만 봤을 때는 굳이 Ghost
란 임시 생성자를 사용할 필요 없이 Parent
생성자를 사용하면 되지 않을까 생각할 수 있다. 하지만 그 방식은 생성자가 만들어내는 속성 때문에 사용할 수 없다. 다시 말하면 Parent
생성자를 Ghost
대신 그대로 사용했다면 Parent
생성자를 통해 만들어진 객체는 name
이란 속성도 갖게 된다. 이 객체를 Child
의 프로토타입으로 사용하게 되면 name
이란 속성을 Child
의 인스턴스들이 같은 내용으로 공유하게 되는데 생성자에서 만든 속성들은 공유하는 게 아니라 인스턴스 별로 소유하게 만들어야 하 때문이다. 인스턴스 별로 name
속성을 만들기 위해 Child
생성자 안에서 Parent
생성자의 생성자 빌려 쓰기를 수행했던 것이다. Ghost
같은 임시 생성자를 이용하는 이유는 순수하게 프로토타입 체인만 연결된 빈 객체를 얻기 위함이다.
ES6
자바스크립트는 상속이 이루어지는 개념은 간단한데 그것을 구현하는 코드가 상당히 장황하다. Object.create()
을 사용해도 장황한 건 마찬가진데 이를 보완하기 위해 ECMAScript6에서 class
스펙이 추가되었다. 클래스라고는 하지만 새로운 개념이 아니고 상속의 구현 원리는 기존과 동일한 내용으로 장황했던 코드를 간결하게 하는 숏컷이 추가됐다고 생각하면 된다. 예제로 만들어봤던 Parent
와 Child
는 ES6의 class
를 이용하면 아래와 같이 사용할 수 있다.
class Parent {
constructor(name) {
this.name = name;
}
getName() {
return this.name;
}
}
class Child extends Parent {
constructor(name) {
super(name);
this.age = 0;
}
getAge() {
return this.age;
}
}
코드가 간결해지고 이해하기 쉽게 바뀌었다. 코드는 달라졌다 하더라도 이를 통해 만들어진 객체의 프로토타입 체인 연결 구조는 기존과 동일하고 또한 동일한 방식의 프로토타입 룩업으로 식별자를 찾아간다.
정리
이 글에서는 프로토타입 체인이 어떤 모습인지와 프로토타입 룩업을 통해 어떻게 식별자를 찾아가는지 그리고 프로토타입 체인을 어떻게 만드는지에 대해 알아봤다. 자바스크립트 개발을 하면서 프로토타입 체인의 애매했던 부분을 이해하는데 조금이라도 도움이 되었으면 하는 마음에서 작성했다. 마지막에 다뤘던 class는 ES6의 스펙으로 아직은 브라우저 지원이 낮은 편이지만 Babel 을 이용해 트랜스파일링하면 ES6를 지원하지 않는 브라우저도 대응할 수 있다.