Study JavaScript 0706 - 클래스 상속(1)

변승훈·2022년 7월 6일
0

Study JavaScript

목록 보기
40/43

클래스 상속

클래스 상속을 사용하면 클래스를 다른 클래스로 확장할 수 있다.
기존의 기능을 토대로 새로운 기능을 만들 수 있다는 것이다.

1. 'extends'

먼저 Animal이라는 클래스를 만들어 보자.

class Animal {
  constructor(name) {
    this.speed = 0;
    this.name = name;
  }
  run(speed) {
    this.speed = speed;
    console.log(`${this.name} 은/는 속도 ${this.speed}로 달립니다.`);
  }
  stop() {
    this.speed = 0;
    console.log(`${this.name} 이/가 멈췄습니다.`);
  }
}

let animal = new Animal("동물");

객체 animal과 클래스 Animal의 관계를 그림으로 나타내면 아래와 같다.

또 다른 클래스 Rabbit을 만들어보자.

토끼는 동물이므로 Rabbit은 동물 관련 메소드가 담긴 Animal을 확장해서 만들어야 하며, 이렇게 하면 토끼도 동물이 할 수 있는 ‘일반적인’ 동작을 수행할 수 있다.

클래스 확장 문법 class Child extends Parent를 사용해 클래스를 확장해 보자!

Animal을 상속받는 class Rabbit를 만들어 보자.

class Rabbit extends Animal {
  hide() {
    console.log(`${this.name} 이/가 숨었습니다!`);
  }
}

let rabbit = new Rabbit("흰 토끼");

rabbit.run(5); // 흰 토끼 은/는 속도 5로 달립니다.
rabbit.hide(); // 흰 토끼 이/가 숨었습니다!

클래스 Rabbit을 사용해 만든 객체는 rabbit.hide() 같은 Rabbit에 정의된 메소드에도 접근할 수 있고, rabbit.run() 같은 Animal에 정의된 메소드에도 접근할 수 있다.

키워드 extends는 프로토타입을 기반으로 동작한다. extendsRabbit.prototype.[[Prototype]]Animal.prototype으로 설정한다. 그렇기 때문에 Rabbit.prototype에서 메소드를 찾지 못하면 Animal.prototype에서 메소드를 가져온다.

엔진은 다음 절차를 따라 메소드 rabbit.run의 존재를 확인한다(그림을 아래부터 보자!).

  1. 객체 rabbitrun이 있나 확인한다, run이 없다.
  2. rabbit의 프로토타입인 Rabbit.prototype에 메소드가 있나 확인한다. hide는 있는데 run이 없다.
  3. extends를 통해 관계가 만들어진 Rabbit.prototype의 프로토타입, Animal.prototype에 메소드가 있나 확인한다. 메소드 run을 찾았다.

내장 객체의 프로토타입에서 알아본 것처럼 javascript의 내장 객체는 프로토타입을 기반으로 상속 관계를 맺는다. Date.prototype.[[Prototype]]Object.prototype인 것처럼..
Date 객체에서 일반 객체 메소드를 사용할 수 있는 이유가 바로 여기에 있다.

※ extends 뒤에 표현식이 올 수도 있다.
이 방법은 조건에 따라 다른 클래스를 상속받고 싶을 때 유용하게 사용할 수 있다.

2. 메소드 오버라이딩

메소드 오버라이딩은 메소드를 재선언 하여 사용하는 것이다.

특별한 사항이 없으면 class Rabbitclass Animal에 있는 메소드를 ‘그대로’ 상속받는다.

그런데 Rabbit에서 stop() 등의 메소드를 자체적으로 정의하면, 상속받은 메소드가 아닌 자체 메소드가 사용된다.

class Rabbit extends Animal {
  stop() {
    // rabbit.stop()을 호출할 때
    // Animal의 stop()이 아닌, 이 메소드가 사용-
  }
}

개발을 하다 보면 부모 메소드 전체를 교체하지 않고, 부모 메소드를 토대로 일부 기능만 변경하고 싶을 때가 생긴다. 부모 메소드의 기능을 확장하고 싶을 때도 있다. 이럴 때 커스텀 메소드를 만들어 작업하게 되는데, 이미 커스텀 메소드를 만들었더라도 이 과정 전·후에 부모 메소드를 호출하고 싶을 때가 있다.

키워드 super는 이럴 때 사용한다.

  • super.method(...)는 부모 클래스에 정의된 메소드, method를 호출한다.
  • super(...)는 부모 생성자를 호출하는데, 자식 생성자 내부에서만 사용 할 수 있다.

이런 특징을 이용해 토끼가 멈추면 자동으로 숨도록 하는 코드를 만들어보자~

class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  run(speed) {
    this.speed = speed;
    console.log(`${this.name}가 속도 ${this.speed}로 달립니다.`);
  }

  stop() {
    this.speed = 0;
    console.log(`${this.name}가 멈췄습니다.`);
  }

}

class Rabbit extends Animal {
  hide() {
    console.log(`${this.name}가 숨었습니다!`);
  }

  stop() {
    super.stop(); // 부모 클래스의 stop을 호출해 멈추고,
    this.hide(); // 숨습니다.
  }
}

let rabbit = new Rabbit("흰 토끼");

rabbit.run(5); // 흰 토끼가 속도 5로 달립니다.
rabbit.stop(); // 흰 토끼가 멈췄습니다. 흰 토끼가 숨었습니다!

Rabbit은 이제 실행 중간에 부모 클래스에 정의된 메소드 super.stop()을 호출하는 stop을 가지게 되었다!

※ 화살표 함수엔 super가 없다.
super에 접근하면 외부 함수에서 가져온다.

3. 생성자 오버라이딩

