ES6 In Depth: 클래스

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

오늘은 지난 글들의 복잡함에서 벗어나 한숨 돌리려고 합니다. 이번 글은 ES6 In Depth: 제너레이터 (이어서) 에서 처럼 한번도 본 적 없는 낯선 이야기를 하지 않을 것입니다. 이번 글은 막강한 프락시 객체 에서 처럼 JavaScript 랭귀지 내부의 동작을 가로채는 심오한 이야기를 하지 않을 것입니다. 이번 글은 재활용 가능한 새로운 데이터 구조체에 대한 이야기도 하지 않을 것입니다. 대신, 해묵은 문법 문제를 정리하는 일에 대한 이야기를 하려고 합니다. 바로 JavaScript 랭귀지로 객체 생성자를 만드는 문법에 대한 이야기입니다.

문제

객체지향 설계 원칙에 관한 가장 기본적인 예제를 만든다고 가정해봅시다. 즉, Circle 클래스를 만든다고 가정해봅시다. 우리는 간단한 캔버스 라이브러리를 사용해서 Circle 클래스를 작성할 것입니다. 우리가 생각해야 하는 요구사항은 다음과 같습니다.

  • 주어진 Circle 을 Canvas 에 그리기.
  • 만들어진 Circle 의 개수를 추적하기.
  • 주어진 Circle 의 반경을 추적하고, 값의 무결성을 보장하기.
  • 주어진 Circle 의 면적을 계산하기.

지금의 JS 문법을 따르면 우리는 먼저 생성자 함수를 만들어야 합니다. 그리고 함수 안에 필요한 속성들을 추가합니다. 그리고 생성자의 prototype 속성에 객체를 할당합니다. 이 prototype 객체에 생성자가 만드는 모든 인스턴스 객체들이 가질 속성들을 포함시킵니다. 아주 간단한 예제지만, 작성해야 하는 초기 코드(boilerplate)의 양이 상당히 많습니다.

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

Circle.draw = function draw(circle, canvas) {
    /* Canvas drawing code */ 
}

Object.defineProperty(Circle, "circlesMade", {
    get: function() {
        return !this._count ? 0 : this._count;
    },

    set: function(val) {
        this._count = val;
    }
});

Circle.prototype = {
    area: function area() {
        return Math.pow(this.radius, 2) * Math.PI;
    }
};

