2022년 9월 14일

클래스 상속

클래스 상속을 사용하면 클래스를 다른 클래스로 확장할 수 있습니다.

기존에 존재하던 기능을 토대로 새로운 기능을 만들 수 있죠.

‘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의 존재를 확인합니다(그림을 아래부터 보세요).

  1. 객체 rabbitrun이 있나 확인합니다. run이 없네요.
  2. rabbit의 프로토타입인 Rabbit.prototype에 메서드가 있나 확인합니다. hide는 있는데 run은 없습니다.
  3. 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]]

어렵습니다.

튜토리얼을 처음 읽는 분이라면 이번 절은 넘어가서도 좋습니다.

이번 절에선 상속과 super의 내부 메커니즘에 대해 다룰 예정입니다.

super에 대해서 좀 더 깊이 파봅시다. 중간중간 흥미로운 점을 발견할 수 있을 겁니다.

먼저 드려야 할 말은 지금까지 배운 내용만으론 super가 제대로 동작하지 않는다는 것입니다.

네 아쉽지만 그렇습니다. "내부에서 super는 ‘어떻게’ 동작할까?"라는 질문을 자신에게 던져봅시다. "객체 메서드가 실행되면 현재 객체가 this가 된다. 이 상태에서 super.method()를 호출하면 엔진은 현재 객체의 프로토타입에서 method를 찾아야 한다."까진 떠올릴 수 있을 겁니다. 그런데 이런 과정은 ‘어떻게’ 일어나는 걸까요?

쉬워 보이는 질문 같지만 실제론 그렇지 않습니다. 엔진은 현재 객체 this를 알기 때문에 this.__proto__.method를 통해 부모 객체의 method를 찾을 수 있을 것 같죠. 하지만 불행하게도 이런 ‘나이브한’ 생각은 들어맞지 않습니다.

구체적인 코드와 함께 문제를 재현해 보겠습니다. 간결성을 위해 클래스가 아닌 일반 객체를 사용해 예시를 구성해 봅시다.

구체적인 내부 동작에 관심이 없으면 이 부분을 지나치고 [[HomeObject]]로 바로 넘어가셔도 좋습니다. 지금부터 다룰 내용을 모르고도 [[HomeObject]] 내용을 이해할 수 있기 때문입니다. 깊게 이해하려면 예시와 관련 내용을 살펴보면 됩니다.

아래 예시의 rabbit.__proto__animal입니다. rabbit.eat()에서 this.__proto__를 사용해 animal.eat()을 호출해보겠습니다.

let animal = {
  name: "동물",
  eat() {
    alert(`${this.name} 이/가 먹이를 먹습니다.`);
  }
};

let rabbit = {
  __proto__: animal,
  name: "토끼",
  eat() {
    // 예상대로라면 super.eat()이 동작해야 합니다.
    this.__proto__.eat.call(this); // (*)
  }
};

rabbit.eat(); // 토끼 이/가 먹이를 먹습니다.

(*)로 표시한 줄에선 eat을 프로토타입(animal)에서 가져오고 현재 객체의 컨텍스트에 기반하여 eat을 호출합니다. 여기서 주의해서 봐야 할 부분은 .call(this)입니다. this.__proto__.eat()만 있으면 현재 객체가 아닌 프로토타입의 컨텍스트에서 부모 eat을 실행하기 때문에 .call(this)이 있어야 합니다.

예시를 실행하면 예상한 내용이 얼럿창에 출력되는 것을 확인할 수 있네요.

자 이제 체인에 객체를 하나 더 추가해보겠습니다. 이제 슬슬 문제가 발생하기 시작합니다.

let animal = {
  name: "동물",
  eat() {
    alert(`${this.name} 이/가 먹이를 먹습니다.`);
  }
};

let rabbit = {
  __proto__: animal,
  eat() {
    // call을 사용해 컨텍스트를 옮겨가며 부모(animal) 메서드를 호출합니다.
    this.__proto__.eat.call(this); // (*)
  }
};

let longEar = {
  __proto__: rabbit,
  eat() {
    // longEar를 가지고 무언가를 하면서 부모(rabbit) 메서드를 호출합니다.
    this.__proto__.eat.call(this); // (**)
  }
};

longEar.eat(); // RangeError: Maximum call stack size exceeded

예상과 달리 longEar.eat()를 호출하니 에러가 발생하네요!

원인이 석연치 않아 보이지만 longEar.eat()이 호출될 때 어떤 일이 발생하는지 하나씩 추척하다보면 이유를 알 수 있습니다. 먼저 살펴봐야 할 것은 (*)(**)로 표시한 줄입니다. 이 두 줄에서 this는 현재 객체인 longEar가 됩니다. 여기에 핵심이 있습니다. 모든 객체 메서드는 프로토타입 등이 아닌 현재 객체를 this로 갖습니다.

따라서 (*)(**)로 표시한 줄의 this.__proto__엔 정확히 같은 값, rabbit이 할당됩니다. 체인 위로 올라가지 않고 양쪽 모두에서 rabbit.eat을 호출하기 때문에 무한 루프에 빠지게 되죠.

