https://infoscis.github.io/2018/02/13/ecmascript-6-introducing-javascript-classes/





JavaScript 클래스 소개

대부분의 공식적인 객체 지향 프로그래밍 언어와는 달리 JavaScript는 만들어질 때 부터 유사 객체 및 관련 객체를 정의하는 주요 방법으로 클래스와 클래스 상속을 지원하지 않았습니다. 이로 인해 많은 개발자들이 혼란 스러웠고, ECMAScript 1 이전 버전부터 ECMAScript 5 까지 많은 라이브러리가 클래스를 지원하는 것처럼 보이게하는 유틸리티를 만들었습니다. 일부 JavaScript 개발자는 JavaScript에 클래스는 필요하지 않다고 강력하게 느낄수도 있지만, 클래스 지원을 목적으로 하는 많은 수의 라이브러리는 클래스를 ECMAScript 6에 포함 시키도록 했습니다.

클래스가 사용하는 기본 메커니즘을 이해하는 것이 ECMAScript 6 클래스를 공부하는데 도움이되므로 이 장에서는 ECMAScript 5 개발자가 클래스와 비슷한 동작을 하는 방법에 대해 논의함으로써 시작합니다. 그러나 나중에 보게 되겠지만, ECMAScript 6 클래스는 다른 언어의 클래스와 완전히 동일하지 않습니다. JavaScript의 동적 특성을 반영하는 독창적인 기능이 있습니다.

ECMAScript 5의 클래스와 비슷한 구조

ECMAScript 5 이전 버전의 JavaScript에는 클래스가 없었습니다. 클래스에 가장 가까운 방법은 생성자를 생성한 다음 생성자의 프로토 타입에 메서드를 할당하는 것으로, 일반적으로 사용자 정의 타입 생성이라고 하는 접근 방식입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
function PersonType(name) {
this.name = name;
}
PersonType.prototype.sayName = function() {
console.log(this.name);
};
let person = new PersonType("Nicholas");
person.sayName(); // outputs "Nicholas"
console.log(person instanceof PersonType); // true
console.log(person instanceof Object); // true

이 코드에서 PersonType은 name이라는 단일 프로퍼티를 생성하는 생성자 함수입니다. sayName() 메서드는 프로토 타입에 할당되어 동일한 함수가 PersonType 객체의 모든 인스턴스에 의해 공유됩니다. 그런 다음, Person의 새로운 인스턴스가 new 연산자를 통해 생성됩니다. person 객체는 프로토 타입 상속을 통해 PersonType과 Object 인스턴스로 간주됩니다.

이 기본 패턴은 클래스를 모방하는 많은 JavaScript 라이브러리의 근간을 이루며 ECMAScript 6 클래스가 시작됩니다.

클래스 선언

ECMAScript 6에서 가장 간단한 클래스 형식은 다른 언어의 클래스와 비슷한 클래스 선언입니다.

클래스 선언의 기본

클래스 선언은 class 키워드와 클래스의 이름으로 시작됩니다. 구문의 나머지 부분은 객체 리터럴의 간결한 메서드와 비슷하지만 쉼표가 필요하지 않습니다. 예를 들어, 다음은 간단한 클래스 선언입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class PersonClass {
// PersonType 생성자와 동일합니다.
constructor(name) {
this.name = name;
}
// PersonType.prototype.sayName과 동일합니다.
sayName() {
console.log(this.name);
}
}
let person = new PersonClass("Nicholas");
person.sayName(); // outputs "Nicholas"
console.log(person instanceof PersonClass); // true
console.log(person instanceof Object); // true
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass.prototype.sayName); // "function"

PersonClass 클래스 선언은 이전 예제의 PersonType과 매우 유사하게 동작합니다. 그러나 함수를 생성자로 정의하는 대신 클래스 선언을 사용하면 특수한 constructor 메서드 이름을 사용하여 클래스 내부에 직접 생성자를 정의할 수 있습니다. 클래스 메서드는 간결한 구문을 사용하기 때문에 function 키워드를 사용할 필요가 없습니다. 그리고 다른 메서드 이름에는 특별한 의미가 없으므로 원하는 만큼 메서드를 추가할 수 있습니다.

Own property는 프로토 타입이 아닌 인스턴스에서 보여지는 프로퍼티는 클래스 생성자 또는 메서드 내부에서만 만들 수 있습니다. 이 예제에서 name은 Own Property입니다. 생성자 함수 내에서 가능한 모든 프로퍼티를 만드는 것이 좋습니다. 왜냐하면 클래스의 한 장소에서 모든 프로퍼티가 표현되기 때문입니다.

흥미롭게도, 클래스 선언은 기존 사용자 정의 타입 선언의 Syntactic sugar입니다. PersonClass 선언은 실제로 constructor 메서드의 동작을 갖는 함수를 생성합니다. 이것이 typeof PersonClass가 “function“을 결과로 주는 이유입니다. sayName() 메서드는 앞의 예제에서 sayName()과 PersonType.prototype 사이의 관계와 비슷하게 이 예제에서 PersonClass.prototype에 대한 메서드로 끝납니다. 이러한 유사점을 사용하면 사용자가 사용하는 타입에 대해 너무 많이 걱정하지 않고도 사용자 정의 타입 및 클래스를 혼합할 수 있습니다.

클래스 구문을 사용해야하는 이유

