• JavaScript 클래스 문법은 어떻게 쓰는 것일까?
    Frontend/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를 반환합니다.
    반응형

    댓글