[모던JS: Core] 클래스 (4)

KG·2021년 5월 23일
0

모던JS

목록 보기
17/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

내장 클래스 확장

1) 내장 클래스 확장

배열(Array), 맵(Map)과 같은 내장 클래스(객체) 역시 상속을 통한 확장이 가능하다. Array의 확장 예시를 살펴보자.

class PowerArray extends Array {
  isEmpty() {
    return this.length === 0;
  }
}

let arr = new PowerArray(1, 2, 5, 10);
console.log(arr.isEmpty());	// false

let filtered = arr.filter(item => item >= 5);
console.log(filtered);	// 5, 10
cnosole.log(filtered.isEmpty());	// false

위와 같이 내장 클래스를 쉽게 확장하고 사용할 수 있다. 이때 흥미로운 점은 배열의 내장 메서드인 filter, map 등은 상속받은 클래스인 PowerArray의 인스턴스(객체)를 반환한다는 점이다. 이 객체를 구현할 때 내부에서는 객체의 constructor 프로퍼티를 사용한다.

  • arr.constructor === PowerArray

따라서 filter 등의 내장 메서드가 호출될 때, 내부에서 기본 Array가 아닌 arr.constructor를 기반으로 새로운 배열이 만들어지고 이를 반환하기 때문에, 반환된 배열 역시 PowerArray 클래스의 일종이 된다.

물론 이러한 동작방식을 변경할 수 있다. 특수 정적 getterSymbol.species를 클래스에 추가할 수 있는데, 이 속성은 map, filter 등의 메서드를 호출할 때 만들어지는 객체의 생성자를 지정할 수 있다. 따라서 개발자가 원하는 생성자를 반환할 수 있다.

class PowerArray extends Array {
  isEmpty() {
    return this.length === 0;
  }
  
  // 내장메서드는 이제 반환값에 명시된 Array를 생성자로 사용
  static get [Symbol.species]() {
    return Array;
  }
}

let arr = new PowerArray(1, 2, 5, 10);
console.log(arr.isEmpty());	// false

let filtered = arr.filter(item => item >= 5);
console.log(filtered.isEmpty());	// Error

위에서 PowerArray 클래스는 Symbol.species에서 그냥 배열(Array)를 반환하도록 설정했기 때문에 filteredPowerArray의 내장 메서드를 사용하지 못하는 것을 볼 수 있다.

2) 내장 객체와 정적 메서드 상속

내장 객체는 Object.keys, Array.isArray 등과 같은 자체 정적 메서드를 가지고 있다. 앞서 살펴본 바와 같이 네이티브 클래스들은 클래스 간에도 상속관계를 맺고 있다. 즉 Array의 경우 Array.prototypeObject.prototype을 상속함과 동시에 Array 역시 Ojbect를 상속받는다.

일반적으로 한 클래스가 다른 클래스를 상속 받으면 정적 메서드와 그렇지 않은 메서드 모두를 상속받는다. 정적 메서드 및 프로퍼티 역시 클래스 상속을 통해 전달되기 때문이다.

그러나 내장 클래스는 이와 다르다. 내장 클래스는 정적 메서드를 상속받지 못한다. 즉 내장 클래스의 경우에는 클래스 간의 상속이 일어나지 않는다.

예를 들어 ArrayDate는 모두 Object를 상속받기 때문에 두 클래스의 인스턴스에서는 Object.prototype에 구현된 메서드를 사용할 수 있다. 그러나 인스턴스가 아닌 클래스 자체에서는 Array.[[Prototype]]Date.[[Prototype]]Object를 참조하지 않으므로 정적 메서드 또는 프로퍼티 사용이 불가하다.

내장 객체 간의 상속과 extends를 이용한 명시적인 상속의 가장 큰 차이점은 바로 위와 같다.

instanceof

instanceof 연산자를 사용하면 객체가 특정 클래스에 속하는지 아닌지를 확인할 수 있다. 또한 instanceof 연산자는 상속 관계 파악에도 활용할 수 있다. 이를 인수의 타입에 따라 다르게 처리하는 다형적인(polymorphic) 함수를 만드는데 응용할 수 있다.

