[REAL Deep Dive into JS] 25. 클래스

young_pallete·2022년 9월 30일
0

REAL JavaScript Deep Dive

목록 보기
26/46

🚦 본론

사실... 생성자 함수를 이해하고 있다면 이 파트는 그저 문법을 이야기하는 것과 거의 비슷하긴 합니다.
따라서, 만약 이 원리가 이해가 안되신다면, 생성자 함수를 다시 이해하는 것을 추천합니다. 😉

문법적 설탕

일단 문법적 설탕에 대한 정의를 알 필요가 있겠습니다.
wikipedia - syntactic sugar에 의하면, 다음과 정의로 기술되어 있습니다.

In computer science, syntactic sugar is syntax within a programming language that is designed to make things easier to read or to express.

핵심은 easier to read to express입니다. 더욱 쉽게 읽고 표현하기 위해 사용하는 프로그래밍 언어라는 거죠.

일각에서는, 클래스를 생성자 함수의 문법적 설탕이라고 해석하기도 합니다.
이를 뒷받침하는 근거는 다음과 같습니다.

  1. 클래스는 객체지향 언어의 상속을 구현할 수 있다.
  2. 기존 프로토타입 기반 패턴을 클래스 기반 패턴처럼 사용할 수 있게 하는, 프로토타입 기반 객체지향 언어를 따른다.

하지만 단순한 문법적 설탕이라고 하기에는, 클래스는 좀 더 진화된 특성을 가지고 있습니다.

  1. 생성자 함수와 달리 호이스팅과 new 연산자 호출에 대해 암묵적으로 strict합니다.
  2. 상속을 지원하는 키워드인 extendssuper을 지원한다.
  3. 프로퍼티를 열거할 수 없습니다.

따라서 단순히 읽기 쉽고, 표현한 기능만 가지기보다는, 새로운 객체 생성 매커니즘으로 보는 것이 바람직합니다.

클래스 정의

클래스는 일급객체이며, 함수입니다.
따라서 익명으로 쓸 수도, 기명으로 사용할 수도 있습니다.

class Person {};

const Person = class {};

const Person = class MyClass {};

클래스 호이스팅

클래스는 함수 선언문처럼, 소스코드 평가(런타임 이전)과정에 먼저 평가되어 함수 객체를 생성합니다. 그때 생성된 함수 객체는, 바로 constructor(생성자) 객체입니다.

이때, constructor는 마치 생성자 함수처럼 호출됩니다. 그렇기에 프로토타입 역시 이때 같이 생성됩니다.

💡 그렇다면, 호이스팅은 어떻게 발생하게 될까요?

답은, 호이스팅이 발생하지만, let const 처럼 TDZ가 걸려 있게 됩니다.
즉, 실행 컨텍스트 중 LexicalEnvironment에서 관리한다고 보는 게 타당하겠군요!

const Person = '';

{
	console.log(Person);
  	class Person {}; // Cannot access 'Person' before initialization
}

인스턴스 생성

클래스는 함수이지만, 오로지 인스턴스를 갖기 위한 목적을 갖고 있습니다.
따라서 무조건 new 연산자와 함께 호출되어 인스턴스를 생성합니다.

🙇🏻‍♂️ 어떻게 보면 너무 당연한 이야기라, 자세한 설명은 생략해도 무방하겠군요!

메서드

크게 3가지가 있습니다.
constructor 메서드, 프로토타입 메서드, 정적 메서드입니다.
여기서 핵심은, 어떤 목적을 가졌는지입니다. 이를 이해한다면 각 특성을 이해하기 쉽습니다.

생성자(constructor)

인스턴스 생성 및 초기화를 담당합니다.
어떻게 보면, 우리가 생성자 함수 때 살펴보았던 프로토타입의 constructor과 동일하다고 보이지만, 사실 관련이 없습니다.

실제 결과를 볼까요?

class TestA {
    constructor(score) {
        this.score = score;
    }
}

const testA = new TestA(100);

console.log(testA)

