자바스크립트의 객체 생성 개요
ES2015에서는 Function, Array 등 내장객체에 대한 상속을 허용하도록 스펙이 변경되었습니다. 하지만 ES5기준으로 보면 다음의 객체들 정도가 특수합니다.
- RegExp – 정규식을 처리하는 객체로 대체하려는 경우 정규식엔진 전체를 구현해야 한다.
- Date – 시스템의 시간을 갖고 오는 IO를 포함하고 있어 대체불가능하다.
- Array – 자동 길이 계산 및 요소에 대한 인덱스관리를 포함한다.
- 이는 ES5의 게터세터를 통해 구현할 수 있어 3.0 때처럼 완전히 대체 불가능한 기능은 아니다.
- Function – 함수를 호출하는 괄호의 해석은 전적으로 인터프리터에 달려있으므로 사용자코드로는 대체할 수 없다.
- Error – 예외 발생시점으로부터 호출스택을 역추적하여 보관하고 있으므로 대체할 수 없다.
그 외에는 전부 Object라 할 수 있습니다. 즉 자바스크립트의 객체 생성은 Object의 생성이라 해도 과언이 아니죠.
* 본 포스팅에서는 ES5를 기준으로 설명하고 있으므로, ES2015에서 내장객체상속을 통한 확장을 무시합니다(실은 ES2015도 완전히 다른 언어입니다^^)
함수를 이용한 프로토타입 상속 및 객체의 생성
현대의 객체지향개발론에서는 각 역할을 수행하는 객체를 정의한 뒤 이 객체들의 협력으로 문제를 해결하는 방법론을 사용합니다.
정적 선언을 하는 언어에서는 클래스를 선언하면서 클래스 내부에 함수를 소유하는 방식을 통해 객체에 대한 바인딩을 강제하는데, 비해 자바스크립트는 모두 런타임에 생성되므로 다른 방법을 통해 함수와 객체를 바인딩 합니다.
이는 일명 프로토타입 상속으로 알려져있는데 이를 구현하는 3.0의 문법은 new 키워드와 함께 함수를 사용하는 것입니다. 우선 이를 깊이 살펴보고 5.0에서의 객체생성을 알아보죠.
기존의 new가 하는 일을 엄청나게 소상히 알아보자.
var Test = function(){}; var instance = new Test();
위의 두 줄은 실로 많은 것을 하는데, 내부에 숨겨진 매크로로직이 한 가득 있습니다. 우선 함수객체를 생성하는 부분부터 풀어보면 다음과 같습니다.
var Test = function(){}; Test.prototype = { constructor:Test };
즉 자바스크립트에서 함수객체가 생성되면 내부에서는 자동으로
- 그 함수에 prototype이라는 키로 오브젝트를 만들고
- 그 오브젝트에는 constructor라는 키에 원래 그 함수가 지정되어있다.
여기까지가 자동으로 일어납니다. 이제 이러한 함수를 바탕으로 new를 하면 다음과 같이 됩니다.
var instance = new Test(); instance = { __proto__:Test.prototype }; var result = Test.apply( instance, arguments ); if( result && typeof result == 'object' ) instance = result;
차근차근 new가 하는 일을 살펴보면
- 오브젝트를 만들되 그 안에 proto 등의 속성에 함수의 프로토타입객체를 지정한다.
- 함수의 컨텍스트를 방금 만든 오브젝트로 해서 생성시의 인자객체를 전달하여 호출한다.
- 만약 생성자의 호출결과가 null이 아닌 객체라면 instance를 그 결과가 된다.
위의 1번이야말로 프로토타입체이닝을 일으킬 수 있는 근거가 되고, instanceof 연산자가 작동하는 원리기이도 하죠. 2번, 3번은 생성자에 대한 작동입니다. 자세한 new의 작동은 공식문서에 기술되어있으므로 아래링크를 참고하면 됩니다.
http://www.ecma-international.org/ecma-262/5.1/#sec-13.2.2
하지만 기존의 new 구문을 다시 검토해보면 여러가지 의문점에 봉착합니다.
1.어째서 함수를 통해서 클래스를 정의할까?
객체의 타입을 정하기 위해 왜 new 를 사용하는가에 대한 많은 논란이 있지만, 그보다 더 중요한 건 왜 함수를 객체의 타입으로 사용하는가 입니다. 정확한 문건을 찾는데는 실패했지만 나름대로 생각해보면 다음과 같은 정도가 아닐까 싶습니다.
- 만약 오브젝트가 백개 있다고 하자.
- 오브젝트들에도 용도가 있을 것이다. 50개는 학생이고 50개는 선생님이라고 하자.
- 그럼 각 오브젝트가 어떤 용도(클래스)인지 마킹을 해야할 것이다.
- {type:’student’} 또는 {type:’teacher’} 라고 할 때의 문제점은 타입을 나타내기 위해 메모리가 낭비된다는 점이다. 즉 50번의 ‘student’와 50번의 ‘teacher’ 가 필요하다.
- 이러한 낭비를 막으려면 기본값이 아닌 참조값을 이용하면 될 것이다.
- 이제 student = {}, teacher = {} 를 이용하면 다른 객체들은 {type:student}, {type:teacher} 와 같이 표시할 수 있다.
- 그럼 문제는 뭐냐면 앞 서 정의한 student와 teacher도 오브젝트 이므로 이들도 type을 가져야한다는 것이다.
- 이런 원리를 일반화해보면 모든 오브젝트는 type을 가져야하는데 type에는 오브젝트가 온다 라는 식이 되어 영원히 순환참조를 거듭해야하는 상황이 되어버린다.
- 이러한 순환참조를 끊어버릴려면 type을 지정할때 오브젝트가 아닌 다른 참조타입을 지정해주면 된다.
- 자바스크립트의 기본 객체 중에 Object가 아닌 참조타입으로 기본적으로 인정되는 것은 함수가 있다.
- 다른 언어처럼 이 부분에서 특수한 참조객체타입(Class같은..)을 더 만드는 방법도 있겠지만, 기존에 이미 있는 객체인 함수를 고르게 된 게 아닐까?
개인적으로는 이러한 차원에서 함수가 마커역할을 하는식으로 설계된건가 하는 생각을 많이 했습니다. 하지만 실제로는…
2.프로토타입체인은 함수자체를 이용하지 않고 어째서 함수의 프로토타입객체를 이용할까?
아마도 상속 체인에 사용될 속성으로 함수 자체를 오염시키기 보다는 별도의 객체를 사용하는 것이 낫다 라는 면도 있거니와 그보다 더 중요한건 체이닝이라는 관점입니다.
위의 정의대로 어떤 종류인지 마킹이 되는 것은 Object입니다. 함수에는 이러한 타입에 대한 마킹이 없습니다. 함수의 prototype객체도 Object이므로 여기에도 타입마커가 들어있습니다. 이를 이용해 연쇄된 상속구조의 체이닝이 성립하죠. 반대로 함수로는 부모의 부모를 기술할 방법이 없습니다.
더 정확하게는
- 함수리터럴이나 함수선언문 등을 통해 생성할 수 있다.
- new Function을 통해 상속되지 않은 네이티브함수를 생성할 수 있다.
- 하지만 체이닝이 된 자식클래스로서의 함수를 new를 통해 생성할 수 없다.
- 따라서 (function(){}) instanceof Function 은 true고
- (function(){}) instanceof Object 는 true지만
- 단지 그뿐 함수를 모태로 하는 프로토타입체인객체를 만들 수 없다.
길게 설명했지만 프로토타입체인에 함수를 이용하지 않고 함수의 프로토타입객체를 이용하는 이유는 간단히 말해 함수를 __ proto __에 넣으면 프로토타입체인을 시킬 수 없기 때문 입니다. 하지만 여기서 다시 1번 항목에 대한 의문이 생깁니다. 결국 이러한 이유로 함수 자체를 마커로 쓰지 못하고 함수의 프로토타입을 마커로 쓰는게 현실이라면 위에서 지적한 순환참조의 문제도 그대로 일어나겠죠. 이러한 순환참조문제는 특이점이 되는 객체인 Object 에서 더이상 __ proto __에 아무것도 없는 유일한 조상이 등장하여 해결됩니다. 즉 언어 상의 자기참조무결성을 깨트리고 특이점을 만드는 것으로 해결을 본 거죠.
그렇다면 더욱 더 왜 함수를 마커로 써야하는지 의문이 드는 것입니다(왜!) ES5는 이러한 의문에 직접적인 해답을 내놓습니다.
* 본 섹션에서는 ES5를 기준으로 설명하고 있으므로 ES2015에서 내장객체상속을 통한 확장을 무시합니다.
3.왜 굳이 new를 써야만 할까?
이건 결국 프로토타입체이닝을 일으키기 위해서인데 new 통해서 생성해야만 특수한 속성인 __ proto __에 함수의 프로토타입을 지정할 수 있기 때문이었습니다.
앞 서 설명한대로 new 키워드는 __ proto __의 설정 외에도 생성자 함수를 호출해주고 그 결과에 따라 인스턴스를 교체하는 등의 부가 기능도 갖고 있지만, 그건 얼마든지 흉내낼 수 있는 기능입니다.
오직 new 만 할 수 있는 기능은 __ proto __의 설정이라고 할 수 있습니다.
그렇다면 반대로 __ proto __를 설정할 수 있는 다른 수단이 있다면 new 가 필요없지 않을까요?
이 역시 ES5에서는 new 를 사용하지 않는 객체 생성으로 대응하고 있습니다.
Object.create를 이용한 객체의 생성
위에서 살펴본 3.0의 new 를 통한 객체 생성에서 몇 가지 의문이 들었죠.
- 굳이 Function을 형구분자로 써야할 이유가 없다.
- proto 생성을 위해 new 를 쓰는 것은 불합리하다.
어짜피 프로토타입체이닝은 Function.prototype에 있는 Object를 통해 하고 있고 new 에는 쓸데없이 java 흉내내느라 생성자 호출이나, 이상한 인스턴스 교체 등을 제외하면 __ proto __ 를 설정하는 기능만 있는 셈이니 쓸데없다면 쓸데없는 기능입니다.
ES2015에서는 아예 객체에 __ proto __속성을 직접 교체하거나 setPrototypeOf 등의 메서드를 통해 강제로 변경하는 것도 가능하지만 ES5에서는 직접 __ proto __를 다루는 레벨로 오픈되지는 않았습니다.
대신 위의 1, 2번의 문제를 크게 개선한 Object.create 메서드를 제공합니다.
var test = Object.create({a:1, b:2}); test.a == 1 test.b == 2
위의 예에서 test인스턴스는 간단히 {a:1, b:2}를 __ proto __에 체이닝객체로 설정한 객체가 됩니다. 즉 내부적으로 다음과 같은 의미가 되죠.
var test = {}; test.__proto__ = {a:1, b:2};
__ proto __에 들어갈 오브젝트만 지정한 형태이므로 처음 언급한 1, 2번의 문제가 말끔히 해결되었습니다. 만약 객체 생성 시 특정 생성자를 호출하여 초기화하고 싶다면 어떻게 해야할까요?
//초기화 var init = function(v){ this.a = v; }; //test생성 var test = Object.create({a:1, b:2}); //초기화 적용 init.call(test, 3); test.a == 3 test.b == 1
원래 생성자의 역할은 생성된 객체에 대해 this 컨텍스트를 바꾼 상태로 호출해주는 정도이므로 완전히 재구축할 수 있습니다.
형체크의 문제
3.0에서 new를 통해 생성한 인스턴스의 경우 new에 사용된 함수를 instanceof 연산자에게 넘기면 손쉽게 형을 체크할 수 있었습니다.
var ClassA = function(){}; var test = new ClassA(); test instanceof ClassA //true
하지만 3.0에서도 이미 표준 메서드로 isPrototypeOf 를 제공하고 있는데, 이를 활용하면 다음과 같이 표현할 수 있습니다.
var ClassA = function(){}; var test = new ClassA(); ClassA.prototype.isPrototypeOf(test) //true
즉 특정 오브젝트가 어떤 인스턴스의 __ proto __나 그 체이닝에 속해있는지 판별하는 메서드입니다. ES2015처럼 자유롭게 __ proto __에 접근할 수 있는 상황이라면 다음과 같이 가상화된 함수를 생각해볼 수 있습니다.
Object.prototype.isPrototypeOf = function(target){ var proto; while (proto = target.__proto__) { if (proto === this) return true; target = proto; } return false; }
__ proto . proto __…라는 식으로 타고 올라가다보면 Object의 __ proto __는 null 이므로 이를 이용하면 손쉽게 isPrototypeOf 의 작동을 예상해볼 수 있습니다(실은 이러한 작동을 연산자레벨로 언어에 내장한 것이 instanceof 입니다)
함수로 프로토타입을 정의하지 않는 Object.create 입장에서 형이라는 것은 프로토타입객체 그 자체입니다. 따라서 형을 검증하려면 Object 를 통해 검증해야 하는 것이 당연한 것이죠. 위에서 생성한 예제를 이용해 형을 검증해보죠.
PrototypeA = {a:1, b:2}; var test = Object.create(PrototypeA); PrototypeA.isPrototypeOf(test) //true
3.0부터 존재하던 isPrototypeOf 메서드의 진가는 오히려 5.0에서 빛을 발한다고 할 수 있습니다. 반대로 instanceof 로는 Object.create 에 대응이 불가능합니다.
상속의 문제
3.0의 new 키워드는 프로토타입 체인에 있어서도 상당히 복잡한 양상을 가져오는데, 이는 초보자들이 프로토타입체이닝을 이해하기 어렵게 만드는 큰 요인이 되었습니다.
- 프로토타입체이닝은 어떤 Object의 키를 찾으려할 때 발생하는데,
- 본인 객체 안에 해당 키가 존재하지 않으면 본인의 proto에 지정된 객체로부터 해당 키를 찾으려한다.
- 거기에도 없으면 그 객체의 proto를 찾는 식으로 탐색하다가 proto가 null이되면 탐색을 멈추고 undefined를 반환한다.
여기까지 보면 그렇게 어렵지는 않은데 이게 new 와 결합하면 난해해집니다. 왜냐면 __ proto __를 설정할 수 있는 유일한 방법이 new 이기 때문이죠.
결론적으로는 기존 new 구문에서 상속을 하려면 ‘**자식 함수의 prototype은 부모함수를 new한 객체가 지정된다**’로 요약됩니다.
var ParentClass = function(){}; ParentClass.prototype.a = 3; var ChildClass = function(){}; ChildClass.prototype = new ParentClass(); ChildClass.prototype.b = 5; var test = new ChildClass(); test.a == 3 test.b == 5
하지만 앞서 지적한대로 __ proto __체이닝을 위해 쓸데없는 함수 등의 객체가 난무하는 상황입니다. 실제로 필요한 내용은 분명 아래있는 정도겠죠.
var Parent = {a:3}; var Child = {b:5, __proto__:Parent}; var test = {__proto__:Child}; test.a == 3 test.b == 5
위의 구조에서 프로토타입체인은 사실 다음과 같은 로직으로 표현할 수 있을 것입니다.
var getValue = function(key){ var target = this; do{ if (key in target) return target[key]; } while (target = target.__proto__) return undefined; }
5.0에서는 이러한 상속구조를 함수와 new를 이용할 때보다 훨씬 간략하게 표현할 수 있다.
var Parent = {a:3}; var Child = Object.create(Parent, {b:{value:5}}); var test = Object.create(Child); test.a == 3 test.b == 5
- 즉 기존에는 함수는 형, 그 형에 지정된 prototype 이 실제 체이닝의 대상이라는 사고가 필요했다면,
- Object.create 에서는 오직 체이닝할 대상 객체만 신경쓰면 되는 것입니다.
이번 포스팅에서는 Object.create 의 두 번째 인자에 대해 다루지 않지만, 위와 같은 구조를 통해 매우 직관적으로 프로토타입체인을 이해할 수 있게 됩니다.
결론
ES5.0에 도입된 Object.create의 의미와 instanceof, isPrototypeOf 등 과의 관계를 살펴봤습니다.
다음 포스팅에서는 속성지정자를 통한 강력하고 안전한 속성지정을 알아보도록 합니다.
recent comment