[JavaScript] 클래스(Class) 이해하기

목적

자바스크립트는 프로토타입 기반 객체지향 언어다. 프로토타입 기반 프로그래밍은 클래스가 필요 없는 객체지향 프로그래밍 스타일로 프로토타입 체인과 클로저 등으로 객체 지향 언어의 상속, 캡슐화 등의 개념을 구현할 수 있다. 하지만 ES6부터는 클래스 기반 언어에 익숙한 프로그래머가 보다 빠르게 학습할 수 있는 단순 명료한 새로운 문법을 제시하고 있다. 우리는 자바스크립트 문법에서 사용하는 Class에 대해서 공부할 것이다.

클래스 정의

클래스는 다음과 같이 정의할 수 있다.

// 클래스 선언문
class Person {
  // constructor(생성자)
  constructor(name) {
    this._name = name
  }

  sayHi() {
    console.log(`Hi ${this._name}`)
  }
}

// 인스턴스 생성
const me = new Person('Lee')
me.sayHi() //Hi Lee

console.log(me instanceof Person) // true

클래스는 클래스 선언문 이전에 참조할 수 없다. 하지만 호이스팅이 발생하지 않는 것은 아니다.

const Foo = ''

{
  // 호이스팅이 발생하지 않는다면 ''가 출력되어야 한다.
  console.log(Foo)
  // ReferenceError: Cannot access 'Foo' before initialization
  class Foo {}
}

표현식으로도 클래스를 정의할 수 있다. 함수와 마찬가지로 클래스는 이름을 가질 수도 갖지 않을 수도 있다. 함수와 마찬가지로 클래스 표현식에서 사용한 클래스 이름은 외부 코드에서 접근 불가능하다.

// 클래스명 MyClass는 함수 표현식과 동일하게 클래스 몸체 내부에서만 유효한 식별자이다.
const Foo = class MyClass {}

const foo = new Foo()
console.log(foo) // MyClass {}

new MyClass() // ReferenceError: MyClass is not defined

인스턴스 생성

생성자 함수와 같이 new 연산자와 함께 클래스 이름을 호출하면 클래스의 인스턴스가 생성된다.

class Foo {}

const foo = new Foo()

new와 함께 호출한 Foo는 클래스가 아니라 constructor(생성자)이다. 생성자는 new 연산자 없이 호출할 수 없다.

constructor

constructor는 인스턴스를 생성하고 클래스 필드를 초기화하기 위한 특수한 메소드이다.

클래스 필드란?

자바스크립트의 생성자 함수에서 this에 추가한 프로퍼티를 클래스 기반 객체지향 언어에서는 클래스 필드라고 부른다.

class Person {
  // constructor(생성자). 이름을 바꿀 수 없다.
  constructor(name) {
    // _name의 클래스 필드이다.
    this._name = name
  }
}

// 인스턴스 생성
const me = new Person('Lee')
console.log(me)

constructor는 클래스 내에 한 개만 존재할 수 있다. 인스턴스를 생성할 때 new 연산자와 함께 호출한 것이 바로 constructor이다. constructor의 파라미터에 전달한 값은 클래스 필드에 할당한다. constructor는 생략할 수 있으나 위와 동일하게 동작한다. 즉 빈 객체를 생성하는데 따로 프로퍼티를 동적으로 추가해야 한다.

class Foo {}

const foo = new Foo()
console.log(foo) // Foo {}

// 프로퍼티 동적 할당 및 초기화
foo.num = 1
console.log(foo) // Foo { num: 1 }

constructor는 인스턴스 생성과 동시에 클래스 필드의 생성과 초기화를 실행한다. 따라서 클래스 필드를 초기화하기 위해선 constructor를 생략하면 안 된다.

클래스 필드

  • 클래스 몸체에서는 메소드만 선언 가능하다.
  • 클래스 필드의 선언과 초기화는 반드시 constructor 내부에서 실시한다.