클래스와 사용자 정의 타입의 유사점에도 불구하고 유의해야 할 몇 가지 중요한 차이점이 있습니다.

  1. 클래스 선언은 함수 선언과 달리 Hoisting되지 않습니다. 클래스 선언은 let 선언과 같이 행동하며, 실행이 선언에 도달할 때까지 Temporal dead zone에 존재합니다.
  2. 클래스 선언의 모든 코드는 strict 모드로 자동 실행됩니다. 클래스의 strict 모드를 거부할 수있는 방법이 없습니다.
  3. 모든 메서드는 Non-enumerable 입니다. Object.defineProperty()를 사용하여 메서드를 Non-enumerable하게 만드는 사용자 지정 타입과 다른 중요한 변경 사항입니다.
  4. 모든 메서드는 내부 [[Construct]] 메서드가 없으며 new로 호출하려고 하면 에러가 발생합니다.
  5. new를 사용하지 않고 클래스 생성자를 호출하면 오류가 발생합니다.
  6. 클래스 메서드 내에서 클래스 이름을 덮어 쓰려고하면 오류가 발생합니다.

이 모든 것을 염두에 두고, 위 예제의 PersonClass 선언은 클래스 구문을 사용하지 않는 다음 코드와 동일합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// PersonClass와 동일합니다.
let PersonType2 = (function() {
"use strict";
const PersonType2 = function(name) {
// new를 이용하여 호출이 되었는지 확인
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.name = name;
}
Object.defineProperty(PersonType2.prototype, "sayName", {
value: function() {
// new를 이용하여 호출하지 않도록 함.
if (typeof new.target !== "undefined") {
throw new Error("Method cannot be called with new.");
}
console.log(this.name);
},
enumerable: false,
writable: true,
configurable: true
});
return PersonType2;
}());

먼저 두 개의 PersonType2 선언이 있음을 주목하십시오(외부 scope에 있는 let 선언과 IIFE 내부에 있는 const 선언). 이것은 클래스 메서드가 클래스 이름을 덮어 쓰는 것을 금지하는 반면 클래스 외부의 코드는 이를 허용합니다. 생성자 함수는 new.target을 검사하여
new로 호출되는지 확인합니다. 그렇지 않은 경우 오류가 발생합니다. 다음으로, sayName()메소드는 Non-enumerable으로 정의되고 메t서드는 new.target을 검사하여 new로 호출되지 않았음을 확인합니다. 마지막 단계는 생성자 함수를 반환합니다.

이 예제는 새로운 구문을 사용하지 않고 클래스가 수행할 수있는 모든 작업할 수 있지만 클래스 구문은 모든 기능을 크게 단순화한다는 것을 보여줍니다.

상수 클래스 이름

클래스의 이름은 클래스 내부에서 const를 사용하는 경우에만 지정됩니다. 즉, 클래스 외부의 클래스 이름은 덮어 쓸 수 있지만 클래스 메서드 내부에서는 덮어 쓸 수 없습니다.

1
2
3
4
5
6
7
8
class Foo {
constructor() {
Foo = "bar"; // 실행될때 에러가 발생합니다.
}
}
// 클래스 선언 이후에는 가능합니다.
Foo = "baz";

이 코드에서 클래스 생성자 안에있는 Foo는 클래스 외부의 Foo와는 별도의 바인딩입니다. 내부의 Foo는 마치 const인 것처럼 정의되고 덮어 쓸 수 없습니다. 생성자가 Foo를 임의의 값으로 덮어 쓰려고하면 에러가 발생합니다. 그러나 외부 Foo는 let 선언처럼 정의되기 때문에 언제든지 값을 덮어 쓸 수 있습니다.

클래스 표현식

클래스와 함수는 선언과 표현식이라는 두가지 형식을 가지고 있다는 점에서 비슷합니다. 함수와 클래스 선언은 적절한 키워드 (각각function 또는class)와 식별자로 시작됩니다. 함수는 function 다음에 식별자를 필요로하지 않는 표현식 형태를 가지고 있고, 비슷하게 클래스는 class 다음에 식별자를 필요로하지 않는 표현식 형태를 가지고 있습니다. 이 클래스 표현식은 변수 선언에 사용되거나 함수로 인수로 전달되도록 설계되었습니다.

기본 클래스 표현식

다음은 이전 PersonClass 예제에 해당하는 클래스 표현식과 그 코드를 사용하는 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let PersonClass = class {
// PersonType constructor와 같습니다.
constructor(name) {
this.name = name;
}
// PersonType.prototype.sayName과 같습니다.
sayName() {
console.log(this.name);
}
};
let person = new PersonClass("Nicholas");
person.sayName(); // "Nicholas" 출력
console.log(person instanceof PersonClass); // true
console.log(person instanceof Object); // true
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass.prototype.sayName); // "function"

이 예제에서 알 수 있듯이, 클래스 표현식은 class 다음에 식별자를 요구하지 않습니다. 구문 외에도 클래스 표현식은 클래스 선언과 기능적으로 동일합니다.

클래스 선언 또는 클래스 표현식 사용 여부는 주로 스타일의 문제입니다. 함수 선언 및 함수 표현식과 달리 클래스 선언과 클래스 표현식은 모두 Hoisting되지 않기 때문에 코드의 런타임 동작에 거의 영향을 미치지 않습니다.

이름이 부여된 클래스 표현식

이전 섹션 예제에서 익명 클래스 표현식을 사용했지만 함수 표현식과 마찬가지로 클래스 표현식의 이름을 지정할 수도 있습니다. 이렇게 하려면 다음과 같이 class 키워드 다음에 이름(식별자)를 포함 시킵니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let PersonClass = class PersonClass2 {
// PersonType constructor와 같습니다.
constructor(name) {
this.name = name;
}
// PersonType.prototype.sayName와 같습니다.
sayName() {
console.log(this.name);
}
};
console.log(typeof PersonClass); // "function"
console.log(typeof PersonClass2); // "undefined"