1) instanceof 연산자

obj instanceof Class

objClass에 속하거나 Class를 상속받는 클래스에 속한다면 true를 반환한다. 또한 instanceof는 생성자 함수와 내장 클래스에서도 사용할 수 있다.

class Rabbit {}
let rabbit = new Rabbit();

console.log(rabbit instanceof Rabbit);	// true

function Rabbit() {}
let rabbit = new Rabbit();

console.log(rabbit instanceof Rabbit);	// true

let arr = [1, 2, 3];
console.log(arr instanceof Array);	// true
console.log(arr instanceof Object);	// true

instanceof 연산자는 보통 프로토타입 체인을 거슬러 올라가며 인스턴스 여부나 상속 여부를 확인한다. 이때 정적 메서드 Symbol.hasInstance를 사용하면 직접 확인 로직 설정이 가능하다. instanceof 연산자의 흐름은 다음과 같다.

  1. 클래스에 정적 메서드 Symbol.hasInstance가 구현되어 있다면 Class[Symbol.hasInstance](obj)가 호출되며, 결과는 true 또는 false를 반환한다. 이를 기반으로 커스터 마이징 역시 가능하다.
class Animal {
  static [Symbol.hasInstance](obj) {
    if (obj.canEat) return true;
  }
}

let obj = { canEat: true };
console.log(obj instanceof Animal);	// true
  1. 그러나 대부분의 클래스에서는 Symbol.hasInstance를 구현하지 않는다. 이럴땐 일반적인 로직이 적용된다. obj instanceOf ClassClass.prototypeobj 프로토타입 체인 상의 프로토타입 중 하나와 일치하는지 확인하여 결과를 반환한다. 비교는 아래서부터 차례로 실시하며 하나라도 일치한다면 true를 반환한다.
obj.__proto__ === Class.prototype ?
obj.__proto__.__proto__ = Class.prototype ?
obj.__proto__.__proto__.__proto__ = Class.prototype ?
...

한편 objAobjB의 프로토타입 체인 상 어딘가에 있다면 true를 반환해주는 메서드인 objA.isPrototypeOf(objB)도 있다. obj instanceof ClassClass.prototype.isPrototypeOf(obj)과 동일하다.

2) Object.prototype.toString

일반 객체를 문자열로 변화하면 [object Object]가 출력되는 것을 앞서 살펴보았다. 이는 toString의 구현방식 때문인데, toString의 숨겨진 기능을 사용하면 typeof 또는 instanceof 연산자의 대안으로 사용할 수 있다.

명세서에 따르면 객체에서 toString을 추출하는 것이 가능하다. 이렇게 추출한 메서드는 모든 값을 대상으로 실행할 수 있고, 호출 결과는 값에 따라 달라진다.

  • 숫자형 : [object Number]
  • 불린형 : [object Boolean]
  • null : [object Null]
  • undefined : [object Undefined]
  • 배열 : [object Array]
  • 그외 : 커스터마이징 가능
// toString 메서드를 변수에 복사
let objectToString = Object.prototype.toString;

let arr = [];

console.log(objectToString.call(arr));	// [object Array]

toString 알고리즘은 내부적으로 this를 검사하고 위에서 나열한 것과 같이 그에 상응하는 결과를 반환하기 때문에 위와 같이 call 내장 메서드를 통해 컨텍스트를 바꾸어 실행화면 위의 결과를 얻을 수 있다.

3) Symbol.toStringTag

특수 객체 프로퍼티 Symbol.toStringTag를 사용하면 toString의 동작을 커스터마이징 할 수 있다.

let user = {
  [Symbol.toStringTag]: "User"
};

console.log({}.toString.call(user));	// [object User]

대부분의 호스트 환경은 자체 객체에 이와 유사한 프로퍼티를 구현해 놓고 있다.

alert( window[Symbol.toStringTag]); // Window
alert( XMLHttpRequest.prototype[Symbol.toStringTag] ); // XMLHttpRequest

