[es2015+] class문은 특별할까?

개요

class문은 기존 prototype의 편의 문법으로 알려져 있지만, 사실은 그렇지도 않습니다. 새로운 매커니즘인 new.target을 중심으로 재편된 es6의 객체 생성 시스템을 살펴봅니다.

기본 방어

생성자를 함수처럼 호출하거나 함수를 생성자처럼 new로 사용하는 고질적인 문제는 기존의 es3.1에서 function 키워드를 통해 클래스와 함수를 둘 다 생성했기 때문입니다.
오죽하면 함수처럼 호출해도 인스턴스를 반환하게 만들어주는 패턴까지 등장할 정도니까요. 하지만 es6의 class문을 사용하면 생성자를 함수처럼 호출하거나 클래스의 메소드 등을 new를 통해 사용하려고 할 때 예외를 발생시켜 줍니다.
사실 이 부분은 기존의 es3.1에서도 어느 정도는 방어 코드를 작성할 수 있지만 완전하지는 않습니다.

var Cls = function(){
  if(!this || this === window){
    //1. 생성자를 일반 함수처럼 호출한 경우
  }else if(this instanceof Cls){
    //2. new를 통해 생성했을 것으로 예상되는 경우
  }else{
    //3. 기타 바인딩으로 호출된 경우
  }
}

위의 세 가지 경우 중 2번에만 정상 작동하게 만드는 것이 기존 es3.1의 전략입니다만, 사실 위의 검증은 아래와 같은 코드로 무력화됩니다.

//처음 호출은 new를 통해 실행되지만
var a = new Cls();

//두 번째 호출은 그저 call에 기존 인스턴스 a가 들어왔을 뿐
Cls.call(a);

따라서 반 쯤 포기하고 이 정도까지 했으면 되겠지..라는 정도의 방어입니다. 하지만 es6는 이를 근본적으로 방어할 새로운 매커니즘을 갖고 있습니다.

new.target

기존에 연산자라고 생각했던 new가 알고보니 객체였어? 라는 느낌의 모양입니다만 new.target은 무려 이 전체 ‘new.target’ 이란 모양이 그대로 엔진에서 제공해주는 속성입니다. es3.1의 function에서 호출되면 내부에 무조건 arguments 속성이 생겨나는 것처럼 es6에서는 function은 호출되면 자동으로 내부에 new.target이란 속성을 갖게 됩니다(경량함수인 화살표함수는 arguments를 갖지 않는 것처럼 new.target도 갖지 않으므로 모두 감싸는 측의 것이 반영됩니다)

이 new.target이란 속성은 new연산자로 생성한 경우에만 해당 클래스를 가리키고 일반 함수로 호출되는 경우는 undefined가 됩니다.

이를 이해하기 위해 간단히 function으로 클래스를 만들어 실험해보죠.

const Cls1 = function(){
  console.log(new.target === Cls1);
};
new Cls1; //true

const Cls2 = class{
  constructor(){
    console.log(new.target == Cls2);
  }
};
new Cls2; //true

기존 function기반의 클래스든 새로운 class키워드 기반의 클래스든 new.target이 정상적으로 작동한다는 걸 알 수 있습니다.
이제 앞에서 다루던 생성자를 일반함수로 호출하기를 막는 코드는 다음과 같이 짤 수 있을 것입니다.

const Cls = function(){
  if(new.target != Cls) throw 'Cls is constructor!!';
};

상속에서의 인스턴스 확인

여기까지는 기존 function으로도 그럭저럭 흉내낼 수 있습니다. 하지만 super에 들어오면 전혀 흉내낼 수 없게 됩니다.
기존의 프로토타입 상속을 살펴보죠.

const Parent = function(){
  console.log('parent', new.target);
};
const Child = function(){
  this.constructor.call(this);
  console.log('child', new.target);
};
Child.prototype = Object.create(Parent.prototype);

es5의 Object.create를 사용하면 그나마 Parent가 상속을 위해 사용된 건지 진짜 생성을 위해 사용된 건지를 판별하는 수고는 덜 수 있습니다.
하지만 자식이 부모생성자를 체인하는 경우 new.target이 작동하지 않게 되는데, 기존 프로토타입체인 구문 하에서의 생성자 체인은 this.constructor를 call로 호출했을 뿐이라 new.target은 생성되지 않기 때문입니다.

