19. Class

객체를 생성하고 상속을 관리하기 위한 간결한 문법적 표현이자 프로토타입 기반 상속을 추상화하여 객체 지향 프로그래밍(OOP), 클래스

Index

클래스의 정의

메서드

프로퍼티

상속에 의한 클래스 확장



1. 클래스의 정의

“클래스는 기본적으로 함수이며, 값처럼 사용할 수 있는 일급객체이다. “

JavaScript의 클래스는 기본적으로 프로토타입(prototype) 기바 객체지향 프로그래밍의 문법적 설탕(syntatic sugar)이다.
즉, 클래스는 기존 프로토타입 기반의 객체지향 문법을 더 직관적이고 사용하기 쉽게 만든 표현이다. JavaScript는 내부적으로 여전히 프로토타입 체인을 사용하여 상속과 메서드 공유를 구현한다.

🧐 Q. 문법적 설탕(Syntatic Sugar)이란?

문법적 설탕이란, 기존에 가능하던 기능을 더 간단하고 보기 좋은 방식으로 표현하기 위한 문법을 뜻합니다.

즉, 새로운 기능을 추가하는 것이 아니라, 기존 기능을 사용하기 편리하고 가독성을 높이는 방식으로 재구성한 것이다.

(1) 프로토타입 방식으로 객체 정의
// (1-1) 생성자 함수
function Person(name, age) {
    this.name = name;
    this.age = age;
}
// (1-2) 프로토타입 메서드
Person.prototype.sayHello = function() {
    console.log(`Hi, I'm ${this.name} and I'm ${this.age} years old.`);
};
// (1-3) 정적 메서드
Person.sayHello = function() {
  console.log('Hello')
}
const person1 = new Person("Alice", 25);
person1.sayHello(); // Hi, I'm Alice and I'm 25 years old.



(2) 클래스 방식으로 객체 정의
class Person {
// (2-1) 생성자
  constructor(name, age) {
        this.name = name;
        this.age = age;
    }
// (2-2) 프로토타입 메서드
    sayHello() {
        console.log(`Hi, I'm ${this.name} and I'm ${this.age} years old.`);
    }
// (2-3) 정적 메서드
  	static sayHello() {
      console.log('Hello!');
    }
}

const person1 = new Person("Alice", 25);
person1.sayHello(); // Hi, I'm Alice and I'm 25 years old.

차이점

  • 프로토타입 방식: 더 많은 코드와 복잡한 구조
  • 클래스 방식: 문법적으로 직관적이고, 객체지향 언어(C++, Java)와 비슷한 표현

하지만, 내부적으로 class로 작성된 코드는 여전히 프로토타입 제인을 사용해 동작한다.



2. 메서드

2.1 프로토타입 메서드

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

  sayHi() {
    console.log(`Hi! My name is ${this.name}`);
  }
}
const me = new Person("Kim");

me.sayHi(); // Hi! My name is Kim

🧐 Q. me.constructor 가 Person 클래스인데 왜 Person.prototype에 constructor 프로퍼티가 있을까요?

Answer)

constructor는 JavaScript 클래스의 특별한 메서드입니다.

  1. constructor는 클래스가 정의될 때, 클래스의 프로토타입(Person.prototype)에 정의되지 않습니다.
    • 실제로 constructor클래스 자체의 함수 정의 내부에 존재합니다.
    • 클래스의 인스턴스를 생성할 때, 이 constructor가 호출됩니다.
  2. Person.prototype에 연결된 이유는 메서드 상속 때문입니다.
    • 클래스 내부에서 정의된 모든 메서드(sayHi 같은)는 클래스의 프로토타입 객체(Person.prototype)에 저장됩니다.
    • constructor도 클래스의 인스턴스를 생성하는 역할을 수행하기 때문에 프로토타입 객체와 연결됩니다.


2.2 정적 메서드

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

  static sayHi() {
    console.log("Hi!");
  }
}

Person.sayHi(); // Hi!
Photo of Static methods, prototype methods, and prototype chain

Static methods, prototype methods, and prototype chain

Ungmo Lee. (2020). Modern Javascript DeepDive. wikibooks. p.431.



3. 프로퍼티

3.1 인스턴스 프로퍼티

class Person {
  constructur(name) {
    this.name = name;
  }
}

const me = new Person("Kim");
console.log(me); // Person {name: "Lee"}


3.2 접근자 프로퍼티

접근자 프로퍼티는 클래스에서도 사용할 수 있다.

Point!

(1) Getter는 속성에 접근할 때 동작하며, 해당 속성을 읽는 것처럼 보이지만 실제로는 함수가 호출됩니다.