만약 prototypeconstructor 프로퍼티가 classconstructor 메서드와 같았다면, constructor에는 동적으로 생성자 함수로써 constructor 메서드가 할당되어야 합니다.

하지만 클래스 자체가 constructor로 주어지는 것을 보니, constructor은 그저 평가 시 함수 객체를 생성하여 인스턴스를 초기화하기 위한 용도라는 것을 알 수 있습니다.

그리고, 이러한 constructor은 암묵적으로 빈 객체를 생성하며, 스스로도 생략이 가능합니다. 그렇게 동작시켜도 암묵적으로 빈 constructor을 스스로 정의합니다.

class A {}
const a = new A();
console.log(a) // A {}

프로토타입 메서드

클래스 함수 내에 [[메서드명]](인자) {} 꼴로 작성합니다.
클래스 몸체에서 정의하면, 기본적으로 프로토타입의 메서드로 정의가 됩니다!

이유라면.. 이전에 서술했듯이, 생성자 함수에서 인스턴스의 프로퍼티로 할당하는 것은 인스턴스를 생성할 때마다 추가적인 연산을 주어야 합니다. 이는 비효율적이므로, 동작 자체를 프로토타입으로 인스턴스에 전달함으로써 최적화를 시켜줄 수 있는 것이죠!

아래의 sayHi는 프로토타입 메서드입니다.

class Person {
	constructor() {}
  	sayHi() {
    	console.log("Hi!");
    }
}

정적 메서드

인스턴스에서 호출하는 것이 아닌, 생성자 함수에서 호출할 수 있는 메서드입니다.
static 키워드를 통해 정적 메서드로 변환이 가능합니다.

class Person {
	constructor() {}
  	static sayHi() {
    	console.log("Hi!");
    }
}

Person.sayHi(); // Hi!

프로토타입 메서드 vs 정적 메서드

둘의 핵심은 인스턴스가 호출의 대상인지를 판별하면 간단합니다.

  • 프로토타입 메서드는 결국 인스턴스를 통해 호출하지만,
  • 정적 메서드는 인스턴스가 아닌 생성자 함수 자체를 통해 호출합니다.

원리는 함수는 객체라는 것에서부터 시작합니다.
(그렇다면 클래스 역시 이에 속하겠군요! 😮)
함수는 객체이기 때문에 프로퍼티와 메서드를 함수 자체도 가질 수 있는 것이죠.
따라서 인스턴스의 생성 과정에서 함수 자기 자신에 대한 메서드까지 메서드로 할당하지는 않습니다. 그저 this로 인스턴스가 가질 프로퍼티와 메서드를 정의할 뿐이죠.

즉, 정적 메서드는 인스턴스의 프로퍼티 상속 및 프로토타입 체인에 등록되지 않으며, 오직 생성자 함수에만 메서드로 바인딩되었다고 할 수 있겠네요!

메서드의 특징

다음과 같은 특징을 지니고 있다고 하네요! 어떻게 보면 지극히 당연합니다.

  • function을 생략한 메서드 축약 표현 사용 - 따라서 non-constructor - new 연산자 호출 불가
  • 각 메서드 정의에 있어 ,가 필요 없음
  • 암묵적 strict mode
  • 메서드 열거 불가 ([[Enumerable]] = false)

클래스의 인스턴스 생성 과정

클래스를 호출하면 [[Construct]]가 호출됩니다.
(이는 생성자 함수 파트에서 봤듯이, 인스턴스를 호출할 때 사용하는 내부 메서드이죠)

1. 인스턴스 생성과 this 바인딩

class constructor은 실제 prototype constructor과 관련이 없습니다. 따라서

  • 이와 별도로 인스턴스를 위한 this가 클래스를 호출할 당시 생성이 되죠.
  • 그리고 this의 프로토타입으로 클래스의 prototype 프로퍼티가 가리키는 객체가 바인딩됩니다.
  • 이후 인스턴스는 this에 바인딩이 됩니다.

2. 인스턴스 초기화