엔진이 new로 객체를 만드는 시점에 직접 new.target을 전달해줘야 하기 때문에 이 문제를 해결할 수 있는 기존의 방법은 없습니다.
이전에는 없는 new.target이 전달되는 새로운 형식의 생성이 필요합니다. es6에서는 이를 해결하는 장치가 Reflect.construct로 제공됩니다. 이를 이용하면 new.target을 전달하여 인스턴스를 만들 수 있습니다.

만들 수 있긴 하지만 그걸 this와 연결할 방법은 없습니다 ^^; this는 할당이 불가능하니까요. 이를 코드로 표현하면 다음과 같습니다.

const Parent = function(){

  //new.target을 전달 받아 생성될 거라 Child를 가리킬 수 있다
  console.log('parent', new.target == Child);

};
const Child = function(){

  //1. Child의 new.target을 전달하여 Parent를 생성할 수는 있지만,
  //2. 이걸 this에 할당할 방법은 없다!
  /*this =*/ Reflect.construct(this.constructor, [], new.target);

  console.log('child', new.target == Child);
};
Child.prototype = Object.create(Parent.prototype);

new Child;
//parent true
//child true

내적동질성과 super()

객체지향의 기본 원리인 대체가능성과 내적동질성 중 내적동질성을 유지하는 정책을 es6도 채택하고 있습니다(당연하게도 ^^)
이는 다음의 코드로 이해할 수 있습니다.

const Parent = class{
  constructor(){
    console.log('parent', new.target == Child);
  }
};

const Child = class extends Parent{
  constructor(){
    super();
    console.log('child', new.target == Child);
  }
};

new Child;

//parent true
//child true

위 코드에서 super()를 통해 부모의 생성자가 호출되었을 때 부모 측의 new.target도 Child를 가리키고 있다는 것을 알 수 있습니다. 이전 예제에서 설명한 대로 es5까지의 구문으로 흉내낼 수 없을 뿐더러 Reflect를 사용해도 불가능합니다. 오직 class문을 사용해야만 가능한 일이죠.

super와 HomeObject

class문에서 super를 사용할 수 있게 하는 것은 새로운 시스템인 [[HomeObject]] 덕분입니다. 이 속성은 class문에서 메소드가 정의되는 시점에 확정되고 변경할 수 없습니다. HomeObject는 상속받은 부모의 연결을 처리하는데 super키워드를 쓸 때 실제로 작동하게 되는 객체 참조가 바로 HomeObject인 셈입니다.
정의 시점에 HomeObject가 확정된다는 사실로부터 프로토타입처럼 남의 메소드를 자신의 메소드로 빌려오는 게 무의미하다는 것을 알 수 있습니다.

const ParentA = class{
  methodP(){console.log('pa');}
};
const A = class extends ParentA{
  methodA(){
    super.methodP();
  }
};

const ParentB = class{
  methodP(){console.log('pb');}
};
const B = class extends ParentB{};
B.prototype.methodB = A.prototype.methodA;
const b = new B;

b.methodB(); //pa
  1. 위의 코드에서 ParentA를 상속받은 A의 경우 이미 methodA가 정의되는 시점에 HomeObject로 ParentA가 지정되어 바인딩이 고정됩니다.
  2. 따라서 B가 A의 methodA를 빌려와 자신의 methodB로 만들었음에도 불구하고
  3. methodB를 실제 호출해보면 본인 부모인 pb가 출력되지 않고 pa가 출력됩니다.

es6 상에서는 이런 이유로 남의 메소드를 빌려오거나 함부로 믹스인을 할 수 없습니다. 보다 정적으로 안정적인 아키텍쳐를 짜야하는 거죠.
이러한 HomeObject 바인딩은 class안의 메소드에만 적용되므로 화살표함수나 function키워드로 생성된 함수에는 만들어지지 않습니다.

빌트인 객체 상속과 생성자의 종류

es6의 또 다른 특성인 빌트인 객체 상속도 class키워드로만 가능합니다. 즉 아래와 같은 기존 방식으로 빌트인 객체인 배열을 상속해도 소용없습니다.

const Cls = function(){}
Cls.prototype = Object.create(Array.prototype);
const arr = new Cls;
arr[0] = 'test';
arr.length; //0  소용없음!

하지만 class문을 이용한 상속은 정상적으로 작동합니다.