이 예제에서 클래스 표현식의 이름은 PersonClass2입니다. PersonClass2식별자는 클래스 정의 내에서만 존재하기 때문에 (이 예제에서sayName() 메서드 처럼) 클래스 메서드 내부에서 사용될 수 있습니다. 하지만 클래스 밖에서 typeof PersonClass2는 PersonClass2 바인딩이 존재하지 않기 때문에 "undefined"입니다. 이러한 이유를 이해하기 위해 아래 예제처럼 클래스를 사용하지 않는 동일한 함수 선언을 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// PersonClass에 이름이 부여된 클래스 표현식과 동일합니다.
let PersonClass = (function() {
"use strict";
const PersonClass2 = function(name) {
// 함수가 new로 호출되었는지 확인합니다.
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.name = name;
}
Object.defineProperty(PersonClass2.prototype, "sayName", {
value: function() {
// 함수가 new를 사용하지 않고 호출되었는지 확인합니다
if (typeof new.target !== "undefined") {
throw new Error("Method cannot be called with new.");
}
console.log(this.name);
},
enumerable: false,
writable: true,
configurable: true
});
return PersonClass2;
}());

이름이 부여된 클래스 표현식을 작성하면 JavaScript 엔진에서 일어나는 일이 약간 바뀝니다. 클래스 선언의 경우, 외부 바인딩 (let으로 정의)은 내부 바인딩 (const로 정의)과 동일한 이름을 가집니다. 이름이 부여된 클래스 표현식은 const 정의에서 그 이름을 사용하므로PersonClass2는 클래스 내부에서만 사용하도록 정의됩니다.

이름이 부여된 클래스 표현식은 이름이 부여된 함수 표현식과 다르게 동작하지만, 두 표현식 사이에는 여전히 많은 유사점이 있습니다. 둘 다 값(Value)으로 사용할 수 있으며, 이는 많은 가능성을 열어줍니다. 이것은 아래에서 다루도록 하겠습니다.

일급 시민(First-Class Citizen)으로서의 클래스

일급 시민: 컴퓨터 프로그래밍 언어 디자인에서, 특정 언어의 일급 객체 (first-class citizens, 일급 값, 일급 엔티티, 혹은 일급 시민)이라 함은 일반적으로 다른 객체들에 적용 가능한 연산을 모두 지원하는 객체를 가리킨다. 함수에 파라미터로 넘기기, 변수에 대입하기와 같은 연산들이 여기서 말하는 일반적인 연산의 예에 해당한다.

프로그래밍 언어에서 어떤것은 값(Value)으로 사용될 수 있고, 이 경우에 First-Class Citizen이라고 말합니다. 즉, 함수로 전달되고 함수에서 반환되며 변수에 할당될 수 있습니다. JavaScript 함수는 First-Class Citizen (때로는 First-Class Function이라 부름)이며, 이는 JavaScript를 고유하게 만드는 요소의 일부입니다.

ECMAScript 6은 클래스를 First-Class Citizen으로 만들어 이러한 전통을 이어가고 있습니다. 이를 통해 클래스를 다양한 방식으로 사용할 수 있습니다. 예를 들어, 파라미터로 함수에 전달할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
function createObject(classDef) {
return new classDef();
}
let obj = createObject(class {
sayHi() {
console.log("Hi!");
}
});
obj.sayHi(); // "Hi!"

이 예제에서, createObject() 함수는 익명의 클래스 표현식을 인수로하여 호출되고, new로 그 클래스의 인스턴스를 생성하여 인스턴스를 리턴합니다. 변수 obj는 반환된 인스턴스를 저장합니다.

클래스 표현식의 또 다른 흥미로운 사용법은 클래스 생성자를 즉시 호출하여 싱글톤을 생성하는 것입니다. 이렇게 하려면 클래스 표현식에
new를 사용해야 하고 마지막에 괄호를 포함해야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
let person = new class {
constructor(name) {
this.name = name;
}
sayName() {
console.log(this.name);
}
}("Nicholas");
person.sayName(); // "Nicholas"

여기서 익명의 클래스 표현식이 생성되고 즉시 실행됩니다. 이 패턴을 사용하면 클래스 참조를 검사할 수 있게하지 않고도 싱글톤을 생성하기
위한 클래스 구문을 사용할 수 있습니다(PersonClass는 바깥 쪽이 아닌 클래스 내에서만 바인딩을 생성한다는 것을 기억하십시오.). 클래스 표현식의 끝 부분에있는 괄호는 함수를 호출하는 동시에 인자를 전달할 수 있는 것을 나타냅니다.

지금까지 이 장의 예제는 메서드가 있는 클래스에 중점을 두었습니다. 그러나 객체 리터럴과 유사한 구문을 사용하여 클래스에 접근자(Accessor) 프로퍼티를 만들 수도 있습니다.

접근자(Accessor) 프로퍼티

클래스 생성자 내에서 자체 프로퍼티를 만들어야 하지만 클래스를 사용하면 프로토 타입에 접근자(Accessor) 프로퍼티를 정의할 수 있습니다. Getter를 만들려면 키워드 get 다음에 공백 문자와 식별자를 사용하합니다. Setter를 만들려면 set 키워드를 사용합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CustomHTMLElement {
constructor(element) {
this.element = element;
}
get html() {
return this.element.innerHTML;
}
set html(value) {
this.element.innerHTML = value;
}
}
var descriptor = Object.getOwnPropertyDescriptor(CustomHTMLElement.prototype, "html");
console.log("get" in descriptor); // true
console.log("set" in descriptor); // true
console.log(descriptor.enumerable); // false

