Frontend/JavaScript

JavaScript 클래스 문법은 어떻게 쓰는 것일까?

엘리브가 2023. 2. 22. 15:45
반응형
프로토타입의 설명은 생략하였습니다.

클래스

  • 자바스크립트는 프로토타입 기반의 객체지향 언어입니다
  • 클래스 문법은 ES6(2015)에 와서야 추가되었습니다.
  • 클래스 문법이 등장 하기 전에는 어떤 방식으로 객체지향 프로그래밍을 할 수 있었을까요?

클래스를 사용한 객체 생성

  • 먼저 널리 알려진 클래스 기반 프로그래밍 언어이자 객체지향 프로그래밍 언어의 대표주자로 불리는 Java와 비교를 하면서 예시를 들겠습니다.
  • JavaScript는 알고 Java를 모르시는 분이 다음과 같은 문법을 보게된다면 뭔가 어디서 본듯 한 느낌을 받을 것입니다.

Java의 클래스 사용법

public class Car {
    String name;
    int position;

    public Car(String name) {
        this.name = name;
        this.position = 0;
    }

    public void move() {
        position += 1;
    }
}
public class Main {
    public static void main(String[] args) {
        Car car = new Car("JavaCar");
        System.out.println(car.name + " " + car.position); // JavaCar 0
        car.move();
        System.out.println(car.name + " " + car.position); // JavaCar 1
    }
}

JavaScript의 클래스 사용법

class ClassCar {
  constructor(name) {
    this.name = name;
    this.position = 0;
  }

  move() {
    this.position += 1;
  }
}

const classCar = new ClassCar('classCar');
console.log(classCar); // ClassCar { name: 'classCar', position: 0 }
classCar.move();
console.log(classCar); // ClassCar { name: 'classCar', position: 1 }
  • 보시다시피 자바스크립트에서 사용한 클래스 문법은 언뜻 보기에 자바와 유사한 점이 많습니다.
  • Java 문법만 보던 사람이 위 예제를 보더라도 바로 이해하고 금방 사용할 수 있을 정도로 문법의 구성이 유사합니다.
  • 그렇다면 ES6 이전의 자바스크립트는 도대체 어떻게 객체를 생성했던 것일까요?
  • 자바스크립트에는 여러 객체 생성 방법이 있지만, 이 중 생성자 함수를 사용한 객체 생성에 대해서 알아보겠습니다.

생성자 함수를 사용한 객체 생성

const FunctionCar = (function () {
  function FunctionCar(name) {
    this.name = name;
    this.position = 0;
  }
  FunctionCar.prototype.move = function () {
    this.position += 1;
  };
  return FunctionCar;
}());

const funCar = new FunctionCar('funCar');
console.log(funCar); // FunctionCar { name: 'funCar', position: 0 }
funCar.move();
console.log(funCar); // FunctionCar { name: 'funCar', position: 1 }
  • 위의 생성자 함수와 프로토타입을 통해서 클래스 없이도 상속을 구현할 수 있다는 특징이 있습니다.

클래스 vs 생성자 함수

  • 앞서 언급했든 클래스는 ES6에 와서야 추가된 문법으로, 기존 자바스크립트의 프로토타입을 기반으로 만들어진 기능입니다.
  • 클래스는 문법적 설탕이라는 표현을 자주 쓰는데, 프로토타입을 조금 더 쉽게 사용할 수 있도록 문법이 추가된 기능이기 때문입니다.
  • 클래스는 new 연산자 없이 호출하면 에러가 발생합니다.
  • 생성자 함수는 new 연산자 없이 호출하면 일반 함수로 호출됩니다.
  • 클래스는 상속을 지원하는 extends와 super 키워드가 존재하지만, 생성자 함수는 이들을 지원하지 않습니다.
  • 클래스는 항상 use strict 모드로 동작합니다.

클래스 생성

class Car {
  constructor(name) { // 생성자
    this.name = name;
    this.position = 0;
  }

  move() { // 메소드
    this.position += 1;
  }

  honk() { // 메소드
    console.log(`${this.name} : ${'빵'.repeat(this.position)}`);
  }
}

