4.3 클래스
16.8 버전이 나오기 전까지 리액트에서는 모든 컴포넌트가 클래스로 작성돼 있었다.
함수로 컴포넌트를 작성하기 시작한 것은 리액트의 역사에 비춰 보았을 때 얼마 되지 않은 일이며, 따라서 최근에 작성된 리액트 애플리케이션이 아닌 개발한지 조금 오래된 애플리케이션이나 라이브러리를 마주한다면 클래스형 컴포넌트를 보게될 일도 종종 있을 것이다.
개발자 스스로가 클러스형 컴포넌트를 작성할 일이 없다고 판단하고, 생명주기 함수도 앞으로 사용할 일이 없으며, 앞으로는 함수형 컴포넌트로만 작성할 예정이라 하더라도 과거에 작성된 리액트 코드를 읽기 위해서, 또 이 코드를 함수형으로 개선하기 위해서는 자바스크립트의 클래스가 어떤 식으로 작동하는지 이해해야 한다.
클래스에 대해 이해한다면, 왜 리액트가 함수형으로 패러다임을 바꾼지도 알 수 있고 나아가 오래된 리액트 코드를 리팩터링하는 데도 도움이 될 것이다.
또한 자바스크립트의 프로토타입 기반으로 작동하는 클래스의 원리를 이해한다면 자연스럽게 프로토타입에 대해서도 알 수 있게 될 것이다.
4.3.1 클래스란 무엇인가?
특정한 객체를 만들기 위한 일종의 템플릿과 같은 개념이다.
즉, 특정한 형태의 객체를 반복적으로 만들기 위해 사용되는 것이 클래스다.
자바스크립트에서 클래스를 활용하면 객체를 만드는 데 필요한 데이터나 이를 조작하는 코드를 추상화해 객체 생성을 더욱 편리하게 할 수 있다.
추후에 살펴보겠지만 클래스가 나오기 이전(ES6)에는 클래스라는 개념이 없어 객체를 만드는 템플릿 같은 역할을 함수가 도맡아 했었다.
반대로 말하면, 우리가 자바스크립트에서 클래스로 하는 모든 것들을 함수로도 동일하게 표현할 수 있다.
// 클래스 예제
// Car 클래스 선언
class Car {
// constructor는 생성자다. 최초에 생성할 때 어떤 인수를 받을지 결정할 수 있으며,
// 객체를 초기화하는 용도로도 사용된다.
constructor(name) {
this.name = name;
}
// 메서드
honk() {
console.log(`${this.name}이 경적을 울립니다!`)
}
// 정적 메서드
static hello() {
console.log('저는 자동차입니다.')
}
// setter
set age(value) {
this.carAge = value
}
// getter
get age() {
return this.carAge
}
}
// Car 클래스를 활용해 car 객체를 만들었다.
const myCar = new Car('자동차')
// 메서드 호출
myCar.honk()
// 정적 메서드는 클래스에서 직접 호출한다.
Car.hello()
// 정적 메서드는 클래스로 만든 객체에서는 호출할 수 없다.
// Uncaught TypeError: myCar.hello is not a function
myCar.hello()
// setter를 만들면 값을 할당할 수 있다.
myCar.age = 32
// getter로 값을 가져올 수 있다.
console.log(myCar.age, myCar.name) // 32 자동차
4.3.1.1 constructor
constructor
는 생성자로, 이름에서 알 수 있는 것처럼 객체를 생성하는 데 사용하는 특수한 메서드다.
단 하나만 존재할 수 있으며, 여러 개를 사용한다면 에러가 발생한다.
그러나 생성자에서 별다르게 수행할 작업이 없다면 생략하는 것도 가능하다.
// X
class Car {
constructor (name) {
this.name = name
}
// SyntaxError: A class may only have one constructor
constructor (name) {
this.name = name
}
}
// O
class Car {
// constructor는 없어도 가능하다
}
4.3.1.2 프로퍼티
프로퍼티란 클래스로 인스턴스를 생성할 때 내부에 정의할 수 있는 속성값을 의미한다.
class Car {
constructor (name) {
// 값을 받으면 내부에 프로퍼티로 할당된다.
this.name = name
}
}
const myCar = new Car('자동차') // 프로퍼티 값을 넘겨주었다.
기본적으로 인스턴스 생성 시 constructor 내부에서 빈 객체가 할당돼 있는데 바로 이 빈 객체에 프로퍼티의 키와 값을 넣어서 활용할 수 있게 도와준다.
다른 언어처럼 접근 제한자가 완벽하게 지원되는 것은 아니지만, #
을 붙여서 private
를 선언하는 방법이 ES2019에 추가됐고, 또 타입스크립트를 활용하면 다른 언어와 마찬가지로 private
, protected
, public
을 사용할 수 있다.
물론 이는 타입스크립트에서 가능한 것일 뿐, 자바스크립트에서는 기본적으로 모든 프로퍼티가 public
이다.
과거 private
이 없던 시절에는 _
를 붙여 접근해서는 안 된다는 코딩 컨벤션이 있긴 했지만 어디까지나 컨벤션일 뿐 기능적으로 private
와 동일한 것은 아니다.
4.3.1.3 getter와 setter
getter
란 클래스에서 무언가 값을 가져올 때 사용된다.getter
를 사용하기 위해서는 get
을 앞에 붙여야 하고, 뒤이어서 getter
의 이름을 선언해야 한다.
class Car {
constructor (name) {
this.name = name
}
get firstCharacter () {
return this.name[0]
}
}
const myCar = new Car('자동차')
myCar.firstCharacter // 자
반대로 setter
란 클래스 필드에 값을 할당할 때 사용한다.
마찬가지로 set
이라는 키워드를 먼저 선언하고, 그 뒤를 이어서 이름을 붙이면 된다.
class Car {
constructor (name) {
this.name = name
}
get firstCharacter () {
return this.name[0]
}
set firstCharacter (char) {
this.name = [char, ...this.name.slice(1)].join('')
}
}
const myCar = new Car('자동차')
myCar.firstCharacter // 자
// '차'를 할당한다.
myCar.firstCharacter = '차'
console.log(myCar.firstCharacter, myCar.name) // 차, 차동차
4.3.1.4 인스턴스 메서드
클래스 내부에서 선언한 메서드를 인스턴스 메서드라고 한다.
이 인스턴스 메서드는 실제로 자바스크립트의 prototype
에 선언되므로 프로토타입 메서드로 불리기도 한다.prototype
에 선언된다는 의미가 무엇인지 다음 코드를 통해 살펴보자.
class Car {
constructor(name) {
this.name = name
}
// 인스턴스 메서드 정의
hello() {
console.log(`안녕하세요, ${this.name}입니다.`)
}
}
위 예제에서 Car
라는 클래스를 선언하고, 그 내부에 hello
라고 하는 인스턴스 메서드를 정의했다.
이 인스턴스 메서드는 다음과 같이 선언할 수 있다.
class Car {
constructor(name) {
this.name = name
}
// 인스턴스 메서드 정의
hello() {
console.log(`안녕하세요, ${this.name}입니다.`)
}
}
const myCar = new Car('자동차')
myCar.hello() // 안녕하세요, 자동차입니다.
위와 같이 새롭게 생성한 객체에서 클래스에서 선언한 hello 인스턴스 메서드에 접근할 수 있는 것을 확인할 수 있다.
이렇게 접근할 수 있는 이유는 앞서 프로토타입 메서드라고도 불리는 이유, 즉 메서드가 prototype
에 선언됐기 때문이다.
class Car {
constructor(name) {
this.name = name
}
// 인스턴스 메서드 정의
hello() {
console.log(`안녕하세요, ${this.name}입니다.`)
}
}
const myCar = new Car('자동차')
Object.getPrototypeOf(myCar) // { constructor: f, hell: f }
Object.getPrototypeOf
를 사용하면, 인수로 넘겨준 변수의 prototype
을 확인할 수 있다.
이에 대한 결과로 {constructor: f, hello: f}
를 반환받아 Car
의 prototype
을 받은 것으로 짐작할 수 있다.
이를 좀 더 확실하게 알아보기 위해 다음 코드를 살펴보자.
class Car {
constructor(name) {
this.name = name
}
// 인스턴스 메서드 정의
hello() {
console.log(`안녕하세요, ${this.name}입니다.`)
}
}
const myCar = new Car('자동차')
Object.getPrototypeOf(myCar) === Car.prototype // true
Object.getPrototypeOf(myCar)
를 Car.prototype
과 비교한 결과 true
가 반환된 것을 볼 수 있다.Object.getPrototypeOf
외에도 해당 변수의 prototype
을 확인할 수 있는 방법은 한 가지 더 있다.
class Car {
constructor(name) {
this.name = name
}
// 인스턴스 메서드 정의
hello() {
console.log(`안녕하세요, ${this.name}입니다.`)
}
}
const myCar = new Car('자동차')
myCar.__proto__ === Car.prototype // true
__proto__
또한 Object.getPrototypeOf
와 동일하게 작동하는 것을 알 수 있다.
__proto__
는 가급적 사용해서는 안 되는 코드다.
왜냐하면 이 __proto__
는 typeof null === 'object'
와 유사하게 원래 의도한 표준은 아니지만 과거 브라우저가 이를 사용했기 때문에 유지되는, 호환성을 지키기 위해서만 존재하는 기능이기 때문이다.
따라서 가급적이면 __proto__
보다는 Object.getPrototypeOf
를 사용하는 것이 좋다.
직접 객체에서 선언하지 않았음에도 프로토타입에 있는 메서드를 찾아서 실행을 도와주는 것을 바로 프로토타입 체이닝
이라고 한다.
모든 객체는 프로토타입을 가지고 있는데, 특정 속성을 찾을 때 자기 자신부터 시작해서 이 프로토타입을 타고 최상위 객체인 Object
까지 훑는다.
이 경우 myCar
에서 시작해서 부모인 Car
에서 hello
를 찾는 프로토타입 체이닝을 거쳐서 비로소 hello
를 호출할 수 있게 됐다.
이와 비슷한 원리로 toString
을 예로 들 수 있다.toString
은 객체 어디에서도 선언하는 경우가 없지만 대부분의 객체에서 모두 사용할 수 있다.
이는 toString
도 마찬가지로 프로토타입 체이닝을 거쳐 Object
에 있는 toString
을 만나기 때문이다.
결론적으로 이 프로토타입과 프로토타입 체이닝이라는 특성 덕분에 생성한 객체에서도 직접 선언하지 않은, 클래스에 선언한 hello()
메서드를 호출할 수 있고, 이 메서드 내부에서 this
도 접근해 사용할 수 있게 된다.
4.3.1.5 정적 메서드
정적 메서드는 특이하게 클래스의 인스턴스가 아닌 이름으로 호출할 수 있는 메서드다.
다음 예제를 살펴보자.
class Car {
static hello() {
console.log('안녕하세요!')
}
}
const myCar = new Car()
myCar.hello() // TypeError: myCar.hello is not a function
Car.hello() // 안녕하세요!
정적 메서드 내부의 this
는 클래스로 생성된 인스턴스가 아닌, 클래스 자신을 가리키기 때문에 다른 메서드에서 일반적으로 사용하는 this
를 사용할 수 없다.
이러한 이유로 리액트 클래스형 컴포넌트 생명주기 메서드인 static getDerivedStateFromProps(props, state)
에서는 this.state
에 접근할 수 없다.
정적 메서드는 비록 this
에 접근할 수 없지만 인스턴스를 생성하지 않아도 사용할 수 있다는 점, 그리고 생성하지 않아도 접근할 수 있기 때문에 객체를 생성하지 않더라도 여러 곳에서 재사용이 가능하다는 장점이 있다.
이 때문에 애플리케이션 전역에서 사용하는 유틸 함수를 정적 메서드로 많이 활용하는 편이다.
4.3.1.6 상속
리액트에서 클래스형 컴포넌트를 만들기 위해서 extends React.Component
또는 extends React.PureComponent
를 선언한 것을 본 적이 있을 것이다.
이 extends
는 기존 클래스를 상속받아서 자식 클래스에서 이 상속받은 클래스를 기반으로 확장하는 개념이라 볼 수 있다.
class Car {
constructor (name) {
this.name = name
}
honk () {
console.log(`${this.name} 경적을 울립니다!`)
}
}
class Truck extends Car {
constructor(name) {
// 부모 클래스의 constructor, 즉 Car의 constructor 를 호출한다.
super(name);
}
load () {
console.log('짐을 싣습니다.')
}
}
const myCar = new Car('자동차')
myCar.honk() // 경적을 울립니다!
const truck = new Truck('트럭')
truck.honk() // 경적을 울립니다!
truck.load() // 짐을 싣습니다.
Car
를 extends
한 Truck
이 생성한 객체에서도, Truck
이 따로 정의하지 않은 honk
메서드를 사용할 수 있는 것을 볼 수 있다.
이 extends
를 활용하면 기본 클래스를 기반으로 다양하게 파생된 클래스를 만들 수 있다.
4.3.2 클래스와 함수의 관계
클래스는 ES6에서 나온 개념으로, ES6 이전에는 프로토타입을 활용해 클래스의 작동 방식을 동일하게 구현할 수 있었다.
반대로 말하면, 클래스가 작동하는 방식은 자바스크립트의 프로토타입을 활용하는 것이라고 볼 수 있다.
다음 Car
클래스의 코드를 바벨에서 트랜스파일하면 다음과 같이 변환된다.
// 클래스 코드를 바벨로 변환한 결과
// 클래스 코드
class Car {
constructor (name) {
this.name = name
}
honk () {
console.log(`${this.name}이 경적을 울립니다!`)
}
static hello () {
console.log('저는 자동차입니다')
}
set age (value) {
this.carAge = value
}
get age () {
return this.carAge
}
}
// 바벨로 변환한 결과
'use strict'
// 클래스가 함수처럼 호출되는 것을 방지
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError('Cannot call a class as a function')
}
}
// 프로퍼티를 할당하는 코드
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i]
descriptor.enumerable = descriptor.enumerable || false
descriptor.configurable = true
if ('value' in descriptor) descriptor.writable = true
Object.defineProperty(target, descriptor.key, descriptor)
}
}
// 프로토타입 메서드와 정적 메서드를 선언하는 코드
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps)
if (staticProps) _defineProperties(Constructor, staticProps)
Object.defineProperty(Constructor, 'prototype', { writable: false })
return Constructor
}
var Car = /*#__PURE__#*/ (function () {
function Car (name) {
_classCallCheck(this, Car)
this.name = name
}
_createClass(
Car,
[
{
key: 'honk',
value: function honk() {
console.log(
''.concat(
this.name,
'\uC774 \uACBD\uC801\uC744 \uC6B8\uB9BD\uB2C8\uB2E4!',
)
)
}
},
{
key: 'age',
get: function get() {
return this.carAge
},
set: function set(value) {
this.carAge = value
},
},
],
[
{
key: 'hello',
value: function hello() {
console.log('저는 자동차입니다')
},
},
],
)
return Car
})();
ES6 미만 환경에서는 동작하지 않는 클래스를 구현하기 위해 _createClass
라는 헬퍼 함수를 만들어 클래스와 동일한 방식으로 동작할 수 있도록 변경한 것을 확인할 수 있다.
위 코드를 조금 더 보기 쉽게 변경해 보자.
var Car = (function () {
function Car (name) {
this.name = name
}
// 프로토타입 메서드. 실제로 프로토타입에 할당해야 프로토타입 메서드로 작동한다.
Car.prototype.honk = function () {
console.log(`${this.name}이 경적을 울립니다!`)
}
// 정적 메서드. 인스턴스 생성 없이 바로 호출 가능하므로 직접 할당했다.
Car.hello = function () {
console.log('저는 자동차이빈다')
}
// Car 객체에 속성을 직접 정의했다.
Object.defineProperty(Car, 'age', {
// get과 set은 각각 접근자, 설정자로 사용할 수 있는 예약어다.
// getter
get: function () {
return this.carAge
},
// setter
set: function (value) {
this.carAge = value
}
})
return Car
})()
클래스 작동을 생성자 함수로 매우 유사하게 재현할 수 있음을 알 수 있다.
즉, 클래스는 객체지향 언어를 사용하던 다른 프로그래머가 좀 더 자바스크립트에 접근하기 쉽게 만들어주는, 일종의 문법적 설탕(syntactic sugar)의 역할을 한다고 볼 수 있다.
또한 자바스크립트 클래스가 프로토타입을 기반으로 작동한다는 사실도 확인할 수 있다.
4.3.3 정리
자바스크립트 개발자라면 아무래도 클래스보다는 함수가 더 익숙하겠지만, 앞서 살펴본 것처럼 다른 객체지향 언어 수준으로 자바스크립트의 클래스도 객체를 생성하기 위해 도움이 되는 여러 기능을 제공하고 있으며 또 계속해서 기능이 추가되고 제안되고 있다.
또한 과거 리액트의 많은 코드들이 클래스형 컴포넌트로 생성됐으므로 클래스를 이해하고 나면 클래스형 컴포넌트에 어떻게 생명주기를 구현할 수 있는지, 왜 클래스형 컴포넌트 생성을 위해 React.Component
나 React.PureComponent
를 상속하는지, 메서드가 화살표 함수와 일반 함수일 때 어떤 차이가 있는지 등을 이해할 수 있을 것이다.