22. 1. 20 자바스크립트) 클래스

divedeepp·2023년 1월 20일
0

JavaScript

목록 보기
9/11

클래스와 기본 문법

클래스 정의

클래스는 객체 지향 프로그래밍에서 특정 객체를 생성하기 위해 멤버 변수와 메서드를 정의하는 일종의 틀(템플릿)로, 객체를 정의하기 위한 상태(멤버 변수)와 메서드(함수)로 구성된다.

본래 과거의 자바스크립트는 클래스가 없는 프로토타입 기반의 객체 지향 언어지만, 모던 자바스크립트에 도입된 클래스 문법을 사용해서 클래스 기반 프로그래밍을 할 수 있게 되었다.

참고로, 도입된 클래스 문법은 사실 일종의 프로로타입 기반 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

클래스는 단순한 Syntatic sugar(편의 문법)가 아니다

// 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'
  1. 클래스에 정의된 메서드는 열거할 수 없다. 즉, 클래스의 prototype 프로퍼티에 추가된 메서드의 enumerable 플래그는 false이다.

  2. 클래스는 항상 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

클래스도 gettersetter를 지원한다. 이들 역시 prototype에 정의된다.

getset을 이용해서 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에는 항상 의도한 값이 들어가게 된다.

클래스 필드의 이런 기능은 브라우저 환경에서 메서드를 이벤트 리스너로 설정해야 할 때 특히 유용하다.


클래스 상속

클래스 상속을 사용하면 클래스를 다른 클래스로 확장하여 기존에 존재하던 기능을 토대로 새로운 기능을 만들 수 있다.

extends 키워드

동물과 관련된 객체를 만들 수 있는 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는 프로토타입을 기반으로 동작한다. extendsRabbit.prototype[[Prototype]]Animal.prototype으로 설정한다. 따라서, Rabbit.prototype에서 메서드를 찾지 못하면 Animal.prototype에서 메서드를 가져온다.

자바스크립트 엔진은 아래와 같은 절차를 따라서 객체의 메서드 rabbit.run의 존재를 확인한다.
1. 객체 rabbitrun이 있나 확인한다. 없다면, 2번을 수행한다.
2. rabbit의 프로토타입인 Rabbit.prototype에 메서드가 있나 확인한다. hide는 있으나, run은 없다. 없다면, 3번을 수행한다.
3. extends를 통해 관계가 만들어진 Rabbit.prototype의 프로토타입, Animal.prototyperun이 있나 확인한다. 찾았다!

자바스크립트의 내장 객체는 프로토타입을 기반으로 상속 관계를 맺는다. 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 Rabbitclass 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에 있는 필드가 사용된 것이다.

이렇게 자바스크립트는 오버라이딩시 필드와 메서드의 동작 방식이 미묘하게 다르다.

다행히도 이런 문제는 오버라이딩한 필드를 부모 생성자에서 사용할 때만 발생한다. 이런 차이가 왜 발생하는지 모르면 결과를 해석할 수 없기 때문에 별도의 공간을 만들어 필드 오버라이딩시 내부에서 벌어지는 일에 대해 자세히 알아보았다.

개발하다가 필드 오버라이딩이 문제가 되는 상황이 발생하면 필드 대신 메서드를 사용하거나 gettersetter를 사용하면 해결된다.