이 코드에서, CustomHTMLElement 클래스는 기존 DOM Element를 감싸는 래퍼로 만들어집니다. 그것은 Element 자체에 대한 innerHTML 메서드에 위임한 html을 위한 Getter와 Setter를 둘 다 가지고 있습니다. 이 접근자 프로퍼티는 CustomHTMLElement.prototype에서 생성되며, 다른 메서드와 마찬가지로 Non-enumerable로 생성됩니다. 클래스를 사용하지 않는 동일한 코드는 아래와 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 위 예제와 동일합니다.
let CustomHTMLElement = (function() {
"use strict";
const CustomHTMLElement = function(element) {
// new를 이용한 호출인지 확인 합니다.
if (typeof new.target === "undefined") {
throw new Error("Constructor must be called with new.");
}
this.element = element;
}
Object.defineProperty(CustomHTMLElement.prototype, "html", {
enumerable: false,
configurable: true,
get: function() {
return this.element.innerHTML;
},
set: function(value) {
this.element.innerHTML = value;
}
});
return CustomHTMLElement;
}());

이전 예제와 마찬가지로 이 코드는 클래스를 이용하는 것이 동일한 기능을 하는 클래스를 사용하지 않는 코드에 비해 얼마나 코드를 줄일수 있는지 보여줍니다. html 접근자 프로퍼티 정의만이 거의 비슷한 크기입니다.

계산된 멤버 이름

객체 리터럴과 클래스 간의 유사점은 아직 끝나지 않았습니다. 클래스 메서드와 접근자 프로퍼티는 계산된 이름을 가질 수도 있습니다. 식별자를 사용하는 대신 표현식 주위에 대괄호를 사용합니다. 이 표현식은 객체 리터럴의 계산된 이름에 사용되는 구문과 동일합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let methodName = "sayName";
class PersonClass {
constructor(name) {
this.name = name;
}
[methodName]() {
console.log(this.name);
}
}
let me = new PersonClass("Nicholas");
me.sayName(); // "Nicholas"

이 버전의 PersonClass는 변수를 사용하여 정의 안에 있는 메서드에 이름을 할당합니다. 문자열 "sayName"은 methodName 변수에 할당되고 메서드를 선언하기 위해 methodName이 사용됩니다. sayName() 메서드는 나중에 직접 액세스됩니다.

접근자 프로퍼티는 다음과 같은 방식으로 계산된 이름을 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let propertyName = "html";
class CustomHTMLElement {
constructor(element) {
this.element = element;
}
get [propertyName]() {
return this.element.innerHTML;
}
set [propertyName](value) {
this.element.innerHTML = value;
}
}

여기서 html에 대한 Getter와 Setter는 propertyName 변수를 사용하여 설정됩니다. .html을 사용하여 프로퍼티에 접근하는 것은 정의에만 영향을 줍니다.

클래스와 객체 리터럴 간에는 메서드, 접근자 프로퍼티 및 계산된 이름등 많은 유사점이 있다는 것을 알았습니다. 그리고 Generator라는 유사점이 하나더 있습니다.

Generator 메서드

8 장에서 Generator를 소개할 때 메서드 이름에 별표 (*)를 추가하여 객체 리터럴에 Generator를 정의하는 방법을 배웠습니다. 클래스에 대해서도 동일한 구문이 적용되어 모든 메서드를 Generator로 사용할 수 있습니다. 다음은 그 예입니다.

1
2
3
4
5
6
7
8
9
10
11
12
class MyClass {
*createIterator() {
yield 1;
yield 2;
yield 3;
}
}
let instance = new MyClass();
let iterator = instance.createIterator();

이 코드는 createIterator() Generator 메서드를 가지는 MyClass라는 클래스를 생성합니다. 이 메서드는 값이 Generator에 하드코딩된 Iterator를 반환합니다. Generator 메서드는 값의 컬렉션을 나타내는 객체가 있고 해당 값을 쉽게 반복할 때 유용합니다. ArraySetMap은 모두 개발자들이 아이템과 상호 작용할 필요가 있는 다양한 방법을 설명하기 위해 여러 Generator 메서드를 가지고 있습니다.

클래스에 대한 기본 Iterator를 정의하면 클래스가 값 컬렉션을 나타내는 경우 훨씬 유용합니다. Symbol.iterator를 사용하여 다음과 같이 Generator 메서드를 정의하여 클래스의 기본 Iterator를 정의할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Collection {
constructor() {
this.items = [];
}
*[Symbol.iterator]() {
yield *this.items.values();
}
}
var collection = new Collection();
collection.items.push(1);
collection.items.push(2);
collection.items.push(3);
for (let x of collection) {
console.log(x);
}
// 결과:
// 1
// 2
// 3

이 예제는 this.items 배열의 values() Iterator에 위임한 Generator 메서드에 대해 계산된 이름을 사용합니다. 컬렉션의 값을 관리하는 모든 클래스에는 기본 Iterator가 포함되어야 합니다. 컬렉션 관련 일부 작업에는 Iterator가 필요하기 때문입니다. 이제, Collection의 어떤 인스턴스도 for-of 루프나 Spread 연산자에 직접 사용될 수 있습니다.

클래스 프로토 타입에 메서드 및 접근자 프로퍼티를 추가하면 객체 인스턴스에 해당 메서드를 표시할 때 유용합니다. 반면에 클래스 자체의 메서드 또는 접근자 프로퍼티를 원하면 정적 멤버를 사용해야합니다.

정적 멤버(Static Member)

정적 멤버를 시뮬레이트하기 위해 생성자에 직접 메서드를 추가하는 것은 ECMAScript 5 및 이전 버전의 또 다른 공통 패턴입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function PersonType(name) {
this.name = name;
}
// static method
PersonType.create = function(name) {
return new PersonType(name);
};
// instance method
PersonType.prototype.sayName = function() {
console.log(this.name);
};
var person = PersonType.create("Nicholas");