class Foo {
  constructor(name = '') {
    this.name = name // 클래스 필드의 선언과 초기화
  }
}
const foo = new Foo('Lee')
console.log(foo) // Foo { name: 'Lee' }

constructor 내부에서 선언한 클래스 필드는 클래스가 생성할 인스턴스를 가리키는 this에 바인딩하고, 클래스 필드는 클래스가 생성할 인스턴스의 프로퍼티가 되며, 클래스의 인스턴스를 통해 클래스 외부에서 언제나 참조할 수 있다. 즉, 예를 들어 public이다.

getter, setter

getter

  • getter는 클래스 필드에 접근할 때마다 클래스 필드의 값을 조작하는 행위가 필요할 때 사용한다.
  • 메소드 이름 앞에 get 키워드를 사용해 정의한다.
class Foo {
  constructor(arr = []) {
    this._arr = arr
  }

  // getter: get 키워드 뒤에 오는 메소드 이름 firstElem은 클래스 필드 이름처럼 사용된다.
  get firstElem() {
    // getter는 반드시 무언가를 반환해야 한다.
    return this._arr.length ? this._arr[0] : null
  }
}

const foo = new Foo([1, 2])
// 필드 firstElem에 접근하면 getter가 호출된다.
console.log(foo.firstElem) // 1

setter

  • setter는 클래스 필드에 값을 할당할 때마다 클래스 필드의 값을 조작하는 행위가 필요할 때 사용한다.
  • 메도스 이름 앞에 set 키워드를 사용해 정의한다.
class Foo {
  constructor(arr = []) {
    this._arr = arr
  }

  // getter: get 키워드 뒤에 오는 메소드 이름 firstElem은 클래스 필드 이름처럼 사용된다.
  get firstElem() {
    // getter는 반드시 무언가를 반환하여야 한다.
    return this._arr.length ? this._arr[0] : null
  }

  // setter: set 키워드 뒤에 오는 메소드 이름 firstElem은 클래스 필드 이름처럼 사용된다.
  set firstElem(elem) {
    // ...this._arr은 this._arr를 개별 요소로 분리한다
    this._arr = [elem, ...this._arr]
  }
}

const foo = new Foo([1, 2])

// 클래스 필드 lastElem에 값을 할당하면 setter가 호출된다.
foo.firstElem = 100

console.log(foo.firstElem) // 100

getter와 setter는 접근자 프로퍼티(accessor property)이다.

정적 메소드

  • 클래스의 정적 메소드를 정의할 때 static 키워드를 사용한다.
  • 정적 메소드는 클래스 인스턴스가 아닌 클래스 이름으로 호출한다. 따라서 클래스의 인스턴스를 생성하지 않아도 호출할 수 있다.
class Foo {
  constructor(prop) {
    this.prop = prop
  }

  static staticMethod() {
    /*
    정적 메소드는 this를 사용할 수 없다.
    정적 메소드 내부에서 this는 클래스의 인스턴스가 아닌 클래스 자신을 가리킨다.
    */
    return 'staticMethod'
  }

  prototypeMethod() {
    return this.prop
  }
}

// 정적 메소드는 클래스 이름으로 호출한다.
console.log(Foo.staticMethod())

const foo = new Foo(123)
// 정적 메소드는 인스턴스로 호출할 수 없다.
console.log(foo.staticMethod()) // Uncaught TypeError: foo.staticMethod is not a function
var Foo = (function() {
  // 생성자 함수
  function Foo(prop) {
    this.prop = prop
  }

  Foo.staticMethod = function() {
    return 'staticMethod'
  }

  Foo.prototype.prototypeMethod = function() {
    return this.prop
  }

  return Foo
})()

var foo = new Foo(123)
console.log(foo.prototypeMethod()) // 123
console.log(Foo.staticMethod()) // staticMethod
console.log(foo.staticMethod()) // Uncaught TypeError: foo.staticMethod is not a function

