[es6] 연구노트 #3 – 명확한 객체정의

개요

s67에서는 ES2015를 주제로 스터디를 전개했습니다. 그 때의 내용을 정리하여 포스팅하는 시리즈입니다. ES2015에 대한 제 개인적인 이해와 정보를 정리해봅니다.

객체지향 기본과 프로토타입

여전히 OOP는 가장 많이 사용되는 설계방법론입니다. 자바스크립트에서는 셀프에서 이어받은 프로토타입을 이용하여 OOP의 기본인 대체가능성과 내적동질성을 확보합니다.
간단히 부모가 되는 Dog클래스와 상속받은 Spitz클래스 사이에서의 작동을 살펴보죠.

var Dog = function(){};
Dog.prototype.bark = function(){
  return 'woof';
};

var Spitz = function(){};
Spitz.prototype = new Dog();
Spitz.prototype.bark = function(){
   return 'woof woof'; //더 시끄러움.
};
 
var dog = new Spitz();
console.log(dog instanceof Dog); //true 대체가능성
console.log(dog.bark()); //woof woof 내적동질성

가만히 살펴보면 proto체인을 통해 instanceof 연산자가 전체 연결 체인을 검사하게 되므로 대체가능성을 갖게 됩니다.
반대로 this는 함수의 호출형태에서 context를 공급하기 때문에 약한 연결이지만 내적동질성을 유지할 수 있는 장치가 됩니다.
따라서 기존 프로토타입을 OOP적인 입장에서 평가해보면 다음과 같은 특징이 있습니다.

  1. 대체가능성 – 형이 엄격한 언어에서야 부모형에 자식형을 대입할 수 있다는 것은 추상층에서의 한정된 기능만 쓰게 한다던가 대표되는 기능으로 구상기능을 감춘다던가 하는 다양한 활용이 생겨납니다만 런타임에 부모형으로부터 상속받았다는 것을 확인하는 정도에서 기능이 제한되므로 활용이 적어지고 수동으로 구현해야할 부분이 많이 생깁니다. 특히 런타임정의 언어에서는 형의 확인보다 덕타입을 즐겨 쓰기 때문에 활용범위가 더 적어집니다.
  2. 내적동질성 – 프로토타입체인 시스템은 본인의 키가 있으면 본인의 키가 사용되고 없으면 체이닝된 프로토타입의 키를 사용하는 구조로 되어있으므로 자연스레 최우선적으로 본인의 키가 사용됩니다. 하지만 내적동질성은 프로토타입체인 때문에 지켜지는 것이 아니라 함수의 호출형태 때문에 유지됩니다. 즉 xxx.func(), xxx[‘func’] (), func,call(xxx), func.apply(xxx) 의 네가지 형태에서 this는 무조건 xxx로 정의되기 때문에 호출되는 형태로 인해 마지막 구상객체가 일관성있게 this로 정의되어 메소드를 선택할 때 적용됩니다. 하지만 호출형태라는 굉장히 약한 기반에 의존하기 때문에 오용되기 쉽습니다.

이상에서 OOP구현이 프로토타입에서 어떤식으로 반영되었는지를 살펴보았습니다. 아슬아슬하게 세이프? 라는 느낌이네요 ^^

프로토타입기반 클래스 구조의 약점과 super

일단 대체가능성과 내적동질성 측면에서 OOP구조를 만들 수 있는 기본에는 세이프지만 굉장히 약한 구조라 많은 약점이 있습니다. 우선 프로토타입체인에 기반하므로 마치 변수의 쉐도잉처럼 키가림 현상이 발생합니다.

  1. 프로토타입은 단일 체이닝 구조로 대상객체에 키가 정의되어있으면 무조건 체이닝될 객체의 키를 가리게 됩니다.
  2. 따라서 하위클래스에서 상위클래스의 기능도 활용하려면 반드시 상위클래스에서 정의된 키이름을 피해서 제작해야만 상호작용할 수 있습니다.

이를 간단히 표현해보죠.

var Parent = function(){};
Parent.prototype.test = function(){return 'parent';};

var Child = function(){};
Child.prototype = new Parent();
Child.prototype.test2 = function(){return this.test() + 'child';};

var child = new Child();
child.test2(); //parentchild;

위의 구조에서 child객체가 부모의 test() 메소드를 사용하려면 본인은 test2()와 같이 부모에 정의된 이름을 피해야만 가능합니다. 현실적으로 다계층의 부모가 존재한다고 했을 때 부모에 정의된 모든 이름을 파악하여 이를 피해 이름을 지어야만 부모의 기능을 사용할 수 있다는 제약은 감당하기가 거의 불가능합니다. 오히려 프로토콜상 동일한 이름의 메소드가 부모와 자식 간에 공유된다고 생각하는 편이 더욱 확장기능을 구현하기 쉬울 것입니다. es2015에서는 super라는 키워드를 도입하여 부모클래스와 자식 클래스 간의 프로토콜 통신을 지원하고 프로토타입체인에 의존하지 않는 어휘로 메소드의 이름을 동일하게 작성해도 상관없는 메커니즘의 표준을 제공합니다.

const Parent = class{
  test(){return 'parent';}
};

const Child = class extends Parent{
  test(){return super.test() + 'child';}
};

const child = new Child();
child.test(); //parentchild

