클래스는 객체 지향 프로그래밍에서 특정 객체를 생성하기 위해 멤버 변수와 메서드를 정의하는 일종의 틀(템플릿)로, 객체를 정의하기 위한 상태(멤버 변수)와 메서드(함수)로 구성된다.
본래 과거의 자바스크립트는 클래스가 없는 프로토타입 기반의 객체 지향 언어지만, 모던 자바스크립트에 도입된 클래스 문법을 사용해서 클래스 기반 프로그래밍을 할 수 있게 되었다.
참고로, 도입된 클래스 문법은 사실 일종의 프로로타입 기반 Syntatic sugar이다.
class MyClass {
constructor() { ... }
method1() { ... }
methodN() { ... }
}
위와 같은 문법으로 클래스를 만들고, new MyClass()
를 호출하면 내부에서 정의한 메서드가 들어 있는 객체가 생성된다.
객체의 초기 상태를 설정해주는 생성자 메서드 constructor()
는 new
에 의해 자동으로 호출되어 객체가 초기화된다.
class User {
constructor(name) {
this.name = name;
}
sayHi() {
alert(this.name);
}
}
let user = new User("Yong");
user.sayHi(); // Yong
위 예시에서 new User("Yong")
를 호출하면 아래와 같은 일이 수행된다.
1. 새로운 객체가 생성된다.
2. 넘겨받은 인수와 함께 constructor
가 자동으로 실행된다.
3. 객체에서 user.sayHi()
와 같은 클래스 내에 구현된 메서드를 호출할 수 있다.
자바스크립트에서 클래스는 사실 함수의 한 종류이다.
class User {
constructor(name) { this.name = name; }
sayHi() { alert(this.name); }
}
alert(typeof User); // function
class User { ... }
문법 구조가 진짜 하는 일은 아래와 같다.
1. User
라는 이름을 가진 함수를 만든다. 함수 본문은 생성자 메서드 constructor
에서 가져온다. 생성자 메서드가 없으면 본문이 비워진 함수가 만들어진다.
2. sayHi
같은 클래스 내에서 정의한 메서드를 앞 서 만들었던 함수의 User.prototype
에 저장한다.
class User
의 선언 결과를 그림으로 나타내면 아래와 같다.
class User {
constructor(name) { this.name = name; }
sayHi() { alert(this.name); }
}
// 클래스는 함수입니다.
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와 동일한 기능을 하는 순수 함수를 만들어보겠습니다.
// 1. 생성자 함수를 만듭니다.
function User(name) {
this.name = name;
}
// 모든 함수의 프로토타입은 'constructor' 프로퍼티를 기본으로 갖고 있기 때문에
// constructor 프로퍼티를 명시적으로 만들 필요가 없습니다.
// 2. prototype에 메서드를 추가합니다.
User.prototype.sayHi = function() {
alert(this.name);
};
// 사용법:
let user = new User("John");
user.sayHi();
위 예시처럼 순수 함수로 클래스 역할을 하는 생성자 함수를 선언하는 방법과 class
키워드를 사용하는 방법의 결과는 거의 같다. 그래서 혹자는 class
가 단순한 Syntatic sugar라고 생각한다.
하지만 두 방법에는 중요한 차이가 몇 가지 있다.
1. class
로 만든 함수에는 특수한 숨김 프로퍼티인 [[IsClassConstructor]]: true
가 이름표처럼 붙어있다. 자바스크립트는 다양한 경우에 [[IsClassConstructor]]: true
를 활용한다. 클래스 생성자를 new
와 함께 호출하지 않으면 에러가 발생하는데, 바로 이때 사용된다.
class User {
constructor() {}
}
alert(typeof User); // User의 타입은 함수이긴 하지만 그냥 호출할 수 없다.
User(); // TypeError: Class constructor User cannot be invoked without 'new'
클래스에 정의된 메서드는 열거할 수 없다. 즉, 클래스의 prototype
프로퍼티에 추가된 메서드의 enumerable
플래그는 false
이다.
클래스는 항상 strict mode
로 실행된다. 클래스 생성자 안 코드 전체에는 자동으로 strict mode
가 적용된다. 등
클래스도 함수처럼 다른 표현식 내부에서 정의, 전달, 반환, 할당할 수 있다.
let User = class {
sayHi() {
alert("안녕하세요.");
}
};
기명 함수 표현식과 유사하게 클래스 표현식에도 이름을 붙일 수 있다. 클래스 표현식에 이름을 붙이면, 오직 클래스 내부에서만 사용할 수 있다.
// 기명 클래스 표현식(Named Class Expression)
// (명세서엔 없는 용어이지만, 기명 함수 표현식과 유사하게 동작한다.)
let User = class MyClass {
sayHi() {
alert(MyClass); // MyClass라는 이름은 오직 클래스 안에서만 사용할 수 있다.
}
};
new User().sayHi(); // 원하는대로 MyClass의 정의를 보여준다.
alert(MyClass); // ReferenceError: MyClass is not defined, MyClass는 클래스 밖에서 사용할 수 없다.
아래와 같이 필요에 따라 클래스를 동적으로 생성하는 것도 가능하다.
function makeClass(phrase) {
// 클래스를 선언하고 이를 반환함
return class {
sayHi() {
alert(phrase);
};
};
}
// 새로운 클래스를 만듦
let User = makeClass("안녕하세요.");
new User().sayHi(); // 안녕하세요.
클래스도 getter
나 setter
를 지원한다. 이들 역시 prototype
에 정의된다.
get
과 set
을 이용해서 user.name
을 조작해보자.
class User {
constructor(name) {
// setter 활성화
this.name = name;
}
get name() {
return this._name;
}
set name(value) {
if (value.length < 1) {
alert("이름이 너무 짧습니다.");
return;
}
this._name = value;
}
}
let user = new User("보라");
alert(user.name); // 보라
user = new User(""); // 이름이 너무 짧습니다.
[]
computed property name
으로 객체의 프로퍼티 이름을 동적으로 지정했던 것처럼, 클래스에서도 대괄호를 이용해서 computed method name
을 만들 수 있다.
class User {
['say' + 'Hi']() {
alert("Hello");
}
}
new User().sayHi();
클래스 필드 문법을 사용해서 어떤 종류의 프로퍼티도 클래스에 추가할 수 있다.
class User {
name = "보라"; // 클래스 필드
sayHi() {
alert(`${this.name}님 안녕하세요!`);
}
}
new User().sayHi(); // 보라님 안녕하세요
클래스 필드의 중요한 특징 중 하나는 User.prototype
이 아닌 개별 객체에서만 클래스 필드가 프로퍼티로 설정된다는 점이다.
class User {
name = "보라";
}
let user = new User();
alert(user.name); // 보라
alert(User.prototype.name); // undefined
또, 클래스 필드에는 복잡한 표현식이나 함수 호출 결과를 사용할 수도 있다.
class User {
name = prompt("이름을 알려주세요.", "보라");
}
let user = new User();
alert(user.name); // 보라
자바스크립트에서 this
는 동적으로 결정된다.
따라서 객체 메서드를 여기저기 전달해서 전혀 다른 컨텍스트에서 호출하게 되면, this
는 메서드가 정의된 객체를 참조하지 않는다.
class Button {
constructor(value) {
this.value = value;
}
click() {
alert(this.value);
}
}
let button = new Button("안녕하세요.");
setTimeout(button.click, 1000); // undefined
이렇게 this
의 컨텍스트를 알 수 없게 되는 문제를 잃어버린 this(losing this)
라고 한다.
이러한 문제는 두 가지 방법을 사용해서 해결할 수 있다.
1. setTimeout(() => button.click(), 1000)
같이 래퍼 함수를 전달하기
2. 생성자 안에서 메서드를 객체에 바인딩하기
위 두 가지 방법 말고 클래스 필드를 사용해도 문제를 해결할 수 있다.
class Button {
constructor(value) {
this.value = value;
}
click = () => {
alert(this.value);
}
}
let button = new Button("안녕하세요.");
setTimeout(button.click, 1000); // 안녕하세요
클래스 필드 click = () => { ... }
는 클래스 Button
으로 만든 각 객체마다 독립적인 함수를 만들어주고, 이 함수의 this
를 해당 객체에 바인딩시켜준다. 따라서 아무곳에나 button.click
을 전달할 수 있고, this
에는 항상 의도한 값이 들어가게 된다.
클래스 필드의 이런 기능은 브라우저 환경에서 메서드를 이벤트 리스너로 설정해야 할 때 특히 유용하다.
클래스 상속을 사용하면 클래스를 다른 클래스로 확장하여 기존에 존재하던 기능을 토대로 새로운 기능을 만들 수 있다.
동물과 관련된 객체를 만들 수 있는 Animal
클래스를 만들어보자.
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name} 은/는 속도 ${this.speed}로 달립니다.`);
}
stop() {
this.speed = 0;
alert(`${this.name} 이/가 멈췄습니다.`);
}
}
let animal = new Animal("동물");
또 다른 클래스 Rabbit
을 만들어보자. 토끼는 동물이므로 Rabbit
은 동물 관련 메서드가 담긴 Animal
을 상속받아 만들 수 있다. 이렇게 상속받은 토끼는 동물이 할 수 있는 일반적인 동작을 수행할 수 있다.
class Rabbit extends Animal {
hide() {
alert(`${this.name} 이/가 숨었습니다!`);
}
}
let rabbit = new Rabbit("흰 토끼");
rabbit.run(5); // 흰 토끼 은/는 속도 5로 달립니다.
rabbit.hide(); // 흰 토끼 이/가 숨었습니다!
키워드 extends
는 프로토타입을 기반으로 동작한다. extends
는 Rabbit.prototype
의 [[Prototype]]
를 Animal.prototype
으로 설정한다. 따라서, Rabbit.prototype
에서 메서드를 찾지 못하면 Animal.prototype
에서 메서드를 가져온다.
자바스크립트 엔진은 아래와 같은 절차를 따라서 객체의 메서드 rabbit.run
의 존재를 확인한다.
1. 객체 rabbit
에 run
이 있나 확인한다. 없다면, 2번을 수행한다.
2. rabbit
의 프로토타입인 Rabbit.prototype
에 메서드가 있나 확인한다. hide
는 있으나, run
은 없다. 없다면, 3번을 수행한다.
3. extends
를 통해 관계가 만들어진 Rabbit.prototype
의 프로토타입, Animal.prototype
에 run
이 있나 확인한다. 찾았다!
자바스크립트의 내장 객체는 프로토타입을 기반으로 상속 관계를 맺는다. Date.prototype
의 [[Prototype]]
이 Object.prototype
인 것처럼 말이다.
클래스 문법은 extends
뒤에 표현식이 와도 처리해준다. 아래 예시처럼 extends
뒤에서 부모 클래스를 만들어주는 함수를 호출할 수도 있다.
function f(phrase) {
return class {
sayHi() { alert(phrase) }
}
}
class User extends f("Hello") {}
new User().sayHi(); // Hello
이러한 방법은 조건에 따라 다른 클래스를 상속받고 싶을 때 유용하다. 조건에 따라 다른 클래스를 반환하는 함수를 만들고, 함수 호출 결과를 상속받게 하면 된다.
특별한 사항이 없으면 class Rabbit
은 class Animal
에 있는 메서드를 그대로 상속받는다.
그런데 Rabbit
에서 stop
등의 메서드를 자체적으로 정의하면, 상속받은 메서드가 아닌 자체적으로 정의한 메서드가 사용된다.
class Rabbit extends Animal {
stop() {
// rabbit.stop()을 호출할 때
// Animal의 stop()이 아닌, 이 메서드가 사용된다.
}
}
개발을 하다 보면 부모 메서드 전체를 교체하지 않고, 부모 메서드를 토대로 일부 기능만 변경하고 싶을 때가 있다. 또는 부모 메서드의 기능을 확장하고 싶을 때도 있다. 이럴 때 자체적으로 메서드를 만들어서 작업하게 되는데, 이미 자체적으로 메서드를 만들었더라도 부모 메서드도 호출하고 싶을 때가 있다.
키워드 super
는 이럴 때 사용한다.
super.methodName()
은 부모 클래스에 정의된 메서드를 호출한다.super(...)
는 부모 생성자를 호출하는데, 자식 생성자 내부에서만 사용할 수 있다.이런 특징을 이용해서 토끼가 멈추면 자동으로 숨도록 하는 코드를 만들어보자.
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed = speed;
alert(`${this.name}가 속도 ${this.speed}로 달립니다.`);
}
stop() {
this.speed = 0;
alert(`${this.name}가 멈췄습니다.`);
}
}
class Rabbit extends Animal {
hide() {
alert(`${this.name}가 숨었습니다!`);
}
stop() {
super.stop(); // 부모 클래스의 stop을 호출한다.
this.hide();
}
}
let rabbit = new Rabbit("흰 토끼");
rabbit.run(5); // 흰 토끼가 속도 5로 달립니다.
rabbit.stop(); // 흰 토끼가 멈췄습니다. 흰 토끼가 숨었습니다!
지금까지 클래스 Rabbit
에는 자체 constructor
가 없었다.
클래스가 다른 클래스를 상속받고 constructor
가 없는 경우에는 아래처럼 비어있는 constructor
가 만들어진다.
class Rabbit extends Animal {
// 자체 생성자가 없는 클래스를 상속받으면 자동으로 만들어진다.
constructor(...args) {
super(...args);
}
}
보다시피 생성자는 기본적으로 부모 constructor
를 호출한다. 이 때 부모 constructor
에도 인수를 모두 전달한다. 클래스에 자체 생성자가 없는 경우에는 이런 일이 모두 자동으로 일어난다.
이제 클래스 Rabbit
에 자체적으로 생성자를 추가해보자.
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
this.speed = 0;
this.name = name;
this.earLength = earLenth;
}
// ...
}
// 동작하지 않는다!
let rabbit = new Rabbit("흰 토끼", 10); // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
위 예시와 같이 에러가 발생한다. 무엇이 잘못됐을까?
이유는 다음과 같다.
상속받은 클래스의 생성자에서는 반드시
super(...)
를 호출해야 하는데,super(...)
를 호출하지 않아서 에러가 발생했다.super(...)
는this
를 사용하기 전에 반드시 호출해야 한다.
왜 super(...)
를 호출해야 할까?
당연한 이유가 있다. 상속받은 클래스의 생성자가 호출될 때 어떤 일이 발생하는지 살펴보면서 이유를 찾아보자.
자바스크립트는 상속 클래스의 생성자 함수(derived constructor)
와 그렇지 않은 생성자 함수
를 구분한다. 상속 클래스의 생성자 함수에는 특수한 숨김 프로퍼티인 [[ConstructorKind]]: "derived"
가 이름표처럼 붙는다.
일반 클래스의 생성자 함수와 상속 클래스의 생성자 함수 간의 차이는 new
와 함께 드러난다.
new
와 함께 실행되면, 빈 객체가 만들어지고 this
에 해당 객체를 할당한다.this
에 해당 객체를 할당하는 일을 부모 클래스의 생성자가 처리해주길 기대한다.이런 차이 때문에 상속 클래스의 생성자에서는 super
를 호출해서 부모 생성자를 실행해 주어야 한다. 그렇지 않으면 this
가 될 객체가 만들어지지 않아 에러가 발생한다.
아래 예시와 같이 this
를 사용하기 전에 super()
를 호출하면 Rabbit
의 생성자가 제대로 동작한다.
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// 이제 에러 없이 동작한다.
let rabbit = new Rabbit("흰 토끼", 10);
alert(rabbit.name); // 흰 토끼
alert(rabbit.earLength); // 10
오버라이딩은 메서드뿐만 아니라 클래스 필드를 대상으로도 적용할 수 있다.
부모 클래스의 생성자 안에 있는 필드에 오버라이딩해서 접근하려고 할 때 자바스크립트는 다른 언어와 다르게 조금 까다롭다. 예시를 살펴보자.
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()
을 실행한 경우가 동일하다는 것이다. 이를 통해 부모 생성자는 자식 클래스에서 오버라이딩한 값이 아닌, 부모 클래스 안의 필드 값을 사용한다는 사실을 알 수 있다.
상속을 받고 필드 값을 오버라이딩했는데 새로운 값 대신 부모 클래스 안에 있는 기존 필드 값을 사용한 점이 이상한데, 이해를 돕기 위해 이 상황을 메서드와 비교해서 생각해보자.
아래 예시에서는 필드 this.name
대신에 메서드 this.showName()
을 사용했다.
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
은 자식 클래스이고 contructor()
가 정의되어 있지 않다. 이런 경우 앞서 설명한 바와 같이 생성자는 비어있는데 그 안에 super(...args)
만 있다고 보면 된다. 따라서, new Rabbit()
을 실행하면 super()
가 호출되고 그 결과 부모 생성자가 실행된다.
그런데 이때 클래스 필드 초기화 순서에 따라 자식 클래스 Rabbit
의 필드는 super()
실행 후에 초기화된다. 부모 생성자가 실행되는 시점에 Rabbit
의 필드는 아직 존재하지 않는다. 이런 이유로 필드를 오버라이딩 했을 때 Animal
에 있는 필드가 사용된 것이다.
이렇게 자바스크립트는 오버라이딩시 필드와 메서드의 동작 방식이 미묘하게 다르다.
다행히도 이런 문제는 오버라이딩한 필드를 부모 생성자에서 사용할 때만 발생한다. 이런 차이가 왜 발생하는지 모르면 결과를 해석할 수 없기 때문에 별도의 공간을 만들어 필드 오버라이딩시 내부에서 벌어지는 일에 대해 자세히 알아보았다.
개발하다가 필드 오버라이딩이 문제가 되는 상황이 발생하면 필드 대신 메서드를 사용하거나 getter
나 setter
를 사용하면 해결된다.
[[HomeObject]]
super
의 내부 메커니즘에 대해 알아보자.
객체의 메서드가 실행되면 현개 객체가 this
가 된다. 이 상태에서 super.method()
를 호출하면 자바스크립트 엔진은 현재 객체의 프로토타입에서 method
를 찾아야 한다. 이런 과정은 어떻게 일어날까?
쉬워 보이는 질문 같지만 실제로는 그렇지 않다. 엔진은 현재 객체 this
를 알기 때문에 this.__proto__.method
를 통해 부모 객체의 method
를 찾는다라는 나이브한 생각은 틀렸다!
구체적인 코드와 함께 문제를 재현해보자. 간결성을 위해 클래스가 아닌 일반 객체를 사용해서 예시를 구성해보자. 아래 예시의 rabbit.__proto__
은 animal
이다. rabbit.eat()
에서 this.__proto__
를 사용해서 animal.eat()
을 호출해보자.
let animal = {
name: "동물",
eat() {
alert(`${this.name} 이/가 먹이를 먹습니다.`);
}
};
let rabbit = {
__proto__: animal,
name: "토끼",
eat() {
// 예상대로라면 super.eat()이 동작해야 한다.
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // 토끼 이/가 먹이를 먹습니다.
(*)
로 표시한 줄에서는 eat
을 프로토타입에서 가져오고, 현재 객체의 컨텍스트에 기반하여 eat
을 호출한다. 여기서 주의해야할 부분은 .call(this)
이다. this.__proto__.eat()
만 있으면 현재 객체가 아닌 프로토타입의 컨텍스트에서 eat
을 실행하기 때문에 .call(this)
가 있어야한다.
위 예시를 실행하면 예상한 내용이 출력된다.
이제 프로토타입 체인에 객체를 하나 더 추가해보자. 이제 문제가 발생하기 시작한다.
let animal = {
name: "동물",
eat() {
alert(`${this.name} 이/가 먹이를 먹습니다.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// call을 사용해 컨텍스트를 옮겨가며 부모(animal) 메서드를 호출한다.
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// longEar를 가지고 무언가를 하면서 부모(rabbit) 메서드를 호출한다.
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // RangeError: Maximum call stack size exceeded
예상과 달리 에러가 발생한다! 왜 이런일이 발생하는지 천천히 살펴보자.
먼저 살펴봐야 할 것은 (*)
과 (**)
로 표시한 줄이다. 이 두 줄에서 this
는 현재 객체인 longEar
가 된다. 여기에 핵심이 있다. 모든 객체 메서드는 현재 객체를 this
로 갖기 때문이다.
따라서 (*)
과 (**)
로 표시한 줄의 this.__proto__
에는 둘 다 모두 rabbit
이 할당된다. 프로토타입 체인 위로 올라가지 않고, 양쪽 모두에서 rabbit.eat
을 호출하기 때문에 자기 자신은 계속 호출해서 무한루프에 빠지게 된다.
이를 그림으로 나타내면 다음과 같다.
이런 문제는 this
만으로는 해결할 수 없다. 대신 자바스크립트에는 이런 문제를 해결할 수 있는 함수 전용 특수한 숨김 프로퍼티인 [[HomeObject]]
가 있다.
클래스이거나 객체 메서드인 함수의 [[HomeObject]]
프로퍼티는 해당 객체가 저장된다.
super
는 [[HomeObject]]
를 이용해서 부모 프로토타입과 메서드를 찾는다.
예시를 통해서 [[HomeObject]]
가 어떻게 동작하는지 살펴보자. 먼저 일반 객체를 이용해보자.
let animal = {
name: "동물",
eat() { // animal.eat.[[HomeObject]] == animal
alert(`${this.name} 이/가 먹이를 먹습니다.`);
}
};
let rabbit = {
__proto__: animal,
name: "토끼",
eat() { // rabbit.eat.[[HomeObject]] == rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: "귀가 긴 토끼",
eat() { // longEar.eat.[[HomeObject]] == longEar
super.eat();
}
};
// 이제 제대로 동작한다.
longEar.eat(); // 귀가 긴 토끼 이/가 먹이를 먹습니다.
[[HomeObject]]
의 메커니즘 덕분에 메서드가 의도한 대로 동작하는 것을 확인할 수 있다. 이렇게 longEar.eat
같은 객체 메서드는 [[HomeObject]]
를 알고 있기 때문에 this
없이도 프로토타입으로부터 부모 메서드를 가져올 수 있다.
자바스크립트에서 함수는 대개 객체이 묶이지 않고 자유롭다. 이런 자유성 때문에 this
가 달라도 객체 간에 메서드를 복사하는 것이 가능하다.
그런데 [[HomeObject]]
는 함수의 자유도를 파괴한다. 메서드가 객체를 기억하기 때문이다. 개발자가 [[HomeObject]]
를 변경할 방법은 없기 때문에 한 번 바인딩 된 함수는 더 이상 변경되지 않는다.
다행인 점은 [[HomeObject]]
는 오직 super
내부에서만 유효하다. 그렇기 때문에 메서드에서 super
를 사용하지 않는 경우에는 메서드의 자유성이 보장된다. 객체 간에 복사 역시 가능하다.
하지만 메서드에서 super
를 사용하면 이야기가 달라진다. 객체 간에 메서드를 잘못 복사한 경우에 super
가 제대로 동작하지 않는 경우를 살펴보자.
let animal = {
sayHi() {
console.log(`나는 동물입니다.`);
}
};
// rabbit은 animal을 상속받는다.
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
console.log("나는 식물입니다.");
}
};
// tree는 plant를 상속받는다.
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // 나는 동물입니다. (?!?)
위 예시는 예상한 결과와 다르다. 원인은 단순하다.
(*)
로 표시한 줄에서 메서드 tree.sayHi
는 중복 코드를 방지하기 위해서 rabbit
에서 메서드를 복사해왔다.rabbit
에서 생성했기 때문에, 해당 메서드의 [[HomeObject]]
는 rabbit
이다.tree.sayHi()
의 내부 코드에는 super.sayHi()
가 있다. rabbit
의 프로토타입은 animal
이므로 super
는 프로토타입 체인 위에 있는 animal
로 올라가 sayHi
를 찾는다.[[HomeObject]]
는 클래스와 일반 객체의 메서드에서 정의된다. 그런데 객체 메서드의 경우 [[HomeObject]]
가 제대로 동작하게 하려면 메서드를 반드시 methodName()
형태로 정의해야 한다. methodName: function()
형태로 정의하면 안된다.
메서드 문법이 아닌 함수 프로퍼티를 사용해서 예시를 작성하면 다음과 같다. [[HomeObject]]
프로퍼티가 설정되지 않기 때문에 상속이 제대로 동작하지 않는 것을 확인할 수 있다.
let animal = {
eat: function() {
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // SyntaxError: 'super' keyword unexpected here ([[HomeObject]]가 없어서 에러가 발생함)
prototype
이 아닌 클래스 함수 자체에 메서드를 설정할 수 있다. 이런 메서드를 정적(static) 메서드
라고 한다.
정적 메서드는 아래와 같이 클래스 안에서 static
키워드를 붙여서 만들 수 있다.
class User {
static staticMethod() {
alert(this === User);
}
}
User.staticMethod(); // true
정적 메서드는 메서드를 프로퍼티 형태로 직접 할당하는 것과 동일한 일을 한다.
class User {}
User.staticMethod = function() {
alert(this === User);
};
User.staticMethod(); // true
정적 메서드는 어떤 특정한 객체가 아닌 클래스에 속한 함수를 구현하고자 할 때 주로 사용된다.
객체 article
이 여러 개 있고, 이들을 비교할 함수가 필요하다고 가정하자. 가장 먼저 아래와 같이 Article.compare
메서드를 추가하는 방법이 있다.
class Article {
constructor(title, date) {
this.title = title;
this.date = date;
}
static compare(articleA, articleB) {
return articleA.date - articleB.date;
}
}
let articles = [
new Article("HTML", new Date(2019, 1, 1)),
new Article("CSS", new Date(2019, 0, 1)),
new Article("JavaScript", new Date(2019, 11, 1))
];
articles.sort(Article.compare);
alert( articles[0].title ); // CSS
여기서 Article.compare
는 article
을 비교해주는 수단으로, 글 전체를 위에서 바라보면 비교를 수행한다. Article.compare
이 글 하나의 메서드가 아닌 클래스의 메서드여야 하는 이유가 여기에 있다.
이번에 살펴볼 예시는 팩토리 메서드를 구현한 코드이다. 다양한 방법을 사용해서 조건에 맞는 article
인스턴스를 만들어야 한다고 가정하자.
title
, date
등)를 이용해서 관련 정보가 담긴 article
생성article
생성 등첫 번째 방법은 생성자를 사용해서 구현할 수 있고, 두 번째 방법은 클래스에 정적 메서드를 만들어 구현할 수 있다.
class Article {
constructor(title, date) {
this.title = title;
this.date = date;
}
static createTodays() {
return new this("Today's digest", new Date());
}
}
let article1 = new Article("첫 번째 article", new Date());
alert( article1.title ); // 첫 번째 article
let article2 = Article.createTodays();
alert( article2.title ); // Today's digest
이제 Today's digest
라는 글이 필요할 때마다 Article.createTodays()
를 호출하면 된다. 여기서도 마찬가지로 Article.createTodays()
는 인스턴스의 메서드가 아닌 전체 클래스의 메서드이다.
정적 메서드는 항목 검색, 저장, 삭제 등을 수행하는 데이터베이스 관련 클래스에도 사용된다.
// Article은 article을 관리해주는 특별 클래스라고 가정하자
// article 삭제에 쓰이는 정적 메서드
Article.remove({id: 12345});
정적 프로퍼티는 일반 클래스 프로퍼티와 유사하지만, static
이 붙는다는 점이 다르다.
class Article {
static publisher = "Ilya Kantor";
}
alert( Article.publisher ); // Ilya Kantor
위 예시는 Article
에 프로퍼티를 직접 할당한 것과 동일하게 동작한다.
Article.publisher = "Ilya Kantor";
정적 프로퍼티와 정적 메서드는 상속된다.
아래 예시에서 Animal.compare
와 Animal.planet
은 상속되어서 각각 Rabbit.compare
와 Rabbit.planet
에서 접근할 수 있다.
class Animal {
static planet = "지구";
constructor(name, speed) {
this.speed = speed;
this.name = name;
}
run(speed = 0) {
this.speed += speed;
alert(`${this.name}가 속도 ${this.speed}로 달립니다.`);
}
static compare(animalA, animalB) {
return animalA.speed - animalB.speed;
}
}
// Animal을 상속받음
class Rabbit extends Animal {
hide() {
alert(`${this.name}가 숨었습니다!`);
}
}
let rabbits = [
new Rabbit("흰 토끼", 10),
new Rabbit("검은 토끼", 5)
];
rabbits.sort(Rabbit.compare);
rabbits[0].run(); // 검은 토끼가 속도 5로 달립니다.
alert(Rabbit.planet); // 지구
위 예시에서 Rabbit.compare
을 호출하면 Animal.compare
가 호출된다. 이게 가능한 이유는 프로토타입 때문이다. extends
키워드는 Rabbit
의 [[Prototype]]
이 Animal
을 참조하도록 한다.
따라서 class Rabbit extends Animal
은 두 개의 [[Prototype]]
참조를 만들어 낸다.
Rabbit
은 프로토타입을 통해 함수 Animal
을 상속받는다.Rabbit.prototype
은 프로토타입을 통해 Animal.prototype
을 상속받는다.이런 과정이 있기 때문에 인스턴스의 일반 메서드 상속과 클래스의 정적 메서드 상속이 가능하다.
class Animal {}
class Rabbit extends Animal {}
// 정적 메서드
alert(Rabbit.__proto__ === Animal); // true
// 일반 메서드
alert(Rabbit.prototype.__proto__ === Animal.prototype); // true
객체 지향 프로그래밍에서 가장 중요한 원리 중 하나는 내부 인터페이스와 외부 인터페이스를 구분 짓는 캡슐화
이다.
객체 지향 프로그래밍에서 프로퍼티와 메서드는 두 그룹으로 분류된다.
내부 인터페이스(internal interface)
: 동일한 클래스 내의 다른 메서드에서는 접근할 수 있지만, 클래스 밖에서는 접근할 수 없는 프로퍼티와 메서드외부 인터페이스(external interface)
: 클래스 밖에서도 접근 가능한 프로퍼티와 메서드내부 인터페이스의 세부사항들은 서로의 정보를 이용해서 객체를 동작시킨다. 밖에서는 세부 요소를 알 수 없고, 접근도 불가능하다. 내부 인터페이스의 기능은 외부 인터페이스를 통해야만 사용할 수 있다.
이런 특징 때문에 외부 인터페이스만 알아도 객체를 가지고 무언가를 할 수 있다. 객체 안이 어떻게 동작하는지 알지 못해도 괜찮다는 점은 큰 장점으로 작용한다.
자바스크립트에는 아래와 같은 두 가지 타입의 객체 필드(프로퍼티와 메서드)가 있다.
public
: 클래스 내/외부 어디서든지 접근할 수 있으며 외부 인터페이스를 구성한다.private
: 클래스 내부에서만 접근할 수 있으며 내부 인터페이스를 구성할 때 쓰인다.자바스크립트 이외의 언어들에서는 클래스 자신과 자식 클래스에서만 접근을 허용하는 protected
필드를 지원한다. protected
필드는 private
필드와 비슷하지만, 자식 클래스에서도 접근이 가능하다는 점이 다르다.
protected
필드도 내부 인터페이스를 만들 때 유용하다. 자식 클래스의 필드에 접근해야 하는 경우가 많기 때문에, protected
필드는 private
필드보다 조금 더 광범위하게 사용된다.
자바스크립트는 protected
필드를 지원하지 않지만, protected
를 사용하면 편리한 점이 많기 때문에 이를 모방해서 사용하는 경우가 많다.
지금까지 배운 프로퍼티 타입을 사용해서 커피머신을 구현해보자.
먼저, 간단한 커피 머신 클래스를 만들어보자.
class CoffeeMachine {
waterAmount = 0; // 물통에 차 있는 물의 양
constructor(power) {
this.power = power;
alert( `전력량이 ${power}인 커피머신을 만듭니다.` );
}
}
// 커피 머신 생성
let coffeeMachine = new CoffeeMachine(100);
// 물 추가
coffeeMachine.waterAmount = 200;
현재 프로퍼티 waterAmount
와 power
는 public
이다. 이들은 손쉽게 읽고 원하는 값으로 변경할 수 있는 상태이다.
이제 waterAmount
를 protected
로 바꿔서 통제해보자. 예시로 waterAmount
를 0 미만의 값으로는 설정하지 못하도록 만들어 보자.
**protected 프로퍼티 명 앞에는 밑줄 _
이 붙는다.
자바스크립트에서 강제한 사항은 아니지만, 밑줄은 개발자들 사이에서 외부 접근이 불가능한 프로퍼티나 메서드를 나타낼 때 쓴다.
waterAmount
에 밑줄을 붙여 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; // Error: 물의 양은 음수가 될 수 없습니다.
power
프로퍼티를 읽기만 가능하도록 만들어보자. 프로퍼티를 생성할 때만 값을 할당할 수 있고, 그 이후에는 값을 절대 수정하지 말아야 하는 경우가 있는데, 이럴 때 읽기 전용 프로퍼티
를 활용할 수 있다.
읽기 전용 프로퍼티를 만들려면 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; // strict mode에서 Error (setter 없음)
참고로 위에서는 get
, set
문법을 사용해서 getter
와 setter
함수를 만들었다. 하지만 대부분은 아래와 같이 get...
, set...
형식의 함수가 선호된다.
class CoffeeMachine {
_waterAmount = 0;
setWaterAmount(value) {
if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다.");
this._waterAmount = value;
}
getWaterAmount() {
return this._waterAmount;
}
}
new CoffeeMachine().setWaterAmount(100);
다소 길어보이지만, 이렇게 함수를 선언하면 다수의 인자를 받을 수도 있고, 이름에서 어떤 동작인지 유추할 수 있다는 장점이 있다. 반면 get
, set
문법은 코드가 짧아진다는 장점이 있다. 어떤걸 사용해야 한다는 규칙은 없으므로 유동적이게 사용하자.
또, protected
필드는 상속된다. class MegaMachine extends CoffeeMachine
로 클래스를 상속받으면, 새로운 클래스의 메서드에서 this._waterAmount
나 this._power
를 사용해서 프로퍼티에 접근할 수 있다.
private
프로퍼티와 메서드는 #
으로 시작하고, 클래스 안에서만 접근할 수 있다.
물 용량 한도를 나타내는 private
프로퍼티 #waterLimit
과 남아있는 물의 양을 확인해주는 private
메서드인 #checkWater
를 구현해보자.
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(200); // Error
coffeeMachine.#waterLimit = 1000; // Error
#
은 자바스크립트에서 지원하는 문법으로, private
필드를 의미한다. private
필드는 클래스 외부나 자식 클래스에서 접근할 수 없다.
private
필드는 public
필드와 상충하지 않는다. 클래스는 private
프로퍼티 #waterAmount
와 public
프로퍼티 waterAmount
를 동시에 가질 수 있다.
#waterAmount
의 접근자 waterAmount
를 만들어보자.
class CoffeeMachine {
#waterAmount = 0;
get waterAmount() {
return this.#waterAmount;
}
set waterAmount(value) {
if (value < 0) throw new Error("물의 양은 음수가 될 수 없습니다.");
this.#waterAmount = value;
}
}
let machine = new CoffeeMachine();
machine.waterAmount = 100;
alert( machine.#waterAmount ); // Error
protected
필드와 달리, private
필드는 언어 자체에 의해 강제된다는 점이 장점이다.
그런데 CoffeeMachine
을 상속받는 자식 클래스에서는 #waterAmount
에 직접 접근할 수 없다. #waterAmount
에 접근하려면 waterAmount
의 getter
와 setter
를 통해야 한다.
class MegaCoffeeMachine extends CoffeeMachine {
method() {
alert( this.#waterAmount ); // Error: CoffeeMachine을 통해서만 접근할 수 있습니다.
}
}
참고로, 보통 this[name]
으로 클래스 필드에 접근할 수 있지만, private
필드는 this[name]
로 사용할 수 없다.
class User {
...
sayHi() {
let fieldName = "name";
alert(`Hello, ${this[fieldName]}`);
}
}
앞 서 설명했듯이 객체 지향 프로그래밍에서 내부 인터페이스와 외부 인터페이스를 구분하는 것을 캡슐화
라고 한다고 했다.
캡슐화는 여러 장점을 가진다.
배열, 맵 같은 내장 클래스도 확장 가능하다.
아래 예시에서 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
프로퍼티를 사용한다.
따라서 아래와 같은 관계를 갖는다.
arr.constructor === PowerArray
arr.filter()
가 호출될 때, 내부에서는 내장 클래스 Array
가 아닌 arr.constructor
를 기반으로 새로운 배열이 만들어지고 여기에 필터 후 결과가 담긴다. 이렇게 되면 PowerArray
에 구현된 메서드를 사용할 수 있다는 장점이 생긴다.
물론 동작 방식을 변경할 수 있다.
특수한 정적 getter
인 Symbol.species
를 클래스에 추가할 수 있는데. Symbol.species
가 있으면 map
, filter
등의 메서드를 호출할 때 반환되어 만들어지는 객체의 생성자를 지정할 수 있다.
map
이나 filter
같은 내장 메서드가 일반 배열을 반환하도록 하려면 아래 예시처럼 Symbol.species
가 Array
를 반환하도록 하면 된다.
class PowerArray extends Array {
isEmpty() {
return this.length === 0;
}
// 클래스 내장 메서드는 반환 값에 명시된 클래스를 생성자로 사용한다
static get [Symbol.species]() {
return Array;
}
}
let arr = new PowerArray(1, 2, 5, 10, 50);
alert(arr.isEmpty()); // false
// filter는 arr.constructor[Symbol.species]를 생성자로 사용해 새로운 배열을 만든다.
let filteredArr = arr.filter(item => item >= 10);
// filteredArr는 PowerArray가 아닌 Array의 인스턴스이다.
alert(filteredArr.isEmpty()); // Error: filteredArr.isEmpty is not a function
이런 방식으로 더는 확장 기능이 전달되지 않는다.
내장 객체는 Object.keys
, Array.isArray
등의 자체 정적 메서드를 갖는다.
내장 클래스들은 서로 상속 관계를 맺는다. Array
는 Object
를 상속받는다.
일반적으로 한 클래스가 다른 클래스를 상속받으면 정적 메서드와 그렇지 않은 메서드 모두를 상속받는다. 그런데 내장 클래스는 다르다. 내장 클래스는 정적 메서드를 상속받지 못한다.
예를 들어보자. Array
와 Date
는 모두 Object
를 상속받기 때문에 두 클래스의 인스턴스에서는 Object.prototype
에 구현된 메서드를 사용할 수 있다. 그런데 내장 클래스 Array
의 [[Prototype]]
과 내장 클래스 Date
의 [[Prototype]]
는 Object
를 참조하지 않기 때문에 Array.keys()
나 Date.keys()
같은 정적 메서드를 인스턴스에서 사용할 수 없다.
아래는 Date
와 Object
의 관계를 나타낸 그림이다.
보다시피 클래스 Date
와 클래스 Object
를 직접 이어주는 링크([[Prototype]]
)이 없다. Date
와 Object
는 독립적이다. 오직, Date.protoype
만 Object.protoype
을 상속받는다.
instanceof
연산자를 사용하면 객체가 특정 클래스에 속하는지 아닌지 확인할 수 있다. 또, 상속 관계도 확인할 수 있다.
이러한 특징으로 intanceof
를 사용해서 매개변수의 타입에 따라 이를 다르게 처리하는 다형적인 함수를 만들어 객체 지향 프로그래밍의 특징인 다형성
을 구현할 수 있다.
obj instanceof Class
객체 obj
가 클래스 Class
에 속하거나, Class
를 상속받는 클래스에 속하면 true
가 반환된다.
class Rabbit {}
let rabbit = new Rabbit();
alert( rabbit instanceof Rabbit ); // true
instanceof
는 생성자 함수에서도 사용할 수 있다.
// 클래스가 아닌 생성자 함수
function Rabbit() {}
alert( new Rabbit() instanceof Rabbit ); // true
물론 Array
같은 내장 클래스에도 사용할 수 있다.
let arr = [1, 2, 3];
alert( arr instanceof Array ); // true
alert( arr instanceof Object ); // true
위 예시처럼 instanceof
연산자는 보통 프로토타입 체인을 거슬러 올라가면서 인스턴스 여부나 상속 여부를 확인한다. 그런데 정적 메서드 Symbol.hasInstance
를 사용하면 직접 확인하는 로직을 구현할 수도 있다.
obj instanceof Class
는 대략 아래와 같은 알고리즘으로 동작한다.
1. 클래스에 정적 메서드 Symbol.hasInstance
가 구현되어 있으면, obj instanceof Class
문이 실행될 때, Class[Symbol.hasInstance](obj)
가 호출된다. 호출 결과는 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
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를 반환한다.
자바스크립트는 단일 상속만을 허용하는 언어이다. 객체에는 단 하나의 [[Prototype]]
만 있을 수 있고, 클래스는 하나의 클래스만 상속받을 수 있다.
그런데 이런 제약사항이 한계처럼 느껴질 때가 있다. 예를 들어, 클래스 User
와 이벤트를 생성해준느 코드가 담긴 클래스 EventEmitter
가 있는데, EventEmitter
의 기능을 User
에 추가해서 사용자가 이벤트를 emit할 수 있게 해주고 싶다고 가정하자. 이럴 때 믹스인
이라는 개념을 사용하면 도움이 된다.
위키피디아에서 믹스인
은 다른 클래스를 상속받을 필요 없이, 이들 클래스에 구현되어있는 메서드를 담고 있는 객체(또는 클래스)라고 정의한다.
다시 말해, 믹스인
은 특정 행동을 실행해주는 메서드를 가지고있는데 단독으로는 쓰이지 않고 다른 클래스에 행동을 더해주는 용도로 사용된다.
객체 지향 언어에서 주로 다른 클래스들의 메서드 조합을 포함하는 클래스를 의미한다.
자바스크립트에서 믹스인을 구현할 수 있는 가장 쉬운 방법은 유용한 메서드 여러 개가 담긴 객체를 하나 만드는 것이다.
이렇게 하면 다수의 메서드를 원하는 클래스의 프로토타입에 쉽게 병할할 수 있다.
// 믹스인
let sayHiMixin = {
sayHi() {
alert(`Hello ${this.name}`);
},
sayBye() {
alert(`Bye ${this.name}`);
}
};
// 사용법
// 클래스 생성
class User {
constructor(name) {
this.name = name;
}
}
// 메서드 복사
Object.assign(User.prototype, sayHiMixin);
// 이제 User 클래스로 생성한 인스턴스는 인사를 할 수 있다.
new User("Dude").sayHi(); // Hello Dude
위 예시는 상속 없이 User
클래스에 믹스인을 사용해서 메서드만 간단히 복사했다. 믹스인을 활용하면 User
가 아래 예시처럼 다른 클래스를 상속받는 동시에, 믹스인에 구현된 추가 메서드도 사용할 수 있다.
class User extends Person {
// ...
}
Object.assign(User.prototype, sayHiMixin);
믹스인 안에서 믹스인 상속을 사용하는 것도 가능하다. 아래 예시에서 sayHiMixin
은 sayMixin
을 상속받는다.
let sayMixin = {
say(phrase) {
alert(phrase);
}
};
let sayHiMixin = {
__proto__: sayMixin, // (Object.create를 사용해 프로토타입을 설정할 수도 있다.)
sayHi() {
// 부모 메서드 호출
super.say(`Hello ${this.name}`); // (*)
},
sayBye() {
super.say(`Bye ${this.name}`); // (*)
}
};
class User {
constructor(name) {
this.name = name;
}
}
// 메서드 복사
Object.assign(User.prototype, sayHiMixin);
// 이제 User가 인사를 할 수 있다.
new User("Dude").sayHi(); // Hello Dude!
아래 그림처럼 객체 sayHiMixin
에서 부모 메서드 super.say()
를 호출하면, 클래스가 아닌 sayHiMixin
의 프로토타입에서 메서드를 찾는다.
이는 sayHi
와 sayBye
가 생성된 곳이 sayHiMixin
이기 때문이다. 따라서 메서드를 복사했더라도, 이 메서드들의 숨김 프로퍼티인 [[HomeObject]]
는 위 그림처럼 sayHiMixin
을 참조한다.
메서드의 super
가 [[HomeObject]]
가 가리키는 객체의 [[Prototype]]
내에서 부모 메서드를 찾기 때문에, 메서드는 클래스 User
[[Prototype]]
이 아닌 객체 sayHiMixin
의 [[Prototype]]
을 검색한다.
실제로 사용할 수 있는 믹스인을 만들어보자.
상당수의 브라우저 객체는 이벤트 생성이라는 중요한 기능을 가지고 있다.
이벤트는 정보를 필요로 하는 곳에 정보를 널리 알리는(broadcast) 훌륭한 수단이다.
아래 예시에서 클래스나 객체에 이벤트 관련 함수를 쉽게 추가할 수 있도록 해주는 믹스인을 만들어 보자. 믹스인은 다음의 메서드를 가질 것이다.
.trigger(name, [...data])
를 제공한다. 인수 name
은 이벤트 이름이고, 뒤따르는 조건부 인수는 이벤트 데이터 정보를 담는다..on(name, handler)
은 name
에 해당하는 이벤트에 리스너로 handler
함수를 추가한다. .on()
은 이벤트(name
)가 트리거 될 때 호출되고, .trigger
호출에서 인수를 얻는다..off(name, handler)
는 handler
리스너를 제거한다.위 믹스인을 추가하면 아래 예시들에 적용할 수있다.
user
가 "login"
이라는 이벤트를 생성할 수 있게 된다.calendar
는 user
가 생성한 이벤트인 "login"
을 듣고 사용자에 맞는 달력을 보여줄 수 있다.menu
가 "select"
라는 이벤트를 생성하고, 어떤 객체는 "select"
에 반응하는 이벤트 핸들러를 할당할 수 있다.이벤트 믹스인을 구현해보자.
let eventMixin = {
/**
* 이벤트 구독
* 사용패턴: menu.on("select", function(item) { ... }
*/
on(eventName, handler) {
if (!this._eventHandlers) this._eventHandler = {};
if (!this._eventHandlers[eventName]) {
this._eventHandlers[eventName] = [];
}
this._eventHandlers[eventName].push(handler);
},
/**
* 구독 취소
* 사용패턴: menu.off("select", handler)
*/
off(eventName, handler) {
let handlers = this._eventHandlers?.[eventName];
if (!handlers) return;
for (let i = 0; i < handlers.length; i++) {
if (handlers[i] === handler) {
handlers.splice(i--, 1);
}
}
},
/**
* 주어진 이름과 데이터를 기반으로 이벤트 생성
* 사용패턴: this.trigger("select", data1, data2);
*/
trigger(eventName, ...args) {
if (!this._eventHandlers?.[eventName]) {
return; // no handlers for that event name
}
// 핸들러 호출
this._eventHandlers[eventName].forEach(handler => handler.apply(this, args));
}
};
위 믹스인은 아래와 같이 동작한다.
on(eventName, handler)
메서드 : eventName
에 해당하는 이벤트가 발생하면 실행시킬 함수 handler
를 할당한다. 한 이벤트에 대응하는 핸들러가 여러 개 있을 때, 프로퍼티 _eventHandlers
는 핸들러가 담긴 배열을 저장한다.off(eventName, handler)
메서드 : 핸들러 리스트에서 handler
를 제거한다.trigger(eventName, ...args)
메서드 : 이벤트를 생성한다. _eventHandlers[eventName]
에 있는 모든 핸들러가 ...args
와 함께 호출된다.아래는 사용 예시다.
// 클래스 생성
class Menu {
choose(value) {
this.trigger("select", value);
}
}
// 이벤트 관련 메서드가 구현된 믹스인 추가
Object.assign(Menu.prototype, eventMixin);
let menu = new Menu();
// 메뉴 항목을 선택할 때 호출될 핸들러 추가
// 메뉴 항목을 선택할 때 호출될 핸들러 추가
menu.on("select", value => alert(`선택된 값: ${value}`));
// 이벤트가 트리거 되면 핸들러가 실행되어 얼럿창이 뜸
menu.choose("123"); // 얼럿창 메시지: Value selected: 123
이제 menu.on(...)
을 사용해서 메뉴 선택이라는 이벤트를 들을 수 있고, 이에 반응하는 코드를 추가할 수 있다.
위와 같은 믹스인 eventMixin
을 사용하면 특정 동작을 구현한 메서드들을 상속 체이닝에 포함시키지 않고도 원하는 클래스 모두에 추가할 수 있다.