다른 프로그래밍 언어에서, PersonType.create()라고 불리는 팩토리 메소드는 정적 메소드로 간주될 것입니다. 왜냐하면 PersonType
인스턴스에 의존하지 않기 때문입니다. ECMAScript 6 클래스는 메서드 또는 접근자 프로퍼티 이름 앞에 정식 static Annotation을 사용하여 정적 멤버 생성을 단순화합니다. 예를 들어, 다음은 이전 예제와 동일한 클래스입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class PersonClass {
// PersonType constructor와 동일합니다.
constructor(name) {
this.name = name;
}
// PersonType.prototype.sayName와 동일합니다.
sayName() {
console.log(this.name);
}
// PersonType.create와 동일합니다.
static create(name) {
return new PersonClass(name);
}
}
let person = PersonClass.create("Nicholas");

PersonClass 정의는 create()라고 하는 하나의 정적 메서드를 가지고 있습니다. 메서드 구문은 static 키워드를 제외하고 sayName()과 동일합니다. static 키워드는 클래스 내의 임의의 메서드 또는 접근자 프로퍼티 정의에 사용할 수 있습니다. 유일한 제약은static을 constructor 메서드 정의와 함께 사용할 수 없다는 것입니다.

정적 멤버는 인스턴스에서 액세스할 수 없습니다. 항상 클래스의 정적 멤버에 직접 액세스해야합니다.

파생 클래스를 사용한 상속

ECMAScript 6 이전에는 사용자 정의 타입으로 상속을 구현할때 대규모 과정이 필요했습니다. 적절한 상속에는 여러 단계가 필요했습니다. 예를 들어 다음 예제를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
function Square(length) {
Rectangle.call(this, length, length);
}
Square.prototype = Object.create(Rectangle.prototype, {
constructor: {
value:Square,
enumerable: true,
writable: true,
configurable: true
}
});
var square = new Square(3);
console.log(square.getArea()); // 9
console.log(square instanceof Square); // true
console.log(square instanceof Rectangle); // true

Square는 Rectangle을 상속받습니다. 그래서 Square.prototype을 Rectangle.prototype에서 생성한 새로운 객체로 덮어 쓰고Rectangle.call() 메서드를 호출해야합니다. 이 단계들은 종종 JavaScript 신참을 혼란스럽게 만들거나 경험 많은 개발자에게 오류의 원인이 되었습니다.

클래스는 익숙한 extends 키워드를 사용하여 클래스가 상속해야하는 함수를 지정함으로써 상속을 보다 쉽게 구현할 수 있도록합니다. 프로토 타입은 자동으로 조정되며 super() 메서드를 호출하여 기본 클래스 생성자에 액세스할 수 있습니다. 다음은 앞의 예제와 동일한 ECMAScript 6 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
}
class Square extends Rectangle {
constructor(length) {
// Rectangle.call(this, length, length)와 같습니다.
super(length, length);
}
}
var square = new Square(3);
console.log(square.getArea()); // 9
console.log(square instanceof Square); // true
console.log(square instanceof Rectangle); // true

이번에 Square 클래스는 extends 키워드를 사용하여 Rectangle에서 상속받습니다. Square 생성자는 super()를 사용하여 지정된 파라미터로 Rectangle 생성자를 호출합니다. ECMAScript 5 버전의 코드와 달리 식별자 Rectangle은 클래스 선언 (extends이후)에서만 사용됩니다.

다른 클래스로부터 상속받은 클래스를 파생 클래스(derived classes)라고합니다. 파생 클래스는 생성자를 지정하는 경우 super()를 사용해야합니다. 그렇지 않으면 오류가 발생합니다. 생성자를 사용하지 않기로 결정한 경우 클래스의 새 인스턴스를 만들 때 super()가 모든 파라미터와 함께 자동으로 호출됩니다. 예를 들어, 다음 두 클래스는 동일합니다.

1
2
3
4
5
6
7
8
9
10
11
class Square extends Rectangle {
// 생성자가 없습니다.
}
// 위 클래스는 아래와 동일합니다.
class Square extends Rectangle {
constructor(...args) {
super(...args);
}
}

이 예제의 두 번째 클래스는 모든 파생 클래스에 대한 기본 생성자와 동일합니다. 모든 파라미터는 순서대로 기본 클래스 생성자에 전달됩니다. 이전에 정의한 예제에서 Square 클래스의 생성자는 하나의 파라미터만 필요하기 때문에 super(...args)는 올바르지 않으므로 수동으로 생성자를 정의하는 것이 좋습니다.

super()를 사용할 때 유의해야할 몇 가지 사항이 있습니다.

  1. 파생 클래스에서만 super()를 사용할 수 있습니다. 비 파생 클래스 (extends를 사용하지 않는 클래스) 또는 함수에서 사용하려고
    하면 오류가 발생합니다.

  2. 생성자에서 this에 접근하기 전에 super()를 호출해야합니다. super()는 this를 초기화할 책임이 있기 때문에 super()를 호출하기 전에 this에 접근하려고 하면 에러가 발생합니다.

  3. super()를 호출하지 않는 유일한 방법은 클래스 생성자에서 객체를 반환하는 것입니다.

클래스 메서드 숨기기

파생 클래스의 메서드는 항상 기본 클래스에서 같은 이름의 메서드를 숨깁니다. 예를 들어, Square에 getArea()를 추가하면 그 기능을 재정의할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
// Rectangle.prototype.getArea()를 숨기고 재정의 합니다.
getArea() {
return this.length * this.length;
}
}

getArea()가 Square의 일부로 정의 되었기 때문에 Rectangle.prototype.getArea() 메서드는 Square 인스턴스에 의해 더이상 호출되지 않습니다. 물론, 다음과 같이 super.getArea() 메서드를 사용하여 메서드의 기본 클래스 버전을 호출하기로 결정할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
// Rectangle.prototype.getArea()를 숨기고 재정의 하여 호출 합니다.
getArea() {
return super.getArea();
}
}

이런 방식으로 super를 사용하는 것은 4 장에서 논의된 super 참조와 똑같이 작동합니다 (“Super 참조를 사용한 쉬운 프로토 타입 액세스” 참조). this값은 자동으로 올바르게 설정되어 간단하게 메소드 호출할 수 있습니다.

