ES6 In Depth: 서브클래스 만들기 (Subclassing)

ES6 In Depth 시리즈는 ECMAScript 표준 6번째 에디션(줄여서 ES6)을 통해 JavaScript 에 새로 추가된 요소들을 살펴보는 시리즈입니다.

지지난 글에서, 우리는 객체의 생성자를 정의할 때 겪는 번거로움을 해소하기 위해 ES6 에 새로 도입된 클래스 시스템을 알아보았습니다. 그리고 클래스 시스템을 이용해서 아래처럼 코딩하는 방법을 알아보았습니다.

class Circle {
    constructor(radius) {
        this.radius = radius;
        Circle.circlesMade++;
    };

    static draw(circle, canvas) {
        // Canvas drawing code
    };

    static get circlesMade() {
        return !this._count ? 0 : this._count;
    };
    static set circlesMade(val) {
        this._count = val;
    };

    area() {
        return Math.pow(this.radius, 2) * Math.PI;
    };

    get radius() {
        return this._radius;
    };
    set radius(radius) {
        if (!Number.isInteger(radius))
            throw new Error("Circle radius must be an integer.");
        this._radius = radius;
    };
}

불행히도, 몇몇 사람들이 지적한 것처럼, ES6 의 클래스 시스템을 모두 설명하기에는 시간이 부족했습니다. (C++ 이나 Java 같은 랭귀지의) 전통적인 클래스 시스템처럼, ES6 도 상속(inheritance)을 지원합니다. 그래서 어떤 클래스가 다른 클래스를 기반으로 기능을 추가, 확장할 수 있습니다. 이번 글은 이 새로운 기능을 설명하려고 합니다.

서브클래스 만들기(subclassing: 상속하기)에 대해 알아보기 전에, 속성의 상속과 동적 프로토타입 체인(dynamic prototype chain)에 대해 잠깐 설명하는 것이 좋을 것 같습니다.

JavaScript 의 상속 (JavaScript Inheritance)

우리는 어떤 객체를 만들 때 그 객체에 속성을 부여할 수 있습니다. 그리고 그 객체는 프로토타입 객체로부터 속성을 상속 받습니다. JavaScript 프로그래머라면 프로토타입 객체 상속을 쉽게 처리해주는 Object.create API 에 익숙할 것입니다.

var proto = {
    value: 4,
    method() { return 14; }
}

var obj = Object.create(proto);

obj.value; // 4
obj.method(); // 14

만약, objproto 에 있는 속성과 같은 이름의 속성을 부여하면, obj 에 새로 부여한 속성이 proto 의 속성을 덮어씁니다(shadow).

obj.value = 5;
obj.value; // 5
proto.value; // 4

서브클래스 만들기 기초 (Basic Subclassing)

이상의 사실을 염두에 두고, 이제 클래스를 이용해서 객체를 만들 경우 프로토타입 체인을 어떻게 조작해야 하는지 알아봅시다. 생각해 보세요. 우리는 클래스를 만들기 위해 클래스 정의 구문에 constructor 메소드를 추가합니다. 그리고 그 클래스 정의 구문에 모든 static 메소드들을 담습니다. 우리는 또 그렇게 만든 함수(클래스)의 prototype 속성에 부여할 객체를 만듭니다. 그리고 그 객체(prototype 객체)에 모든 인스턴스 메소드들을 담습니다. 새로운 클래스를 만들 때 모든 static 속성들을 상속하려면, 우리는 수퍼클래스(superclass) 함수 객체를 상속하는 새로운 함수 객체를 만들어야 합니다. 유사하게, 모든 인스턴스 메소드들을 상속하려면, 수퍼클래스의 prototype 객체를 상속해서 새로운 함수 객체에 부여할 prototype 객체를 만들어야 합니다.

말이 너무 어렵습니다. 새로운 문법의 도움 없이 이 일을 처리하려면 어떤 코드를 만들어야 하는지 예제를 봅시다. 그리고 코드가 조금이라도 간결해지도록 손질해 봅시다.

