개발 공부/JavaScript

[JavaScript] 프론트엔드에서 Class는 왜 쓰는걸까?

U_D 2023. 2. 6. 17:25

 

들어가며

사실 class를 자체적으로 만들어 쓰고있지 않아 어떤 상황에서 써야 하는지에 대한 의문이 많이드는 시간이었다. 나름대로 단순하게 class가 필요한 순간을 고려해면 다음과 같은 상황에 적용해보면 좋을거 같다.

 

  • 객체 지향적 프로그래밍이 필요할 때
    -> 이 말은 즉 확장과 재사용을 고려한 구조를 잡으며 사용할 때라고도 볼 수 있을거 같다.
         class는 흔히 쓰는 함수와 다르게 만들어 둔 Class를 가져와 확장하여 쓸 수 있다.
  • node 에러 핸들링
    -> node가 가지는 Error에 대한 정의를 가져와서 커스텀 할 때, 즉 에러 핸들링을 할 때 class를 통한 확장이 가능해보인다.
  • 변하지 않는 값이 고정적으로 들어갈 때
    -> 단편적으로 api 호출에 대한 Fetch가 있을 때 헤더,바디 등 고정적으로 들어가는 옵션들의 대한 정의로 쓰는 경우가 있었다.

 

자바스크립트와 클래스의 관계

자바스크립트는 "프로토타입 기반 객체지향 언어" 이다. 이 말인즉슨, 클래스가 필요 없는 (class-free) 객체지향 언어라는 뜻이다. ES5 시절까지만 해도 생성자 함수와 프로토타입을 통해 상속을 구현했지만, ES6 에서 도입된 "클래스" 개념 덕분에 객체지향 프로그래밍을 훨씬 간편하게 구현할 수 있게 되었다.

 

// 생성자 함수 (ES5)
var Person = (function() {
    function Person(name) {
    	this.name = name;
    }
    Person.prototype.sayHi = function () {
    	console.log('Hi, ' + this.name)
    }
}());

var me = new Person('Kang')
me.sayHi() // Hi, Kang

// 클래스 (ES6)
class Person {
    constructor(name) {
    	this.name = name;
    }
    sayHi() {
    	console.log('Hi, ' + this.name)
    }
}

const me = new Person('Kang')
me.sayHi() // Hi, Kang

 

하지만 기존(ES5) 에도 객체지향을 구현할 수 있었다면, 굳이 새로 클래스를 도입한 이유는 무엇일까?

 

그 이유는, 생성자 함수도 프로토타입 기반의 인스턴스를 생성하기는 하나, 제공하지 않는 여러 기능으로 인해 객체지향을 완전히 구현하는 데에는 제약이 있었기 때문이다. 가령, 생성자 함수와 클래스의 차이를 살펴보면 다음과 같다.

 

  • new 연산자 없이 호출 : 생성자 함수는 일반 함수로 호출되지만, 클래스는 오류가 난다.
  • extends 와 super : 생성자 함수에는 제공되지 않지만, 클래스에는 제공된다.
  • 호이스팅 : 생성자 함수에는 호이스팅이 발생하나, 클래스에는 발생하지 않는다.
  • strict mode : 생성자 함수에는 암묵적으로 지정되지 않지만, 클래스에는 암묵적으로 지정된다.
  • [[Enumerable]] : 클래스의 constuctor, 프로토타입 메서드, 정적 메서드는 모두 [[Enumerable]] 의 값이 false 다.  

 

클래스는 생성자 함수보다 상속 관계 구현을 더욱 명료하게 하기 때문에, 객체지향을 더욱 견고하게 만든다고 하기도 한다. 그렇다면 클래스가 어떻게 작동하는지, 기본부터 조금씩 알아보도록 하자. 

 

클래스의 정의와 메서드

우선 클래스를 정의할 때는 다음 원칙을 따르면 된다.

1. class 키워드를 사용하여 정의

2. Pascal Case 사용 

 

class Person {
    // some code ...
}

 

인스턴스를 생성할 때는 다음 원칙을 따른다.

1. new 연산자 사용 

 

const me = new Person();

// new 연산자 없을 경우
const you = Person(); // TypeError: Class constructor Person cannot be invoked without 'new'

 

클래스는 표현식으로도 정의될 수 있는데, 이 때 클래스 이름은 없을 수도 있다. 

 

const Person = class {}
const Person1 = class Test {}

 

클래스는 일급 객체이며, 다음 특징을 갖는다.

  • 런타임에 생성이 가능하다.
  • 변수나 자료구조에 저장할 수 있다. 
  • 함수의 매개변수에 전달할 수 있다.
  • 함수의 반환값으로 사용할 수 있다. 
💡 일급 객체란? (First-class object)
함수의 매개변수로 전달되거나, 함수의 결과로 반환되거나, 변수에 할당될 수 있는 모든 객체를 가리킨다. 

 

클래스는 인스턴스를 생성하기 위한 생성자 함수이기 때문에, 클래스도 결국 함수의 프로퍼티를 모두 상속받는다. 

 

 