super 키워드와 [[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.comparearticle을 비교해주는 수단으로, 글 전체를 위에서 바라보면 비교를 수행한다. 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.compareAnimal.planet은 상속되어서 각각 Rabbit.compareRabbit.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

private, protected 프로퍼티와 메서드

객체 지향 프로그래밍에서 가장 중요한 원리 중 하나는 내부 인터페이스와 외부 인터페이스를 구분 짓는 캡슐화이다.

내부 인터페이스와 외부 인터페이스

객체 지향 프로그래밍에서 프로퍼티와 메서드는 두 그룹으로 분류된다.

  • 내부 인터페이스(internal interface) : 동일한 클래스 내의 다른 메서드에서는 접근할 수 있지만, 클래스 밖에서는 접근할 수 없는 프로퍼티와 메서드
  • 외부 인터페이스(external interface) : 클래스 밖에서도 접근 가능한 프로퍼티와 메서드

내부 인터페이스의 세부사항들은 서로의 정보를 이용해서 객체를 동작시킨다. 밖에서는 세부 요소를 알 수 없고, 접근도 불가능하다. 내부 인터페이스의 기능은 외부 인터페이스를 통해야만 사용할 수 있다.

이런 특징 때문에 외부 인터페이스만 알아도 객체를 가지고 무언가를 할 수 있다. 객체 안이 어떻게 동작하는지 알지 못해도 괜찮다는 점은 큰 장점으로 작용한다.

자바스크립트에는 아래와 같은 두 가지 타입의 객체 필드(프로퍼티와 메서드)가 있다.

  • public : 클래스 내/외부 어디서든지 접근할 수 있으며 외부 인터페이스를 구성한다.
  • private : 클래스 내부에서만 접근할 수 있으며 내부 인터페이스를 구성할 때 쓰인다.

자바스크립트 이외의 언어들에서는 클래스 자신과 자식 클래스에서만 접근을 허용하는 protected 필드를 지원한다. protected 필드는 private 필드와 비슷하지만, 자식 클래스에서도 접근이 가능하다는 점이 다르다.

protected 필드도 내부 인터페이스를 만들 때 유용하다. 자식 클래스의 필드에 접근해야 하는 경우가 많기 때문에, protected 필드는 private 필드보다 조금 더 광범위하게 사용된다.

자바스크립트는 protected 필드를 지원하지 않지만, protected를 사용하면 편리한 점이 많기 때문에 이를 모방해서 사용하는 경우가 많다.

지금까지 배운 프로퍼티 타입을 사용해서 커피머신을 구현해보자.

protected 프로퍼티로 프로퍼티 보호하기

먼저, 간단한 커피 머신 클래스를 만들어보자.

class CoffeeMachine {
  waterAmount = 0;	// 물통에 차 있는 물의 양

  constructor(power) {
    this.power = power;
    alert( `전력량이 ${power}인 커피머신을 만듭니다.` );
  }
}

// 커피 머신 생성
let coffeeMachine = new CoffeeMachine(100);

// 물 추가
coffeeMachine.waterAmount = 200;

현재 프로퍼티 waterAmountpowerpublic이다. 이들은 손쉽게 읽고 원하는 값으로 변경할 수 있는 상태이다.

이제 waterAmountprotected로 바꿔서 통제해보자. 예시로 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 문법을 사용해서 gettersetter 함수를 만들었다. 하지만 대부분은 아래와 같이 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._waterAmountthis._power를 사용해서 프로퍼티에 접근할 수 있다.

private 프로퍼티

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 프로퍼티 #waterAmountpublic 프로퍼티 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에 접근하려면 waterAmountgettersetter를 통해야 한다.

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에 구현된 메서드를 사용할 수 있다는 장점이 생긴다.

물론 동작 방식을 변경할 수 있다.

특수한 정적 getterSymbol.species를 클래스에 추가할 수 있는데. Symbol.species가 있으면 map, filter 등의 메서드를 호출할 때 반환되어 만들어지는 객체의 생성자를 지정할 수 있다.

map이나 filter 같은 내장 메서드가 일반 배열을 반환하도록 하려면 아래 예시처럼 Symbol.speciesArray를 반환하도록 하면 된다.

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 등의 자체 정적 메서드를 갖는다.

내장 클래스들은 서로 상속 관계를 맺는다. ArrayObject를 상속받는다.

일반적으로 한 클래스가 다른 클래스를 상속받으면 정적 메서드와 그렇지 않은 메서드 모두를 상속받는다. 그런데 내장 클래스는 다르다. 내장 클래스는 정적 메서드를 상속받지 못한다.

예를 들어보자. ArrayDate는 모두 Object를 상속받기 때문에 두 클래스의 인스턴스에서는 Object.prototype에 구현된 메서드를 사용할 수 있다. 그런데 내장 클래스 Array[[Prototype]]과 내장 클래스 Date[[Prototype]]Object를 참조하지 않기 때문에 Array.keys()Date.keys() 같은 정적 메서드를 인스턴스에서 사용할 수 없다.

아래는 DateObject의 관계를 나타낸 그림이다.

보다시피 클래스 Date와 클래스 Object를 직접 이어주는 링크([[Prototype]])이 없다. DateObject는 독립적이다. 오직, Date.protoypeObject.protoype을 상속받는다.


instanceof로 클래스 확인하기

instanceof 연산자를 사용하면 객체가 특정 클래스에 속하는지 아닌지 확인할 수 있다. 또, 상속 관계도 확인할 수 있다.

이러한 특징으로 intanceof를 사용해서 매개변수의 타입에 따라 이를 다르게 처리하는 다형적인 함수를 만들어 객체 지향 프로그래밍의 특징인 다형성을 구현할 수 있다.

instanceof 연산자

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)가 호출된다. 호출 결과는 truefalse 이어야 한다. 이런 규칙을 기반으로 instanceof의 동작을 커스터마이징 할 수 있다.

// canEat 프로퍼티가 있으면 animal이라고 판단할 수 있도록
// instanceof의 로직을 직접 설정한다.
class Animal {
  static [Symbol.hasInstance](obj) {
    if (obj.canEat) return true;
  }
}

let obj = { canEat: true };

alert(obj instanceof Animal);	// true
  1. 그런데, 대부분의 클래스에는 정적 메서드 Symbol.hasInstance가 구현되어있지 않다. 이럴 때는 일반적인 로직이 사용된다. obj instanceof ClassClass.prototype이 객체 obj의 프로토타입 체인 상의 프로토타입 중 하나와 일치하는지 확인한다. 비교는 차례 차례 거슬러 올라가면 확인한다.
obj.__proto__ === Class.prototype?
obj.__proto__.__proto__ === Class.prototype?
obj.__proto__.__proto__.__proto__ === Class.prototype?
...
// 이 중 하나라도 true라면 true를 반환한다.
// 그렇지 않고 체인의 끝에 도달하면 false를 반환한다.

믹스인(mixin)

자바스크립트는 단일 상속만을 허용하는 언어이다. 객체에는 단 하나의 [[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);

믹스인 안에서 믹스인 상속을 사용하는 것도 가능하다. 아래 예시에서 sayHiMixinsayMixin을 상속받는다.

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의 프로토타입에서 메서드를 찾는다.

이는 sayHisayBye가 생성된 곳이 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"이라는 이벤트를 생성할 수 있게 된다.
  • 객체 calendaruser가 생성한 이벤트인 "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을 사용하면 특정 동작을 구현한 메서드들을 상속 체이닝에 포함시키지 않고도 원하는 클래스 모두에 추가할 수 있다.


참고 문헌

https://ko.javascript.info/classes

profile
더깊이

0개의 댓글