이전 예제를 계속 이용할 것입니다. 우리에게 Shape 클래스가 주어져 있다고 가정합시다. 이제 우리는 Shape 클래스를 계승하는 서브클래스를 만들 것입니다.

class Shape {
    get color() {
        return this._color;
    }
    set color(c) {
        this._color = parseColorAsRGB(c);
        this.markChanged();  // repaint the canvas later
    }
}

이때, 우리는 이전 글에서 static 속성 때문에 겪었던 것과 똑같은 문제를 겪게 됩니다. 함수를 정의할 때 함수의 프로토타입을 함께 조작할 문법이 없는 것입니다. Object.setPrototypeOf 를 써서 이 문제를 우회할 수 있기는 하지만, 이렇게 우회하는 방법은 프로토타입만 써서 함수를 만드는 방법보다 실행 성능이 나쁘고 엔진 입장에서 최적화 하기가 힘듭니다.

class Circle {
    // As above
}

// 인스턴스 속성 처리
Object.setPrototypeOf(Circle.prototype, Shape.prototype);

// static 속성 처리
Object.setPrototypeOf(Circle, Shape);

코드가 아주 보기 안 좋습니다. 우리는 최종 객체의 형상을 한 곳에 정의하기 위해 객체의 모든 로직을 캡슐화하는 클래스 문법을 추가했습니다. 나중에 별도의 “처리” 로직을 덧붙이려는 의도가 아니었습니다. Java, Ruby, 그리고 기타 다른 객체지향 랭귀지들은 어떤 클래스가 다른 클래스를 계승함을 선언하는 문법을 갖고 있습니다. 우리도 그래야 합니다. 이를 위해 extends 키워드를 사용합시다. 그러면 코드를 다음과 같이 작성할 수 있습니다.

class Circle extends Shape {
    // As above
}

extends 뒤에는 모든 표현식이 올 수 있습니다. 그 표현식이 prototype 속성을 갖는 올바른 생성자라면 말입니다. 예를 들어 다음과 같은 표현식들이 올 수 있습니다.

  • 또다른 클래스
  • 기존 프레임워크에 존재하는 클래스와 비슷한 함수들
  • 통상적인 함수
  • 함수 또는 클래스를 담고 있는 변수
  • 어떤 객체에 존재하는 속성
  • 함수 호출 (function call)

심지어 null 도 올 수 있습니다. null 이 올 경우, 생성하는 인스턴스가 Object.prototype 으로부터 계승되지 않는다는 사실을 명시적으로 선언하는 의미가 됩니다.

수퍼클래스의 속성들(Super Properties)

이렇게 우리는 서브클래스를 만들 수 있습니다. 그래서 속성을 계승할 수 있습니다. 그리고 때때로 계승한 메소드를 덮어쓸 수 있습니다(override 입니다). 그런데 만약 계승한 메소드를 덮어쓰지 못하게 방지하고 싶으면 어떻게 하죠?

Circle 클래스를 계승해서 새로운 클래스를 작성해 봅시다. 새로운 클래스에는 지정된 비율로 원의 크기를 조절하는 기능을 부여할 것입니다. 이를 위해, 약간 부자연스럽지만, 다음처럼 클래스를 작성해 봅시다.

class ScalableCircle extends Circle {
    get radius() {
        return this.scalingFactor * super.radius;
    }
    set radius() {
        throw new Error("ScalableCircle radius is constant." +
                        "Set scaling factor instead.");
    }

    // Code to handle scalingFactor
}

radius 속성의 getter 메소드에 있는 super.radius 라는 표현에 주의합시다. 이 새로운 super 키워드 덕분에 우리는 현재 클래스의 속성을 우회(bypass)할 수 있습니다. 그래서 속성 검색을 프로토타입 객체에서 시작할 수 있습니다. 따라서 뭔가 새로 덮어쓴(shadowing) 속성이 있다면 이를 우회할 것입니다.