ES11 기준으로 클래스의 몸체에는 메서드만 선언할 수 있으며, 선언할 수 있는 메서드의 종류는 세 가지로 구분된다. (constructor(생성자) / 프로토타입 메서드 / 정적 메서드) 

 

constructor(생성자)

인스턴스를 생성하고 초기화하기 위한 특수한 메서드이며, 이름을 변경할 수 없다. 

 

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

 

constructor 는 클래스 내에 최대 한 개만 존재할 수 있으며, 생략도 가능하다. 만약 생략할 경우에는 빈 constructor 가 암묵적으로 정의되어, 빈 객체를 생성한다.

 

class Person {

}

// 위와 동일함
class Person {
	constructor(){
    }
}

 

constructor 는 별도의 반환문을 갖지 않아야 하는데 그 이유는 클래스가 new 연산자와 함께 호출되는 즉시 암묵적으로 this (인스턴스) 객체를 반환하기 때문이다. this 가 아닌 다른 객체를 반환한다면 this 반환이 무시되고 반환값이 반환된다. (하지만 원시값을 반환하는 경우에는 반환이 무시된다.)

 

class Person {
    constructor(name){
    	this.name = name;
        return {}
    }
}

const me = new Person('Ha');
console.log(me) // {}

// 원시값 반환
class Person {
    constructor(name){
    	this.name = name;
        return 100
    }
}

const me = new Person('Ha');
console.log(me) // {name: 'Ha'}

 

프로토타입 메서드

클래스의 prototype 에 메서드를 추가한다면, 기본적으로 프로토타입 메서드가 된다. 

 

class Person {
    constructor(name){
    	this.name = name;
    }
    
    // 프로토타입 메서드
    sayHi() {
    	console.log('Hi, I am' + this.name);
    }
}

 

이때, 클래스가 생성한 인스턴스는 포로토타입 체인의 일원이 되며, 클래스에서 정의한 메서드는 인스턴스의 프로토타입에 존재하는 프로토타입 메서드가 되는 것이다. 인스턴스는 이러한 메서드를 "상속"받아 사용하는 것이다.

 

const me = new Person('Ha');
Object.getPrototypeOf(me) === Person.prototype // true;
me.constructor === Person;
Object.getPrototypeOf(Person.prototype) === Object.prototype // true;

 

 

정적 (static) 메서드

정적 메서드는 인스턴스를 생성하지 않아도 호출할 수 있는 메서드이다. 정적 메서드를 만드는 방법은 두 가지가 있는데, 하나는 프로토타입에 직접 함수를 할당해주는 것이며, 둘째는 클래스 내부에서 static 키워드를 붙여 메서드를 생성하는 것이다. 

 

class Person {
    constructor(name){
    	this.name = name;
    }
}
// 정적 메서드 방법 1 
Person.sayHi = function () {
    console.log('Hi')
}

class Person {
    constructor(name){
    	this.name = name;
    }
    
    // 정적 메서드 방법 2 
    static sayHi() {
    	console.log('Hi, I am' + this.name);
    }
}

// 정적 메서드 호출 방법 
Person.sayHi();

 

정적 메서드는 클래스에 바인딩된 메서드가 되며, 클래스는 클래스가 평가되는 시점에 함수 객체가 되므로, 정적 메서드는 클래스 정의 이후에 인스턴스를 생성하지 않아도 호출할 수 있다. (별도의 생성 과정 필요 없음) 정적 메서드는 클래스로 호출하며, 인스턴스로 호출할 수 없다. (인스턴스의 프로토타입 체인에 존재하지 않기 때문)

 

쉽게 예를 들기 위해 아래의 그림을 보자. C 는 Child 클래스 (또는 생성자 함수) 가 생성하는 인스턴스이며, C 의 [[Prototype]] 는 Child.prototype 이다. 이 Child.prototype 안에 contstructor 와 prototype Method 가 들어있는 것이며, Child 안에 static Method 가 들어있다. Child 는 생성자 함수 / 클래스이기에 [[Prototype]] 인 Parent 로 Function.prototype 를 가지며, Child.prototype 는 객체이므로 Parent 로 Object.prototype 를 갖는다. 

 

 

따라서 정적 메서드와 프로토타입 메서드가 각각 속하는 프로토타입 체인이 다르며, 호출하는 주체도 다르다. (this 바인딩의 대상도 다른데, 이 내용을 조금 더 알기 위해서는 Javascript 의 this 포스트를 읽어봐도 좋다.)

💡 [[Prototype]]
- 함수를 포함한 모든 객체가 가지고 있는 인터널 슬롯
- 객체의 입장에서 자신의 부모 역할을 하는 프로토타입 객체

💡 prototype 프로퍼티
- 함수 객체만 가지고 있는 프로퍼티이
- 함수 객체가 생성자로 사용될 때 이 함수를 통해 생성될 객체의 부모 역할을 하는 객체(프로토타입 객체)

 

💡 정적 메서드 더 알아가기

자바스크립트에는 여러 표준 빌트인 객체 (전역의 기능을 제공하는 객체) 들이 존재한다. Math, Number, JSON, Object 등... 이들에게도 여러 개의 정적 메서드들이 존재하며, 애플리케이션 전역에서 사용되는 유틸리티 함수 (utility function) 으로 지칭되기도 한다.

 