const Cls = class extends Array{};
const arr = new Cls;
arr[0] = 'test';
arr.length; //1

헌데 어떻게 이게 가능하게 되는걸까요? 이걸 가능하게 하는 매커니즘은 또 다른 것인데, es6의 클래스가 객체를 생성하는 새로운 방식을 쓰기 때문입니다.
class문에서의 내부적으로 constructor는 2가지 종류가 존재합니다.

  • 기본(base) 생성자
  • 파생(derived) 생성자

위의 class구문에서 Array의 경우 당연히 기본 생성자 입니다. 이에 비해 상속 받은 Cls의 경우는 자동으로 파생 생성자가 됩니다.
즉 클래스가 상속을 받으면 무조건 파생 생성자를 갖게 됩니다.

기존에는 이러한 구분이 전혀 필요 없는데 이유는 모든 생성자가 함수의 프로토타입을 갖을 수 밖에 없도록 설계되고 예외는 빌트인 객체들 뿐이기 때문입니다.
다음의 코드로 이를 이해해보죠.

//1. 빌트인객체의 프로토타입
const a = new Array(); //생성자의 프로토타입이 Array.prototype임
//생성자 Array의 prototype은 Array.prototype


//2. 그 외의 사용자정의 클래스의 프로토타입
const Cls = function(){};
const b = new Cls(); //생성자의 프로토타입이 Function.prototype임
//Cls의 prototype은 Function.prototype

생각해보면 당연한데 함수를 통해 클래스를 정의했으므로 사용자 정의 클래스는 무조건 함수의 프로토타입을 갖게 되는 것입니다.
예외는 오직 빌트인 객체로서 Array는 Function이 아니라 Array로 인식되므로 엔진은 이를 통해 인스턴스 a에 Array다운 변화를 처리할 수 있는 것입니다.

하지만 es6에서는 class구문으로 클래스를 생성하면 상속되는 경우 자식 클래스로 인스턴스를 생성하지 않고 기본 생성자를 거슬러 올라가 그 기본 생성자로 객체를 생성하므로 빌트인 객체를 생성할 수 있는 것입니다.

const Cls = class extends Array{
  constructor(length){
    super(length);
  }
};

새삼 위의 코드를 자세히보면 부모인 Array의 경우

  • 생성자 종류 – 기본(base) 생성자
  • 생성자의 프로토타입 – Array.prototype

자식인 Cls의 경우

  • 생성자 종류 – 파생(derived) 생성자
  • 생성자의 프로토타입 – Function.prototype

이런 식의 정보를 내부의 ECrecord에 저장해두게 됩니다.
지금까지의 지식으로 생성자가 하는 일을 간단한 의사코드로 표현할 수 있습니다.

const Cls = class extends Array{
  constructor(){
    if(classKind == 'base'){ //본인 기본생성자라면 this는 본인이 생성하지만
      this = Object.create(new.target.prototype);
    }else{ //아니라면 부모에게 위임하자. 하지만 new.target은 유지한다.
      this = Reflect.construct(Array, [], new.target); 
    }
  }
};

즉 기존에는
1. 인스턴스가 무조건 자식 클래스로 생성되는데 반해,
2. class문을 이용한 객체 생성은 기본 생성자에게 객체 생성을 위임하기 때문에,
3. 빌트인 클래스를 상속한 경우 빌트인 클래스가 기본 생성자가 되니,
4. 우선 객체는 빌트인 클래스로 생성하되,
5. new.target은 자식클래스로 지정된 상태가 되는 것입니다.

따라서 이제 생성된 객체가 무엇인가에 대한 정보는 new.target으로 유지되고 실제 객체는 기본 생성자로 만들어지는 것입니다.

Symbol.species

이제 new.target의 전이와 생성자체인 및 기본 생성자로부터의 객체 생성에 대한 전반적인 이해에 도달했으므로(진짜?) 외부에 결정될 타입에 간섭하는 방법을 배울 차례입니다. 여기서 말하는 외부는 일반적인 외부가 아니라 특정 메소드가 작동할 때 Symbol.species를 바라보는 경우를 의미하는 것입니다.
이터레이터처럼 일종의 인터페이스로서 외부에 제공할 생성자 정보를 제공하는 역할을 합니다. 아래와 같은 형식으로 구현됩니다.

class{
  static get [Symbol.species](){
    return 클래스;
  }
}