위의 예에서 child도 test라는 동일한 메소드명을 갖고 있음에도 super컨텍스트를 이용하여 부모의 test()메소드를 결합한 데코레이터를 구현했습니다. super도 다른 언어요소와 마찬가지로 폴리필이 가능한가의 문제가 아니라 현재 클래스와 부모클래스 간의 통신형태를 표준적인 메커니즘으로 정의했다는데 의미가 있습니다. 이제 클래스를 이용한 프로그래밍은 super를 활용한 컨텍스트를 사용하는 것이 당연한 알고리즘이 되는 것이죠(표준이니까!) 물론 super는 사용처에 따라 약간 다르게 작동합니다만 기본적으로 아래와 같은 의미를 자바스크립트에 표준적으로 부여하게 됩니다.

  1. 기존의 this가 현재 주체가 되는 객체의 참조로 동작하는 컨텍스트를 제공했다면,
  2. super는 상위 클래스에 대한 상대경로를 제공하는 새로운 컨텍스트를 제공합니다.

즉 this가 인스턴스에 제네릭한 알고리즘으로 메소드로 작동할 함수를 구현하게 하는 장치였다면 super는 상위클래스와의 협력과 재활용을 메소드 내에서 적극적으로 활용하게 촉진할 장치라고 할 수 있겠죠.
역템플릿구조나 세밀한 메소드별 기본 구조를 제공하는 기반 클래스 제작 등을 유도하게 됩니다.

또한 기존의 생성자 체인에 대한 모순도 크게 완화합니다. es3에서 프로토타입체인은 생성자 체인이 굉장히 불편한 구조로 되어있습니다.

var isExtends = {}; //상속전용 값 정의

var Parent = function(v){

  //상속을 위한 경우라면 여기서 정리
  if(v === isExtends) return;

  //정상인 경우는 값을 정의한다.
  this.value = v;
};


var Child = function(v){
    //생성자 체인
    this.constructor.call(this, v);
};

//상속을 위해 생성시에는 생성자로 작동하지 않는다.
Child.prototype = new Parent(isExtends);
Child.prototype.log = function(){console.log(this.value);};

위에서 중요하게 살펴볼 부분은 Child가 Parent를 상속받는 부분입니다. 이 경우의 Parent호출은 오직 체이닝을 위한 설정이므로 생성자로 작동하면 안됩니다. 이를 위한 상수 isExtends 등을 이용해 확실하게 경우를 나눠줘야합니다. 이에 비해 es5의 Object.create는 그러한 불편함을 제거합니다.

var Parent = function(v){
    this.value = v;
};

var Child = function(v){
    this.constructor.call(this, v);
};
Child.prototype = Object.create(Parent.prototype);

이제 es5를 이용해 생성자의 모순을 해결했지만 여전히 생성자 체인에 굉장히 위험한 constructor 속성을 사용합니다. constructor는 함수생성시 prototype이 자동으로 생성되면서 그 안에 함수 자신을 참조로 잡는 속성으로 사용자는 얼마든지 이 값을 변경할 수 있습니다.

var Parent = function(v){
    this.value = v;
};
Parent.prototype.constructor = alert;

이제 Child의 체이닝은 엉뚱하게 alert을 띄우게 될 것입니다. 이는 constructor를 찾는 과정이 프로토타입 체이닝과 함수객체의 prototype객체생성 및 constructor기본값 생성에 대한 복잡한 스펙으로 인해 발생합니다.

  1. this.constructor는 this[[Prototype]].constructor 에 매칭되고
  2. 이는 다시 (new Parent).constructor에 매칭되며
  3. 최종적으로 Parent.prototype.constructor에 매칭된다.

이렇듯 언어에 대한 스펙을 외운 사람만 알 수 있는 코드인데다가 변경까지 할 수 있으며 다층 상속시에는 또다른 의미를 갖게되는 굉장히 나쁜 코드인 것입니다.
하지만 기존에는 Parent를 직접 하드코딩하지 않는 이상 전용스펙을 만들어야만 안전하게 체이닝 할 수 있었습니다. 이제는 이러한 체이닝을 super()를 통해 진행하는 표준이 생겼기 때문에 더 이상 고민할 필요가 없는 것이죠. 결국 다시 요점을 강조하지만 super가 표준화 되었다는건 es2015지원브라우저에서는 당연히 super를 사용하는 것이고 구형 브라우저에서도 클래스 구조를 제작할때 super컨텍스트를 활용하는 방향으로 제작해가는 것이 새로운 언어와 의미적으로 호환되는 구현 표준이라는 것입니다.

결론

기존 prototype기반의 약간 OOP정의에서 보다 확정적이고 코드상으로 인지할 수 있는 super컨텍스트 도입으로 한결 명확하게 변모한 es2015의 class에 대한 의미를 살펴봤습니다.
super외에도 class로 생성한 경우 prototype이 자동으로 freeze되어 건드릴 수 없게 된다던가 인스턴스에 대해 proto를 동적으로 변경할 수 없는 등 최대한 봉인된 클래스의 특징을 갖도록 많은 장치를 덧붙였습니다. 이제는 정적이면서 사전에 설계된 OOP구조는 super 등으로 보다 명확하게 정의하고 런타임시에도 보다 확정적이고 안전하게 사용할 수 있는 표준적인 class를 사용해야할 때가 아닌가 싶습니다.
또한 이러한 표준 class가 지원되지 않는 브라우저에도 커스텀 클래스유틸리티를 구현할때 최대한 표준과 철학을 공유하는 방향으로 API를 만든다면 개발 상에서의 연속성을 확보하기 쉬울 것입니다.

클래스와 관련되어 참고하실만한 글은 아래와 같습니다.

[es6for3] Class 생성기