가령, Math 객체에는 max() 라는 정적 메서드가 존재하는데, 이 덕분에 this 바인딩이 이루어지지 않고 고유의 함수 기능을 수행할 수 있게 된다. 만약 max() 가 정적 메서드가 아니었다면, Math 클래스의 인스턴스가 생성되었을 때 해당 인스턴스의 값에 따라 max() 함수의 결과가 달라질 것이다. JSON.stringify(), Object.is() 등과 같은 메서드 모두 동일한 특징을 가지고 있다. 

 

 

클래스와 인스턴스

클래스를 구성하고 나면, 이 클래스의 인스턴스 (instance) 라는 것을 만들 수 있다고 했다. 인스턴스는 무엇이며, 어떻게 만들 수 있는 것일까? 클래스가 하나의 "개체군" 에 가깝다면, 인스턴스는 해당 군에 소속된 하나의 개별 "개체" 에 가깝다. 

 

클래스 앞에 new 연산자를 붙여 호출하면 다음 순서로 일들이 진행된다. 

 

1. 클래스의 내부 메서드 [[Construct]] 호출

2. 빈 객체 (인스턴스) 생성

- [[Prototype]] 으로 클래스의 prototype 가 가리키는 객체 설정 (위 그림의 Child.prototype) 

- 빈 객체는 this 에 바인딩

💡 this 바인딩이란 식별자와 값을 연결하는 과정이다.
변수가 선언될 떄, 변수 이름(식별자)와 메모리 주소가 연결되는데, 이 과정 역시 바인딩이다.
this 바인딩은 this 식별자와 this 가 가리킬 값을 연결하는 과정이며, 함수 호출 방식 뿐만 아니라 strict mode 에도 영향을 받는다.

 

3. 클래스의 constructor 내부 코드 실행

- this 는 인스턴스를 가리킴 (바인딩)

- 인스턴스 초기화 (프로퍼티 추가, constructor 전달받은 초기값으로 값 초기화) 

4. 인스턴스 반환

- 완성된 인스턴스가 바인딩된 this 반환 (암묵적) 

 

class Person {
    constructor(name){
    // 인스턴스 생성, this 바인딩, {} 
    	this.name = name; // this 바인딩된 인스턴스 초기화 
    // this 반환 (암묵적), {name = name} 
    }
}

 

 

클래스와 프로퍼티

클래스가 갖는 프로퍼티의 종류는 인스턴스 프로퍼티와 접근자 프로퍼티로 나뉠 수 있다.

 

  • 인스턴스 프로퍼티
    앞서 논의한 this 바인딩이 이루어진 대상의 프로퍼티와 같은 값을 의미하며, constructor 내부에서 정의되어야 한다. 이떄, 인스턴스 프로퍼티는 언제나 public 한 상태를 갖는다. 즉, 모든 코드에서 해당 값에 접근할 수 있다. 

 

  • 접근자 프로퍼티
    자체적으로 값을 갖지 않고 다른 프로퍼티의 값을 읽거나 저장할 때 사용하는 접근자 함수로 구성된 프로퍼티다. 

 

const person = {
    firstName: 'Haeun',
    lastName: 'Kang',
    
    // 접근자 함수로 구성된 접근자 프로퍼티
    get fullName() {
    	return `${this.firstName} ${this.lastName}`
    }
}

console.log(person.fullName); // Haeun Kang

 

이러한 접근자 프로퍼티는 클래스에서도 다음과 같이 사용될 수 있다. 

 

class Person = {
    constructor(first, last) {
        this.firstName: first,
        this.lastName: last,
    }
    
    get fullName() {
    	return `${this.firstName} ${this.lastName}`;
    }
    
    set fullName(name) {
    	[this.firstName, this.lastName] = name.split(' ');
    }
}

const me = new Person('Haeun', 'Kang');

me // {firstName: 'Haeun', lastName: 'Kang'}
me.fullName // 'Haeun Kang'
me.fullName = 'Haeun Kim'
me.fullName // 'Haeun Kim'
me // {firstName: 'Haeun', lastName: 'Kim'}

 

위에도 나왔다시피, 접근자 프로퍼티는 자체적으로 값을 갖지 않는다. 하지만 접근자 함수 (getter, setter 함수) 로 구성되어, 다른 데이터 프로퍼티의 값을 읽거나 저장한다. 이때 접근자 함수의 이름은 인스턴스 프로퍼티처럼 사용되어, 함수처럼 호출되는 것이 아니라 프로퍼티처럼 참조하는 형식으로 사용된다. 

💡 getter 함수
- get 키워드를 사용해 정의
- 프로퍼티 값을 접근하여 값에 대해 무언가 수행할 때 사용
- 반드시 반환값 있어야 함

💡 setter 함수  
- set 키워드를 사용해 정의
- 프로퍼티 값을 할당할 때 사용 
- 반드시 매개변수 있어야 함 

 

 

출처 : https://haeunyah.tistory.com/119