수퍼클래스의 속성(super property: 부모 클래스의 속성)을 참조하는 기능은 모든 메소드에서 사용할 수 있습니다 (조금 다른 얘기지만, super[expr] 표현도 허용됩니다). 수퍼클래스의 속성을 참조하는 메소드를 원래 객체에서 축출해도, 속성 접근 경로는 해당 메소드가 정의된 객체에 단단히 고정됩니다. 즉, 메소드를 로컬 변수로 뽑아내더라도 super 키워드에 의한 속성 접근 경로는 유지됩니다.

var obj = {
    toString() {
        return "MyObject: " + super.toString();
    }
}

obj.toString(); // MyObject: [object Object]
var a = obj.toString;
a(); // MyObject: [object Object]

빌트인(Builtin) 객체 계승하기

우리에게 필요한 기능이 또 하나 있다면 JavaScript 랭귀지에 내장된 빌트인 객체를 확장하는 기능입니다. JavaScript 랭귀지는 빌트인 객체 덕분에 큰 힘을 발휘합니다. 그 힘을 지랫대 삼아 쓸 수 있는 새로운 타입을 만들 수 있다면 놀랄만큼 유용할 것입니다. 사실 이 문제는 서브클래싱 기능을 설계할 때부터 고려한 문제입니다. 지금부터, 버전이 관리되는 배열을 만들어봅시다. (무엇을 걱정하시는지 압니다. 일단 저를 믿어 보세요.) 우리는 이 배열을 이용해서 무언가 데이터를 바꿀 수 있어야 하고, 바꾼 데이터를 커밋하거나 바꾸기 이전 상태로 되돌릴 수 있어야 합니다. 이 배열을 구현하는 가장 빠른 방법은 Array 를 계승해서 서브클래스를 만드는 것입니다.

class VersionedArray extends Array {
    constructor() {
        super();
        this.history = [[]];
    }
    commit() {
        // 변경 내용을 히스토리에 저장.
        this.history.push(this.slice());
    }
    revert() {
        this.splice(0, this.length, this.history[this.history.length - 1]);
    }
}

VersionedArray 인스턴스는 중요한 속성을 몇 개만 갖고 있습니다. 그럼에도 불구하고 VersionedArray 인스턴스는 정말로 완전한 Array 인스턴스입니다. VersionedArray 인스턴스에는 map, filter, sort 메소드들이 구비되어 있습니다. Array.isArray()VersionedArray 인스턴스를 Array 인스턴스로 인정합니다. VersionedArray 인스턴스는 자동으로 갱신되는 length 속성도 제공합니다. 심지어, 새로운 배열 인스턴스를 리턴하는 함수들 (예를 들어 Array.prototype.slice() 같은 함수들)은 VersionedArray 인스턴스를 리턴합니다!

계승된 클래스의 생성자

혹시 예제 코드의 constructor 메소드에 있는 super() 구문을 보셨나요? 이게 뭘까요?

전통적인 클래스 모델에서, 생성자(constructor)의 역할은 클래스 인스턴스의 내부 상태를 초기화하는 것입니다. 서브클래스는 자신이 새로 정의하는 상태를 초기화할 책임이 있습니다. 이런 초기화 작업은 연쇄적으로 이루어져야 합니다. 그래서 수퍼클래스(부모 클래스)의 초기화 코드가 이를 확장한(extends) 서브클래스들 사이에서 동일하게 공유되어야 합니다.

수퍼클래스의 생성자를 호출하기 위해, 또 super 키워드를 사용합니다. 이번에는 함수처럼 사용합니다. 이 문법은 extends 를 쓰는 클래스 정의 구문의 constructor 안에서만 유효합니다. super 키워드를 이용하면 Shape 클래스를 다음처럼 다시 작성할 수 있습니다.

class Shape {
    constructor(color) {
        this._color = color;
    }
}

class Circle extends Shape {
    constructor(color, radius) {
        super(color);

        this.radius = radius;
    }

    // As from above
}

