Javascript class 설탕문법?

blackbell·2020년 6월 18일
5

javascript

목록 보기
2/6

MDN 등 유명 사이트나 블로그에서 es6의 class는 syntax sugar(설탕문법)이라고 하고, 저 역시도 그렇게 배웠기 때문에 그렇게 생각하고 있었습니다. (class는 prototype의 편의 문법)

코드스피츠 유튜브를 좋아하는데 강의 중에 실제로 class 문법은 프로토타입 체인을 통한 상속과 방식이 다르다는 얘기가 나왔습니다. (TMI 😄) 관련된 것을 찾아보다가 좋은 글들을 찾을 수 있었고, new.target, HomeObject 등 키워드를 알게된 것을 간단히 정리해보려고 합니다.
(아래에 있는 출처를 참고해서 제 나름대로 정리하는 느낌입니다😖)

이 글을 읽으면서 new.target, HomeObject, super에 대해서 간단히 알아가는 시간이 되었으면 좋겠습니다.

저는 Built-In 객체(Array, Set etc)등을 es6 이전 문법에서는 상속할 수가 없었는데 Class 문법은 가능하다는 것이 큰 차이점이라고 생각했습니다. 따라서 왜 Built-In 객체를 상속할 수 없는지 따라가보면서 위의 나온 개념들에 대해 알아보겠습니다.

class Cls extends Array {
  static classname = 'name: Cls'
}
const c = new Cls();
c[0] = 'data';
c.push('data2');
console.log(c.length); // 2
console.log(c); // ['data', 'data2']
console.log(c.classname); // name: Cls

다음과 같이 Built-In 객체인 Array를 상속받아 Array 인스턴스로 작동하는 클래스를 만들고 싶습니다. 이를 위해 먼저 내장 객체들의 prototype에 대해서 알고 계시다고 믿고 진행하도록 하겠습니다.

‘extends’문법은 두 개의 프로토타입을 설정합니다.
1. 생성자 함수의 "prototype" 사이(일반 메서드용)
2. 생성자 함수 자체 사이(정적 메서드용)
Object.create를 이용하여 상속을 간단히(?) 구현해보도록 하겠습니다.

const Cls = function () {};
Cls.classname = 'name: Cls';
// 1
Cls.prototype = Object.create(Array.prototype);
Cls.prototype.constructor = Cls;
// 2
Cls.__proto__ = Array;

const c = new Cls();
c[0] = 'data';
c.push('data2');
console.log(c.length); // 1
console.log(c); // ['data2']
console.log(Cls.classname); // name: Cls

다음과 같이 작성하면 Array를 상속하는 클래스를 만들 수 있을 줄 알았지만, 그렇지 않습니다.
이해하기 쉽게 Set을 상속하는 클래스를 만들어보겠습니다.

const Cls = function () {};
Cls.classname = 'name: Cls';
// 1
Cls.prototype = Object.create(Set.prototype);
Cls.prototype.constructor = Cls;
// 2
Cls.__proto__ = Set;

const c = new Cls();
c.add('data');
// Uncaught TypeError: Method Set.prototype.add called on incompatible receiver #<Cls>

위와 똑같이 저희가 원하는 대로 작동하지 않고 에러까지 발생합니다.
MDN에는 다음과 같은 이유라고 적혀있습니다.

a function (on a given object), is called with a this not corresponding to the type expected by the function. -MDN-
MDN-Called on incompatible type

즉, 해당 function이 기대하는 type의 this가 아니라는 것입니다.
Cls의 인스턴스(자식 클래스의 인스턴스)로 작동하기 때문에 일어나는 일입니다.

// 똑같은 에러발생
new Set().add.call({}, 'what');
//Uncaught TypeError: Method Set.prototype.add called on incompatible receiver #<Object>

그렇다면 위의 Array에서는 왜 에러가 나지 않았을까요??
Array는 특별하게 exotic-object이기 때문입니다.
https://tc39.es/ecma262/#array-exotic-object
https://2ality.com/2015/02/es6-classes-final.html 의 4.3
이 글에서는 넘어가도록 하겠습니다.

class문법에서와 같이 prototype을 연결해주었는데 class문법에서만 잘 동작하는 것일까요??
그걸 알기 위해서는 class문법의 내부동작에 대해 살펴봐야 합니다.
드디어 차례대로 new.target, super, [[HomeObject]]에 대해 알아보겠습니다.

new.target

new.target은 function이 호출되면 arguments가 있는 것처럼 new.target이 생깁니다. new로 호출하지 않을 시에는 undefined입니다.

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

// class의 경우
class A {
  constructor() {
    console.log(new.target === A);
  }
}
new A(); // true

그럼 상속의 경우 어떻게 될까요?

class Parent {
  constructor() {
    console.log('parent', new.target === Child);
  }
  action() { this.say(); }
  say() { console.log('hello parent!'); }
}