이를 그림으로 나타내면 다음과 같습니다.

  1. longEar.eat() 내부의 (**)로 표시한 줄에서 rabbit.eat을 호출하는데, 이때 thislongEar입니다.

    // longEar.eat()안의 this는 longEar입니다.
    this.__proto__.eat.call(this) // (**)
    // 따라서 윗줄은 아래와 같아집니다.
    longEar.__proto__.eat.call(this)
    // longEar의 프로토타입은 rabbit이므로 윗줄은 아래와 같아집니다.
    rabbit.eat.call(this);
  2. rabbit.eat 내부의 (*)로 표시한 줄에서 체인 위쪽에 있는 호출을 전달하려 했으나 thislongEar 이기 때문에 또다시 rabbit.eat이 호출됩니다.

    // rabbit.eat()안의 this 역시 longEar입니다.
    this.__proto__.eat.call(this) // (*)
    // 따라서 윗줄은 아래와 같아집니다.
    longEar.__proto__.eat.call(this)
    // longEar의 프로토타입은 rabbit이므로 윗줄은 아래와 같아집니다.
    rabbit.eat.call(this);
  3. 이런 내부 동작 때문에 rabbit.eat은 체인 위로 올라가지 못하고 자기 자신을 계속 호출해 무한 루프에 빠지게 됩니다.

이런 문제는 this만으론 해결할 수 없습니다.

[[HomeObject]]

자바스크립트엔 이런 문제를 해결할 수 있는 함수 전용 특수 내부 프로퍼티가 있습니다. 바로 [[HomeObject]]입니다.

클래스이거나 객체 메서드인 함수의 [[HomeObject]] 프로퍼티는 해당 객체가 저장됩니다.

super[[HomeObject]]를 이용해 부모 프로토타입과 메서드를 찾습니다.

예시를 통해 [[HomeObject]]가 어떻게 동작하는지 살펴봅시다. 먼저 일반 객체를 이용해 보겠습니다.

let animal = {
  name: "동물",
  eat() {         // animal.eat.[[HomeObject]] == animal
    alert(`${this.name} 이/가 먹이를 먹습니다.`);
  }
};

let rabbit = {
  __proto__: animal,
  name: "토끼",
  eat() {         // rabbit.eat.[[HomeObject]] == rabbit
    super.eat();
  }
};

let longEar = {
  __proto__: rabbit,
  name: "귀가 긴 토끼",
  eat() {         // longEar.eat.[[HomeObject]] == longEar
    super.eat();
  }
};

// 이제 제대로 동작합니다
longEar.eat();  // 귀가 긴 토끼 이/가 먹이를 먹습니다.

[[HomeObject]]의 메커니즘 덕분에 메서드가 의도한 대로 동작하는 것을 확인해 보았습니다. 이렇게 longEar.eat같은 객체 메서드는 [[HomeObject]]를 알고 있기 때문에 this 없이도 프로토타입으로부터 부모 메서드를 가져올 수 있습니다.

메서드는 자유롭지 않습니다

자바스크립트에서 함수는 대개 객체에 묶이지 않고 ‘자유롭습니다’. 이런 자유성 때문에 this가 달라도 객체 간 메서드를 복사하는 것이 가능하죠.

그런데 [[HomeObject]]는 그 존재만으로도 함수의 자유도를 파괴합니다. 메서드가 객체를 기억하기 때문입니다. 개발자가 [[HomeObject]]를 변경할 방법은 없기 때문에 한 번 바인딩 된 함수는 더 이상 변경되지 않죠.

다행인 점은 [[HomeObject]]는 오직 super 내부에서만 유효하다는 것입니다. 그렇기 때문에 메서드에서 super를 사용하지 않는 경우엔 메서드의 자유성이 보장됩니다. 객체 간 복사 역시 가능하죠. 하지만 메서드에서 super를 사용하면 이야기가 달라집니다.

객체 간 메서드를 잘못 복사한 경우에 super가 제대로 동작하지 않는 경우를 살펴봅시다.

let animal = {
  sayHi() {
    console.log(`나는 동물입니다.`);
  }
};

// rabbit은 animal을 상속받습니다.
let rabbit = {
  __proto__: animal,
  sayHi() {
    super.sayHi();
  }
};

let plant = {
  sayHi() {
    console.log("나는 식물입니다.");
  }
};

// tree는 plant를 상속받습니다.
let tree = {
  __proto__: plant,
  sayHi: rabbit.sayHi // (*)
};

tree.sayHi();  // 나는 동물입니다. (?!?)

tree.sayHi()를 호출하니 "나는 동물입니다."가 출력됩니다. 뭔가 잘못된 것이 분명해 보이네요.

