[모던JS: Core] 클래스 (2)

KG·2021년 5월 22일
0

모던JS

목록 보기
15/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

클래스 상속

앞서 프로토타입을 통해 상속을 구현하는 법을 살펴보았다. 당연히 클래스 역시 상속을 지원한다. 자바에 익숙하다면 해당 방법의 상속이 더 쉽게 다가올 수 있다. 클래스 상속을 이용하여 기존 클래스를 다른 클래스로 확장해보자.

1) extends 키워드

클래스의 상속은 extends 키워드를 사용해서 수행할 수 있다. 이때 상속을 받는 클래스를 자식 또는 서브클래스라고 하며, 상속의 모태가 되는 클래스를 부모 또는 슈퍼클래스라고 칭한다.

// 부모(슈퍼) 클래스 선언
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} 숨었다!`);
  }
}

let rabbit = new Rabbit('흰 토끼');
rabbit.run(5);	// 흰 토끼의 속도: 5
rabbit.hide();	// 흰 토끼 숨었다!

자식 클래스인 객체인 rabbit 에서는 자기가 가지고 있는 메서드 hide에도 접근이 가능하고, 부모 클래스에서 선언된 run 메서드에도 정상적으로 접근하고 있는 것을 볼 수 있다.

extends 키워드 역시 프로토타입을 기반으로 동작한다. extendsRabbit.prototype.[[Prototype]]Animal.prototype으로 설정한다. 따라서 rabbit 객체가 Rabbit.prototype에서 메서드를 찾지 못하면, 프로토타입 체이닝에 의해 상위의 Animal.prototype에서 메서드를 가져오게 된다.

이전 챕터에서 네이티브 프로토타입에서 알아본 바와 같이 자바스크립트의 내장 객체는 프로토타입을 기반으로 상속관계를 맺는다. 이는 클래스 역시 예외가 아니다.

2) 메서드 오버라이딩

메서드 오버라이딩 역시 객체 지향형 프로그래밍에서 상속을 다룰때 흔히 등장하는 개념이다. 자바스크립트에서도 클래스를 사용할 때 메서드 오버라이딩을 적용할 수 있다.

보통 특별한 사항이 없다면 자식 클래스는 부모 클래스의 메서드를 있는 그대로 상속받게 된다. 하지만 자식 클래스에서 부모 클래스에서 물려받은 메서드와 동일한 메서드를 자체적으로 재정의하게 되면, 재정의 한 메서드를 호출하게 된다.

class Rabbit extends Animal {
  stop() {
    ...
    // rabbit.stop() 호출 시 부모 클래스의 stop()이 아닌
    // 여기서 재정의 된 메서드가 호출된다.
  }
}

간혹 부모 메서드 전체를 교체하는 것이 아닌, 부모 메서드를 토대로 일부만 변경하고 싶을 때가 있다. 또는 부모 메서드의 기능을 이어받되 추가적인 기능을 덧붙이는 등 확장을 원할 수도 있다. 이 경우 위와 같이 커스텀 메서드를 만들어 재정의하게 되는데, 재정의 과정에서 부모 메서드를 호출하고 싶다면 super 키워드를 사용할 수 있다. super 키워드는 크게 다음과 같은 두 가지 쓰임새가 있다.

  • super.method(...)는 부모 클래스에서 정의된 메서드를 호출한다.
  • super(...)는 부모 생성자를 호출하는데, 자식 생성자 내부에서만 사용 가능하다.
class Rabbit extends Animal {
  hide() {
    console.log(`${this.name}` 숨었다!);
  }
  
  // 메소드 오버라이딩
  stop() {
    // 부모클래스의 stop 메서드를 호출하고
    super.stop();
    // 자신의 hide 메서드 호출
    this.hide();
  }
}

화살표 함수에는 super 키워드 역시 존재하지 않는다. 따라서 외부 함수의 super에 접근해야 하는 경우 유용하게 사용할 수 있다.

class Rabbit extends Animal {
  stop() {
    // 여기서 super는 stop() 메서드의 super와 동일하다
    // 따라서 1초 후에 부모 stop을 호출
    setTimeout(() => super.stop(), 1000);
  }
}

3) 생성자 오버라이딩

생성자 역시 메서드와 유사하게 오버라이딩이 가능하다. 개념 자체는 동일하지만 메서드와 다르게 조금 까다롭다. 위에서 선언한 Rabbit 클래스에는 자체 constructor가 없었다. 이처럼 하나의 클래스가 다른 클래스를 상속받고 있으면서 constructor가 없는 경우엔 아래와 같이 비어있는 constructor가 자바스크립트 엔진에 의해 수행된다. 이때 엔진에 의해 만들어지는 constructor는 기본적으로 부모의 constructor를 호출하고 관련 인수를 모두 전달한다.

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

그렇다면 이번엔 Rabbit 클래스에서 자체 constructor를 정의해보자. 인수로는 기존 name과 더불어 추가로 earLength를 전달하도록 하자.

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

위와 같이 에러가 발생하는 것을 볼 수 있다. 그 이유는, 상속 클래스의 생성자에서는 반드시 super(...)를 호출해야 하는데 이를 호출하지 않음으로써 발생한 에러이다. 또한 super(...)this를 사용하기 전에 반드시 호출해야 한다.

자바스크립트는 상속 클래스의 생성자 함수(derived constructor)와 그렇지 않는 생성자 함수를 구분한다. 상속 클래스의 생성자 함수에는 특수 내부 프로퍼티인 [[ConstructorKind]]: "derived"가 이름표처럼 붙기 때문에 엔진이 이를 구별할 수 있다. 이때 일반 클래스의 생성자 함수와 상속 클래스의 생성자 함수 간 차이는 new 키워드와 함께 드러난다.

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

따라서 상속 클래스의 생성자에서는 super를 호출해 부모 생성자를 실행해주지 않으면 위와 같이 에러가 발생하는 것이다. 이를 생략하는 것은 this가 될 객체가 만들어지지 않는 것과 같다. 따라서 위의 함수를 아래와 같이 수정한다면 에러를 고칠 수 있다.

class Rabbit extends Animal {

  constructor(name, earLength) {
    // this 사용전 super(...) 호출
    super(name);
    this.earLength = earLength;
  }
  ...
}

4) 클래스 필드 오버라이딩

앞서 클래스 필드는 현재는 최신 브라우저 일부에서 지원하는 기능이라고 말한 바있다. 이 클래스 필드 역시 오버라이딩을 적용할 수 있는데, 자바스크립트에서는 다른 프로그래밍 언어와 달리 이 과정이 조금 까다롭다.

class Animal {
  // 클래스 필드
  name = 'animal'

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

class Rabbit extends Animal {
  // 클래스 필드 오버라이딩
  name = 'rabbit';
}

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

위 코드를 살펴보면 Animal을 상속받는 Rabbit이란 클래스가 있고, Rabbit 클래스에서는 name 필드를 오버라이딩 한 걸 볼 수 있다. 그리고 Rabbit 클래스에서는 별도의 생성자가 없고, 때문에 부모클래스의 생성자를 호출하는 생성자가 자동으로 만들어질 것이다.

그러나 실행결과를 보면 두 클래스 모두 동일한 name 값을 출력하는 것을 볼 수 있다. 결과적으로 보자면, 부모 생성자는 자식 클래스에서 오버라이딩한 값이 아닌, 부모 클래스 내부의 필드 값을 사용한다라는 것을 알 수 있다. 클래스 필드값은 위에서 살펴본 메서드 오버라이딩과 달리 오버라이딩이 적용되지 않는 것일까?

그 이유는 초기화 순서 차이에 있다. 클래스 필드는 다음과 같은 규칙에 따라 초기화 순서가 달라지게 된다.

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

위 예시에서 Rabbit 클래스는 하위 클래스이고 생성자가 정의되지 않았기 때문에 super(...) 생성자 함수가 실행된다. 이때 하위 클래스 필드는 초기화 순서에 따라 super(...) 실행 후에 초기화될 것이다. 따라서 흐름은 부모 생성자가 실행되는 시점으로 올라가고, 이 시점에서 Rabbit의 필드는 아직 존재하지 않는다. 따라서 부모 클래스에서 필드값을 출력했을때 부모 클래스의 필드값이 그대로 출력된 것이다.

물론 이러한 문제는 예시와 같이 오버라이딩한 필드를 부모 생성자에서 사용하는 경우에만 발생한다. 이런 상황이 보통 흔치 않지만, 이러한 초기화 순서에 차이가 있다는 점을 알아두면 추후 디버깅에 도움이 될 수 있다. 만약 필드 오버라이딩이 문제가 되는 상황이 발생하면 필드 대신 메서드를 사용해 오버라이딩하거나, getter/setter를 사용해 해결하는 방법도 있다.

5) super 키워드와 [[HomeObject]]

super 키워드의 내부 동작방식을 좀 더 딥(deep)하게 파고 들어가보자. 객체 메서드가 실행되면 현재 객체가 this가 되는 것은 앞서 여러번 살펴보았다. 이 상태에서 super.method()를 호출하면 엔진은 현재 객체의 프로토타입에서 method를 찾아 호출하게 될 것이다. 그런데 이러한 과정은 어떻게 일어나는 것일까?

엔진은 현재 this를 알고 있기 때문에, this.__proto__.method를 통해 부모 객체의 method를 찾을 수 있을까? 좋은 접근이었지만 애석하게도 정답은 아니다. 구체적인 코드와 함께 문제를 재현해보자. 다만 코드 간결성을 위해 클래스가 아닌 일반 객체를 사용해보자. 클래스 역시 프로토타입 체이닝이 내부적으로 사용되기 때문에 내부 매커니즘을 파악하기엔 큰 문제가 되지 않을 것이다.

let animal = {
  name: '동물',
  eat() {
    console.log(`${this.name}가 먹다`);
  }
};

let rabbit = {
  __proto__: animal,	// 프로토타입 체인 형성 (상속)
  name: '토끼',
  eat() {
    // super.eat() 이 동작해야 한다.
    this.__proto__.eat.call(this);
  }
};

rabbit.eat();	// 토끼가 먹다

this.__proto__.eat.call(this)를 하나씩 살펴보자. 먼저 앞절의 thisrabbit 객체 자신이 된다. 따라서 this.__proto__는 부모 객체(클래스)인 animal이 될 것이다. 따라서 animal에 선언된 eat() 메서드를 호출하고 있는 것과 같다. 이때 call() 내장 메서드를 이용해 thisrabbit에 바인딩시켜 호출하고 있다. 따라서 animal에서 eat() 메서드 내부의 thisrabbit이 되어 토끼가 정상적으로 출력될 것이다.

여기까지는 이해한 바와 동일하게 정상적으로 호출이 되는 것 같다. 그렇다면 객체를 하나 더 추가해보자. 그렇다면 다음과 같은 문제가 발생한다.

let anmial = {...};

let rabbit = {...};

let longEar = {
  __proto__: rabbit,
  eat() {
    this.__proto__.eat.call(this);
  }
}

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

생각지도 못한 에러가 발생했다. 왜인지 모르겠지만 호출 스택이 터져버렸다. 이는 무한루프가 발생할때 나오는 에러인데, 어떤 연유로 무한루프가 생겨버린 것일까?

객체 longEar에서 this.__proto__.eat.call(this)의 의미는 위에서 살펴본 것과 동일하다. 따라서 자신이 상속하고 있는 객체 rabbiteat()을 호출하게 된다.

이때 rabbit 역시 동일하게 this.__proto__.eat.call(this)를 호출한다. 그러나 이 경우엔 우리가 앞서 샅샅이 분석한 것과 달리 전혀 다른 컨텍스트를 가진다. longEar에서 call 메서드와 바인드 된 것은 longEar 객체 자기 자신이다. 그리고 자바스크립트의 this는 항상 동적으로 런타임에 결정된다. 즉 rabbit에서의 this는 호출이 시작된 longEar에서 바인드되었기 때문에 여전히 longEar를 가리키게 된다. 그렇게 되면 rabbit에서의 this.__proto__는 다시 자기 자신인 rabbit을 가리키게 되고 여기에 다시 longEar를 바인딩시켜 호출하게 되는 무한루프에 빠지게 되는 것이다. 체인 위로 더 이상 올라가지 않고 양쪽 모두에서 rabbit.ear를 호출하게 되었다. 이를 그림으로 나타내면 다음과 같다.

이러한 문제는 this만 가지고 해결할 수 없다. 자바스크립트에는 이런 문제를 해결할 수 있는 함수 전용 특수 내부 프로퍼티 [[HomeObject]]가 존재한다. 클래스이거나 객체 메서드인 함수의 [[HomeObject]] 프로퍼티는 해당 객체가 저장된다. 이때 super[[HomeObject]]를 이용해 부모 프로토타입과 메서드를 찾게 된다. 위의 코드를 다음과 같이 고쳐보자.

let animal = {
  name: '동물',
  eat() {	// animal.eat.[[HomeObject]] == animal
    console.log(`${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]]를 알고 있기 때문에 이처럼 this를 사용하지 않고도 프로토타입으로부터 부모 메서드를 가져올 수 있다.