Object.defineProperty(Circle.prototype, "radius", {
    get: function() {
        return this._radius;
    },

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

코드 양이 많을 뿐 아니라, 직관적이지도 않습니다. 코드를 이해하려면 함수의 동작 양식에 대한 상당한 수준의 이해가 필요합니다. 그리고 다양한 속성들이 객체 인스턴스들 안에 존재하는 양식에 대한 이해도 필요합니다. 이런 접근 방식이 복잡하다고 느껴진다면, 걱정 마세요. 이번 글의 목적이 바로 이런 복잡한 코드를 훨씬 간단하게 만드는 방법에 대한 것입니다.

메소드 정의 문법

이런 복잡함을 해결하기 위한 첫번째 시도로, ES6 는 객체에 속성을 추가하는 새로운 문법을 만들었습니다. 예제 코드에서도 Circle.prototype 객체에 area 메소드를 추가하는 것은 쉬웠던 반면, radius 속성의 getter/setter 쌍을 추가하는 것은 복잡했습니다, JS 가 점점 더 객체지향적인 랭귀지가 되어감에 따라, 사람들은 객체에 accessor(접근자)를 추가하는 깔끔한 방법에 관심을 갖기 시작했습니다. 우리에게는 Object.defineProperty 처럼 복잡한 코드가 아니라 obj.prop = method 처럼 간단한 코드로 객체에 “메소드”를 추가하는 새로운 방법이 필요했습니다. 사람들이 원한 것은 다음과 같은 일들을 쉽게 처리할 수 있는 방법이었습니다.

  1. 객체에 평범한 함수 속성을 추가하기.
  2. 객체에 제너레이터 함수 속성을 추가하기.
  3. 객체에 accessor 함수 속성을 추가하기.
  4. 위의 모든 속성을 [] 문법으로 객체에 추가하기. 앞으로 이 문법을 속성이름-연산(computed property names) 방식이라고 부르겠습니다.

이들 중 일부는 ES6 이전에는 불가능했습니다. 예를 들어, obj.prop 할당과 함께 getter 함수나 setter 함수를 정의하는 방법이 없었습니다. 그래서 새로운 문법을 추가해야 했습니다. 이제 우리는 다음과 같이 코드를 작성할 수 있습니다.

var obj = {
    // 이제 function 키워드 없이 메소드를 추가할 수 있습니다.
    // 속성 이름을 메소드 이름으로 사용하면 됩니다.
    method(args) { ... },

    // 제너레이터 메소드를 추가하려면, 메소드 이름 앞에 '*'만
    // 붙이세요.
    *genMethod(args) { ... },

    // |get|과 |set| 덕분에 accessor를 인라인으로 정의할 수 있습니다.
    // 해당 함수를 그냥 인라인으로 정의하세요. 제너레이터로 구현할
    // 필요 없습니다.

    // 이런 방식으로 정의하는 getter메소드에는 인자가 없어야 합니다.
    get propName() { ... },

    // 이런 방식으로 정의하는 setter메소드에는 인자가 하나만 있어야
    // 합니다.
    set propName(arg) { ... },

    // 앞서 언급한 (4)번 항목과 관련해서, 이제 name만 전달할 수
    // 있으면 언제나 [] 문법을 사용할 수 있습니다! name은 심볼로
    // 전달할 수도 있고, 함수 호출로 전달할 수도 있고, 문자열의
    // 조합으로 전달할 수도 있습니다. 속성 id를 만들어내는 모든
    // 표현이 허용됩니다. 이 예제는 메소드만 정의하고 있지만
    // accessor 나 제너레이터도 이렇게 정의할 수 있습니다.
    [functionThatReturnsPropertyName()] (args) { ... }
};

새로운 문법을 이용하면, 처음 예제 코드를 다음처럼 재작성할 수 있습니다.

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

Circle.draw = function draw(circle, canvas) { 
    /* Canvas drawing code */ 
}

Object.defineProperty(Circle, "circlesMade", {
    get: function() {
        return !this._count ? 0 : this._count;
    },

    set: function(val) {
        this._count = val;
    }
});

Circle.prototype = {
    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;
    }
};

의미론적으로, 이 코드는 처음 예제와 조금 다릅니다. 객체 리터럴 안에 정의한 메소드는 설정 가능하며(configurable), 열거 가능합니다(enumerable). 반면 처음 예제의 accessor 들은 설정 가능하지 않고(non-configurable), 열거 가능하지 않습니다(non-enumerable). 현실적으로, 이 차이는 쉽게 발견되지 않습니다. 그래서 뜻을 간결하게 전달하기 위해 사소한 차이를 무시했습니다.

이정도면, 조금 나아졌군요. 그런가요? 불행히도, 새로운 메소드 정의 문법을 도입했지만, Circle 을 함수로 정의하는 코드가 여전히 장황합니다. 여전히 생성자 함수를 정의하면서 생성자 함수 속성을 정의할 방법이 없습니다.

클래스 정의 문법

조금 나아지기는 했지만, 메소드 정의 문법은 JavaScript 에서 깔끔한 객체지향적 설계를 하고 싶은 사람들을 만족시키지 못했습니다. 사람들은 토론했습니다. 다른 랭귀지들은 객체지향적 설계를 다루기 위한 생성자 문법을 제공합니다. class 키워드 말입니다.

좋습니다. 그럼 class 를 추가합시다.

우리는 생성자에 메소드를 추가할 수 있는 문법 체계를 원합니다. 동시에 .prototype 에도 메소드를 추가할 수 있는 문법 체계를 원합니다. 그래서 추가한 메소드들이 class 로 만든 인스턴스들에 존재하기를 원합니다. 일단 새롭고 매력적인 메소드 정의 문법이 있으니 이 문법을 활용합시다. 그러면 이제, 추상화된 클래스에 메소드를 추가하는 것과 개별 인스턴스에 메소드를 추가하는 것을 구분하는 문법만 더 있으면 됩니다. C++ 이나 Java 는 이 목적을 위해 static 키워드를 사용합니다. 이 키워드도 class 키워드만큼 좋아보입니다. static 키워드도 사용합시다.

메소드들 중 하나를 생성자로 지정하는 방법도 있으면 좋겠습니다. C++ 이나 Java 의 경우, 생성자는 리턴 타입이 없고 클래스와 같은 이름을 갖는 함수입니다. 원래 JS 는 리턴 타입을 지정하지 않았고, 또 우리는 .constructor 속성이 필요한 상황이니, 하휘 호환성을 염두에 두고 해당 메소드를 constructor 라고 이름 지읍시다.

종합하면, 우리는 Circle 클래스를 아래처럼 멋진 모습으로 재작성할 수 있습니다.

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;
    };
}

우와! Circle 에 관련된 모든 것을 함께 정의할 수 있을 뿐 아니라, 모든 것이 아주… 깔끔해 보입니다. 분명히 이 코드는 처음 예제 코드보다 낫습니다.