이걸 구현한다고 instanceof 나 new.target 등의 엔진 작동에 영향을 주는 것은 아닙니다. 단지 이걸 이용하려는 메소드들이 이 정보를 참고할 수도 있다는 것입니다.
예를 들어 “Array를 상속받은 클래스”에서 복사본을 만드는 clone이라는 메소드를 만든다고 해보죠.

const Base = class extends Array{
  clone(target){
    const result = new Base();
    target.forEach((v,i)=>result[i] = v);
    return result;
  }
};
const origin = new Base();
origin[0] = 1;
origin[1] = 2;

const copier = new Base();
const copy = copier.clone(origin);

copy instanceof Array //true
copy instanceof Base //true

위 예에서 clone의 결과물은 new Base()를 통해 만들었으니, 당연히 Base클래스의 인스턴스로 인식됩니다. 만약 clone이 Symbol.species를 이용하려 한다면 다음과 같이 메소드를 바꿀 수 있을 것입니다.

const Base = class extends Array{
  clone(target){

    let result; //result는 Symbol.speice에 따라 만들자!

    switch(target.constructor[Symbol.species]){
    case Array://배열인 경우
      result = [];
      break;
    case Base://Base인 경우
      result = new Base();
      break;
    }

    target.forEach((v,i)=>result[i] = v);
    return result;
  }
};

const origin = new Base();
const copy = origin.clone(origin);

copy instanceof Base //true
copy instanceof Array //true

수정된 clone은 target의 Symbol.species을 반환값 객체를 결정할 때 사용하는 것을 보여주고 있습니다.
모든 클래스의 Symbol.species 기본 값은 클래스 자신입니다. 위 예제에서 별 달리 Symbol.species를 구현하지 않았으므로 기본 값인 Base가 반환됩니다.
따라서 clone에 Base를 인자로 보내는 이상 항상 Base로 분기될 것입니다. 이제 Symbol.species가 Array를 반환하게 구현해보죠.

const Base = class extends Array{

  //Symbol.species 재정의
  static get [Symbol.species](){
    return Array; //Array를 반환하자
  }

  clone(target){
    let result;
    switch(target.constructor[Symbol.species]){
    case Array:
      result = [];
      break;
    case Base:
      result = new Base();
      break;
    }

    target.forEach((v,i)=>result[i] = v);
    return result;
  }
};

const origin = new Base();
const copy = origin.clone(origin);

copy instanceof Base //false
copy instanceof Array //true

똑같은 Base 객체를 보냈는데도 clone이 생성된 결과는 Array가 됩니다.
이렇듯 Symbol.species는 특별한 기능이라기 보다 어떤 메소드 구현이 이를 참조할지 말지를 알고리즘에 포함시킬 수 있는 옵션일 뿐입니다.
위의 예처럼 클래스나 메소드에서 이를 이용할 수도 있고 아닐 수도 있습니다. 즉 메소드가 작동할 때 외부에서 분기시킬 수 있는 훅(hook)인 셈입니다.

Symbol.species가 빌트인 메소드에서 사용될 때

중요한 점은 이 인터페이스가 표준이기 때문에 빌트인 객체들은 이 기능을 이용한다는 점입니다.
대표적으로 Array의 메소드들인 map, filter 등의 새로운 배열로 반환되는 메소드들이 주로 이것을 참고합니다.

다음의 예제는 Symbol.species를 검색해보면 가장 많이 볼 수 있는 예제입니다.

//1. Symbol.species를 구현하지 않은 경우
const Cls1 = class extends Array{};

//map메소드의 결과는 구상한 Cls1이 된다.
(new Cls1).map(v=>v) instanceof Cls1 //true
(new Cls2).map(v=>v) instanceof Array //true

//2. Symbol.species를 Array로 구현한 경우
const Cls2 = class extends Array{
  static get [Symbol.species](){return Array;}
};

//map메소드의 결과는 Array가 된다.
(new Cls2).map(v=>v) instanceof Cls2 //false
(new Cls2).map(v=>v) instanceof Array //true

하지만 이 정도로는 그저 “Array가 되거나 자신이 된다” 정도의 지식 밖에 안됩니다.
이전 clone구현 예제를 생각해보면 결국 map이 어디까지 Symbol.species로 넘어온 클래스를 알고리즘에 반영하는가의 문제라 할 수 있습니다.
다양한 클래스를 Symbol.species를 통해 보내볼 수 있습니다만 대체 map은 어떻게 이를 반영하는 것이고, 관련된 스펙은 어디에 명시되어있는걸까요?