6) 메서드의 자유도

자바스크립트에서 함수는 대개 객체에 묶이지 않고 자유롭다. 이러한 자유성 때문에 this가 다르더라도 객체 간 메서드를 복사해서 유연성있게 사용할 수 있다.

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

다행인 점은 [[HomeObject]]는 오직 super 내부에서만 유효하다는 점이다. 그렇기 때문에 메서드에서 super를 사용하지 않는 경우엔 메서드의 자유성이 보장된다. 즉 super를 사용하지 않는다면 객체 간 복사 역시 가능한다. 다음의 예시를 살펴보자.

let animal = {
  sayHi() {
    console.log("I'm animal");
  }
};

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

let plant = {
  sayHi() {
    console.log("I'm plant");
  }
};

let tree = {
  __proto__: plant,
  sayHi: rabbit.sayHi,
};

rabbit.sayHi();	// I'm animal
tree.sayHi();	// I'm animal (?)

tree.sayHi()를 호출하면 I'm animal이 호출되는 것을 볼 수 있는데 이는 의도와 다르다. tree 객체에서는 중복 코드 방지를 위해 rabbit에 있는 메서드를 복사해왔다. 부모 클래스에 있는 메서드를 호출한다는 내부 메커니즘은 완전히 동일하기 때문이다.

  • 그러나 복사한 메서드는 rabbit 에서 생성했기 때문에 이 메서드의 [[HomeObject]]rabbit이다. 개발자는 이미 결정된 [[HomeObject]]을 변경할 수 없다.
  • tree.sayHi()는 복사를 통해 내부에서 super 키워드를 사용한다. 이때 super 키워드는 내부적으로 [[HomeObject]]에 접근하여 프로토타입에 접근한다.
  • 복사된 메서드의 [[HomeObject]]는 앞서 본 것과 같이 rabbit이기 때문에 두 객체 모두 animalsayHi 메서드를 호출한다.

super 키워드를 사용할 때는 이와 같이 자바스크립트의 특징 중 하나인 함수의 자유도가 상당 부분 침해받을 수 있다. 따라서 super 키워드 사용에 있어서 [[HomeObject]]의 존재유무를 염두해두자.

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

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

개발자의 입장에서는 두 방식의 차이가 그렇게 크게 와닿지 않지만 자바스크립트의 엔진은 이를 구분한다. 메서드 문법이 아닌 함수 프로퍼티 방식으로 작성하는 경우에는 [[HomeObject]] 프로퍼티가 설정되지 않기 때문에 상속이 제대로 이루어지지 않는다.

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

// rabbit에서 eat 메서드는 [[HomeObject]] 할당이 이뤄지지 않아
// 에러가 발생한다.
rabbit.eat();	// SyntaxError

References

  1. https://ko.javascript.info/classes
  2. https://helloworldjavascript.net/pages/270-class.html
  3. https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Classes
profile
개발잘하고싶다

0개의 댓글