안녕하세요. 동쪽별입니다.
프로토타입을 다룬 포스트에서 언급했던 것처럼 이번엔 클래스 상속에 대해 살펴보겠습니다.
자바스크립트는 프로토타입 기반 객체지향 언어입니다.
그래서 프로토타입 기반 상속을 하는 건 알겠는데.. 클래스 상속은 또 뭐여?
라고, 제가 그랬었습니다 🤣
목차
1. 클래스 도입
2. 상속에 의한 클래스 확장
3. 상속 클래스의 인스턴스 생성 과정
클래스 도입
Java, C++, C#, Python.. 등 대부분 프로그래밍 언어는 클래스 기반 객체 지향을 지원합니다.
따라서, 대부분의 프로그래머들은 클래스 기반 언어에 익숙하겠죠..
그러한 프로그래머들에게 프로토타입 기반 언어인 자바스크립트는 혼란을 야기할 수 있으며, 어렵게 느껴지게 하는 하나의 장벽처럼 인식될 수 있었습니다.
그래서! ES6 클래스가 도입되었습니다.
객체지향 프로그래밍에 익숙한 프로그래머들이 자바스크립트를 더욱 빠르게 학습하도록 돕기 위해서요.
하지만!!
클래스가 기존의 프로토타입 기반 객체지향 모델을 폐지하고, 새롭게 클래스 기반 객체지향 모델을 제공하는 것은 절~대 아닙니다.
사실, 클래스는 함수이며 기존 프로토타입 기반 패턴을 클래스 기반 패턴처럼 사용할 수 있도록 합니다.
무슨 말이냐고요?
아래 이미지를 봅시다.
문법적인 키워드만 다르지, 전반적인 구조 및 역할은 같습니다?!
또, 아래 이미지를 볼까요?
클래스도 프로토타입을 기반으로 인스턴스를 생성하는 것을 볼 수 있습니다!
어라..? 그럼 프로토타입이랑 클래스랑 같은건가?
No! 정확히 동일하게 동작하지는 않습니다.
1. 클래스는 new 연산자 없이 호출하면 에러가 발생합니다.
- 인스턴스를 생성하는 것이 유일한 존재 이유이므로, 반드시 new 연산자와 함께 호출해야 합니다.
2. 클래스는 상속을 지원하는 extends와 super 키워드를 제공합니다.
- 이는 상속 관계를 더욱 간결하고 명료하게 합니다. → 클래스의 장점!
3. 클래스는 호이스팅이 발생하지 않는 것처럼 동작합니다.
- 클래스 선언문은 마치 호이스팅이 발생하지 않는 것처럼 보이나 그렇지 않습니다.
- 호이스팅이 발생하지만 let, const 키워드로 선언한 변수처럼 호이스팅됩니다.
- 즉, 바인딩이 실행되기 전까지 액세스할 수 없는 현상인 TDZ(Temporal Dead Zone)에 빠지는 것이죠.
자바스크립트에서 호이스팅(hoisting)이란, 인터프리터가 변수와 함수의 메모리 공간을 선언 전에 미리 할당하는 것을 의미합니다. var로 선언한 변수의 경우 호이스팅 시 undefined로 변수를 초기화합니다. 반면 let과 const로 선언한 변수의 경우 호이스팅 시 변수를 초기화하지 않습니다.
4. 클래스 내의 모든 코드에는 암묵적으로 strict mode가 지정되어 실행되며 해제할 수 없습니다.
5. 클래스의 constructor, 프로토타입 메서드, 정적 메서드는 모두 프로퍼티 속성 [[Enumerable]] 값이 false입니다.
- 즉, 열거되지 않습니다.
상속에 의한 클래스 확장
상속에 의한 클래스 확장은 프로토타입 기반 상속과는 다른 개념입니다.
- 프로토타입 기반 상속 → 프로토타입 체인을 통해 다른 객체를 상속 받습니다.
- 상속에 의한 클래스 확장 → 기존 클래스를 상속받아 새로운 클래스를 확장하여 정의합니다.
아래 이미지를 봅시다.
수퍼 클래스와 서브 클래스는 인스턴스의 프로토타입 체인 뿐 아니라, 클래스 간의 프로토타입 체인도 생성하는 것을 볼 수 있습니다!
즉, 프로토타입 메서드와 정적 메서드, 프로퍼티 모두 상속이 가능한거죠.
이러한 클래스의 특징을 생성자 함수로도 흉내낼 수 있습니다. 아래 코드처럼요!
const Animal = (function () {
function Animal(age, weight) {
this.age = age;
this.weight = weight;
}
Animal.prototype.eat = function () {
return 'eat';
};
Animal.prototype.move = function () {
return 'move';
};
return Animal;
})();
const Bird = (function () {
function Bird() {
// Animal 생성자 함수에게 this와 인수를 전달하면서 호출
Animal.apply(this, arguments);
}
// Bird.prototype을 Animal.prototype을 프로토타입으로 갖는 객체로 교체
Bird.prototype = Object.create(Animal.prototype);
// Bird.prototype.constructor을 Animal에서 Bird로 교체
Bird.prototype.constructor = Bird;
Bird.prototype.fly = function () {
return 'fly';
};
return Bird;
})();
const bird = new Bird(1, 5);
보시다시피.. 참고만 합시다 😅
동적 상속
extends 키워드는 클래스 뿐만 아니라 생성자 함수([[Constructor]] 내부 메서드를 갖는 모든 함수 객체)를 상속받아 클래스를 확장할 수 있습니다.
단, extends 키워드 앞에는 반드시 클래스가 와야합니다.
따라서, 이를 통해 동적으로 상속받을 대상을 결정할 수 있습니다.
function Base1() {}
class Base2 {}
let condition = true;
class Derived extends (condition ? Base1 : Base2) {}
이렇게요 🤗
super()
super()는 수퍼 클래스의 constructor를 호출하여 인스턴스를 생성합니다.
class Base {]
class Derived extends Base {}
위 코드는 아래와 동일합니다.
class Base {
constructor() {}
}
class Derived extends Base {
constructor(...args) { super(..args); }
}
클래스에서 constructor를 생략하면 암묵적으로 정의되며, 서브 클래스에서는 암묵적으로 super(...args)를 호출합니다.
만약, 서브 클래스에서 construct를 생략하지 않은 경우에는?
반드시! super를 호출해야 합니다.
super를 호출하기 전에는 this를 참조할 수 없습니다.
이유에 대해서는 좀있다 알아보겠습니다.
메서드 내에서 super를 참조하면 수퍼 클래스의 메서드 또한 호출할 수 있습니다.
class Derived extends Base {
sayHi() {
return `${super.sayHi()}`;
}
}
super 참조를 통해 수퍼 클래스의 메서드를 참조하려면,
수퍼 클래스의 메서드가 바인딩된 객체, 즉 수퍼 클래스의 prototype 프로퍼티에 바인딩된 프로토타입을 super가 참조할 수 있어야 합니다.
따라서, 아래 코드는 위 코드와 동일한 작업을 수행합니다.
class Base {
constructor(name) {
this.name = name;
}
sayHi() {
return `Hi ${this.name}`
}
}
class Derived extends Base {
sayHi() {
// Derived 클래스가 가리키는 프로로타입의 프로토타입
const __super = Object.getPrototypeOf(Derived.prototype);
return `${__super.sayHi.call(this)} how are you doing?`;
}
}
super는 자신을 참조하고 있는 메서드(Derived의 sayHi)가 바인딩되어 있는 객체(Derived.prototype)의 프로토타입(Base.prototype)을 가리킵니다.
따라서 super.sayHi는 Base.prototype.sayHi을 가리키는 것입니다.
만약, 위 예시에서 Base.prototype.sayHi 를 호출할 때 call 메서드를 사용하여 this를 전달하지 않으면?
Base.prototype.sayHi 메서드 내부의 this는 Base.prototype을 가리킵니다.
이처럼 super 참조가 동작하기 위해서는 super를 참조하고 있는 메서드(서브 클래스의 메서드)가 바인딩되어 있는 객체(서브 클래스.prototype)의 프로토타입(수퍼 클래스.prototype)을 찾을 수 있어야 합니다!
이를 위해 메서드는 내부 슬롯 [[HomeObject]]를 가지며, 자신을 바인딩하고 있는 객체를 가리킵니다.
정리하면,
- [[HomeObject]]는 메서드 자신을 바인딩하고 있는 객체를 가리킵니다.
- [[HomeObject]]를 통해 메서드 자신을 바인딩하고 있는 객체의 프로토타입을 찾을 수 있습니다.
- 예를 들어, Derived 클래스의 sayHi 메서드는 Derived.prototype에 바인딩되어 있습니다.
- 따라서 Derived 클래서의 sayHi 메서드의 [[HomeObject]]는 Derived.prototype을 가리키고
- 이를 통해 Derived 클래스의 sayHi 메서드 내부의 super 참조가 Base.prototype으로 결정됩니다.
- 최종적으로 super.sayHi는 Base.prototype.sayHi를 가리키게 되는 것입니다.
따라서, super는 아래 코드로 나타낼 수 있는 거죠!
super = Object.getPrototypeOf([[HomeObject]])
주의사항 ❗️
ES6의 메서드 축약 표현으로 정의된 함수 만이 [[HomeObject]]를 가집니다.
상속 클래스의 인스턴스 생성 과정
1. 서브 클래스의 super 호출
자바스크립트 엔진은 클래스를 평가할 때 수퍼 클래스와 서브 클래스를 구분하기 위해 "base" 또는 "derived"를 값으로 갖는 내부 슬롯 [[ConstructorKind]]를 갖습니다.
클래스를 상속받지 않는 클래스는 내부 슬롯 [[ConstructorKind]]의 값이 "base",
다른 클래스를 상속받는 서브클래스는 내부 슬롯 [[ConstructorKind]]의 값이 "derived" 입니다.
이를 통해, 수퍼 클래스와 서브 클래스는 new 연산자와 함께 호출되었을 때 동작이 구분됩니다.
서브 클래스는 자신이 직접 인스턴스를 생성하지 않습니다. 대신 수퍼 클래스에게 인스턴스 생성을 위임합니다.
이것이 바로 서브 클래스의 constructor에서 반드시 super를 호출해야 하는 이유입니다.
서브 클래스가 new 연산자와 함께 호출되면 constructor 내부의 super 키워드가 함수처럼 호출됩니다.
(super 호출이 없으면? 당연히 에러! 인스턴스를 생성할 수 없으니까요.)
그럼, 수퍼 클래스의 constructor가 호출되겠죠?
2. 수퍼 클래스의 인스턴스 생성과 this 바인딩
수퍼 클래스의 constructor 내부의 코드가 실행되기 이전에 암묵적으로 빈 객체를 생성합니다.
이 빈 객체가 바로 클래스가 생성한 인스턴스입니다.
암묵적으로 생성된 빈 객체, 즉 인스턴스는 this에 바인딩됩니다.
따라서, 수퍼 클래스의 constructor 내부의 this는 생성된 인스턴스를 가리키게 되는 것이죠.
class Base {
constructor(x, y) {
console.log(this); // Derived {}
console.log(new.target); //Derived
}
}
class Derived {}
위 코드를 봅시다.
이때, 인스턴스는 수퍼 클래스가 생성한 것입니다.
하지만 new.target은 서브 클래스를 가리킵니다.
new.target : new 연산자와 함께 호출된 함수를 가리킵니다.
따라서, 인스턴스는 서브 클래스가 생성한 것으로 처리된다는 것을 알 수 있습니다.
그렇기에 생성된 인스턴스의 프로토타입은 서브 클래스의 prototype 프로퍼티가 가리키는 객체가 되는 것이죠.
3. 수퍼 클래스의 인스턴스 초기화
수퍼 클래스의 constructor가 실행되어 this에 바인딩되어 있는 인스턴스를 초기화합니다.
즉, this에 바인딩되어 있는 인스턴스에 프로퍼티를 추가하고 constuctor가 인수로 받은 초기값으로 인스턴스의 프로퍼티를 초기화합니다.
4. 서브 클래스 constructor로의 복귀와 this 바인딩
super의 호출이 종료되고 제어 흐름이 서브 클래스 constructor로 돌아옵니다.
이때, super가 반환한 인스턴스가 this에 바인딩됩니다.
즉, 서브 클래스는 별도의 인스턴스를 생성하지 않고, super가 반환한 인스턴스를 그대로 사용하는 것입니다.
만약, super가 호출되지 않으면? 인스턴스가 생성되지 않으며, this 바인딩 역시 할 수 없겠죠!
이것이 서브 클래스의 constructor에서 super를 호출하기 전에 this를 참조할 수 없는 이유입니다.
따라서, 서브 클래스 constructor 내부의 인스턴스 초기화는 반드시 super 호출 이후에 처리해야 합니다!
5. 서브 클래스의 인스턴스 초기화
서브 클래스의 constructor에 기술되어 있는 인스턴스 초기화를 실행합니다.
3 과 마찬가지로요!
6. 인스턴스 반환
클래스의 모든 처리가 끝나면 완성된 인스턴스가 바인딩된 this가 암묵적으로 반환됩니다.
그럼 프로토타입 상속과 클래스 상속, 무엇을 사용하는 것이 좋을까요?
extends와 super를 통한 상속의 간편, 명료의 장점으로 클래스를 사용하는 것이 직관성에는 더 좋을 것 같습니다.
다만, 클래스 상속을 이용한 방법은 변경하기가 어렵습니다.
기반이 되는 클래스를 수정했을 때 서브 클래스들이 영향을 쉽게 받을 수 있는 계급 계층구조를 만들기 때문입니다.
따라서, '무엇을 사용하자'에 답은 없다고 생각합니다.
경우에 따라 각자 스타일에 맞게 사용하면 좋을 것 같습니다 😁
.
.
.
클래스에 대한 전반적인 내용을 다루지는 않았고, '상속' 이라는 키워드에 초점을 맞추어 중요한 부분을 선별하여 다루었습니다.
프로토타입 상속과 클래스 상속에 대한 또 다른 견해가 있으시다면 댓글 달아주시면 감사하겠습니다!
※ 참조
📖 모던 자바스크립트 Deep Dive (이웅모 지음)