(2) Setter는 속성에 값을 할당할 때 동작하며, 해당 속성에 값을 저장하는 것처럼 보이지만 실제로는 함수가 호출됩니다.

(3) 클래스의 메서드는 기본적으로 프로토타입 메서드가 된다. 따라서 클래스의 접근자 프로퍼티 또한 인스턴스 프로퍼티가 아닌 프로토타입의 프로퍼티가 된다.

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  // Getter: fullName에 접근할 때 호출됨
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }

  // Setter: fullName에 값을 할당할 때 호출됨
  set fullName(name) {
    [this.firstName, this.lastName] = name.split(" ");
  }
}

const me = new Person("Jinho", "Tak");

// Getter 호출
console.log(me.fullName); // "Jinho Tak"

// Setter 호출
me.fullName = "John Doe";
console.log(me.firstName); // "John"
console.log(me.lastName); // "Doe"


3.3 클래스 필드 정의제안 & private 필드 정의 & statics 필드 정의

class MyClass {
  // 정적 프로퍼티
  static staticProperty = 'I am a static property';

  // 인스턴스 프로퍼티
  // 😀 클래스 필드 문법을 사용하면 constructor 없이도 동일한 결과를 얻을 수 있습니다.
  instanceProperty = 'I am an instance property';

  // 비공개(Private) 프로퍼티
  #privateProperty = 'I am private';

  // 비공개 프로퍼티에 접근하는 메서드
  getPrivateProperty() {
    return this.#privateProperty;
  }
}

const instance = new MyClass();
console.log(MyClass.staticProperty); // "I am a static property"
console.log(instance.instanceProperty); // "I am an instance property"
console.log(instance.#privateProperty); 1)
console.log(instance.getPrivateProperty()); 2)

1) 클래스 외부에서 비공개 필드에 직접 접근하려는 시도
console.log(instance.#privateProperty);
// SyntaxError: Private field '#privateProperty' must be declared in an enclosing class

2) 클래스 내부에서 정의된 메서드로 #privateProperty에 접근
console.log(instance.getPrivateProperty());
// "I am private"



4. 상속에 의한 클래스 확장

4.1 클래스 상속

JavaScript에서 클래스는 extends 키워드를 사용하여 기존 클래스를 상속하고 확장할 수 있습니다. 이 문법은 자식 클래스가 부모 클래스의 속성과 메서드를 재사용하거나 확장할 수 있도록 지원한다.



4.2 extends 키워드

베이스 클래스(Base Class)

  • 기본 클래스로, 다른 클래스에 의해 상속되는 클래스를 의미한다.
  • 일반적으로 계층 구조의 최상위에 있는 클래스를 지칭한다.
  • 다른 말로 슈퍼클래스(Superclass)라고도 사용된다.

슈퍼클래스(Superclass)

  • 부모 클래스로, 다른 클래스(서브클래스)에 의해 상속되는 클래스다.
  • 자식 클래스는 슈퍼클래스의 속성과 메서드를 상속받는다.
  • 베이스 클래스와 비슷한 의미로 사용되지만, 슈퍼클래스는 계층 구조의 중간에서도 사용될 수 있다.

서브클래스(Subclass)

  • 자식 클래스로, 다른 클래스(슈퍼클래스)를 상속받아 기능을 확장하거나 재정의하는 클래스다.
  • 서브클래스는 슈퍼클래스의 속성과 메서드를 상속받으며, 필요하면 새로운 속성과 메서드를 추가할 수 있다.
class BaseClass {
  greet() {
    console.log("Hello from BaseClass");
  }
}

class SuperClass extends BaseClass {
  greet() {
    console.log("Hello from SuperClass");
  }
}

class SubClass extends SuperClass {
  greet() {
    console.log("Hello from SubClass");
  }
}

const subInstance = new SubClass();
subInstance.greet(); // "Hello from SubClass"



4.3 동적상속

  • 부모 클래스(슈퍼클래스)를 런타임에 동적으로 지정할 수 있습니다.

  • extends 키워드에는 정적 클래스뿐만 아니라 동적으로 반환된 클래스 또는 생성자 함수도 사용할 수 있습니다.

  • 동적 상속은 상속 계층 구조를 유연하게 설계하고, 상황에 따라 다른 부모 클래스를 사용할 수 있도록 합니다.

function getBaseClass() {
  return Math.random() > 0.5
    ? class A {
        greet() {
          console.log("Hello from A");
        }
      }
    : class B {
        greet() {
          console.log("Hello from B");
        }
      };
}

class Child extends getBaseClass() {
  greetChild() {
    console.log("Hello from Child");
  }
}

const instance = new Child();
instance.greet(); // "Hello from A" 또는 "Hello from B"
instance.greetChild(); // "Hello from Child"