Array.prototype.map이 Symbol.species를 활용하는 경우

Array.prototype.map을 예를 들어 이 빌트인 메소드가 어떻게 작동하는지를 스펙을 따라 차근차근 확인해보죠. 하나를 학습하고 나면 나머지 빌트인 객체의 메소드도 어렵지 않게 분석하실 수 있을 겁니다(정말?)

  • Array.prototype.map 을 보면 5번에서 반환할 새 객체를 만들 때 내부 함수 ArraySpeciesCreate를 호출해 얻는다는 걸 알 수 있습니다.
  • ArraySpeciesCreate 은 결국 originalArray가 Array가 아닌 경우 7번 항목까지 내려와 Symbol.species(@@species)를 찾은 뒤 Construct에게 자신과 length를 넘겨 객체 생성을 위임하게 됩니다.
  • Construct내부 함수는 평범한 객체 생성처리기입니다.

요점을 정리하면 Symbol.species에 뭐가 오든 인자에 length를 보내 해당 클래스로 객체를 생성하려고 한다는 것입니다. 따라서 첫 번째 인자로 length를 받을 수 있는 객체라면 문제 없이 지정할 수 있습니다.

//ArrayBuffer의 경우
const ClsArrayBuffer = class extends Array{
  static get [Symbol.species](){return ArrayBuffer;}
};
const test = new ClsArrayBuffer;
test[0] = 1, test[1] = 2;

const result = test.map(v=>v);
result instanceof ArrayBuffer //true
console.log(result); //ArrayBuffer{0: 1, 1: 2}

위의 예에서는 Array대신 ArrayBuffer를 보내도 잘 수행된다는 사실을 알 수 있습니다.

이번엔 map의 결과를 Set으로 저장하고 싶은 경우를 생각해보죠. 우선 떠오르는 코드는 다음과 같을 것입니다.

const ClsSet = class extends Array{
  static get [Symbol.species](){return Set;}
};
const test = new ClsSet;
test[0] = 1, test[1] = 2;

const result = test.map(v=>v);

위의 코드에서 기대하는 것은 result가 Set이 되는 것이지만 실제 실행해보면 대충 아래 같은 예외로 죽어버립니다.

VM108:8 Uncaught TypeError: undefined is not a function
at new Set (native)
at ClsSet.map (native)
at :8:21

의미인 즉 “map이 어떻게 든 Set을 만들어 보려고 했는데 안되더라” 라는 뜻입니다. 실패한 이유는 뭘까요?
간단한 이유인데, Set은 생성자에 선택적인 인자로 iterator인터페이스만 받을 수 있기 때문입니다.

  1. 위에 스펙에서 map은 length를 보내서 객체를 생성하려고 시도하기 때문에
  2. Set생성자의 인자조건에 맞지 않아 죽어버리는 것입니다.

즉 아래 코드도 거의 비슷한 예외로 죽어버립니다.

new Set(1);
Uncaught TypeError: undefined is not a function
at new Set (native)
at :1:1

map 내부에서 new Set(length)를 시도했다가 실패한 것입니다. 이를 보완하려면 length를 인자로 보낼 수 있는 Set을 만들어야 하고 이는 Set을 상속받은 자식 클래스를 만드는 것으로 해결할 수 있습니다.

//Set을 상속한 클래스
const CustomSet = class extends Set{
  constructor(length){ //길이를 받지만 아무것도 안함
    super();
  }
};
const ClsSet = class extends Array{
  //이제 Set말고 CustomSet을 반환하자!
  static get [Symbol.species](){return CustomSet;}
};

const test = new ClsSet;
test[0] = 1, test[1] = 2;

const result = test.map(v=>v);
console.log(result);

위에서는 물론 오류는 발생하지 않습니다. 그리고 콘솔의 결과도 물론 Set이긴 합니다.
하지만 Set에 add메소드를 통해 추가된 게 아니므로 그저 속성 0, 1에 각각의 값이 들어있을 뿐이지, 실제 Set의 데이터가 되지 않았기 때문에 size는 여전히 0인 상태입니다.

이를 보완하려면 map이 인덱스로 값을 넣으려고 할 때, add메소드로 변환해주는 장치가 필요합니다.