class constructor method가 일할 차례입니다.
만약 생략되어 있다면 이 과정은 건너뜁니다.

  • 우선 constructor의 내부코드를 실행하면서, this에 바인딩된 인스턴스를 초기화합니다.
  • constructor에서 인수로 받은 초기 값을 인스턴스의 프로퍼티 값으로 초기화합니다.

3. 인스턴스 반환

  • 모든 처리가 끝나면 인스턴스가 바인딩된 this가 암묵적으로 반환이 됩니다.

프로퍼티

인스턴스 프로퍼티

전술했듯, constructor에서 정의합니다.
정의된 인스턴스 프로퍼티는 public의 성격을 가져서, 인스턴스를 통해 언제든지 접근이 가능합니다.

😖 사실상... 생성자 함수의 내용과 비슷해서 생략합니다!

접근자 프로퍼티

이전에 프로퍼티 어트리뷰트에서 설명했듯, 접근자 프로퍼티는 [[Value]]라는 게 결여된 친구입니다.
따라서 자체적인 값이 없고, 단지 이름에 걸맞게, 다른 프로퍼티의 값을 읽거나 저장하는 용도입니다.

😖 이 역시, 지금까지 달려오면서 너무나 반복적인 내용이라 생략해도 무방하겠군요.

클래스 필드 정의 제안

클래스 필드

클래스 기반 객체지향 언어에서, 클래스가 생성할 인스턴스의 프로퍼티를 지칭합니다.
즉, 클래스를 쓸 때 인스턴스의 프로퍼티를 필드라고 할 수 있겠군요!

private 필드 정의 제안

이러한 필드는, 아까 인스턴스의 프로퍼티에서 서술했듯 public한 성격을 가지고 있었습니다.

이는 다른 객체 지향형 언어와 달리 언제든지 접근을 할 수 있다는 측면에서 보안 및 안정성 측면에서 골칫거리였어요. 이러한 배경에서 나온 것이, ES10의 클래스 필드 정의 제안입니다.

구현과정에서 나온 현상 - constructor가 아닌 클래스 몸체에서 인스턴스 프로퍼티 정의

일단 이 사양을 구현하기 위해 최신 자바스크립트 엔진들은 미리 선제적으로, 클래스 필드를 클래스 몸체에서도 정의 가능하도록 했습니다.

class Test {
	score = 0
}

const test = new Test();
console.log(test); // Test { score: 100 }

이때 주의할 것은, 항상 참조 시 this를 통해 인스턴스의 프로퍼티로 인식할 수 있게끔 해야 합니다.

class Test {
	score = 100
    constructor() {
        this.weight = 1.5
    }
	getResult() {
		const testResult = this.weight * this.score
		return testResult;
	}
}

const test = new Test()
console.log(test.getResult()) // 150

private 필드

서두가 길었군요.
private 필드에 대해서 이야기를 하자면 간단합니다.
클래스 몸체에서 #을 붙여서 인스턴스 프로퍼티와 메서드를 정의해주면 끝입니다.

이러한 private 필드는 오직 클래스 내부에서만 참조가 가능하게 됩니다.

class Test {
	#score = 100
    constructor() {
        this.weight = 1.5
    }
	getResult() {
		const testResult = this.weight * this.#score
		return testResult;
	}
}

const test = new Test()
console.log(test.getResult()) // 150
console.log(test.score) // undefined

static 필드 정의 제안

ES12에서 나온 것으로, 2021년 1월 TC39에 제안되었다고 합니다.
이를 통해 정적 메서드를 클래스에서 static 키워드로 구현이 가능하게 되었습니다.

class Test {
    constructor() {
      // 정적 메서드에서 인스턴스의 프로퍼티는 참조할 수 없다. 왜냐하면 인스턴스로 호출하지 않기 때문이다.
      this.score = 100 
    }
  	static #score = 0

	static alertTestStart() {
    	console.log(`Start Test... now Score: ${Test.#score}`)
    }
}

console.log(Test.alertTestStart()) // Start Test... now Score: 0