const car = new Car('가브리엘');
  • 앞서 예시 자료를 보여줬지만, 작성된 클래스는 new 키워드로 인스턴스화 할 수 있습니다. 인스턴스는 클래스로 생성된 오브젝트(객체)를 의미합니다.
  • 생성된 오브젝트(객체)는 변수에 할당하여 접근할 수 있습니다.
  • new로 객체를 생성하면 constructor가 자동으로 실행되고, 생성자에서 넘겨준 이름과 위치의 초기 값을 멤버 변수에 할당하게 됩니다.
    • 클래스 필드는 constructor 메서드 내부에서만 정의할 수 있었으나, 최근에는 클래스 몸체에 직접 정의하는 것이 가능합니다.
  • 객체가 생성된 이후에는 객체에 포함되어있는 객체 메서드를 호출할 수 있습니다.
  • 위 예시에서는 move()와 honk()를 메서드로 정했습니다.
    • 클래스를 new 키워드로 생성하면 Car라는 이름을 가진 함수를 만들게 되고, 함수에는 생성자 메서드인 constructor에서 가져옵니다.
    • 나머지 메서드들은 Car.prototype에 저장하게 됩니다.
    • 객체를 만들고, 객체의 메서드를 호출하면 prototype 프로퍼티를 통해서 가져오게 됩니다.
  •  

클래스를 생성하면 일어나는 일

// 클래스는 함수입니다.
console.log(typeof Car); // function

//  명확하게 말해서 클래스는 생성자 메서드와 동일합니다.
console.log(Car === Car.prototype.constructor); // true

// 클래스 내부에서 정의한 메서드는 Car.prototype에 저장됩니다. (일반적인 메서드는 프로퍼티에 저장됩니다.)
console.log(Car.prototype.move); // [Function: move]

// 현재 프로토타입의 메서드를 조회할 수 있습니다.
console.log(Object.getOwnPropertyNames(Car.prototype)); // [ 'constructor', 'move', 'honk' ]
  • 클래스를 뜯어보면 위와 같은 형태의 구성임을 알 수 있습니다.

메서드

  • 클래스 내부의 메서드는 0개 이상이어야 합니다.
  • 클래스 내부에서 정의할 수 있는 메서드는 생성자(constructor), 프로토타입 메서드, 정적 메서드 3가지 입니다.
    • constructor는 최대 1개 올 수 있습니다. 2개 이상 오는 경우 에러가 발생합니다.
    • constructor는 인스턴스를 생성하고 초기화하는 데 쓰는 특수한 메서드 입니다.
    • constructor는 자기 자신을 참조하고 있고, 암묵적으로 this를 반환하므로 별도의 return을 하지 않아야 합니다.
    • 클래스의 프로토타입 메서드는 move(){}처럼 프로토타입 프로퍼티를 직접 조작하지 않아도 기본적으로 프로토타입 메서드가 됩니다.
    • 클래스가 생성한 인스턴스는 생성자 함수와 같이 프로토타입 체인에 포함이 됩니다.
    프로토타입 메서드
    • 생성자 함수에서의 프로토타입 메서드는 앞선 생성자 함수의 예제의 FunctionCar.prototype.move = function () {}처럼 프로토타입에 직접 명시적으로 프로토타입에 메서드를 추가하였습니다.
    • 클래스 몸체에서 정의한 메서드는 인스턴스의 프로토타입에 존재하는 프로토타입 메서드가 됩니다.
    • 인스턴스는 프로토타입 메서드를 상속받아서 사용할 수 있습니다.
    • 클래스에 의해 생성된 인스턴스는 기존의 객체 생성 방식과 동일하게 적용되므로 생성자 함수와 마찬가지로 프로토타입 기반의 객체 생성 메커니즘을 가집니다.
    정적 메서드
    • 정적 메서드는 인스턴스를 생성하지 않고 호출이 가능한 메서드를 의미합니다.
    • 메서드 앞에 static을 입력하면 정적 메서드가 됩니다.
    • 정적 메소드는 프로토타입이 아닌 클래스 함수 자체에 설정합니다.

생성자 메서드

class Car {
  static staticMethod() {
    console.log(this === Car);
  }
}

Car.staticMethod(); // true
  • 예제처럼 인스턴스 생성 없이 호출이 가능합니다.
    class Car {
      constructor(name, date) {
        this.name = name;
        this.date = date;
      }
    
      static createCar() {
        // this는 Car입니다.
        return new this('가브리엘', new Date());
      }
    }
    
    const car = Car.createCar();
    
    console.log(car); // Car { name: '가브리엘', date: 2023-02-21T18:03:43.162Z }
    
    • 클래스를 new 키워드 (생성자) 없이 정적처리 하기 위해 사용됩니다.

getter/setter

  • 클래스는 접근자 프로퍼티로 getter와 setter를 지원합니다.
  • get 키워드와 set 키워드로 사용할 수 있습니다.
class Car {
  constructor(name) { // 생성자
    this.name = name;
    this.position = 0;
  }

  get name() {
    return this._name;
  }

  set name(newName) {
    this._name = newName;
  }
}