정적 멤버 상속

기본 클래스에 정적 멤버가 있는 경우 해당 정적 멤버는 파생 클래스에서도 사용할 수 있습니다. 상속은 다른 언어의 경우와 마찬가지로 작동하지만 이는 JavaScript에서는 새로운 개념입니다. 다음은 그 예입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Rectangle {
constructor(length, width) {
this.length = length;
this.width = width;
}
getArea() {
return this.length * this.width;
}
static create(length, width) {
return new Rectangle(length, width);
}
}
class Square extends Rectangle {
constructor(length) {
// same as Rectangle.call(this, length, length)
super(length, length);
}
}
var rect = Square.create(3, 4);
console.log(rect instanceof Rectangle); // true
console.log(rect.getArea()); // 12
console.log(rect instanceof Square); // false

이 코드에서는 새로운 정적 create() 메서드가 Rectangle 클래스에 추가되었습니다. 이 메서드는 상속을 통해 Square.create()로 사용할 수 있으며 Rectangle.create() 메서드와 같은 방식으로 동작합니다.

표현식에서 파생된 클래스

아마도 ECMAScript 6에서 파생 클래스의 가장 강력한 부분은 표현식에서 클래스를 파생시킬 수 있는 능력입니다. 표현식이 [[Construct]]와 프로토 타입을 가진 함수로 해석되는한 어떤 표현식이라도 extends을 사용할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
class Square extends Rectangle {
constructor(length) {
super(length, length);
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x instanceof Rectangle); // true

Rectangle은 ECMAScript 5 스타일 생성자로 정의되고 Square는 클래스입니다. Rectangle은 [[Construct]]와 프로토 타입을 가지고 있기 때문에 Square 클래스는 직접 상속받을 수 있습니다.

extends 이후에 어떤 타입의 표현식이라도 받아들이면 상속받을 것을 동적으로 결정하는 것과 같은 강력한 가능성을 제공합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Rectangle(length, width) {
this.length = length;
this.width = width;
}
Rectangle.prototype.getArea = function() {
return this.length * this.width;
};
function getBase() {
return Rectangle;
}
class Square extends getBase() {
constructor(length) {
super(length, length);
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x instanceof Rectangle); // true

getBase() 함수는 클래스 선언의 일부로서 직접 호출되어 Rectangle을 반환합니다. 이 예제는 기능적으로 이전의 것과 같습니다. 그리고 기본 클래스를 동적으로 결정할 수 있으므로 서로 다른 상속 접근법을 만들 수 있습니다. 예를 들어 mixin을 효과적으로 만들 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
let SerializableMixin = {
serialize() {
return JSON.stringify(this);
}
};
let AreaMixin = {
getArea() {
return this.length * this.width;
}
};
function mixin(...mixins) {
var base = function() {};
Object.assign(base.prototype, ...mixins);
return base;
}
class Square extends mixin(AreaMixin, SerializableMixin) {
constructor(length) {
super();
this.length = length;
this.width = length;
}
}
var x = new Square(3);
console.log(x.getArea()); // 9
console.log(x.serialize()); // "{"length":3,"width":3}"

이 예에서는 클래식 상속 대신 mixin이 사용됩니다. mixin() 함수는 mixin 객체를 나타내는 파라미터를 취합니다. base라는 함수를 생성하고 각 mixin 객체의 프로퍼티를 프로토 타입에 할당합니다. mixin() 함수는 Square가 extends를 사용할 수 있도록 함수를 반환합니다. extends가 여전히 사용되기 때문에 생성자에서 super()를 호출해야 한다는 것을 명심하십시오.

Square의 인스턴스는 AreaMixin의 getArea()와 SerializableMixin의 serialize()를 가지고 있습니다. 이는 프로토 타입 상속을 통해 수행됩니다. mixin() 함수는 새로운 함수의 프로토 타입을 각 mixin의 모든 프로퍼티로 동적으로 채웁니다. (여러 mixin이 동일한 속성을 갖는 경우 마지막 프로퍼티만 추가됩니다.)

모든 표현식은extends 다음에 사용될 수 있지만, 모든 표현식이 유효한 클래스가되는 것은 아닙니다. 특히 다음 표현식 유형은 오류를 유발합니다.

  • null
  • Generator 함수 (Chapter 8에서 설명함)

이러한 경우, 호출할 [[Construct]]가 없으므로 클래스의 새 인스턴스를 만들려고하면 오류가 발생합니다.

내장(Built-in) 상속

JavaScript Array가 추가될때부터 개발자는 상속을 통해 자신만의 특별한 Array 타입을 만들고 싶어했습니다. ECMAScript 5 및 이전 버전에서는 이것이 가능하지 않았습니다. 고전적인 상속을 사용하려고 시도해도 코드가 작동하지 않았습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// built-in array 작동방법
var colors = [];
colors[0] = "red";
console.log(colors.length); // 1
colors.length = 0;
console.log(colors[0]); // undefined
// ES5에서 array를 상속하도록 시도함
function MyArray() {
Array.apply(this, arguments);
}
MyArray.prototype = Object.create(Array.prototype, {
constructor: {
value: MyArray,
writable: true,
configurable: true,
enumerable: true
}
});
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 0
colors.length = 0;
console.log(colors[0]); // "red"

이 코드의 끝 부분에 있는 console.log() 출력은 Array에 JavaScript의 고전적인 상속 형태를 사용하여, 어떤 예기치 않은 동작을 일으키는 방법을 보여줍니다. MyArray의 인스턴스에서 length와 숫자 프로퍼티는 Array.apply() 또는 prototype을 할당해도 Built-in Array에서 처럼 동작하지 않습니다.

ECMAScript 6 클래스의 목표 중 하나는 모든 Built-in 함수에서 상속을 허용하는 것입니다. 이를 달성하기 위해 클래스의 상속 모델은 ECMAScript 5 및 이전 버전에서 보여준 고전적인 상속 모델과 약간 다릅니다.

ECMAScript 5 전통적인 상속에서 this의 값은 파생된 타입 (예를 들면, MyArray)에 의해 먼저 생성되고, Array.apply() 메서드와 같은 기본 타입 생성자가 호출됩니다. 즉, 이것은 MyArray 인스턴스로 시작하여 Array의 추가 프로퍼티로 꾸며져 있음을 의미합니다.

ECMAScript 6 클래스 기반 상속에서, this의 값은 먼저 기본타입(Array)에 의해 생성된 다음 파생 클래스 생성자 (MyArray)에 의해 수정됩니다. 결과적으로 this는 기본타입의 모든 Built-in 기능으로 시작하여 그에 관련된 모든 기능을 올바르게 상속합니다.

다음 예제는 클래스 기반 특수 Array의 실행을 보여줍니다.

1
2
3
4
5
6
7
8
9
10
class MyArray extends Array {
// empty
}
var colors = new MyArray();
colors[0] = "red";
console.log(colors.length); // 1
colors.length = 0;
console.log(colors[0]); // undefined

MyArray는 Array에서 직접 상속되므로 Array와 같이 작동합니다. 숫자 프로퍼티와 상호 작용하면 length 프로퍼티를 업데이트하고, length 프로퍼티를 조작하면 숫자 프로퍼티를 업데이트합니다. 즉, Array를 상속 받아서 자신만의 파생 Array 클래스를 만들고 다른 Built-in 함수를 상속받을 수 있다는 것을 의미합니다.

Symbol.species 프로퍼티

Built-in 함수의 상속이 흥미로운 점은 Built-in 함수의 인스턴스를 반환하는 모든 메서드가 파생 클래스 인스턴스를 자동으로 반환한다는 것입니다. 그래서 Array를 상속받은 MyArray라는 파생 클래스가 있다면 slice()와 같은 메서드는 MyArray의 인스턴스를 리턴합니다.

1
2
3
4
5
6
7
8
9
class MyArray extends Array {
// empty
}
let items = new MyArray(1, 2, 3, 4),
subitems = items.slice(1, 3);
console.log(items instanceof MyArray); // true
console.log(subitems instanceof MyArray); // true

이 코드에서 slice() 메서드는 MyArray 인스턴스를 반환합니다. slice()메서드는 Array로부터 상속 받고 Array의 인스턴스를 정상적으로 리턴합니다. 하지만 뒤에서 이 변화를 일으키는 것은 Symbol.species 프로퍼티입니다.

Symbol.species Symbol은 함수를 반환하는 정적(Static) 접근자(Accessor) 프로퍼티를 정의하는데 사용됩니다. 이 함수는 클래스의 인스턴스가 인스턴스 메서드 내부에서 만들어져야 할 때마다 생성자 대신 사용하는 생성자입니다. 아래의 Built-in 타입은 Symbol.species가 정의되어 있습니다.