alert( {}.toString.call(window) ); // [object Window]
alert( {}.toString.call(new XMLHttpRequest()) ); // [object XMLHttpRequest]

이처럼 toStringtoStringTag를 사용하면 typeof 연산자를 원시 자료형 뿐만 아니라 내장 객체에도 확장해서 적용할 수 있는 훌륭한 대안으로 활용할 수 있다. 내장 객체의 타입 확인을 넘어 타입을 문자열로 반환받고 싶다면 instanceof 대신 {}.toString.call()을 사용할 수 있다.

동작대상반환값
typeof원시형문자열
{}.toString원시형, 내장 객체, Symbol.toStringTag가 있는 객체문자열
instanceof객체true 또는 false

믹스인(Mixin)

자바스크립트는 단일 상속만을 허용한다. 객체에는 단 하나의 [[Prototype]]만 있을 수 있으며, 클래스는 클래스 하나만을 상속받을 수 있다.

그런데 가끔 이러한 제약이 한계처럼 느껴질 수 있다. 하나의 클래스에서 이미 구현된 여러개의 클래스의 기능을 모두 포함하고 싶은 경우에는 상속을 통해서는 구현이 불가하다. 이런 경우 믹스인이라고 불리는 개념을 사용할 수 있다.

위키피디아에서는 믹스인을 다른 클래스를 상속 받을 필요 없이 이들 클래스에 구현되어 있는 메서드를 담고 있는 클래스라고 정의하고 있다. 즉 믹스인은 특정 행동을 실행해주는 메서드를 제공해주는데, 단독으로 쓰이지 않고 다른 클래스에 믹스되어 행동을 더해주는 용도로 사용된다.

1) 믹스인 예시

자바스크립트에서 믹스인을 구현할 수 있는 가장 쉬운 방법은 유용한 메서드 여러 개가 담긴 객체를 하나 만드는 것이다. 믹스인이라는 새로운 용어가 등장했지만 우리는 앞서서 이미 이와 유사한 기법을 살펴본 적이 있다. 객체의 prototype에 공통되는 로직을 추가해주는 방법에 대해 이미 살펴본 적이 있는데, 사실 믹스인은 이 개념과 크게 다르지않다. 클래스 또는 객체의 prototype에 사용할 추가적인 특정 행동을 병합하여 손쉽게 믹스인을 구현해보자.

// 믹스인 구현 - 메서드를 담고 있는 객체와 다를바 없다
let sayHiMixin = {
  sayHi() {
    console.log(`Hello ${this.name}`);
  },
  sayBye() {
    console.log(`Bye ${this.name}`);
  }
};

// 클래스 선언
class User {
  constructor(name) {
    this.name = name;
  }
}

// 선언된 클래스 프로토타입에 믹스인 추가
Object.assign(User.prototype, sayHiMixin);

let user = new User("KG");
user.sayHi();	// Hello KG
user.sayBye();	// Bye KG

믹스인은 위와 같이 할당해서 사용할 수 있다. 앞서 살펴본 프로토타입에 메서드를 저장하는 것과 별반 다를바가 없다는 것을 알 수 있다. 다만 믹스인이라는 객체로 이들 메서드를 통합하여 관리하고 있는 것을 볼 수 있다.

믹스인을 추가한 클래스는 자신의 프로토타입에 관련 메서드를 저장하고 있기 때문에, 해당 클래스를 상속 받는 자손 클래스들 역시 이 믹스인의 기능에 접근할 수 있다.

또한 믹스인은 extends 키워드를 통한 상속으로 메서드를 넘겨받는 것이 아니다. 따라서 이미 상속을 받은 클래스 역시 추가적인 상속은 불가하지만 믹스인을 통해 추가적인 기능을 더해줄 수 있다.

// User는 이미 상속을 받은 클래스
class User extends Person {
  // ...
}

// 상속을 받았더라도 믹스인은 여전히 추가 가능
Object.assign(User.prototype, sayHiMixin);