class Child extends Parent {
  constructor() {
    super(); // 부모의 생성자를 호출함 -> 뒤에서 더 설명합니다.
    console.log('child', new.target === Child);
  }
  say() { console.log('hello child!'); }
}
new Child().action(); 
// parent true
// child true
// hello Child!

Child의 인스턴스를 만들게 되면 부모 측의 new.target도 Child를 가리키고 있습니다.
즉, 객체지향의 기본원리인 대체가능성과 내적동질성중에 내적동질성을 당연히 잘 만족하고 있습니다. (본질은 Child! -> this는 자기자신(?)인 Child를 가리킴)

super와 [[HomeObject]]

super에 대해 더 자세히 알아보도록 하겠습니다.
자식은 super를 통해 부모를 찾을 수 있습니다. 부모의 있는 method를 실행하고 싶을 때, 프로토타입 체인으로 생각해보면 this가 자기자신(Child)이기 때문에 this.__proto__.method()와 같이 실행할 거 같습니다. 하지만 안타깝게도 실제로는 그렇게 동작하지 않습니다.
다음 예시를 살펴보겠습니다.

class ParentA {
  action() {
    console.log('parentA! action');
  }
}
class ChildA extends ParentA {
  say() {
    super.action();
  }
}
class ParentB {
  action() {
    console.log('parentB! action');
  }
}
class ChildB extends ParentB {}
ChildB.prototype.say = ChildA.prototype.say;
new ChildB().say(); // parentA! action

저희가 예상했던 값과 다릅니다.
super가 this.__proto__.method()과 같이 동작한다면
ChildB의 __proto__인 ParentB를 찾아서 parentB! action을 출력해야할 것만 같습니다. 이는 바로 [[HomeObject]] 때문입니다. [[HomeObject]]라는 내부 프로퍼티는 정의시점에 결정됩니다. 이 프로퍼티는 class문에서 메소드가 정의되는 시점에 확정되고 변경할 수 없습니다. super는 [[HomeObject]]를 통해 부모의 프로토타입과 메서드를 찾는 것입니다.
즉, 위에서 ChildA.say.[[HomeObject]]는 ChildA이기 때문에 ChildB가 say를 호출하더라도 super는 [[HomeObject]]를 추척해서 ParentA의 action 메소드를 실행합니다.

드디어 위의 이야기를 이어나갈 수 있게 되었습니다.

내부적으로 class문은 [[ConstructorKind]]라는 내부 프로퍼티가 존재합니다. 생성자의 2가지 종류가 존재합니다.

[[ConstructorKind]] base | derived
Whether or not the function is a derived class constructor. - 스펙문서 -

[[ContructorKind]]는 base 혹은 derived 값을 갖습니다.
상속받은 class의 [[ContructorKind]] 'derived'라는 갖고, 그렇지 않은 경우 'base'값을 갖습니다.

기존에 function에서 new 연산자와 함께 생성자 함수를 호출했을 때에는 다음과 같은 일이 벌어집니다.
1. 빈 객체 생성 및 this 바인딩
2. this를 통한 프로퍼티 생성
3. 생성된 객체 반환

function A() {
  this.name = 'AA';
  this.method = function () {
    console.log('A method');
  }
  return this; // 생략가능
}
const a = new A();
a.method();
console.log(a.name);

하지만 class문법을 이용하게 되면 derived 생성자로 인스턴스를 생성하지 않고, base생성자까지 올라가 base 생성자에서 인스턴스를 생성합니다.

class Cls extends Array {
  static classname = 'name: Cls'
  constructor() { super(); } // 생략가능
}
const c = new Cls();
c[0] = 'data';
c.push('data2');
console.log(c.length); // 2
console.log(c); // ['data', 'data2']
console.log(c.classname); // name: Cls

제일 처음에 나온 코드를 다시 보게 되면 super를 통해 base 생성자까지 올라가 Array인스턴스를 생성하는 것입니다.

정리하면, es6의 class문법은 기존의 프로토타입과 다른 방식으로 객체를 생성합니다.

class문법을 사용하면 상속받은 클래스는 derived 생성자가 되고, 상속받지 않은 클래스(부모가 없는 클래스)는 base 생성자를 갖습니다.
new로 인스턴스를 생성하는 시점에 derived 생성자의 인스턴스를 만들지 않고 끝없이 부모로 보내 base 생성자까지 거슬러 올라간 뒤 기본 생성자로 인스턴스를 만듭니다.
즉, class Cls extends Array를 하면 new Cls()를 했을 때 Cls의 인스턴스가 만들어지지 않고 Array의 인스턴스가 만들어지는 이유입니다.
하지만 이렇게 되면 Array 인스턴스가 되는거야? instanceof는 잘 작동하던데??와 같은 의문이 생깁니다. 이것을 위해 앞서 말했던 new.target이 있습니다. 객체 생성은 base 생성자로 하였지만 그 외의 __proto__ 등의 설정에는 new.target을 이용하여 처리하므로 기존의 프로토타입체계(instanceof 등)에서는 derived 생성자의 객체로 인식할 수 있습니다.