4.4 서브클래스의 constructor

서브클래스에서 construtor를 생략하면 클래스에 다음과 같은 constructor가 암묵적으로 정의된다. args는 new 연산자와 함께 클래스를 호출할 때 전달한 인수의 리스트다.

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

class Child extends Parent {}

const child = new Child("Alice");
console.log(child.name); // "Alice"

암묵적으로 정의된 constructor는 다음과 같다.

class Child extends Parent {
  constructor(...args) {
    super(...args);
  }
}



4.5 super 키워드

4.5.1 super 호출

/* 
1) 서브클래스에서 `constructor` 를 생략하지 않는 경우 서브클래스의 `constructor`에서는 반드시 `super`를 호출해야 한다.
*/
class Parent {
  constructor(name) {
    this.name = name;
  }
}

class Child extends Parent {
  constructor(name, age) {
    // this.age = age; // super 호출 전에 this를 참조하면 에러 발생
    super(name); // 부모 클래스의 생성자 호출,
    this.age = age; // 이후에 this를 참조 가능
  }
}

const child = new Child("Alice", 25);
console.log(child.name); // "Alice"
console.log(child.age); // 25

/* 
2) 서브클래스의 `constructor`에서 `super`를 호출하기 전에는 `this`를 참조할 수 없다.
*/
class Parent {
  constructor(name) {
    this.name = name;
  }
}

class Child extends Parent {
  constructor(name, age) {
    // this.age = age; // super 호출 전에 this를 참조하면 에러 발생
    super(name); // 부모 클래스의 생성자 호출
    this.age = age; // 이후에 this를 참조 가능, 아닐경우 ReferenceError
  }
}

const child = new Child("Alice", 25);
console.log(child.name); // "Alice"
console.log(child.age); // 25

/* 
3) `super`는 반드시 서브클래스의 `constructor`에서만 호출한다. 서브클래스가 아닌 클래스의 `constructor`나 함수에서 `super`를 호출하면 에러가 발생한다.
*/
class Parent {
  constructor(name) {
    this.name = name;
  }
}

class Child extends Parent {
  constructor(name) {
    super(name); // 서브클래스의 constructor에서만 호출 가능
  }
}

const child = new Child("Alice");
console.log(child.name); // "Alice"


4.5.2 super 참조

JavaScript에서 super 참조는 [[HomeObject]]를 가지는 함수에서만 사용할 수 있다.

class Parent {
  greet() {
    console.log("Hello from Parent");
  }
}

class Child extends Parent {
  greet() {
    super.greet(); // Parent의 greet 메서드 호출
    console.log("Hello from Child");
  }
}

const child = new Child();
child.greet();
// 출력:
// "Hello from Parent"
// "Hello from Child"
/*
Child 클래스의 greet 메서드는 super.greet()를 호출하여 부모 클래스 Parent의 greet 메서드를 실행합니다.
이 동작이 가능한 이유는 greet 메서드가 클래스의 메서드로 정의되어 [[HomeObject]]를 가지고 있기 때문입니다.
*/

/*
2) 객체 리터럴의 축약 메서드에서 super 참조
*/
const parent = {
  greet() {
    console.log("Hello from Parent");
  },
};

const child = {
  greet() {
    super.greet(); // parent 객체의 greet 메서드 호출
    console.log("Hello from Child");
  },
};

// child의 프로토타입을 parent로 설정
Object.setPrototypeOf(child, parent);

child.greet();
// 출력:
// "Hello from Parent"
// "Hello from Child"
/*
child.greet는 축약 메서드로 정의되었으므로 [[HomeObject]]를 가집니다.
이를 통해 super.greet()를 호출할 때 parent.greet 메서드를 참조할 수 있습니다.
*/

/*
3) 일반 함수에서 super 참조
*/
class Parent {
  greet() {
    console.log("Hello from Parent");
  }
}

class Child extends Parent {
  greetFunction() {
    const func = function () {
      super.greet(); // 에러 발생
    };
    func();
  }
}

const child = new Child();
child.greetFunction();
// SyntaxError: 'super' keyword unexpected here
/*
일반 함수(위의 func)는 [[HomeObject]]를 가지지 않으므로 super를 참조할 수 없습니다.
*/

/*
4) 화살표 함수에서의 super
*/
class Parent {
  greet() {
    console.log("Hello from Parent");
  }
}

class Child extends Parent {
  greet() {
    const arrowFunc = () => {
      super.greet(); // 부모의 greet 메서드 호출
    };
    arrowFunc();
  }
}

const child = new Child();
child.greet();
// 출력:
// "Hello from Parent"
/*
화살표 함수는 this와 super를 상위 스코프에서 상속받으므로, super.greet()를 사용할 수 있습니다.
*/