결론

정적 메소드인 staticMethod는 생성자 함수 Foo의 메소드(함수는 객체이므로 메소드를 가질 수 있다.)이고, 일반 메소드인 prototypeMethod는 프로토타입 객체 Foo.prototype의 메소드이다. 따라서 staticMethod는 foo에서 호출할 수 없다.

클래스 상속

클래스 상속은 코드 재사용 관점에서 매우 유용하다. 새롭게 정의할 클래스가 기존에 있는 클래스와 유사하다면, 상속을 통해 그대로 사용하되 다른 점만 구현하면 된다. 코드 재사용은 개발 비용을 현저히 줄일 수 있다.

extends

extends 키워드는 부모 클래스를 상속받는 자식 클래스를 정의할 때 사용한다. 부모 클래스 Circle을 상속받는 자식 클래스 Cylinder를 정의해 보자.

// 부모 클래스
class Circle {
  constructor(radius) {
    this.radius = radius // 반지름
  }

  // 원의 지름
  getDiameter() {
    return 2 * this.radius
  }

  // 원의 둘레
  getPerimeter() {
    return 2 * Math.PI * this.radius
  }

  // 원의 넓이
  getArea() {
    return Math.PI * Math.pow(this.radius, 2)
  }
}

// 자식 클래스
class Cylinder extends Circle {
  constructor(radius, height) {
    super(radius)
    this.height = height
  }

  // 원통의 넓이: 부모 클래스의 getArea 메소드를 오버라이딩하였다.
  getArea() {
    // (원통의 높이 * 원의 둘레) + (2 * 원의 넓이)
    return this.height * super.getPerimeter() + 2 * super.getArea()
  }

  // 원통의 부피
  getVolume() {
    return super.getArea() * this.height
  }
}

// 반지름이 2, 높이가 10인 원통
const cylinder = new Cylinder(2, 10)

// 원의 지름
console.log(cylinder.getDiameter()) // 4
// 원의 둘레
console.log(cylinder.getPerimeter()) // 12.566370614359172
// 원통의 넓이
console.log(cylinder.getArea()) // 150.79644737231007
// 원통의 부피
console.log(cylinder.getVolume()) // 125.66370614359172

// cylinder는 Cylinder 클래스의 인스턴스이다.
console.log(cylinder instanceof Cylinder) // true
// cylinder는 Circle 클래스의 인스턴스이다.
console.log(cylinder instanceof Circle) // true

오버라이딩(Overriding) 상위 클래스가 가지고 있는 메소드를 하위 클래스가 재정의하여 사용하는 방식이다.

오버로딩(Overloading) 매개변수의 타입 또는 갯수가 다른, 같은 이름의 메소드를 구현하고 매개변수에 의해 메소드를 구별하여 호출하는 방식이다. 자바스크립트는 오버로딩을 지원하지 않지만 arguments 객체를 사용해 구현할 수는 있다.

super

super 키워드는 부모 클래스를 참조할 때 또는 부모 클래스의 constructor를 호출할 때 사용한다.

// 부모 클래스
class Circle {
...
}

class Cylinder extends Circle {
  constructor(radius, height) {
    // ① super 메소드는 부모 클래스의 constructor를 호출하면서 인수를 전달한다.
    super(radius);
    this.height = height;
  }

  // 원통의 넓이: 부모 클래스의 getArea 메소드를 오버라이딩하였다.
  getArea() {
    // (원통의 높이 * 원의 둘레) + (2 * 원의 넓이)
    // ② super 키워드는 부모 클래스(Base Class)에 대한 참조
    return (this.height * super.getPerimeter()) + (2 * super.getArea());
  }

  // 원통의 부피
  getVolume() {
    // ② super 키워드는 부모 클래스(Base Class)에 대한 참조
    return super.getArea() * this.height;
  }
}

// 반지름이 2, 높이가 10인 원통
const cylinder = new Cylinder(2, 10);

참고