우리는 JavaScript 의 생성자에서 this 객체를 조작합니다. this 객체를 조작해서 속성을 추가하고, 내부 상태를 초기화합니다. 보통 this 객체는 new 명령으로 생성자를 호출할 때 생성됩니다. new 명령으로 생성자를 호출하는 것은 생성자의 prototype 을 갖고 Object.create() 를 호출하는 것처럼 객체를 생성하는 역할을 합니다. 그런데, 어떤 빌트인 객체들은 특이한 내부 메모리 구조를 갖고 있습니다. 예를 들어, Array 는 메모리 상에서 통상적인 객체들과 다른 레이아웃을 갖습니다. 빌트인 객체를 계승할 수 있으려면 가장 바닥에 있는(basemost) 생성자가 this 객체를 할당해야 합니다. 만약 그것이 빌트인 객체 생성자라면, 우리는 우리가 원하는 레이아웃의 객체를 얻게 될 것입니다. 만약 그것이 일반 객체 생성자라면, 이번에도 우리는 우리가 원하는 디폴트 this 객체를 얻게될 것입니다.

서브클래스의 생성자 안에서 this 객체가 다뤄지는 상황을 충분히 이해할 필요가 있습니다. 가장 바닥에 있는 생성자가 실행되기 전까지, 그래서 그 생성자가 메모리에 this 객체를 할당하기 전까지, 우리는 유효한 this 값을 갖지 못합니다. 그 결과, 수퍼클래스의 생성자가 호출되기 전까지 서브클래스의 생성자 안에서 일어나는 this 에 대한 모든 접근은 ReferenceError 에러를 냅니다.

지난번 글에서 설명한 것처럼, 우리는 constructor 메소드를 생략할 수 있습니다. 서브클래스에서 생성자를 생략하면, 마치 다음과 같은 생성자가 존재하는 것처럼 동작합니다.

constructor(...args) {
    super(...args);
}

때때로, 생성자가 this 객체를 사용하지 않는 경우도 있습니다. 즉, 생성자가 다른 방식으로 객체를 만들고, 만든 객체를 초기화해서, 그 객체를 바로 리턴하는 경우도 있을 것입니다. 이 경우에는, super 생성자를 꼭 호출하지 않아도 됩니다. super 생성자의 호출 여부와 상관 없이 모든 생성자는 객체를 리턴합니다.

new.target

가장 바닥에 있는(basemost) 기초 클래스가 this 객체를 할당하게 함으로써 발생하는 이상한 사이드 이펙트 하나는 때때로 가장 바닥에 있는 클래스가 어떤 객체를 할당해야 하는지 모를 수 있다는 것입니다. 우리가 객체 프레임워크 라이브러리를 만든다고 가정해 봅시다. 우리는 Collection 이라는 기초 클래스를 만들려고 합니다. Collection 클래스로부터 파생되는 클래스라면 아마도 array 거나, map 일 것입니다. 그런데, 우리가 Collection 생성자를 실행시킬 때, Collection 생성자는 어떤 객체를 만들어야 하는지 판단할 수 없습니다!

우리가 빌트인 객체를 계승할 수 있기 때문에, 빌트인 객체의 생성자를 호출할 때 우리는 내부적으로 오리지널 객체의 prototype 을 이미 알고 있어야 합니다. 그렇지 않으면, 적합한 인스턴스 메소드를 갖춘 객체를 생성할 수 없습니다. 이런 이상한 Collection 문제를 해결하기 위해, 우리는 오리지널 객체를 알려주는 문법을 JavaScript 에 추가했습니다. 우리가 추가한 것은 new.target 이라는 메타 속성입니다. new.targetnew 와 함께 사용된 생성자를 가리킵니다. new 를 이용해서 어떤 함수를 호출하면 new.target 속성이 호출된 바로 그 함수로 설정됩니다. 그래서 그 함수 안에서 super 를 호출할 때, new.target 에 설정된 값이 그대로 전달됩니다.

말로는 이해하기 어렵습니다. 다음 코드를 봐주세요.