원인은 꽤 단순합니다.

  • (*)로 표시한 줄에서 메서드 tree.sayHi는 중복 코드를 방지하기 위해 rabbit에서 메서드를 복사해왔습니다.
  • 그런데 복사해온 메서드는 rabbit에서 생성했기 때문에 이 메서드의 [[HomeObject]]rabbit입니다, 개발자는 [[HomeObject]]를 변경할 수 없습니다.
  • tree.sayHi()의 코드 내부엔 super.sayHi()가 있습니다. rabbit의 프로토타입은 animal이므로 super는 체인 위에있는 animal로 올라가 sayHi를 찾습니다.

일련의 과정을 그림으로 나타내면 다음과 같습니다.

함수 프로퍼티가 아닌 메서드 사용하기

[[HomeObject]]는 클래스와 일반 객체의 메서드에서 정의됩니다. 그런데 객체 메서드의 경우 [[HomeObject]]가 제대로 동작하게 하려면 메서드를 반드시 method() 형태로 정의해야 합니다. "method: function()" 형태로 정의하면 안 됩니다.

개발자 입장에선 두 방법의 차이는 그리 중요하지 않을 수 있지만, 자바스크립트 입장에선 아주 중요합니다.

메서드 문법이 아닌(non-method syntax) 함수 프로퍼티를 사용해 예시를 작성해 보면 다음과 같습니다. [[HomeObject]] 프로퍼티가 설정되지 않기 때문에 상속이 제대로 동작하지 않는 것을 확인할 수 있습니다.

let animal = {
  eat: function() { // 'eat() {...' 대신 'eat: function() {...'을 사용해봅시다.
    // ...
  }
};

let rabbit = {
  __proto__: animal,
  eat: function() {
    super.eat();
  }
};

rabbit.eat();  // SyntaxError: 'super' keyword unexpected here ([[HomeObject]]가 없어서 에러가 발생함)

요약

  1. 클래스 확장하기: class Child extends Parent
    • Child.prototype.__proto__Parent.prototype이 되므로 메서드 전체가 상속됩니다.
  2. 생성자 오버라이딩:
    • this를 사용하기 전에 Child 생성자 안에서 super()로 부모 생성자를 반드시 호출해야 합니다.
  3. 메서드 오버라이딩:
    • Child에 정의된 메서드에서 super.method()를 사용해 Parent에 정의된 메서드를 사용할 수 있습니다.
  4. super 키워드와 [[HomeObject]]
    • 메서드는 내부 프로퍼티 [[HomeObject]]에 자신이 정의된 클래스와 객체를 기억해놓습니다. super[[HomeObject]]를 사용해 부모 메서드를 찾습니다.
    • 따라서 super가 있는 메서드는 객체 간 복사 시 제대로 동작하지 않을 수 있습니다.

추가 사항:

  • 화살표 함수는 thissuper를 갖지 않으므로 주변 컨텍스트에 잘 들어맞습니다.

과제

중요도: 5

아래 코드에서 RabbitAnimal을 상속받습니다.

그런데 Rabbit 객체를 만들 수가 없습니다. 무엇이 잘못된 것일까요? 코드를 수정해보세요.

class Animal {

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

}

class Rabbit extends Animal {
  constructor(name) {
    this.name = name;
    this.created = Date.now();
  }
}

let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined
alert(rabbit.name);

자식 클래스의 생성자에서 super()를 호출하지 않아 에러가 발생했습니다.

수정 후 코드는 다음과 같습니다.

class Animal {

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

}

class Rabbit extends Animal {
  constructor(name) {
    super(name);
    this.created = Date.now();
  }
}

let rabbit = new Rabbit("White Rabbit"); // 잘 동작합니다.
alert(rabbit.name); // White Rabbit
중요도: 5

매 초마다 시간을 출력해주는 클래스 Clock이 있습니다.

class Clock {
  constructor({ template }) {
    this.template = template;
  }

  render() {
    let date = new Date();

    let hours = date.getHours();
    if (hours < 10) hours = '0' + hours;

    let mins = date.getMinutes();
    if (mins < 10) mins = '0' + mins;

    let secs = date.getSeconds();
    if (secs < 10) secs = '0' + secs;

    let output = this.template
      .replace('h', hours)
      .replace('m', mins)
      .replace('s', secs);

    console.log(output);
  }

  stop() {
    clearInterval(this.timer);
  }

  start() {
    this.render();
    this.timer = setInterval(() => this.render(), 1000);
  }
}

Clock을 상속받는 ExtendedClock을 만들고, precision(정확도)이라는 매개변수도 추가해보세요. precision은 ‘초’ 사이의 간격을 의미하고, 기본값은 1000(1초)이 되어야 합니다.

  • 새로운 파일(extended-clock.js)을 만들어 답을 작성해주세요.
  • clock.js은 수정하면 안 됩니다. 상속을 사용하세요.

샌드박스를 열어 정답을 작성해보세요.

class ExtendedClock extends Clock {
  constructor(options) {
    super(options);
    let { precision = 1000 } = options;
    this.precision = precision;
  }

  start() {
    this.render();
    this.timer = setInterval(() => this.render(), this.precision);
  }
};

샌드박스를 열어 정답을 확인해보세요.

튜토리얼 지도