상속에 의한 클래스 확장

클래스 상속, 생성자 함수 상속

굉장히 재미있는 파트입니다.
기존의 프로토타입은 생성자 함수 자체가 체인식으로 상속받지 않았는데요!
하지만 상속을 통한 클래스 확장은 클래스 자체가 기존 클래스를 상속 받습니다.

즉, 프로토타입뿐만 아니라, 인스턴스의 프로퍼티, 메서드까지 기존 클래스로부터 상속을 받는 것입니다!

이때, 클래스들을 다음과 같이 부릅니다.

  • 상속한 클래스(상위 클래스): 수퍼클래스, 베이스 클래스, 부모 클래스
  • 상속받은 클래스(하위 클래스): 서브클래스, 파생 클래스, 자식 클래스
class Test {
  constructor(score) {
    this.score = score;

	this.weights = {
		math: 1.5,
      	english: 1.2
    }
  }
}

class MathTest extends Test {
	getResult() {
    	return this.score * this.weights.math
    }
}

class EnglishTest extends Test {
	getResult() {
    	return this.score * this.weights.english
    }
}

const mathTest = new MathTest(100);
const englishTest = new EnglishTest(100);

const totalWeightScore = mathTest.getResult() + englishTest.getResult()
console.log(totalWeightScore) // 270

동적 상속

삼항 연산자를 통해 동적으로 상속 대상을 결정할 수 있는 문법입니다.
실제로 사용해본 적은 없긴 한데...
좋은 코드는 아니지만 다음과 같이 쓸 수 있겠군요!


class SignoPen {
  	...
	static salePrice = 1500;
	...
}

class MonamiPen {
  	...
	static salePrice = 2000;
	...
}

const needs = {
	note: 'morningGlory',
  	pencil: 'tombow',
  	pen: 'monami'
}

class Pen extends (needs.pen === 'monami' ? MonamiPen : SignoPen) {}

console.log(Pen.salePrice); // 2000

서브클래스의 constructor

너무나 당연한 이야기입니다.
서브 클래스에서 constructor을 생략하면, 빈 constructor가 아닌, 상속에 따른 constructor 정의가 이루어지겠죠!

이를 내부 코드로 설명하자면 다음과 같습니다.

constructor(...args) { super(...args) }

super 키워드

그렇다면 super 키워드는 무엇일까요?
super 키워드는 식별자처럼 참조도 가능하며, 함수처럼 호출도 가능한 키워드입니다.

호출

호출을 하면, 기본적으로 수퍼클래스의 constructor을 호출합니다.
그 다음에 다시 constructor에서 생성할 인스턴스의 프로퍼티를 재정의할 때 사용하는 거죠.

class A {
	constructor(a, b) {
    	this.a = a * 2;
      	this.b = b * 2;
    }
}

class B extends A {
	constructor(a, b) {
    	super(a, b)
      	this.b = b;
    }
}

const b = new B(10, 10);
console.log(b.a, b.b); // 20 10

그런데 주의사항이 있습니다.

만약 이렇게 서브클래스에서 constructor을 명시한다면, 반드시 super 키워드를 호출해야 합니다.
섀도잉을 피해야 하기 위해서는 super을 통해 constructor을 재정의해야 하기 때문입니다.

또한, super은 항상 서브클래스 인스턴스 프로퍼티 할당보다 우선합니다.
즉, constructor에서 super의 호출은 최상단을 권장합니다.

마지막으로 super은 서브클래스에서만 유효합니다.
만약 서브클래스가 아닌 베이스클래스에서 호출한다면 에러가 발생합니다.

super 참조

메서드 내에서 super를 참조하면, 수퍼클래스의 메서드 호출이 가능합니다.
이때 참조하는 대상은 수퍼클래스의 prototype 프로퍼티에 바인딩된 프로토타입 객체 참조가 가능해야 합니다.
그리고, 만약 해당 인스턴스의 프로퍼티를 해당 수퍼클래스의 메서드에 바인딩하고 싶다면?
그럴 때는 Function.prototype.call, Function.prototype.apply를 사용하면 됩니다!

