자바스크립트 객체와 클래스에 대한 포스팅에 이어 이번에는 프로토타입에 대해서 정리해 보려고 한다.
[[Prototype]]
자바스크립트 객체는 [[Prototype]]이라는 내부 프로퍼티가 있고 다른 객체를 참조하는 단순 레퍼런스로 사용한다. [[Prototype]]은 어디에 쓰이는 걸까?
객체 프로퍼티를 참조할 때(myObject.a), 객체는 기본적으로 객체 자체에 해당 프로퍼티가 있는지를 찾고, 만약 있다면 그 프로퍼티를 사용한다. 하지만 만약에 myObject에 a라는 프로퍼티가 없다면 그 다음에는 이 객체의 [[Prototype]] 링크다.
두 번째 예제에서 connectObject는 anotherObject와 [[Prototype]]이 링크되었다. 분명히 connectObject.a 라는 프로퍼티는 없지만 anotherObject에서 2라는 값을 대신 찾아 프로퍼티 접근의 결과값으로 반환한다. 만약 anotherObject에서도 못 찾으면 [[Prototype]] 연쇄를 다시 따라 올라간다. 이렇게 연쇄가 반복되다가 연쇄 끝에 가서도 프로퍼티가 발견되지 않으면, [[Get]]은 결과적으로 undefined를 반환한다.
그렇다면 [[Prototype]] 연쇄가 끝나는 지점은 어디일까? 일반 [[Prototype]] 연쇄는 내장 프로토타입 Object.prototype에서 끝난다. 모든 자바스크립트 객체는 Object.prototype 객체의 자손이므로 Object.prototype에는 자바스크립트에서 두루 쓰이는 다수의 공용 유틸리티가 포함되어 있다. 우리에게 익숙한 .toString() 이나 .valueOf() 같은 유틸리티도 여기에 포함된다.
객체 프로퍼티 세팅은 단지 어떤 객체에 프로퍼티를 새로 추가하거나 기존 프로퍼티 값을 바꾸는 것 이상의 의미가 있다.
myObject.foo = "bar";
foo라는 이름의 데이터 접근 프로퍼티가 myObject 객체에 직속(directly present)되었다면 이 할당문은 기존 프로퍼티 값을 고치는 단순한 기능을 한다. foo가 myObject에 직속된 프로퍼티가 아니면 [[Prototype]] 연쇄를 순회하기 시작하고, 그래도 foo가 발견되지 않으면 그 때 foo라는 프로퍼티를 myObject 객체에 추가한 후 주어진 값을 할당한다.
만약에 foo라는 프로퍼티명이 myObject 객체와 [[Prototype]] 연쇄의 상위 수준 두 곳에서 동시에 발견되게 된다면, 가려짐(shadowing)이 일어난다. myObject의 foo 때문에 상위 연쇄의 foo가 가려지는 것이다. 가려짐은 그 이용 가치에 비해 지나치게 복잡하고 애매한 구석이 있으니 사용하지 않는 것을 권한다. 가려짐이 미묘하게 암시적으로 발생하는 경우를 보도록 하자.
겉보기엔 myObject.a++가 (위임을 통해) anotherObject.a 프로퍼티를 찾아 1만큼 값을 증가시킬 것 같지만 ++ 연산자는 결국 myObject.a = myObject.a + 1을 의미한다. 따라서 [[Prototype]]을 경유하여 [[Get]]을 먼저 찾고 anotherObject.a에서 현재 값 2를 얻은 뒤 1만큼 증가시킨 후, 그 결과값 3을 다시 [[Put]]으로 myObject에 새로운 가려짐 프로퍼티 a를 생성한 뒤 할당한다.
클래스
자바스크립트는 여타 클래스 지향 언어에서 제공하는 클래스라는 추상화된 패턴이나 설계가 없다. 다만 객체가 있을 뿐이다. 실제로 자바스크립트는 클래스 없이 곧바로 객체를 생성할 수 있으므로 '객체 지향'이라는 말이 가장 잘 어울리는 프로그래밍 언어 중 하나이다. 객체는 자신의 작동을 손수 제어한다. 객체밖에 없기 때문이다.
클래스 지향 언어에서는 클래스를 다중 복사(multiple copies, 인스턴스) 할 수 있다. 하지만 자바스크립트는 이러한 복사 과정이 전혀 없고 클래스에서 여러 인스턴스를 생성할 수도 없다. 예를 들어
var a = new Foo();
라는 코드가 있다고 했을 때, new Foo()로 새 객체(a) 가 만들어지고 이 객체는 Foo.prototype 객체와 내부적으로 [[Prototype]]과 연결이 맺어진다. 결국, 상호 연결된 두 개의 객체로 귀결된다. 클래스의 인스턴스화 과정은 없다. 두 객체를 연결한 것이 전부이다.
자바스크립트에서 상속이라는 용어를 써서 의미의 혼란을 가져오는 경우가 많다. 상속은 기본적으로 복사를 수반하지만, 자바스크립트는 객체 프로퍼티를 복사하지 않는다. 대신 두 객체에 링크를 걸어 두고 한 쪽이 다른 한 쪽의 프로퍼티/함수에 접근할 수 있게 위임한다. 작동 위임에 대해서는 다음 포스팅에서 더 자세히 다루도록 하겠다.
Foo.prototype 객체에는 기본적으로 열거 불가한 공용 프로퍼티 .constructor가 세팅되는데, 이는 객체 생성과 관련된 함수(Foo)를 다시 참조하기 위한 레퍼런스다. 마찬가지로 '생성자' 호출 new Foo()로 생성한 객체 a도 .constructor 프로퍼티를 가지고 있어서 '자신을 생성한 함수'를 가리킬 수 있다.
앞 예제에서 new를 붙여 Foo 함수를 호출하고 그래서 객체가 '생성'되었으므로 Foo를 생성자라고 볼 수도 있다. 하지만 Foo는 생성자가 아니라 그냥 하나의 함수이다. 함수는 결코 생성자가 아니지만, 그 앞에 new를 붙여 호출하는 순간 이 함수는 '생성자 호출'을 한다. 용어가 혼동되기 쉽지만 명확하게 구별할 필요가 있다.
a.constructor === Foo 가 true라는 말은 a에 Foo를 참조하는 .constructor 프로퍼티가 존재한다는 것의 의미할까? 많은 개발자들이 착각하기 쉬운 부분인데 정답은 '아니다'이다. 실은 .constructor 역시 Foo.prototype에 위임된 레퍼런스로서 a.constructor는 Foo를 가리킨다.
클래스 지향을 흉내내는 노력은 다음에도 나타나 있다.
이 예제에서 사용한 클래스 지향 꼼수는 다음과 같다.
- this.name = name 할당 시 .name 프로퍼티가 a,b 두 객체에 추가된다. 마치 클래스 인스턴스에서 데이터 값을 캡슐화하는 모습처럼 보인다.
- Foo.prototype.myName = ... 부분이 아주 흥미로운 기법으로, 프로퍼티를 Foo.prototype 객체에 추가한다. 그래서 놀랍게도 a.myName()처럼 쓸 수 있다.
Foo.prototype 객체의 프로퍼티/함수가 a,b 생성 시 각각의 객체로 복사될 거라 짐작하기 쉽지만 절대로 그런 일은 일어나지 않는다. 앞서 설명한 프로토타입의 동작 원리에 따라, a와 b는 생성 직후 각자의 내부 [[Prototype]]이 Foo.prototype에 링크된다. myName은 a, b에서 찾을 수 없으므로 위임을 통해 Foo.prototype에서 찾는다.
프로토타입 상속
위 그림을 보면 [a1 -> Foo.prototype]뿐 아니라 [Bar.prototype -> Foo.prototype]으로도 위임이 일어나는데, 이는 부모-자식 간의 상속 개념과 유사하다. 화살표는 위임 링크 방향을 나타낸다.
위임 링크를 생성하는 프로토타입 스타일 코드를 보자.
Bar.prototype = Object.create( Foo.prototype ) 부분을 주목해서 보자. Object.create()를 실행하면 새로운 객체를 만들고 [[Prototype]]을 지정한 객체(Foo.prototype)에 링크한다. 다른 말로, "Foo 점 프로토타입과 연결된 새로운 Bar 점 프로토타입 객체를 생성하라"는 뜻이다.
Bar() { } 함수를 선언하면 Bar는 여타 함수처럼 기본으로 .prototype 링크를 자신의 객체에 갖고 있다. 이 객체를 Foo.prototype과 연결하고 싶은데, 현재 그렇게 연결되어 있지는 않다. 따라서 애초 연결된 객체와 헤어지고 Foo.prototype과 연결된 새로운 객체를 생성한 것이다. ES6 부터 Object.setPrototypeOf() 유틸리티가 도입되면서 두 객체의 프로토타입을 연결하는 방법이 조금 바뀌었다.
a 같은 객체가 어떤 객체로 위임할지는 어떻게 알 수 있을까? 왼쪽에 일반 객체, 오른쪽에 함수를 피 연산자로 둔 instanceof 연산자는 a의 [[Prototype]] 연쇄를 순회하면서 Foo.prototype가 가리키는 객체가 있는지 조사한다.
a instanceof Foo; // true
하지만 이 방법은 대상함수(.prototype 레퍼런스가 붙은 Foo)에 대해 주어진 객체(a)의 '계통'만 살펴볼 수 있다는 뜻으로, 2개의 객체가 있으면 instanceof만으로 두 객체가 서로 [[Prototype]] 연쇄를 통해 연결되어 있는지는 알 수 없다.
[[Prototype]] 리플렉션을 확인할 수 있는 훨씬 훌륭한 대안이 있다. 바로 isPrototypeOf() 이다.
Foo.prototype.isPrototypeOf( a ); // true
그리고 관계를 확인하고 싶은 두 개의 객체(b, c)가 있다면 다음과 같이 적어주면 된다. c의 [[Prototype]] 연쇄 어딘가에 b가 존재하는가에 대한 코드이다.
b.isPrototypeOf( c );
객체 링크
[[Prototype]] 체계는 다른 객체를 참조하는 어떤 객체에 존재하는 내부 링크이다. 이 연결 고리는 객체의 프로퍼티/메서드를 참조하려고 하는데, 그런 프로퍼티/메서드가 해당 객체에 존재하지 않을 때 주로 활용한다. 엔진은 [[Prototype]]에 연결된 객체를 하나씩 따라가면서 프로퍼티/메서드를 찾아보고 발견될 때까지 같은 과정을 되풀이한다. 이렇게 객체 사이에 형성된 연속적인 링크를 프로토타입 연쇄(Prototype Chain)이라고 한다.
앞서 여러번 언급한 Object.create()의 위력을 이번에 설명하고자 한다. Object.create()는 먼저 새로운 객체를 생성하고 주어진 객체와 연결한다. 이것만으로도 클래스나 생성자 호출, 헷갈리는 .prototype이나 .constructor 레퍼런스 등을 동원한 함수로 복잡하게 하지 않는다는 점에서 대단하다. 두 객체에 의미있는 관계를 맺어주는 데 클래스가 필수적인 것은 아니다. 객체의 위임 연결만 신경써서 잘 처리하면 되는데, Object.create() 덕분에 클래스 뭉치 없이도 깔끔하게 처리할 수 있다.
Object.create()는 ES5부터 추가되어서 ES5 이전 환경까지 고려하면 Object.create()의 대역을 맡을 부분적인 폴리필(Polyfill)이 필요하다.
이 폴리필은 임시 함수 F를 이용하여 F.prototype 프로퍼티가 링크하려는 객체를 가리키도록 오버라이드한다. 그런 다음 new F()로 우너하는 연결이 수립된 새 객체를 반환한다.
정리하기
- 객체에 존재하지 않는 프로퍼티를 접근하려고 시도하면 [[Get]]은 해당 객체의 내부 [[Prototype]] 링크를 따라 다음 수색 장소를 결정한다. 프로퍼티를 찾아 이 객체에서 저 객체로 줄줄이 삼만리 순회를 하기 위한 연결 경로는 (중첩된 스코프 연쇄와 비슷한) '프로토타입 연쇄'에 잘 정의되어 있다.
- 모든 일반 객체의 최상위 프로토타입 연쇄(스코프 식으로 말하면 전역 스코프)에는 내장 Object.prototype이 버티고 있다. 결국, 이 지점까지 이르러서도 발견되지 않으면 프로퍼티 수색 작업은 종료된다. toString(), valueOf() 등의 공용 유틸리티들은 바로 Object.prototype에 구현된 덕분에 자바스크립트의 모든 객체가 언제든지 이용할 수 있다.
- 두 객체를 서로 연결짓는 가장 일반적인 방법은 함수 호출 시 new 키워드를 앞에 붙이는 것이다. 새로운 객체와 손잡은 '다른 객체'는 new를 이용하여 호출한 함수의 .prototype이라고 임의로 명명한 프로퍼티를 통해 참조할 수 있다. 여기서 new로 호출한 함수를 '생성자'라고 보통 일컫는데, 여타의 클래스 지향 언어에서 말하는 생성자하고는 개념이 전혀 다르다.
- 자바스크립트 체계가 전통적인 클래스 지향 언어의 '클래스 인스턴스화 및 클래스 상속'과 유사해 보이지만 자바스크립트에서는 복사가 일어나지 않는다는 결정적인 차이가 있다. 그러나 객체는 결국 다른 객체와 내부 [[Prototype]] 연쇄를 통해 연결된다.
- 자바스크립트 객체 간의 관계는 복사되는 게 아니라 위임 연결이 맺어진 것이므로 '위임'이라고 해야 더 적절한 표현이다.
참고자료
- <YOU DON'T KNOW JS (this와 객체 프로토타입, 비동기와 성능)> 카일 심슨 저
'Prog. Langs & Tools > JavaScript' 카테고리의 다른 글
JS #7. 비동기성(Asynchrony): 지금과 나중(now and later) (1) | 2020.05.19 |
---|---|
JS #6. 자바스크립트 작동 위임(behavior delegation) (0) | 2020.05.06 |
JS #4. 자바스크립트 클래스(Class) (0) | 2020.04.22 |
JS #3. 자바스크립트 객체(Object) (0) | 2020.04.15 |
JS #2. this에 대한 모든 것(ES5, ES6) (0) | 2020.04.08 |