생성자 오버라이딩은 좀 더 까다롭다.

지금까진 Rabbit에 자체 constructor가 없었다.

명세서에 따르면, 클래스가 다른 클래스를 상속받고 constructor가 없는 경우엔 아래처럼 ‘비어있는’ constructor가 만들어진다.

class Rabbit extends Animal {
  // 자체 생성자가 없는 클래스를 상속받으면 자동으로 만들어짐
  constructor(...args) {
    super(...args);
  }
}

보다시피 생성자는 기본적으로 부모 constructor를 호출하는데, 이때 부모 constructor에도 인수를 모두 전달한다. 클래스에 자체 생성자가 없는 경우엔 이런 일이 모두 자동으로 일어난다.

이제 Rabbit에 커스텀 생성자를 추가해보자. 커스텀 생성자에서 nameearLength를 지정해보자!

class Animal {
  constructor(name) {
    this.speed = 0;
    this.name = name;
  }
  // ...
}

class Rabbit extends Animal {

  constructor(name, earLength) {
    this.speed = 0;
    this.name = name;
    this.earLength = earLength;
  }

  // ...
}

// 동작하지 않습니다!
let rabbit = new Rabbit("흰 토끼", 10); // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

에러가 발생하여 토끼를 만들 수 없다.

이유는 다음과 같다.

  • 상속 클래스의 생성자에선 반드시 super(...)를 호출해야 하는데, super(...)를 호출하지 않아 에러가 발생했다. 즉, super(...)this를 사용하기 전에 반드시 호출해야 한다!

그런데 왜 super(...)를 호출해야 하는 걸까?

상속 클래스의 생성자가 호출될 때 어떤 일이 일어나는지 알아보며 이유를 찾아보자!

javascript는 '상속 클래스의 생성자 함수(derived constructor)'와 그렇지 않은 생성자 함수를 구분한다. 상속 클래스의 생성자 함수엔 특수 내부 프로퍼티인 [[ConstructorKind]]:"derived"가 이름표처럼 붙는다.

일반 클래스의 생성자 함수와 상속 클래스의 생성자 함수 간 차이는 new와 함께 드러난다.

  • 일반 클래스가 new와 함께 실행되면, 빈 객체가 만들어지고 this에 이 객체를 할당한다.
  • 상속 클래스의 생성자 함수가 실행되면, 일반 클래스에서 일어난 일이 일어나지 않는다. 상속 클래스의 생성자 함수는 빈 객체를 만들고 this에 이 객체를 할당하는 일을 부모 클래스의 생성자가 처리해주길 기대한다.

이런 차이 때문에 상속 클래스의 생성자에선 super를 호출해 부모 생성자를 실행해 주어야 하며, 그렇지 않으면 this가 될 객체가 만들어지지 않아 에러가 발생한다.

아래 예시와 같이 this를 사용하기 전에 super()를 호출하면 Rabbit의 생성자가 제대로 동작한다!

class Animal {

  constructor(name) {
    this.speed = 0;
    this.name = name;
  }

  // ...
}

class Rabbit extends Animal {

  constructor(name, earLength) {
    super(name);
    this.earLength = earLength;
  }

  // ...
}

// 이제 에러 없이 동작합니다.
let rabbit = new Rabbit("흰 토끼", 10);
console.log(rabbit.name); // 흰 토끼
console.log(rabbit.earLength); // 10

클래스 필드 오버라이딩: 까다로운 내용

오버라이딩은 메소드뿐만 아니라 클래스 필드를 대상으로도 적용할 수 있다.

부모 클래스의 생성자 안에 있는 오바라이딩한 필드에 접근하려고 할 때 javascript는 다른 프로그래밍 언어와는 다르게 조금 까다롭다.

예시를 살펴보자.

class Animal {
  name = 'animal'

  constructor() {
    console.log(this.name); // (*)
  }
}

class Rabbit extends Animal {
  name = 'rabbit';
}

new Animal(); // animal
new Rabbit(); // animal

Animal을 상속받는 Rabbit에서 name 필드를 오버라이딩 했다.

Rabbit에는 따로 생성자가 정의되어 있지 않기 때문에 Rabbit을 사용해 인스턴스를 만들면 Animal의 생성자가 호출된다.

흥미로운 점은 new Animal()new Rabbit()을 실행할 때, 두 경우 모두 (*)로 표시한 줄에 있는 console.log 함수가 실행되면서 console창에 animal이 출력된다는 점이다.

이를 통해 우리는 ‘부모 생성자는 자식 클래스에서 오버라이딩한 값이 아닌, 부모 클래스 안의 필드 값을 사용한다’ 는 사실을 알 수 있다!

그런데 클래스 필드는 자식 클래스에서 필드를 오버라이딩해도 부모 생성자가 오버라이딩한 필드 값을 사용하지 않는다. 부모 생성자는 항상 부모 클래스에 있는 필드의 값을 사용한다.

이유는 필드 초기화 순서 때문인데, 클래스 필드는 다음과 같은 규칙에 따라 초기화 순서가 달라진다.

  • 아무것도 상속받지 않는 베이스 클래스는 생성자 실행 이전에 초기화됨
  • 부모 클래스가 있는 경우엔 super() 실행 직후에 초기화됨

이렇게 javascript는 오버라이딩시 필드와 메소드의 동작 방식이 미묘하게 다르다.

이런 문제는 오버라이딩한 필드를 부모 생성자에서 사용할 때만 발생한다.

개발하다가 필드 오버라이딩이 문제가 되는 상황이 발생하면 필드 대신 메소드를 사용하거나 getter나 setter를 사용해 해결하면 된다.

profile
잘 할 수 있는 개발자가 되기 위하여

0개의 댓글