클래스 상속

RHUK2·2022년 4월 28일
0

Javascript

목록 보기
42/56
post-custom-banner

📚 Reference


javascript.info, https://ko.javascript.info/class-inheritance

참고 사이트에 내용을 개인적으로 복습하기 편하도록 재구성한 글입니다.
자세한 설명은 참고 사이트를 살펴보시기 바랍니다.


"extends" 키워드


먼저, 클래스 Animal을 만들어보겠습니다.

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

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

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

또 다른 클래스 Rabbit을 만들어보겠습니다.

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

클래스 확장 문법 class Child extends Parent를 사용해 클래스를 확장해 봅시다.

Animal을 상속받는 class Rabbit를 만들어 보겠습니다.

class Rabbit extends Animal {
  hide() {
    alert(`${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의 존재를 확인합니다(그림을 아래부터 보세요).

🕐 객체 rabbitrun이 있나 확인합니다. run이 없네요.
🕑 rabbit의 프로토타입인 Rabbit.prototype에 메서드가 있나 확인합니다. hide는 있는데 run은 없습니다.
🕒 extends를 통해 관계가 만들어진 Rabbit.prototype의 프로토타입, Animal.prototype에 메서드가 있나 확인합니다. 드디어 메서드 run을 찾았습니다.

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


🔥 extends 뒤에 표현식이 올 수도 있습니다.

클래스 문법은 extends 뒤에 표현식이 와도 처리해줍니다.

아래 예시처럼 extends 뒤에서 부모 클래스를 만들어주는 함수를 호출할 수 있죠.

function f(phrase) {
  return class {
    sayHi() { alert(phrase) }
  }
}

class User extends f("Hello") {}

new User().sayHi(); // Hello

여기서 class Userf("Hello")의 반환 값을 상속받습니다.

이 방법은 조건에 따라 다른 클래스를 상속받고 싶을 때 유용합니다. 조건에 따라 다른 클래스를 반환하는 함수를 만들고, 함수 호출 결과를 상속받게 하면 됩니다.


메서드 오버라이딩


이제 한발 더 나아가 메서드를 오버라이딩 해봅시다. 특별한 사항이 없으면 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;
    alert(`${this.name}가 속도 ${this.speed}로 달립니다.`);
  }

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

}

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

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

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

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

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


🔥 화살표 함수엔 super가 없습니다.

화살표 함수 다시 살펴보기에서 살펴본 바와 같이, 화살표 함수는 super를 지원하지 않습니다.

super에 접근하면 아래 예시와 같이 super를 외부 함수에서 가져옵니다.

class Rabbit extends Animal {
  stop() {
    setTimeout(() => super.stop(), 1000); // 1초 후에 부모 stop을 호출합니다.
  }
}

화살표 함수의 superstop()super와 같아서 위 예시는 의도한 대로 동작합니다. 그렇지만 setTimeout안에서 "일반" 함수를 사용했다면 에러가 발생했을 겁니다.

// Unexpected super
setTimeout(function() { super.stop() }, 1000);

생성자 오버라이딩


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

지금까진 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(...)를 호출해야 하는 걸까요?

당연히 이유가 있습니다. 상속 클래스의 생성자가 호출될 때 어떤 일이 일어나는지 알아보며 이유를 찾아봅시다.

자바스크립트는 "상속 클래스의 생성자 함수(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);
alert(rabbit.name); // 흰 토끼
alert(rabbit.earLength); // 10

클래스 필드 오버라이딩


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

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

class Animal {
  name = "animal"

  constructor() {
    alert(this.name); // (*)
  }
}

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

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

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

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

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

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

상속을 받고 필드 값을 오버라이딩했는데 새로운 값 대신 부모 클래스 안에 있는 기존 필드 값을 사용하다니 이상하지 않나요?

이해를 돕기 위해 이 상황을 메서드와 비교해 생각해봅시다.

아래 예시에선 필드 this.name 대신에 메서드 this.showName()을 사용했습니다.

class Animal {
  showName() {  // this.name = "animal" 대신 메서드 사용
    alert("animal");
  }

  constructor() {
    this.showName(); // alert(this.name); 대신 메서드 호출
  }
}

class Rabbit extends Animal {
  showName() {
    alert("rabbit");
  }
}

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

필드를 오버라이딩한 위쪽 예시와 결과가 다르네요.

위와 같이 자식 클래스에서 부모 생성자를 호출하면 오버라이딩한 메서드가 실행되어야 합니다. 이게 우리가 원하던 결과죠.

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

왜 이런 차이가 있을까요?

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

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

위 예시에서 Rabbit은 하위 클래스이고 constructor()가 정의되어 있지 않습니다. 이런 경우 앞서 설명한 바와 같이 생성자는 비어있는데 그 안에 super(...args)만 있다고 보면 됩니다.

따라서 new Rabbit()을 실행하면 super()가 호출되고 그 결과 부모 생성자가 실행됩니다. 그런데 이때 하위 클래스 필드 초기화 순서에 따라 하위 클래스 Rabbit의 필드는 super() 실행 후에 초기화됩니다. 부모 생성자가 실행되는 시점에 Rabbit의 필드는 아직 존재하지 않죠. 이런 이유로 필드를 오버라이딩 했을 때 Animal에 있는 필드가 사용된 것입니다.

이렇게 자바스크립트는 오버라이딩시 필드와 메서드의 동작 방식이 미묘하게 다릅니다.

다행히도 이런 문제는 오버라이딩한 필드를 부모 생성자에서 사용할 때만 발생합니다. 이런 차이가 왜 발생하는지 모르면 결과를 해석할 수 없는 상황이 발생하기 때문에 별도의 공간을 만들어 필드 오버라이딩시 내부에서 벌어지는 일에 대해 자세히 알아보았습니다.

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


super 키워드와 [[HomeObject]]


🔥 추후 필요 시 작성

profile
생각 많이 하지 않기 😎
post-custom-banner

0개의 댓글