이번 주는 내가 그토록 보고 싶었던 Class파트 인강을 봤다. 나는 기본적으로 inflearn 김영보 선생님의 JS강의를 보고 있는데, Class는 가장 마지막 부분에 비동기와 함께 위치하고 있다. 그래서 이제서야 그곳에 도착한 것이다!
이때까지는 Class을 사용해야 할 때 마다 Javascript tutorial과 같은 사이트에서 찾아봤는데, 시간이 지나면서 이전에 느꼈던 지식의 파편화를 느꼈다. 필요한 부분을 위주로 읽다 보니, '저기서 저렇고 여기서 이렇고'하는 정보들이 머리에서 뒤섞인 것이다. 그 상태에서 최근에 Typescript강의에서 interface를 이야기 하면서 abstact Class에 대해서 이야기를 하면서 그 상태가 절정에 달했다!
이번에 Class 인강을 본 것은 그 파편들을 정리하는 과정이었다. '이래서 그때 그게 안된 거구나!'하는 느낌이 몇 번 들었다. 오늘은 확실한 정리를 위해서 WIL에서 Class에 대해 다시 한 번 정리하는 시간을 가져볼까 한다. 여러 글에서 기본적인 Class에 대한 사용에 대해서 잘 설명을 해두었기 때문에, 나는 내가 Aha-Point라고 생각한 부분을 기준으로 작성해볼까 한다.
객체 지향 프로그래밍을 지칭하는 말인데, 보통은 Class를 객체 지향의 대표적인 예라고 한다. 김영보 선생님 역시 그렇게 소개를 하면서 한 가지를 강조하셨다.
Class가 객체지향이라기 보다는 OOP를 Class를 통해서 구현한다는 표현이 더 정확하다.
당연한 이야기이지만 Class에 집중하다 보면 그 주객이 전도되기 쉬운데, 이 말을 듣고서는 그 시선에서 강의를 들을 수 있었다.
이 시선에서 객체 지향이 뭘까에 대한 답을 생각해 보자!
Object가 있어서 JS의 프로퍼티들로 구성되어 있는 Object를 떠올릴 수 있지만, 이 Object는 그 Object가 아니다. OOP의 Object는 개념적인 것이다. 행위와 속성으로 객체의 특성을 표현하는 방법인데, 아래와 같은 문장으로 예를 들어보자.
쌀밥을 먹다.
주꾸미를 먹다.
삼겹살을 먹다.
치토스를 먹다.
여기서 행위는 '먹다'는 것이고 속성은 '쌀밥', '주꾸미', '삼겹살', '치토스'이다. '먹다'라는 공통의 행위를 나타내는 네 개의 다른 문장이지만 각각의 속성이 다른 것이다.
이것을 코드로 나타낸다면 객체가 클래스이고, '먹다'를 나타내는 행위는 메소드이고, 각각의 음식들은
프로퍼티가 되는 것이다.
즉 객체지향이라는 개념적인 것들을 클래스, 메소드, 프로퍼티로 구현한 것이다.
JS도 ECMA Script의 스펙에 OOP라고 작성이 되어있는 엄연한 OOP 프로그래밍 언어이다.
하지만 나 뿐만 아니라 많은 초보 JS 개발자들은 이 사실 자체를 잊고 공부를 하는 경우가 매우 잦다. 그 이유는 여러 가지겠지만 다음 두 가지가 가장 큰 이유인 것 같다.
- JS 라이브러리, 프레임워크들에서 Class를 잘 사용하지 않아서
- Class는 단순히 sugar문법이라는 의견이 있어서
실제로 자주 쓰인다면 다들 배우겠지만 확실히 JS에서 Class를 타 언어들 보다 잘 안 쓰는 것은 맞다.
하지만 JS는 OOP이고 OOP를 구현하는 대표적인 것이 Class라는 것, 그리고 Class는 sugar문법이 아니라 앞으로 Class를 더 활용하게 될 것이라는 추세적인 부분까지 고려해서 추가되었다고 볼 수 있다는 점이 Class를 배워야 할 이유인 거 같다.
다른 언어와 OOP의 개념은 동일하지만 클래스의 구조와 구현 방법이 다르다. property에 메소드를 연결하는 구조가 그 대표적인 예이다. 하지만 선생님은 결코 다른 언어와 비교할 필요가 없다고 한다. 애시당초 언어가 다르고 그 언어의 구조가, 클래스의 구조가 다르기 때문이다. 오히려 JS의 특징인 prototype을 잘 살린 부분이다.
이번 강의에서는 객체 지향의 중요한 부분인 상속에 대해서 아파트에 비유를 했다. 지금 생각해보면 prototype을 통해서 계층적으로 이루어진 JS Class의 특징을 잘 살린 비유인 것 같다.
// 슈퍼 클래스
class Book {
set setTitle(title) {
this.title = title;
}
}
// 서브 클래스
class CallTitle extends Book {
get getTitle() {
return this.title;
}
}
// 인스턴스 생성
const instance = new CallTitle();
// setter로 인스턴스 프로퍼티 할당
instance.setTitle = "Class배워보기";
// getter로 인스턴스 프로퍼티 읽기
console.log(instance.getTitle);
Class에서도 getter, setter를 일반 객체처럼 사용할 수 있다는 것을 보여주기 위해서 위와 같이 예제 코드를 작성해 보았다. 위 코드가 어떻게 구성되어 있는지 엔진에서 살펴보도록 하자.
엔진을 보면 서브 클래스(자식 클래스)인 CallTitle의 prototype에는(1층) getTitle이 자리잡고 있다. 그리고 한 단계 더 내려간 [[Prototype]](2층)에는 슈퍼 클래스(부모 클래스)인 Book의 메소드인 setTitle이 자리잡고 있는 것을 확인할 수 있다.
이처럼 상속을 하면 서브 클래스의 [[Prototype]]가 슈퍼 클래스의 메소드들을 참조하게 된다. 이런 식으로 계층을 이루면서 1층과 2층을 모두 사용할 수 있게 되는 것이다.
이렇게 상속을 받게 된 CallTitle로 인스턴스를 생성해서 할당한 instance를 엔진에서 살펴보면 위와 같은 구조를 가진다. 매우 잘 계층화된 구조를 확인할 수 있다.
추가적으로 여기서 title처럼 instance에 바로 설정되는 프로퍼티들을 인스턴스 프로퍼티라고 부른다.
super키워드를 사용하면 슈퍼 클래스의 메소드를 호출할 수 있다. 부모 클래스라고 하는 것 보다 슈퍼 클래스라고 표현하는 것에 익숙해 진다면 super 키워드의 쓰임에 대해서는 자연스럽게 다가올 것이다.
class Book {
get getTitle() {
return "Super Class 배우기";
}
}
class CallTitle extends Book {
get getTitle() {
const super__title = super.getTitle; // super키워드를 통해서 슈퍼 클래스에 접근
console.log(super__title); // console :: Super Class 배우기
return "Sub Class 배우기";
}
}
const instance = new CallTitle();
console.log(instance.getTitle); // console :: Sub Class 배우기
원래 instance.getTitle이라고 하면 "Sub Class 배우기"만 출력이 돼야 하지만 super 키워드를 통해서 슈퍼 클래스의 getTitle 메소드도 호출하고 있기 때문에 "Super Class 배우기"도 출력할 수 있다.
이 강의를 듣고 이해하기 전에 가장 어지러웠던 constructor. 이와 더불어 this에 대해서 알아볼까 한다. 벌써 어지럽다.
class Book{
constructor(title){
this.title = title;
}
}
const obj = new Book("Class배워보기");
여기서 엔진은 Class 키워드를 만나면 function 키워드를 만난 것처럼 객체를 만든다. 그리고 그 prototype에 메소드를 연결하고 프로퍼티들도 설정한다. 그런데 new 연산자로 인스턴스를 생성한다면 엔진은 어떻게 작동할까?
- 기존의 JS에서 작동했듯이 new 연산자를 통해서 Point 클래스 오브젝트의 constructor를 호출해 준다.
- 함수에서 파라미터를 넘겨주는 것처럼 파라미터 값을 constructor로 넘겨준다. 이렇게 되면서 title 파라미터 값은 "Class배워보기"가 된다.
- 엔진은 빈 오브젝트{}를 생성하게 되는데 이것이 인스턴스이다. 이렇게 빈 객체를 만든 이후에 프로퍼티 이름과 값을 설정하여 인스턴스 구조를 만들게 된다.(prototype.[[Prototype]]과 같은 구조를 말함)
- 이 다음에 constructor을 실행하게 된다. 인스턴스에서 this는 인스턴스를 참조한다는 사실을 알고 있을 것이다. 여기서도 동일하다. 이미 인스턴스는 만들어져 있기 때문에 this가 인스턴스를 참조할 수 있다.
- 위에서 title 파라미터 값은 "Class배워보기"이기 때문에 title 프로퍼티 값은 "Class배워보기"가 된다.
이런 과정을 거쳐서 Class를 통해서 인스턴스가 만들어 진다.
constructor가 없는 경우도 왕왕 있다. 하지만 ES5에서는 이런 경우가 일반적이었다. 왜냐하면 ES5까지는 constructor를 작성할 수 없었기 때문이다. ES6부터는 작성할 수 있게 되면서 Class를 작성할 때 인스턴스 프로퍼티를 작성하여 초깃값을 설정하는 부분에서 constructor를 사용할 수 있게 되었다. 다시 본론으로 돌아가서 constructor가 없다면 엔진은 무엇을 호출할까?
엔진이 Class키워드를 만나서 클래스 오브젝트를 생성할 때 기본적으로 constructor는 클래스 전체를 참조하도록 환경을 만든다. 그래서 constructor를 작성하지 않는다면 디폴트로 설정된 prototype.constructor를 사용하기 때문에 인스턴스를 생성할 수 있다. ES6부터는 constructor를 작성할 수 있다고 했는데 constructor를 작성하게 되면 디폴트로 Class를 전체를 참조하는 prototype.constructor를 오버라이드 하게 된다.
나는 상속을 받는 상황에서 cosntructor가 조금 더 복잡하게 다가왔다. 어떤 경우에는 앞서 살펴본 super 키워드를 통해서 서브 클래스에서 this를 바인딩 해줘야 하고 어떤 경우는 그냥 써도 되었기 때문이다.
이런 부분에 대해서는 경우를 나누어 생각을 해주면 이해가 더 편했다.
- 슈퍼 클래스와 서브 클래스 모두 constructor가 없는 경우
- 슈퍼 클래스만 constructor가 있는 경우
- 서브 클래스만 constructor가 있는 경우
- 슈퍼 클래스와 서브 클래스 모두 constructor가 있는 경우
이렇게 되면 default constructor가 호출된다. super를 설명하면서 쓴 예시를 다시 한 번 확인해보자.
class Book {
get getTitle() {
return "Super Class 배우기";
}
}
class CallTitle extends Book {
get getTitle() {
const super__title = super.getTitle;
console.log(super__title);
return "Sub Class 배우기";
}
}
const instance = new CallTitle();
console.log(instance.getTitle);
이 예시에서는 Book, CallTitle 모두 constructor가 없다. 이렇게 되면 엔진은 디폴트로 설정된, CallTitle 전체를 참조하는 constructor를 호출하게 되고 이어서 Book 클래스의 constructor를 호출하게 된다. 하지만 Book도 constructor가 작성되어 있지 않기 때문에 디폴트로 설정된, Book 전체를 참조하는 constructor가 호출된다.
즉, 이 경우는 각 constructor를 모두 호출하게 된다. 이렇게 하는 이유는 instance를 생성하기 위해서는 prototype의 constructor를 호출해야 하는데, 상속을 받는 경우에는 상위 클래스의 메소드까지 계층적으로 [[Prototype]]에 연결해야 하기 때문에 모든 constructor를 호출해 주는 것이다.
이런 경우에는 파라미터를 슈퍼로 넘겨주게 된다.
class Book {
constructor(title) {
this.title = title;
}
}
class CallTitle extends Book {}
const instance = new CallTitle("Class 배우기");
인스턴스를 만들 때, 서브 클래스의 constructor가 호출되고 이어서 슈퍼 클래스의 constructor가 호출되는데 이때 파라미터 값을 슈퍼 클래스의 constructor의 파라미터로 넘겨준다. 인스턴스의 인스턴스 프로퍼티로 파라미터 값이 할당된 모습이다.
class Book {}
class CallTitle extends Book {
constructor(title) {
this.title = title; // Uncaught ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
}
}
const instance = new CallTitle("Class 배우기");
에러가 발생한다!! 서브 클래스에만 constructor가 있는 경우는 있을 수 없다. 결코!!
이 경우가 super()를 호출해야 하는 경우이다.
class Book {
constructor(price) {
this.price = price;
}
}
class CallTitle extends Book {
constructor(price, title) {
super(price);
this.title = title;
}
}
const instance = new CallTitle(10000, "Class 배우기");
이렇게 서브의 constructor에서 this를 사용하려면 this를 사용하기 전에 명시적으로 슈퍼의 constructor를 super()로 호출해야 한다.
이 이유는 'this는 인스턴스를 참조한다'는 사실과도 연결이 된다!
서브 클래스의 constructor 안에서 super()로 Book클래스를 호출해 주기 전에는 Book 클래스의 인스턴스가 형성되지 않은 상태이기 때문에 this를 사용할 수가 없다. super()를 이용해서 먼저 호출해 주어 Book 클래스의 인스턴스를 생성해 주어야 this.title의 this를 생성할 수 있는 것이다.
이 부분이 내가 가장 어려워 했던 부분이었는데 ECMA Script를 기반으로 JS를 설명해 주니 굉장히 쉽게 이해할 수 있었다.
나 역시 Class를 잘 안 쓰는 사람 중 한 명이었다. 어려워서도 있지만 잘 몰라서가 훨씬 컸다. 하지만 nestJS에서도 그렇고 JS나 TS에서 더 생산성 있는 코딩을 하기 위해서는 Class는 반드시 거쳐야 하는 관문이었다. JS를 시작한 지도 거의 1년이 다 되어 가는데 드디어 그 관문을 거친 것 같아서 매우 기쁘다. 물론 잘 사용하는 것은 지금부터의 내 노력이지만 말이다.