map메소드가 Set에 값을 넣으려는 순간을 외부에서 간섭하려면 Proxy를 사용해야 합니다.
단 Proxy의 trap을 구현할 때 주의할 점이 있는데, map메소드가 어떻게 각 인덱스 값을 복사하는가를 면밀히 봐야 필요한 trap을 걸 수 있다는 점입니다.
일반적으로 map메소드를 생각하기엔

  1. result[index] = f(v, index); 이렇게 직접 인덱스에 넣어주는 게 아닐까? 란 생각을 하게 됩니다.
  2. 이 방향으로 빠지면 set을 trap에 걸게 되죠.

실제 작동은 스펙문서에 기술되어있으므로 차근차근 따라가보죠.

  • Array.prototype.map의 7번은 반복하며 값을 넣는 과정인데 더 상세한 c-iii를 보면 내장함수 CreateDataPropertyOrThrow(A, Pk, mappedValue) 를 사용한다고 되어있습니다.
  • CreateDataPropertyOrThrow는 다시 CreateDataProperty에게 위임하는 가벼운 밸리데이터입니다.
  • 비로소 도달한 CreateDataProperty를 보면 Descriptor를 작성하고 DefineOwnProperty를 한다는 사실을 알 수 있습니다.

즉 이 과정을 통해 실제 map메소드 작동에 trap을 걸고 싶다면 set trap이 아니라 defineProperty trap에 걸어야 한다는 사실을 알 수 있습니다.
또한 defineProperty나 set트랩은 걸면 쌍으로 get trap도 걸어주는 게 일반적이므로 간단하게 같이 작성합니다.
이제 CustomSet은 이러한 복합적인 map을 중계하는 adater 역할을 하고 있으므로 이름을 AdapterSet으로 바꿉니다.

const AdapterSet = class extends Set{
  constructor(length){
    super();
    return new Proxy(this, {

      //숫자인덱스면 add하고 아니면 원래대로..
      defineProperty:(t, prop, desc)=>/^[0-9]+$/.test(prop) ? 
        t.add(desc.value) : Object.defineProperty(t, prop, desc),

      //기본 trap
      get:(t, prop)=>prop == 'has' ? t.has.bind(t) : Reflect.get(t, prop)
    });
  }
};
const ClsSet = class extends Array{
  static get [Symbol.species](){return AdapterSet;}
};

const test = new ClsSet;
test[0] = 1, test[1] = 2;
const result = test.map(v=>v);

console.log(result instanceof Set); //true
console.log(result.has(2));//true
console.log(result.size); //2

이제서야 원하는 결과를 얻을 수 있습니다. 제대로 Set이 나왔을 뿐 아니라 size도 2가 표시되고 있습니다.

여전히 남는 constructor키의 문제

기존 es3.1까지는 어떤 객체의 생성자를 확인할 때 인스턴스 레벨의 constructor키만 참조했는데, es6에서는 표준적으로 Symbol.species를 이용해 생성자가 무엇인지 명시하거나 변조(?)할 수 있는 수단이 생겼습니다.

하지만 내부적으로는 여전히 constructor키에 의존적이긴 합니다. ArraySpeciesCreate의 실제 작동절차를 보면 5번 항목에서 인스턴스의 contructor키를 참고하여 생성자를 얻은 후에나 생성자의 @@species를 참조하기 때문에 해당 객체의 constructor가 변조되면 제대로 작동하지 않을 수 있습니다.

위의 코드에서 실제 생성된 인스턴스 test의 constructor를 변조해보죠.

const test = new ClsSet;
test[0] = 1, test[1] = 2;

//test의 constructor를 변조하자!
test.constructor = Map;

const result = test.map(v=>v);
/*
VM823:18 Uncaught TypeError: undefined is not a function
at new Map (native)
at ClsSet.map (native)
at <anonymous>:18:21
*/

바로 예외가 떠버립니다. 스펙문서의 절차에서 constructor를 이용하여 생성자를 얻은 후 Species를 참조하기 때문에 constructor의 값을 Map으로 변조시켰으므로 Species도 Map이 되어버려 length를 인자로 보내면 Map의 생성자가 예외를 발생시켜 버리는 것이죠.
사실 하위 호환성과도 크게 상관없는데 굳이 아직도 메인 엔진의 작동이 깨지기 쉬운 constructor에 의존해야 하는가에 대한 회의감이 들긴 하지만 스펙은 스펙이니까요.