const car = new Car('가브리엘');
console.log(car); // Car { _name: '가브리엘', position: 0 }
console.log(car.name); // 가브리엘
car.name = '엘리브가';
console.log(car.name); // 엘리브가
  • 예제를 확인해보시면 초기 생성된 Car 객체의 이름은 가브리엘이고, 새로운 이름인 엘리브가로 덮어씌우는 모습을 확인할 수 있습니다.
  • 특이한 점으로는 생성 직후에 car 객체를 출력해보면, 이름을 name이 아닌 _name으로 관리하는 것을 알 수 있습니다. 반면에 position은 처음 정해준 이름 그대로를 사용합니다.
  • 즉, constructor의 this.name은 사실 set을 의미한다는 것 입니다.
  • 그렇다면 왜 하필 this._name을 사용했을까요? 그냥 this.name을 해주면 안될까요?

getter/setter 오류

class Car {
  constructor(name) { // 생성자
    this.name = name;
  }

  get name() {
    return this.name;
  }

  set name(newName) {
    this.name = newName;
  }
}

const car = new Car('가브리엘');
lass-getter-setter-error.js:11
    this.name = newName;
              ^

RangeError: Maximum call stack size exceeded
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)
    at set name [as name] (/Users/gabrielyoon7/Code/wooteco_lv1/javascript-for-tecotalk/class/class-getter-setter-error.js:11:15)

Node.js v18.14.0
  • constructor의 this.name은 클래스 내부에 있는 set 함수를 조작하게 됩니다.
  • 그리고 set 함수에 있는 this.name도 자기 자신인 set 함수를 조작하게 됩니다.
  • 이 과정이 무한 반복됩니다.
  • 따라서 클래스 안에서 this.name = 어떤값; 의 표현이 set 함수를 건들라는 의미를 가지게 되므로, 저장하는 멤버 변수의 이름을 다른 이름인 this._name으로 관리를 하는 것입니다. (_표현법은 자바스크립트 개발자들 사이에서 등장한 암묵적인 표기법입니다.)
  • 어차피 이 객체의 멤버 변수는 getter/setter를 통해 접근하게 될 것이므로 실제 멤버변수의 이름이 무엇이든간에 상관이 없어지게 됩니다.
  • 최신 자바스크립트는 멤버변수의 private를 지원하므로 this.#name 따위로 관리하는 것이 조금 더 안전할 것입니다.

모호한 표현으로 인해 무한한 호출이 일어난다

priavte를 활용한 getter/setter

class Car {
  #name = '';

  constructor(name) { // 생성자
    this.name = name;
  }

  get name() {
    return this.#name;
  }

  set name(newName) {
    this.#name = newName;
  }
}

const car = new Car('가브리엘');
console.log(car); // Car {}
console.log(car.name); // 가브리엘
  • 당연하지만 name 변수가 private하게 관리되므로 car를 조회했을 때에는 아무것도 조회가 되지 않을 것입니다.

계산된 메소드 이름

class Car {
  ['honk' + 'ManyTimes']() {
    console.log('빵'.repeat(5));
  }
}

const car = new Car();
car.honkManyTimes(); // 빵빵빵빵빵
  • 메소드 이름을 계산하여 정하는 것도 가능합니다.

클래스 상속

  • 상속은 보통 클래스를 확장하기 위해 사용되는 기능입니다.
  • 자바스크립트의 객체는 기본적으로 프로토타입을 상속받고 있으나, 클래스의 상속은 기존 클래스를 상속받아 새로운 클래스를 확장하여 정의하는 것에 기인합니다.
  • extends 키워드는 클래스를 다른 클래스의 자식으로 만들기 위해 사용됩니다.
class Vehicle {
  constructor(name) {
    this.name = name;
    this.position = 0;
  }

  move() {
    this.position += 1;
  }
}

class Car extends Vehicle {
  honk() {
    console.log(`${this.name} : ${'빵'.repeat(this.position)}`);
  }
}

const car = new Car('가브리엘');
console.log(car); // Car { name: '가브리엘', position: 0 }

car.move();
car.move();
car.move();
console.log(car); // Car { name: '가브리엘', position: 3 }

car.honk(); // 가브리엘 : 빵빵빵

console.log(car instanceof Car); // true
console.log(car instanceof Vehicle); // true
  • 탈것인 Vehicle을 상속받은 Car 예제입니다.
  • Car 객체는 move() 메소드가 없음에도 불구하고 움직일 줄 알고, honk()도 가능합니다.
  • 상속을 통해 확장된 클래스는 서브클래스/파생클래스/자식클래스 등으로 불리고
  • 상속을 해준 클래스는 수퍼클래스/베이스클래스/부모클래스 등으로 부릅니다.