class foo {
    constructor() {
        return new.target;
    }
}

class bar extends foo {
    // 설명을 위해 생성자 코드를 포함시켰습니다.
    // 이 생성자 코드를 생략해도 결과는 같습니다.
    constructor() {
        super();
    }
}

// foo를 직접 호출했습니다. 그래서 new.target은 foo입니다
new foo(); // foo

// 1) bar를 직접 호출했습니다. 그래서 new.target은 bar입니다
// 2) bar가 super()를 통해 foo를 호출하기 때문에 
//    new.target은 여전히 bar입니다
new bar(); // bar

이제 앞서 설명한 Collection 문제가 해결됐습니다. Collection 생성자가 new.target 을 통해 클래스 계승 관계를 유추할 수 있기 때문에, Collection 생성자가 어떤 빌트인 객체를 만들지 결정할 수 있습니다.

new.target 은 모든 함수 호출에 적용됩니다. 하지만 함수가 new 를 통해 호출되지 않은 경우, 그 값은 undefined 입니다.

양쪽 세계의 장점

새로운 기능에 대한 지금까지의 설명을 잘 따라 오셨는지요? 여기까지 읽어주셔서 감사합니다. 이제 잠깐 멈춰서 새로운 기능이 문제 해결에 도움이 되는지에 대해 이야기해 봅시다. 계승(inheritance) 기능 자체가 랭귀지의 일부로 삼을만큼 좋은 기능이 아니라는 주장을 제기하는 사람들이 많았습니다. 어쩌면 당신도 계승(inheritance) 보다는 콤포지션(composition)이 객체를 만드는 더 좋은 방법이라고 생각할지 모릅니다. 아니면 새로운 문법이 제공하는 간결함보다 전통적인 프로토타입 방식이 제공하는 유연함이 더 가치 있다고 생각할지 모릅니다. mixin 이 객체를 만드는 중요한 방법이 되었다는 사실은 부정할 수 없습니다. mixin 은 코드를 확장 가능한 형태로 공유할 수 있는 방법입니다. 또 mixin 은 연관 없는 코드를 모아서 새로운 객체를 만들 수 있는 손 쉬운 방법이기도 합니다. 연관 없는 코드 사이에서 공통의 계승(inheritance) 관계를 만들 필요가 없기 때문입니다.

이에 대해 다양하고도 격렬한 많은 논쟁들이 존재합니다.하지만 저는 가치 없는 것은 없다고 생각합니다. 첫째, class 를 랭귀지의 기능으로 추가했다고 해서 반드시 class 를 사용해야 하는 것은 아닙니다. 둘째, 이것 역시 중요한데, class 를 랭귀지의 기능으로 추가했다는 사실이 class 가 계승 문제를 해결하는 최선의 방법이라는 것을 의미하지 않습니다! 사실, 어떤 문제들은 프로토타입 방식으로 해결하는 것이 더 좋습니다. 결국 class 는 당신이 사용할 수 있는 또 다른 도구일 뿐입니다. 유일한 도구도 아니고 최선의 도구도 아닙니다.

만약 당신이 mixin 방식을 계속 원하면서, 동시에 다수의 mixin 들을 계승하는 class 들도 사용하고 싶다면, 그냥 각각의 mixin 을 계승(inherit)해서 훌륭하게 문제를 해결할 수 있습니다. 불행히도, 계승 모델을 당장 바꾸기는 어려울 것입니다. JavaScript 는 클래스의 다중상속(multiple inheritance)을 지원하지 않습니다. 하지만, 클래스를 기반으로 하는 프레임워크에서 mixin 을 사용하는 방법도 있습니다. 우리가 잘 아는 mixin 들을 확장(extend) 해서 사용하는 아래 함수를 한번 보세요.

function mix(...mixins) {
    class Mix {}

    // Programmatically add all the methods and accessors
    // of the mixins to class Mix.
    for (let mixin of mixins) {
        copyProperties(Mix, mixin);
        copyProperties(Mix.prototype, mixin.prototype);
    }
    
    return Mix;
}