super를 사용할 수 있는 함수

  • 클래스 메서드: 클래스 내부에서 정의된 메서드는 [[HomeObject]]를 가지므로 super를 사용할 수 있습니다.
  • 객체 리터럴의 축약 메서드: 객체의 축약 메서드는 [[HomeObject]]를 가지므로 super를 참조할 수 있습니다.
  • 화살표 함수: 자체적으로 [[HomeObject]]를 가지지 않지만, 상위 메서드에서 super를 상속받아 사용할 수 있습니다.

super를 사용할 수 없는 함수

  • 일반 함수: function 키워드로 정의된 함수는 [[HomeObject]]를 가지지 않으므로 super를 참조할 수 없습니다.


4.5.3 수퍼클래스의 정적메서드도 사용가능

class Parent {
  static staticMethod() {
    console.log("Hello from Parent");
  }
}

class Child extends Parent {}

// 서브클래스에서 슈퍼클래스의 정적 메서드 호출
Child.staticMethod(); // "Hello from Parent"



4.6 상속 클래스의 인스턴스 생성과정

추후 업데이트 예정



4.7 표준 빌트인 생성자 함수 확장

class MyArray extends Array {
  uniq() {
    return this.filter((v, i, self) => self.indexOf(v) === i);
  }

  average() {
    return this.reduce((pre, cur) => pre + cur, 0) / this.length;
  }
}

const myArray = new MyArray(1, 1, 2, 3);
console.log(myArray);

console.log(myArray.uniq());
console.log(myArray.average());

🧐 Q. MyArray 클래스에서 메서드(uniq, average)를 구현했을 때, Array의 기본 동작 때문에 새롭게 반환되는 배열이 MyArray가 아니라 Array의 인스턴스로 반환될 수 있다. 이경우 메서드 체이닝이 불가능한데 어떻게 해야 할까?

🧐 Q. 먼저 이런일이 왜 발생되는 걸까?

Array의 메서드(filter, map, slice 등)는 새로운 배열을 반환할 때, 기본적으로 Array 생성자를 사용하여 반환한다.
즉, 상속받은 MyArray 클래스에서 호출하더라도 새롭게 생성된 배열은 MyArray가 아닌 Array의 인스턴스다.

class MyArray extends Array {
  uniq() {
    return this.filter((v, i, self) => self.indexOf(v) === i); // 새로운 배열 반환
  }
}

const myArray = new MyArray(1, 1, 2, 3);
const uniqArray = myArray.uniq(); // filter 호출

console.log(uniqArray instanceof MyArray); // false
console.log(uniqArray instanceof Array); // true


Answer)

Symbol.species를 사용한 해결 방법

Array와 같은 클래스는 Symbol.species라는 특수한 속성을 가지고 있습니다. Symbol.speciesfilter, map 같은 메서드가 새롭게 배열을 반환할 때 사용할 생성자(constructor)를 지정합니다.

기본 동작:

  • Array의 기본 Symbol.speciesArray 생성자를 반환합니다.
  • 즉, filter 같은 메서드는 항상 기본 배열(Array)을 반환합니다.

Symbol.species를 커스터마이징:

MyArray 클래스에서 Symbol.speciesMyArray 생성자를 반환하도록 설정하면, filter, map 같은 메서드가 새 배열을 반환할 때 MyArray 인스턴스를 반환하도록 동작을 변경할 수 있습니다.

class MyArray extends Array {
  static get [Symbol.species]() {
    return MyArray; // 반환 타입을 MyArray로 설정
  }

  uniq() {
    return this.filter((v, i, self) => self.indexOf(v) === i); // MyArray 인스턴스를 반환
  }
}

const myArray = new MyArray(1, 1, 2, 3);
const uniqArray = myArray.uniq(); // MyArray 인스턴스를 반환

console.log(uniqArray instanceof MyArray); // true
console.log(uniqArray instanceof Array); // true
const result = myArray
  .uniq()
  .map((x) => x * 2)
  .average(); // 메서드 체이닝도 가능!
console.log(result); // (2 + 4 + 6) / 3 = 4

Symbol.species를 사용하여 반환 타입을 지정함으로써, MyArray 메서드가 항상 MyArray 인스턴스를 반환하도록 설정함으로써 문제를 해결한다!


🧐 Q. Symbol.speciesstatic getter로 정의하는 이유는 무엇일까?

Answer)

Symbol.species`가 클래스 전체(즉, 클래스의 모든 인스턴스)에서 일관되게 동작해야 하기 때문이다.!




reference: 모던자바스크립트 Deep Dive 25장. 클래스