다시 도전 💪

하지만 포기하지 않고 불굴의 의지로 다시 기존의 프로토타입 체인을 통해 class문법처럼 Built-In 객체를 상속해봅시다. 별로 어려운 것이 아닙니다(?). 부모의 생성자를 통해 인스턴스를 만들며, new.target을 자식 클래스로 지정해주면 됩니다.

function Parent() {
  console.log(new.target);
}
function Child() {
  this.constructor.call(this); // Object.create로 prototype을 연결했기 때문에 빈 객체를 만들었음. 따라서 프로토타입 체인을 통해 this.constructor는 Parent
  console.log(new.target === Child);
}
Child.prototype = Object.create(Parent.prototype);
new Child();
// undefined
// true

this.constructor.call(this)는 new 키워드를 통해 생성한 것이 아니기 때문에 Parent의 new.target은 undefined를 나타냅니다.
new.target을 Parent에 보내주기 위해서 Reflect.construct를 이용해봅시다.

function Parent() {
  console.log(new.target === Child);
}
function Child() {
  Reflect.construct(this.constructor, [], new.target);
  console.log(new.target === Child);
}
Child.prototype = Object.create(Parent.prototype);
new Child();
// true
// true

new.target을 가져오는 것은 성공하였으니, 부모의 인스턴스를 생성하고 자식에게 연결시켜주기 위해서 this = Reflect.construct(this.constructor, [], new.target);와 같은 코드를 작성하고 싶으나 this는 할당이 안되기 때문에 불가능합니다.

결론

굉장히 주관적인 결론을 내리도록 하겠습니다. 다른 방향의 결론이지만, 제 결론은 class문법이 syntax sugar(설탕문법)이라는 논쟁에 휘말리기보다는 class문법을 제대로 알고 사용하는 것이 중요하다는 것입니다.
결론을 내보려다가 이런 생각에 휩쓸려서 잘 모르겠다는 결론에 도달했습니다😭
1. babel로 class A extends Array{}를 transpile하면 Built-In 객체도 상속받을 수 있는 거 아닌가??
2. transpile한 결과가 너무 복잡함!
3. 개발자가 Built-In 객체의 상속을 위해 저런 많은 코드를 작성해야 할까?
4. 그래도 코어로직 한번만 작성하면 사용할만 한 거 아닌가?
5. 그럼 babel로 transpile 가능한 것들이 다 설탕문법인가?
6. 모르겠다😭

아시는 분은 저에게 댓글로 알려주시면 감사하겠습니다😊

추가적인 이야기

이전에 함수를 생성자처럼 호출하거나, 생성자를 함수처럼 사용하는 문제는 function을 통해 생성자와 클래스를 동시에 생성했기 때문입니다.
이를 막기 위해서 클래스를 의미하는 것은 대문자로 하거나 function내에서 방어코드를 작성하곤 했습니다.

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

2번만 실행시키기 위한 것이 목적!

// Scope-Safe Constructor Pattern
const Cls = function(arg) {
  if (!(this instanceof Cls)) {
    return new Cls(arg);
  }

  // 프로퍼티 생성과 값의 할당
  this.value = arg ? arg : 0;
}

var a = new A(100);
var b = A(10);

console.log(a.value); // 100
console.log(b.value); // 10

위의 경우처럼 간단히 막으면 된다고 생각할 수 있지만 다음과 같은 코드를 작성하면 무력화됩니다.

//처음 호출은 new를 통해 실행되지만
var a = new Cls();
 
//두 번째 호출은 그저 call에 기존 인스턴스 a가 들어왔을 뿐
Cls.call(a);

이것을 es6에서는 원천적으로 막을 수가 있습니다. 그것이 new.target입니다.
따라서 다음과 같이 방어코드를 작성하면 됩니다.

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

// 즉 위의 방어 코드를 다음과 같이 수정할 수 있습니다.
const Cls = function (arg) {
  if (new.target !== Cls) return new Cls(arg);
  this.value = arg ?? 0;
};

console.log(new Cls(100).value); // 100
console.log(Cls().value); // 0

하지만 class문법을 통해 Error 막는 것이 좋지 않을까요??

class Cls {
  value;
  constructor(value) {
    this.value = value ?? 0;
  }
}
console.log(new Cls(100).value); // 100
console.log(Cls().value); // Uncaught TypeError: Class constructor Cls cannot be invoked without 'new'

출처
https://www.bsidesoft.com/5370
https://ko.javascript.info/prototype-inheritance
https://ko.javascript.info/class
https://poiemaweb.com/js-this

profile
알고 싶은게 많은 프론트엔드 개발자입니다.

0개의 댓글