super 키워드 (생성자)

  • super를 호출하면 수퍼클래스의 constructor를 호출합니다.
  • 수퍼클래스의 constructor 내부에서 추가한 프로퍼티를 그대로 갖는 인스턴스를 생성한다면 서브클래스의 constructor를 생략할 수 있습니다.
  • 예를 들면 Car 객체는 consturctor가 없지만 암묵적으로 정의된 constructor의 super가 활성화되어 부모 객체인 Vehicle객체의 constructor에 전달됩니다.
  • super는 반드시 서브클래스의 constructor에서만 호출할 수 있습니다.
class Base {
  constructor(a, b) {
    this.a = a;
    this.b = b;
  }
}

class Derived extends Base {
  constructor(a, b, c) {
    super(a, b);
    this.c = c;
  }
}

const derived = new Derived(1, 2, 3);
console.log(derived); // Derived { a: 1, b: 2, c: 3 }
  • new로 호출한 Derived 객체는 우선 Derived 클래스의 constructor에 전달됩니다.
  • 이때 전달된 인자는 1, 2, 3인데, super(a, b)에 의해서 Base의 constructor에 a, b가 전달되어 a, b를 저장하게 됩니다.
  • 그리고 그 다음 줄인 this.c = c;에서 c를 저장하게됩니다.

super 키워드 (참조)

메서드 내에서 super를 참조하는 경우 수퍼클래스의 메서드를 호출합니다.

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

  sayHi() {
    return `Hi${this.name}`;
  }
}차

class Derived extends Base {
  sayHi() {
    return `${super.sayHi()}`;
  }
}

const derived = new Derived('가브리엘');
console.log(derived.sayHi()); // Hi가브리엘
  • super 참조로 수퍼클래스의 메서드를 참조하려면 super가 수퍼클래스의 메서드가 바인딩된 객체, 즉, 수퍼클래스의 prototype 프로퍼티에 바인딩 된 프로토타입을 참조할 수 있어야 합니다.

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

  • 상속 관계에 있는 두 클래스가 협력을 어떻게 하는지 예제를 통해 살펴보면 다음과 같습니다.
class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(`${this.name}은(는) 소리를 냅니다.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  speak() {
    super.speak();
    console.log(`${this.name}은(는) 짖습니다.`);
  }

  getBreed() {
    console.log(`${this.name}의 품종은 ${this.breed} 입니다.`);
  }
}

const dog = new Dog('뽀삐', '말티즈');
dog.speak(); // 뽀삐은(는) 소리를 냅니다. 뽀삐은(는) 짖습니다.
dog.getBreed(); // 뽀삐의 품종은 말티즈 입니다.
  • 강아지와 동물의 관계는 상속 관계입니다.
  • 동물은 소리를 낼 수 있습니다.
  • 강아지는 짖는 소리도 내고, 구체적인 품종이 있습니다.
  • 따라서 Animal의 speak() 을 상속하되, 추가 동작을 수행할 수 있습니다.
  • getBreed()는 강아지의 고유 메서드로, 부모에게 없는 메서드를 추가하여 사용합니다.
class Animal {
  constructor(name) {
    console.log(1);
    console.log(this); // Dog {}
    this.name = name;
    console.log(2);
    console.log(this); // Dog { name: '뽀삐' }
  }

  speak() {
    console.log(`${this.name}은(는) 소리를 냅니다.`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    console.log(3);
    console.log(this); // Dog { name: '뽀삐' }
    this.breed = breed;
    console.log(4);
    console.log(this); // Dog { name: '뽀삐', breed: '말티즈' }
  }

  speak() {
    super.speak();
    console.log(`${this.name}은(는) 짖습니다.`);
  }

  getBreed() {
    console.log(`${this.name}의 품종은 ${this.breed} 입니다.`);
  }
}

const dog = new Dog('뽀삐', '말티즈');
dog.speak(); // 뽀삐은(는) 소리를 냅니다. 뽀삐은(는) 짖습니다.
dog.getBreed(); // 뽀삐의 품종은 말티즈 입니다.
  • 실행 순서를 보면 위와 같습니다.
  • Animal의 생성자에서 this를 출력해보면 Animal이 아닌 Dog가 출력되는 것을 확인할 수 있는데, 이는 new 연산자가 사용된 곳이 Dog이기 떄문입니다.
  • Dog 의 생성자의 super()는 Animal에서 반환한 인스턴스가 this에 바인딩되기 때문에, Dog에 부모의 프로퍼티가 살아있는 것입니다.
  • 서브클래스의 constructor에서 super키워드 이전에 this를 사용하려고 하면 오류가 나는데, super가 호출되지 않으면 인스턴스가 생성되지 않고, this 바인딩도 할 수 없기 때문입니다.
  • 클래스의 모든 처리가 끝나면 완성된 인스턴스가 바인딩 된 this를 반환합니다.
반응형