그럼에도 불구하고, 의문이 있거나 문제를 제기하는 분들이 있을 지도 모르겠습니다. 아래 답변이 도움이 되면 좋겠습니다.

  • 세미콜론이 왜 필요하죠? – “가능한 전통적인 class 구문과 비슷하게 만들자” 는 의도로, 좀 더 전통적인 구분자를 쓰기로 결정했습니다. 이 구분자가 마음에 들지 않나요? 세미콜론은 옵셔널 항목입니다. 구분자를 쓰지 않아도 무방합니다.

  • 생성자는 정의하지 않고 메소드만 정의하고 싶으면 어떻게 하죠? – 문제 없습니다. constructor 메소드는 전적으로 옵셔널 항목입니다. 만약 생성자를 작성하지 않으면 constructor() {} 구문이 디폴트 생성자로 사용됩니다.

  • constructor 메소드를 제너레이터로 만들어도 괜찮은가요? – 안됩니다! constructor 를 일반적이지 않은 메소드로 정의하면 TypeError 가 발생합니다. 일반적이지 않은 메소드로는 제너레이터 메소드와 accessor 메소드가 있습니다.

  • constructor 메소드를 속성이름-연산(computed property name) 방식으로 만들 수 있나요? – 불행히도 그럴 수 없습니다. 생성자를 그렇게 정의하면 컴파일러 입장에서 생성자를 인식하기가 정말 힘들어집니다. 그래서 그렇게 하지 않기로 결정했습니다. 만약 constructor 라는 이름의 메소드를 속성이름-연산(computed property name) 방식으로 정의하면, 해당 객체에는 constructor 라는 이름의 메소드가 존재하게 되겠지만, 그 메소드는 클래스의 생성자가 아닙니다.

  • Circle 의 값을 바꾸면 어떻게 되나요? 그러면 new Circle 구문이 제대로 동작하지 못할까요? – 아닙니다! 함수 표현식(function expression)처럼, class 는 주어진 이름과 내부적으로 연결(binding)됩니다. 이 내부적인 연결은 외부적인 힘으로 바꿀 수 없습니다. 그래서 당신이 내부 스코프(enclosing scope) 안에서 Circle 변수를 어떻게 설정하더라도, 생성자에 있는 Circle.circlesMade++ 구문은 기대하는 바 대로 동작할 것입니다.

  • 좋습니다. 하지만 저는 객체 리터럴을 함수의 인자로 직접 전달할 수 있습니다. 새로 도입된 class 구문은 그런 일을 할 수 없을 것 같습니다. – 다행스럽게도, ES6 는 클래스 표현식(class expressions)도 추가했습니다! 클래스 표현식은 이름이 붙은 것일 수도 있고 익명일 수도 있습니다. 그래서 앞서 말씀하신 것과 똑같은 방식으로 동작합니다. 단지 클래스 표현식의 경우 클래스 표현식을 정의하는 스코프 안에 변수를 만들지 않는다는 것만 다릅니다.

  • 새로 도입한 클래스 정의 문법을 사용할 때 객체의 열거 가능성(enumerability)은 어떻게 되나요? – 사람들은 클래스를 정의하면서 객체에 메소드를 추가할 수 있기를 바랬지만, 그렇게 정의한 객체를 열거(enumerate)할 때는 객체의 데이터 속성만 반환되기를 원했습니다. 합리적입니다. 이런 이유로, 추가된 메소드는 설정 가능(configurable) 하지만, 열거 가능(enumerable) 하지는 않습니다.

  • 아, 잠시만요… 음..? 인스턴스 변수는 어떻게 정의하나요? static 상수(constant)는요? – 아픈 곳을 지적하셨군요. ES6 의 클래스 정의 문법에는 그런 것들이 존재하지 않습니다. 하지만, 다행스러운 소식이 있습니다! 랭귀지 스펙에 관여하는 몇몇 사람들과 저는 class 문법에 static 키워드와 함께 const 키워드가 반드시 추가되어야 한다고 생각합니다. 사실, 이미 스펙 회의에서 이 문제가 논의 됐습니다! 앞으로 진행될 논의를 기대해 볼 필요가 있을 것 같습니다.

  • 좋아요, 이 정도로도 훌륭합니다! 이 문법을 지금 쓸 수 있나요? – 완벽하지는 않습니다. 하지만 사용 가능한 폴리필이 존재합니다 (특히 Babel). 당장은 폴리필을 이용해서 class 구문을 사용할 수 있습니다. 불행히도, 주요 브라우저들이 class 구문을 직접 지원하기까지는 시간이 조금 걸릴 것 같습니다. 저는 오늘 논의된 모든 것들을 Firefox Nightly 버전에 구현했습니다. Edge 브라우저와 Chrome 브라우저의 경우 기능은 구현되어 있지만 디폴트 설정에서 비활성화되어 있습니다. 불행히, Safari 브라우저에는 현재 구현되어 있지 않은 것 같습니다.

  • Java 와 C++ 은 서브클래싱(subclassing)을 지원하고 super 키워드도 지원합니다. 하지만 오늘 글에는 이에 대한 언급이 없군요. JS 도 서브클래싱과 super 키워드를 지원하나요? – 지원합니다! 하지만 해당 내용은 별도의 글로 다룰만한 가치가 있습니다. 서브클래싱에 대해 다룰 다음 글을 기다려 주세요. 그 글에서 JavaScript class 의 강력함에 대해 좀 더 알아볼 것입니다.

Jason OrendorffJeff Walden 의 안내와 엄청난 코드 리뷰가 없었다면 class 를 구현할 수 없었을 것입니다.

다음 글에서는, 다시 Jason Orendorff 가 복귀해서 letconst 에 대해 설명할 것입니다.

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

작성자: ingeeKim

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

ingeeKim가 작성한 문서들…


댓글이 없습니다.

댓글 쓰기