function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
        if (key !== "constructor" && key !== "prototype" && key !== "name") {
            let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
        }
    }
}

이제 우리는 이 mix 들을 조합한(composed) 수퍼클래스를 생성할 수 있습니다. 여러 개의 mixin 들 사이에 명시적인 계승(inheritance) 관계를 생성할 필요 없이 말이죠. 여럿이 함께 편집할 수 있는 도구를 만든다고 상상해 보세요. 모든 편집 내역은 로그(log)로 남겨져야 하고, 편집된 내용은 저장되어야(serialize) 합니다. 우리는 mix 함수를 이용해서 DistributedEdit 클래스를 작성할 수 있습니다.

class DistributedEdit extends mix(Loggable, Serializable) {
    // Event methods
}

이것이 양쪽 세계의 장점을 조합한 모습입니다. 이 방법은 또 여러 개의 mixin 클래스들을 확장하는 모델을 쉽게 이해할 수 있게 해줍니다. 수퍼클래스를 갖는 mixin 클래스들 여러 개를 확장하는 모델을 구현할 경우, 이 방법으로 mixin 클래스들을 확장하지 않는다면 주어진 모델을 쉽게 이해할 수 없을 것입니다. 우리는 그저 mix 함수에 수퍼클래스를 전달하기만 하면 됩니다. 그러면 전달한 수퍼클래스를 확장(extend)한 클래스가 리턴됩니다.

현재 사용 가능성

좋습니다. 지금까지 빌트인 객체로부터 서브클래스를 만드는 것을 비롯해서 새로운 기능들에 대해 이야기했습니다. 그런데 이 기능들을 지금 쓸 수 있나요?

글쎄요, 경우에 따라 다릅니다. 주요 브라우저들마다 사정이 다릅니다. Chrome 브라우저는 오늘 우리가 이야기한 모든 기능들을 이미 제공하고 있습니다. strict 모드에서 우리가 논의한 모든 기능들을 사용할 수 있습니다. Array 객체만 빼고 다른 모든 빌트인 객체들로부터 서브클래스를 만드는 것도 가능합니다. Array 객체의 경우 몇 가지 해결해야 할 문제가 있습니다. 이 문제가 어렵다는 것은 충분히 이해할만 합니다. Firefox 브라우저에는 제가 기능을 구현했습니다. 저의 목표는 Chrome 과 같은 수준의 목표(Array 를 제외한 모든 것을 지원하는 목표)를 가능한 빨리 달성하는 것이었습니다. 보다 상세한 정보는 bug 1141863 를 참조해 주세요. 몇 주 내에 Firefox Nightly 버전에 탑재될 예정입니다.

Edge 브라우저는 super 키워드를 지원합니다. 하지만, 빌트인 객체로부터 서브클래스를 만드는 기능은 지원하지 않습니다. Safari 브라우저는 서브클래스 관련 기능을 전혀 지원하지 않습니다.

서브클래스 만들기의 경우 트랜스파일러를 쓰는 것이 좋지 않습니다. 트랜스파일러를 써서 클래스를 만드는 것은 가능하지만, 그리고 super 키워드도 사용할 수 있지만, 트랜스파일러를 쓸 경우에는 빌트인 객체로부터 서브클래스를 만들 수 없습니다. 빌트인 메소드를 호출해서 기초 클래스의 인스턴스를 얻으려면 엔진의 도움이 필요하기 때문입니다 (Array.prototype.splice 를 생각해 보세요).

휴! 긴 이야기였습니다. 다음 글은 Jason Orendorff 가 복귀해서 ES6 모듈 시스템에 대해 설명할 것입니다.

이 글은 Eric Faust 가 쓴 ES6 In Depth: Subclassing 의 한국어 번역본입니다.

작성자: ingeeKim

"누구에게나 평등하고 자유로운 웹"에 공감하는 직장인.

ingeeKim가 작성한 문서들…


댓글이 없습니다.

댓글 쓰기