여기까지 와보면 현재 스펙문서에 따라 Array.prototype.map의 내부를 어느 정도는 구현해볼 수 있습니다.

Array.prototype.map = function(f){

  //1. 생성자를 얻는다.
  const c = this.constructor;

  //2. species를 얻는다.
  const construct = c[Symbol.species];

  //3. species를 이용해 length를 인자로 넘겨 반환할 객체를 생성한다.
  const result = new construct(this.lenght);

  //4. 루프를 돌며 속성을 정의해준다.
  for(let i = 0; i < this.length; i++){
    Object.defineProperty(result, i, {value:this[i], writable: true, enumerable: true, configurable: true});
  }
  
  return result;
};

기존과 달리 알고리즘 단계에 species를 인식하는 단계가 추가되어 그 마법이 일어나는 거죠.
이 코드와 MDN의 폴리필을 비교하면 큰 차이가 있습니다. 사실 MDN코드는 es3.1을 위한 폴리필 코드고 위의 코드는 실제 es6스펙의 map메소드에 대한 가상의 구현이기 때문이죠.

결론

es6의 class문은 기존의 프로토타입과는 굉장히 다른 방식으로 객체를 생성합니다. 우선 빌트인 객체를 상속할 수 있게 하자라는 대명제를 실현하려면 프로토타입으로는 무리입니다.

  1. 프로토타입은 함수가 반드시 생성자가 되므로
  2. 생성된 모든 객체는 생성자가 함수를 기반으로 하는 객체가 될 뿐 아니라
  3. 근본적으로 proto에 속성을 설정할 뿐이지 Object의 인스턴스일 뿐입니다.

즉 Object의 인스턴스가 Array로 작동할 수는 없는 것입니다. es6는 하위호환을 기본으로 하므로 이를 대대적으로 수정할 수는 없는 노릇입니다.
헌데 갑자기 빌트인 객체의 상속이 가능한 마법은 어디서 온 걸까요?

  1. class구문을 사용하면 부모가 없는 최초의 클래스는 기본 생성자가 되고
  2. 상속받은 클래스는 파생생성자가 되어
  3. new로 인스턴스를 생성하는 시점에 파생생성자의 인스턴스를 만들지 않고 끝없이 부모로 보내
  4. 기본 생성자까지 거슬러 올라간 뒤 기본 생성자로 인스턴스를 만듭니다.

이게 바로 class A extends Array를 하면 new A를 했을 때 A의 인스턴스가 만들어지지 않고 Array의 인스턴스가 만들어지는 비밀인 것입니다.
하지만 이렇게 되면 Array일뿐 A로 인식할 수 없습니다. 이걸 보완하는 장치가 바로 new.target입니다.

  1. new하는 시점에 지정된 생성자를 new.target의 가상 컨텍스트로 EC record에 잡아둡니다.
  2. 이후 객체의 생성은 기본 생성자를 이용했지만 그 외의 proto등의 설정에는 new.target을 이용하여 처리하므로
  3. 기존의 프로토타입체계(instanceof 등)에서 이 객체를 바라보면 기본 생성자의 객체가 아닌 파생 생성자의 객체로 인식할 수 있게 되는 것입니다.

그 외에도 Symbol.species 시스템이 도입되었죠. 클래스 레벨에서 Symbol.species를 이용해서 생성자가 무엇인지 명시하거나 변조(?)할 수 있는게 되었습니다만 이는 단지 표준 인터페이스일 뿐 이를 활용하는 건 각 구현별 사정입니다.

마지막으로..
class문은 굉장히 복잡하고 기존과는 다른 매커니즘을 많이 내장하고 있습니다. 단순히 편의문법이나 es5로 대체가능할거라 생각할 수 없죠. 특히 빌트인객체 상속을 위해 도입된 시스템은 근본적으로 es5까지의 객체생성방식을 완전히 개선시키고 실질적으로 proto속성만 갈아치우는 객체 생성에서 벗어나 HomeObject기반의 생성시점 바인딩을 지원하고 내적동질성이 깨질까봐 new.target도 내장하게 되었습니다. es2015+ 세계에서 통용되는 철학과 언어적인 지원은 class를 사용하여 생성된 객체에 초점이 맞춰지고 있습니다.

%d 블로거가 이것을 좋아합니다: