클래스는 객체 지향 프로그래밍에서 특정 객체를 생성하기 위해 변수와 메소드를 정의하는 일종의 틀로, 객체를 정의하기 위한 상태(멤버 변수)와 메서드(함수)로 구성된다.
모던 자바스크립트에 도입된 클래스(class)라는 문법을 사용하면 객체 지향 프로그래밍에서 사용되는 다양한 기능을 자바스크립트에서도 사용할 수 있다.
근데 내 생각에는 모든 객체에 대해서 functional 하게 기능적으로만 사용하는데 class가 필요한가? 라는 생각을 한다. 물론 클래스를 사용한다고 해서 성능적으로 더 좋거나 그런 건 없다고는 한다.
프로그래밍 언어에서 유명한 OOP의 캡슐화를 유지하기 위해 클래스라는 객체를 사용하는 경우가 드물게도 있다고 합니다. 해당 글에서 참고했으니 궁금하신 분은 추가로 읽어보길 바란다.
하지만 우리가 놓치지 말아야하는 것은 실제로 class는 자바스크립트의 하나의 함수이다. 이제 이 클래스가 어떤 기능을 갖고 있는지 단순한 변수랑 메서드 제공한다로 알고 있지말고 추가적으로 알아야한다.
class User {
constructor(name) { this.name = name; }
sayHi() { alert(this.name); }
}
// User가 함수라는 증거
alert(typeof User); // function
// 정확히는 생성자 메서드와 동일합니다.
alert(User === User.prototype.constructor); // true
// 클래스 내부에서 정의한 메서드는 User.prototype에 저장됩니다.
alert(User.prototype.sayHi); // alert(this.name);
// 현재 프로토타입에는 메서드가 두 개입니다.
alert(Object.getOwnPropertyNames(User.prototype)); // constructor, sayHi
class User {...} 문법 구조가 진짜 하는 일은 다음과 같다.
- User라는 이름을 가진 함수를 만듭니다. 함수 본문은 생성자 메서드 constructor에서 가져옵니다. 생성자 메서드가 없으면 본문이 비워진 채로 함수가 만들어진다.
- sayHi같은 클래스 내에서 정의한 메서드를 User.prototype에 저장한다.
new User를 호출해 객체를 만들고, 객체의 메서드를 호출하면 함수의 메서드를 prototype 프로퍼티를 통해 가져옵니다. 이 과정이 있기 때문에 객체에서 클래스 메서드에 접근할 수 있다.
그래서 보통 JS 개발자 중 가볍게 아는 사람들은 class를 보고 간단한 편의 문법 (syntactic sugar, 문법 설탕) 라고 말한다.
순수함수를 통해서도 만들 수 있지만 class랑 만드는 근본적인 차이점 몇 가지가 있다.
- class로 만든 함수엔 특수 내부 프로퍼티인
[[IsClassConstructor]]: true
가 이름표처럼 붙습니다. 이것만으로도 두 방법엔 분명한 차이가 있음을 알 수 있습니다.
class User {
constructor() {}
}
alert(typeof User); // User의 타입은 함수이긴 하지만 그냥 호출할 수 없습니다.
User(); // TypeError: Class constructor User cannot be invoked without 'new'
자바스크립트는 다양한 경우에 [[IsClassConstructor]]: true
를 활용합니다. 클래스 생성자를 new와 함께 호출하지 않으면 에러가 발생하는데 이 때 [[IsClassConstructor]]: true
가 사용됩니다.
클래스 생성자를 문자열로 형변환하면 'class…'로 시작하는 문자열이 되는데 이때도 [[IsClassConstructor]]: true
가 사용됩니다.
class User {
constructor() {}
}
alert(User); // class User { ... }
- 클래스에 정의된 메서드는 열거할 수 없습니다(non-enumerable). 클래스의 prototype 프로퍼티에 추가된 메서드의 enumerable 플래그는 false입니다.
- for..in으로 객체를 순회할 때, 메서드는 순회 대상에서 제외하고자 하는 경우가 많으므로 해당 특징은 기억해 두어야한다.
- 클래스는 항상 엄격 모드로 실행됩니다(use strict). 클래스 생성자 안 코드 전체엔 자동으로 엄격 모드가 적용된다.
클래스를 상속하는 방식 중에는 extends
키워드를 통해 진행할 수 있다.
- 클래스 확장하기:
class Child extends Parent
Child.prototype.__proto__
가Parent.prototype
이 되므로 메서드 전체가 상속된다.
상속에 대해서 부모 메서드 전체를 교체하지 않고, 부모 메서드를 토대로 일부 기능만 변경하고 싶을 때, super
키워드를 활용하면 된다.
- 생성자 오버라이딩:
this
를 사용하기 전에 Child 생성자 안에서super()
로 부모 생성자를 반드시 호출해야 한다.
- 메서드 오버라이딩:
- Child에 정의된 메서드에서
super.method()
를 사용해Parent
에 정의된 메서드를 사용할 수 있다.
- Child에 정의된 메서드에서
오버라이딩은 메서드뿐만 아니라 클래스 필드를 대상으로도 적용할 수 있다.
class Animal {
name = 'animal'
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
Animal을 상속받는 Rabbit에서 name 필드를 오버라이딩을 진행했고, Rabbit에는 따로 생성자가 정의되어 있지 않기 때문에 Rabbit을 사용해 인스턴스를 만들면 Animal의 생성자가 호출됩니다.
new Animal()과 new Rabbit()을 실행할 때 두 경우 모두 (*)로 표시한 줄에 있는 alert 함수가 실행되면서 얼럿 창에 animal이 출력된다는 점이다. 이를 통해 우리는 ‘부모 생성자는 자식 클래스에서 오버라이딩한 값이 아닌, 부모 클래스 안의 필드 값을 사용한다’ 는 사실을 알 수 있다.
그렇다면 해당 코드에서는
class Animal {
showName() { // this.name = 'animal' 대신 메서드 사용
alert('animal');
}
constructor() {
this.showName(); // alert(this.name); 대신 메서드 호출
}
}
class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}
new Animal(); // animal
new Rabbit(); // rabbit
위와 같이 자식 클래스에서 부모 생성자를 호출하면 오버라이딩한 메서드가 실행되어야 한다. 그런데 클래스 필드는 자식 클래스에서 필드를 오버라이딩해도 부모 생성자가 오버라이딩한 필드 값을 사용하지 않는다. 부모 생성자는 항상 부모 클래스에 있는 필드의 값을 사용한다.
이러한 차이의 이유는 필드 초기화 순서 때문이다. 클래스 필드는 다음과 같은 규칙에 따라 초기화 순서가 달라진다.
- 아무것도 상속받지 않는 베이스 클래스는 생성자 실행 이전에 초기화됨
- 부모 클래스가 있는 경우엔
super()
실행 직후에 초기화됨
위 예시에서 Rabbit은 하위 클래스이고 constructor()
가 정의되어 있지 않았으며, 이런 경우 앞서 설명한 바와 같이 생성자는 비어있는데 그 안에 super(...args)
만 있다고 보면 된다.
따라서 new Rabbit()
을 실행하면 super()
가 호출되고 그 결과 부모 생성자가 실행된다. 그런데 이때 하위 클래스 필드 초기화 순서에 따라 하위 클래스 Rabbit의 필드는 super()
실행 후에 초기화됩니다. 부모 생성자가 실행되는 시점에 Rabbit의 필드는 아직 존재하지 않죠. 이런 이유로 필드를 오버라이딩 했을 때 Animal에 있는 필드가 사용된 것이다.
이렇게 자바스크립트는 오버라이딩시 필드와 메서드의 동작 방식이 미묘하게 다르다.
다행히도 이런 문제는 오버라이딩한 필드를 부모 생성자에서 사용할 때만 발생하며. 이런 차이가 왜 발생하는지 모르면 결과를 해석할 수 없는 상황이 발생하기 때문에 별도의 공간을 만들어 필드 오버라이딩시 내부에서 벌어지는 일에 대해 자세히 알아야한다. 개발하다가 필드 오버라이딩이 문제가 되는 상황이 발생하면 필드 대신 메서드를 사용하거나 getter나 setter를 사용해 해결하는 것을 추천한다.
메서드는 내부 프로퍼티 [[HomeObject]]
에 자신이 정의된 클래스와 객체를 기억해놓는 특징이 있다. super
는 [[HomeObject]]
를 사용해 부모 메서드를 찾는다.
따라서 super가 있는 메서드는 객체 간 복사 시 제대로 동작하지 않을 수 있다.
"prototype"이 아닌 클래스 함수 자체에 메서드를 설정할 수도 있다. 이런 메서드를 정적(static) 메서드라고 부른다.
User.staticMethod()
가 호출될 때 this의 값은 클래스 생성자인 User
자체가 된다(점 앞 객체).
정적 메서드는 어떤 특정한 객체가 아닌 클래스에 속한 함수를 구현하고자 할 때 주로 사용된다
- 팩토리메서드의 활용이나 항목 검색, 저장, 삭제 등을 수행해주는 데이터베이스 관련 클래스에도 사용에 주로 활용된다.
정적 프로퍼티의 경우에는 동일하게 static으로 생성한다.
정적 프로퍼티와 메서드는 상속되며, 동일하게 자식 클래스에서 호출이 가능하다. 이것이 가능한 이유는 프로토타입이라는 특성때문입니다.
extends
키워드는 자식의 [[Prototype]]
이 부모를 참조하도록 해준다.
객체 지향에서 가장 중요한 개념 중 하나는 내부 인터페이스와 외부 인터페이스를 구분하는 것이다.
- 내부 인터페이스 : 동일한 클래스 내의 다른 메서드에서 접근할 수 있지만, 클래스 밖에서는 접근할 수 없는 프로퍼티와 메서드
- 외부 인터페이스 : 클래스 밖에서도 접근 가능한 프로퍼티와 메서드
이러한 두 가지 타입의 객체 필드가 있다는 것을 이해했다면 자바스크립트에서는 public과 private를 통해 외부와 내부 인터페이스를 구별할 수 있다.
- public : 어디서든지 접근할 수 있으며, 외부 인터페이스를 구성한다.
- private : 클래스 내부에서만 접근할 수 있으며, 내부 인터페이스를 구성할 떄 활용한다.
자바스크립트 이외의 다수 언어에서 클래스 자신과 자손 클래스에서만 접근 가능한 protected를 지원한다. private와 비슷하지만, 자손 클래스에도 접근 가능하다는 점이 차이점이다. 이는 내부 인터페이스를 구성할 때 유용하게 사용한다.
자바스크립트에서 protected 필드를 활용하지 않지만, 이를 모방해서 사용하는 것이 있다.
protected 프로퍼티 명 앞에는 _
가 붙는다.
class CoffeeMachine {
_waterAmount = 0;
set waterAmount(value) {
if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다.");
this._waterAmount = value;
}
get waterAmount() {
return this._waterAmount;
}
constructor(power) {
this._power = power;
}
}
// 커피 머신 생성
let coffeeMachine = new CoffeeMachine(100);
// 물 추가
coffeeMachine.waterAmount = -10;
프로퍼티를 생성할 떄만 값을 할당 할 수 있고, 그 이후에는 값을 절대 수정하지 말아야하는 경우가 있는데 이때, 읽기 전용 프로퍼티를 활용할 수 있다.
읽기 전용 프로퍼티를 만들려면 setter(설정자)는 만들지 않고 getter(획득자)만 만들어야한다.
class CoffeeMachine {
// ...
constructor(power) {
this._power = power;
}
get power() {
return this._power;
}
}
// 커피 머신 생성
let coffeeMachine = new CoffeeMachine(100);
alert(`전력량이 ${coffeeMachine.power}인 커피머신을 만듭니다.`); // 전력량이 100인 커피머신을 만듭니다.
coffeeMachine.power = 25; // Error (setter 없음)
private 프로퍼티와 메서드는 제안 목록에 등재된 문법임을 유념하자
private 프로퍼티와 메서드는 #
으로 시작한다.
class CoffeeMachine {
#waterLimit = 200;
#checkWater(value) {
if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다.");
if (value > this.#waterLimit) throw new Error("물이 용량을 초과합니다.");
}
}
let coffeeMachine = new CoffeeMachine();
// 클래스 외부에서 private에 접근할 수 없음
coffeeMachine.#checkWater(); // Error
coffeeMachine.#waterLimit = 1000; // Error
private 필드는 public 필드와 상충하지 않기 때문에, 예를 들어 private 프로퍼티 #waterAmount와 public 프로퍼티 waterAmount를 동시에 가질 수 있다.
protected 필드와 달리, private 필드는 언어 자체에 의해 강제된다는 점이 장점이지만, CoffeeMachine을 상속받는 클래스에선 #waterAmount에 직접 접근할 수 없다 #waterAmount에 접근하려면 waterAmount의 getter와 setter를 통해야 한다.
class MegaCoffeeMachine extends CoffeeMachine {
method() {
alert( this.#waterAmount ); // Error: CoffeeMachine을 통해서만 접근할 수 있습니다.
}
배열, 맵 같은 내장 클래스도 확장 가능하며.
아래 예시에서 PowerArray는 기본 Array를 상속받아 만들수 있다.
// 메서드 하나를 추가합니다(더 많이 추가하는 것도 가능).
class PowerArray extends Array {
isEmpty() {
return this.length === 0;
}
}
let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false
let filteredArr = arr.filter(item => item >= 10);
alert(filteredArr); // 10, 50
alert(filteredArr.isEmpty()); // false
여기에서 filter, map 등의 내장 메서드가 상속받은 클래스인 PowerArray의 인스턴스(객체)를 반환한다. 이 객체를 구현할 땐 내부에서 객체의 constructor 프로퍼티를 사용한다는 것을 알 수 있다.
내장 객체 Object.keys
, Array.isArray
등의 자체 정적 메서드를 갖는다.
(네이티브 클래스들 서로 상속 관게를 맺는다. Array는 Object를 상속 받는다)
일반적으로 한 클래스가 다른 클래스를 상속 받으면 정적 메서드와 그렇지 않은 메서들ㄹ 모두를 상속 받지만, 내장 클래스에서는 정적 메서드를 상속 받지 못한다.
즉, Date와 Array는 모두 Object를 사용하기에 Object.prototype에 구현된 메서드를 사용할 수 있지만, Object 자체를 참조하는 것이 아니기 때문에 정적 메서드를 인스턴스로 사용할 수 없다.
이것이 내장 객체 간의 상속과 extends를 사용한 상속의 가장 큰 차이점이다.
instanceof
연산자를 사용하면 객체가 특정 클래스에 속하는지 아닌지를 확인할 수 있다.
문법은 아래와 같다.
class Rabbit {}
let rabbit = new Rabbit();
// rabbit이 클래스 Rabbit의 객체인가요?
alert( rabbit instanceof Rabbit ); // true
// obj가 Class에 속하거나 Class를 상속받는 클래스에 속하면 true가 반환된다.
let arr = [1, 2, 3];
alert( arr instanceof Array ); // true
alert( arr instanceof Object ); // true
instanceof 연산자는 보통, 프로토타입 체인을 거슬러 올라가며 인스턴스 여부나 상속 여부를 확인한다. 그런데 정적 메서드 Symbol.hasInstance을 사용하면 직접 확인 로직을 설정할 수도 있다.
클래스에 정적 메서드 Symbol.hasInstance가 구현되어 있으면, obj instanceof Class문이 실행될 때, ClassSymbol.hasInstance가 호출된다. 호출 결과는 true나 false이어야 한다. 이런 규칙을 기반으로 instanceof의 동작을 커스터마이징 할 수 있다
// canEat 프로퍼티가 있으면 animal이라고 판단할 수 있도록
// instanceOf의 로직을 직접 설정합니다.
class Animal {
static [Symbol.hasInstance](obj) {
if (obj.canEat) return true;
}
}
let obj = { canEat: true };
alert(obj instanceof Animal); // true, Animal[Symbol.hasInstance](obj)가 호출됨
대부분의 클래스엔 Symbol.hasInstance가 구현되어있지 않다. 이럴 땐 일반적인 로직이 사용된다. obj instanceOf Class는 Class.prototype이 obj 프로토타입 체인 상의 프로토타입 중 하나와 일치하는지 확인한다.
obj.__proto__ === Class.prototype?
obj.__proto__.__proto__ === Class.prototype?
obj.__proto__.__proto__.__proto__ === Class.prototype?
...
// 이 중 하나라도 true라면 true를 반환합니다.
// 그렇지 않고 체인의 끝에 도달하면 false를 반환합니다.
- typeof의 동작 대상은 원시형이고 반환 값은 문자열
- {}.toString의 동작 대상은 원시형, 내장 객체, Symbol.toStringTag가 있는 객체이며, 반환 값은 문자열
- instanceof의 동작 대상은 객체이고, 반환 값은 true나 false
자바스크립트는 단일상속만을 허용하는 언어이다. 객체엔 단 하나의 [[Prototype]]
만 있을 수 있으며, 클래스는 클래스 하나만 상속 받을 수 있다.
이러한 재약에 대해서 허용하는 방식이 믹스인을 활용한다.
믹스인은 다른 클래스를 상속 받을 필요 없이 이들 클래스에 구현되어있는 메서드를 담고 있는 클래스라 정의한다.
특정 행동을 실행해주는 메서드를 제공하는데 단독으로 쓰이지 않고, 다른 클래스에 행동을 더해주는 용도로 사용된다.
자바스크립트는 단일 상속을 지원하지만, 메섣르르 복사해 프로토타입에 구현할 수 있다. 이는 이벤트 핸들링 등의 행동을 추가할 수 있으며, 클래스를 확장하는 용도로 사용할 수 있다.
믹스인이 실수로 기존 클래스를 덮어쓰면 충돌이 발동할 수 있으니, 사용 시에는 충돌이 발생하지 않도록 메서드의 이름도 신중하게 정해야한다.