  • Array
  • ArrayBuffer (Chapter 10에서 논의 합니다.)
  • Map
  • Promise
  • RegExp
  • Set
  • Typed Array (Chapter 10에서 논의 합니다.)

이 각각의 타입은 this를 반환하는 디폴트 Symbol.species 프로퍼티를 가지고 있습니다. 이것은 프로퍼티가 항상 생성자 함수를 반환한다는 것을 의미합니다. 커스텀 클래스에서 이 기능을 구현한다면 코드는 다음과 같이 보일 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 몇몇 builtin 타입은 이와 유사한 방법을 사용합니다.
class MyClass {
static get [Symbol.species]() {
return this;
}
constructor(value) {
this.value = value;
}
clone() {
return new this.constructor[Symbol.species](this.value);
}
}

이 예제에서 잘 알려진 Symbol.species Symbol은 MyClass에 정적 접근자 프로퍼티를 할당하는데 사용됩니다. 클래스의 타입 변경은 불가능하므로 setter가 없는 getter만 있습니다. this.constructor [Symbol.species]를 호출하면 MyClass가 리턴됩니다. clone() 메서드는 직접 MyClass를 사용하지 않고 클래스 정의(Class Definition)를 사용하여 새로운 인스턴스를 반환합니다. 그리고 파생 클래스가 그 값을 오버라이드(override) 할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class MyClass {
static get [Symbol.species]() {
return this;
}
constructor(value) {
this.value = value;
}
clone() {
return new this.constructor[Symbol.species](this.value);
}
}
class MyDerivedClass1 extends MyClass {
// empty
}
class MyDerivedClass2 extends MyClass {
static get [Symbol.species]() {
return MyClass;
}
}
let instance1 = new MyDerivedClass1("foo"),
clone1 = instance1.clone(),
instance2 = new MyDerivedClass2("bar"),
clone2 = instance2.clone();
console.log(clone1 instanceof MyClass); // true
console.log(clone1 instanceof MyDerivedClass1); // true
console.log(clone2 instanceof MyClass); // true
console.log(clone2 instanceof MyDerivedClass2); // false

여기에서 MyDerivedClass1은 MyClass를 상속 받고 Symbol.species 프로퍼티는 변경하지 않습니다. clone()이 호출되면, this.constructor [Symbol.species]가 MyDerivedClass1을 리턴하기 때문에 MyDerivedClass1의 인스턴스를 리턴합니다. MyDerivedClass2 클래스는 MyClass를 상속 받아 Symbol.species를 오버라이드하여 MyClass를 반환합니다. MyDerivedClass2의 인스턴스에서 clone()이 호출될 때, 반환 값은 MyClass의 인스턴스입니다. Symbol.species를 사용하여 파생 클래스는 메서드가 인스턴스를 리턴할 때 리턴되어야 하는 값의 타입을 판별할 수 있습니다.

예를 들어 Array는 Symbol.species를 사용하여 배열을 반환하는 메서드에 사용할 클래스를 지정합니다. Array에서 파생된 클래스에서 다음과 같이 상속된 메서드에서 반환된 객체의 타입을 결정할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
class MyArray extends Array {
static get [Symbol.species]() {
return Array;
}
}
let items = new MyArray(1, 2, 3, 4),
subitems = items.slice(1, 3);
console.log(items instanceof MyArray); // true
console.log(subitems instanceof Array); // true
console.log(subitems instanceof MyArray); // false

이 코드는 Array를 상속받은 MyArray의 Symbol.species를 오버라이드합니다. Array를 반환하는 모든 상속된 메서드는 이제MyArray 대신 Array 인스턴스를 사용합니다.

일반적으로 클래스 메서드에서 this.constructor를 사용하려고 할 때마다 Symbol.species 프로퍼티를 사용해야합니다. 이렇게하면 파생 클래스가 반환 형식을 쉽게 재정의할 수 있습니다. 또한 Symbol.species가 정의된 클래스에서 파생 클래스를 만드는 경우에도 생성자 대신 사용해야 합니다.

클래스 생성자에서 new.target 사용

3 장에서는 new.target과 함수가 호출되는 방식에 따라 값이 변경되는 방법에 대해 알게되었습니다. 클래스 생성자에서 new.target을 사용하여 클래스가 호출되는 방법을 결정할 수도 있습니다. new.target은 다음 예제와 같이 클래스 생성자 함수와 같습니다.

1
2
3
4
5
6
7
8
9
10
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
// new.target은 Rectangle입니다.
var obj = new Rectangle(3, 4); // true 출력

이 코드의 new.target은 new Rectangle(3, 4)이 호출될 때 Rectangle과 동일하다는 것을 보여줍니다. 클래스 생성자는 new가 없으면 호출될 수 없으므로 new.target 프로퍼티는 항상 클래스 생성자 내부에서 정의됩니다. 그러나 그 값이 항상 같지는 않을 수도 있습니다. 다음 코드를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Rectangle {
constructor(length, width) {
console.log(new.target === Rectangle);
this.length = length;
this.width = width;
}
}
class Square extends Rectangle {
constructor(length) {
super(length, length)
}
}
// new.target is Square
var obj = new Square(3); // 결과는 false

Square는 Rectangle 생성자를 호출하고 Rectangle 생성자가 호출될 때 new.target은 Square와 같습니다. 이는 각 생성자에게 호출되는 방식에 따라 동작을 변경할 수있는 기능을 제공하므로 중요합니다. 예를 들어, 다음과 같이 new.target을 사용하여 추상적인 기본 클래스 (직접적으로 인스턴스화할 수없는 클래스)를 생성할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// abstract base class
class Shape {
constructor() {
if (new.target === Shape) {
throw new Error("This class cannot be instantiated directly.")
}
}
}
class Rectangle extends Shape {
constructor(length, width) {
super();
this.length = length;
this.width = width;
}
}
var x = new Shape(); // throws error
var y = new Rectangle(3, 4); // no error
console.log(y instanceof Shape); // true

이 예제에서 Shape 클래스 생성자는 new.target이 Shape 일 때마다 에러를 던집니다. 즉, new Shape ()는 항상 에러를 던집니다. 그러나 여전히 Rectangle에서 사용하는 것처럼 Shape을 기본 클래스로 사용할 수 있습니다. super() 호출은 Shape 생성자를 실행하고 new.target은 Rectangle과 같습니다. 그래서 생성자는 오류없이 계속됩니다.

new가 없으면 클래스를 호출할 수 없으므로 new.target 프로퍼티는 결코 클래스 생성자 내부에서 정의되지 않습니다.

요약

ECMAScript 6 클래스는 JavaScript에서 상속을 사용하기 쉽게하기 때문에 다른 언어에서 상속에 대한 기존의 이해를 버릴 필요가 없습니다. ECMAScript 6 클래스는 ECMAScript 5의 클래식 상속 모델에 대한 Syntactic sugar로 시작하지만 실수를 줄이기위한 많은 기능을 추가합니다.

ECMAScript 6 클래스는 클래스 프로토 타입에 비 정적 메서드를 정의하여 프로토 타입 상속과 함께 작동하고, 정적 메서드는 생성자 자체에서 끝납니다. 클래스의 모든 메소드는 Non-enumerable이며, 기본적으로 Non-enumerable인 Built-in 객체의 동작과 더 잘 일치하는 기능입니다. 또한 클래스 생성자는 new가 없으면 호출할 수 없으므로 실수로 클래스를 함수로 호출할 수 없습니다.

클래스 기반 상속을 사용하면 다른 클래스, 함수 또는 표현식에서 클래스를 파생시킬 수 있습니다. 이 기능을 사용하여 상속할 올바른 기준을 결정하는 함수를 호출할 수 있으므로 mixin 및 다른 여러 구성 패턴을 사용하여 새 클래스를 만들 수 있습니다. 상속은 Array와 같은 Built-in 객체를 상속하는 것이 가능합니다.

클래스 생성자에서 new.target을 사용하여 클래스가 어떻게 호출되는지에 따라 다르게 행동할 수 있습니다. 가장 보편적인 사용은 직접적으로 인스턴스화 될 때 오류를 던지지만 다른 클래스를 통해 상속을 허용하는 추상 기본 클래스를 만드는 것입니다.

전반적으로 클래스는 JavaScript에 중요한 추가 요소입니다. 보다 간결한 구문과 사용자 정의 객체 타입을 안전하고 일관된 방식으로 정의하기 위한 더 나은 기능을 제공합니다.



+ Recent posts