class Milk {
	constructor() {
    	this.price = 1000
    }
  
  	getSalePrice() {
    	return this.price * 1.2
    }
}

class ChocoMilk extends Milk {  
  	getSalePrice() {
        // 지금은 명시적인 this 바인딩이 없으므로 `getSalePrice`의 this는 Milk를 바인딩합니다.
      	const superPrice = super.getSalePrice.call(this);
    	return superPrice * 1.5
    }
}

const chocoMilk = new ChocoMilk()
console.log(chocoMilk.getSalePrice()) // 1800

상속 클래스의 인스턴스 생성 과정

1. 서브클래스의 super 호출

우선적으로 수퍼클래스와 서브클래스를 구분하기 위해 자바스크립트 엔진은 [[ConstructorKind]]라는 내부 슬롯을 갖게 됩니다.

이때, 수퍼클래스는 base, 서브클래스는 derived를 할당합니다.
이때, 수퍼클래스는 기존 인스턴스 생성과정과 동일합니다.
다만 서브클래스는 super 호출이 발생합니다. (암묵적으로도요!)
이후, 수퍼클래스의 constructor가 호출되면, 수퍼클래스 평가 후 생성된 함수 객체의 코드가 실행됩니다.

즉, 생성 과정의 주체를 수퍼클래스에게 위임한다는 것이 포인트죠!

2. 수퍼클래스의 인스턴스 생성, this 바인딩

수퍼클래스가 비록 인스턴스를 생성하지만, this 바인딩은 서브클래스에게 바인딩됩니다.
이유는, 결국 this 바인딩은 호출한 대상에 따라 동적으로 바인딩되는데 new를 통해 호출한 대상이 서브클래스이기 때문입니다.

3. 수퍼클래스의 인스턴스 초기화

constructor 이후에는 this에 바인딩된 인스턴스를 초기화하게 되겠죠?!

4. 서브클래스의 constructor 복귀, this 바인딩

원래는 암묵적인 this가 생성되는 것이 맞지만, 우리는 이 this의 생성과정을 수퍼클래스에게 위임했습니다.
따라서 이때 생성된 this는 바로 수퍼클래스가 반환한 인스턴스입니다.

this에 서브클래스는 인스턴스를 바인딩하겠군요! (이것이 클래스 상속이 일어나는 핵심입니다.)

5. 서브클래스 인스턴스 초기화

다음부터는 간단합니다.
똑같은 방식으로 인스턴스를 초기화시킵니다.

6. 인스턴스 반환

결과적으로 this를 반환하겠군요!

표준 빌트인 생성자 함수 확장

어떻게 보면 매우 당연한 개념입니다.
결국 표준 빌트인 생성자 함수 역시 확장할 수 있죠! 따라서 생략합니다.

🌈 마치며

후... 이 파트 정말 길군요. 약 60쪽을 서술하니 진이 빠지네요. 😭
사실 클래스를 서술하지만, 결국 생성자 함수의 과정이 정말 많이 녹아 있습니다.
이러한 특성 때문에, 클래스가 생성자 함수의 문법적 설탕이라는 말이 나오는 것이겠죠?

그러나, 분명 클래스는 클래스만의 독자적인 강력한 기능들을 제공합니다.
따라서 새로운 객체 생성 방법으로 보는 게 합당하다는 저자의 의견이 납득이 되네요!

사실 이번에, 이전 글들을 보면서 뭔가 스스로 피드백을 해봤습니다.
확실히, 평소에 좀 기분 좋게 글을 써서 그런지, 신나있어 보여서(?) 내용들에 집중이 가질 않더라구요. 😭
좀 더 차분한 호흡으로, 앞으로 공부를 탐구하고 정리해보려 합니다.

다들, 그럼 즐거운 코딩하길 바라며, 이상!

profile
People are scared of falling to the bottom but born from there. What they've lost is nth. 😉

0개의 댓글