믹스인 안에서 믹스인 상속을 사용하는 것도 문제가 없다. 믹스인은 자바스크립트에서 결국 객체이고, 객체 간 상속이 가능하기 때문이다.

let sayMixin = {
  say(phrase) {
    console.log(phrase);
  }
}

let sayHiMixin = {
  // 또는 Object.create를 사용해 프로토타입 설정 가능(권장)
  __proto__: sayMixin,
  
  sayHi() {
    // super를 통해 [[HomeObject]]를 참조하여 부모 메서드 호출
    super.say(`Hello ${this.name}`);
  },
  sayBye() {
    // super를 통해 [[HomeObject]]를 참조하여 부모 메서드 호출
    super.say(`Bye ${this.name}`);
  }
}

class User {
  constructor(name) {
    this.name = name;
  }
}

Object.assign(User.prototype, sayHiMixin);

let user = new User('KG');
user.sayHi();	// Hello KG
user.sayBye();	// Bye KG

sayHiMixin에서 부모 메서드 super.say()를 호출하면 클래스가 아닌 sayHiMixin의 프로토타입에서 메서드를 찾는 것에 주목하자. 이를 그림으로 나타내면 다음과 같다.

믹스인 보통 객체로 생성하고, 이를 클래스의 prototype에 지정한다는 것에 항상 유의하자!

2) 이벤트 믹스인

자바스크립트는 이벤트 기반(Event Driven)의 특징을 가지고 있다. 이벤트는 정보를 필요로 하는 곳에 정보를 널리 알리는 훌륭한 수단이고 특히 브라우저에서 많이 활용된다. 따라서 자바스크립트에서는 이벤트를 기반으로 하여 함수의 동작이 많이 발생하는데, 아래 예시에서 클래스나 객체에 이벤트 관련 함수를 쉽게 추가할 수 있도록 해주는 믹스인을 만들어보자.

let eventMixin = {
  /**
   * 이벤트 구독
   * 사용패턴: menu.on('select', function(item) {...})
   */
  on(eventName, handler) {
    if (!this._eventHandlers) this._eventHandlers = {};
    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(evnetName, ...args) {
    if (!this._eventHandlers?.[eventName]) {
      return;
    }
    
    this._eventHandlers[eventName].forEach(handler => handler.apply(this, args));
  }
};
  • on(eventName, handler) : eventName에 해당하는 이벤트가 발생하면 실행시킬 함수 handler를 할당한다. 한 이벤트에 대응하는 핸들러가 여러개라면 배열을 저장한다.
  • off(eventName, handler) : 핸들러 리스트에서 handler를 제거한다.
  • trigger(eventName, ...args) : 이벤트를 생성한다.

만약 다음과 같이 menu 라는 객체가 있을때, 이 객체를 선택할 때 select라는 이벤트를 생성하고 다른 객체는 select에 반응하는 이벤트 핸들러를 할당할 수 있다.

class Menu {
  choose(value) {
    // (2) 밑에서 믹스인을 할당받고, 믹스인에 있는 trigger 실행
    this.trigger('select', value);
  }
}

// (1) 클래스 프로토타입에 믹스인 할당
Object.assign(Menu.prototype, eventMixin);

let menu = new Menu();

menu.on('select', value => console.log(value));

// 메뉴에 선택 이벤트가 발생
// 트리거는 `select`를 트리거하고, 연결된 핸들러 실행
menu.choose('123');	// 123 출력

믹스인의 핵심은 위와 같은 관련 동작을 프로토타입 체이닝 또는 상속 체이닝에 끼어들지 않고도 원하는 클래스 모두에 추가할 수 있다는 점이다.

믹스인을 만들때는 실수로 기존 클래스 메서드를 덮어쓸 수 있는데 이 경우에는 충돌이 발생하여 예상치 못한 결과를 마주칠 수 있다. 따라서 믹스인을 만들땐 기존의 메서드와 충돌이 나지 않도록 메서드 이름 또는 믹스인 이름의 중복을 피해야 한다.

References

  1. https://ko.javascript.info/classes
profile
개발잘하고싶다

0개의 댓글