React - 클래스 컴포넌트

박상준

2024년 12월 26일

1

React

함수형 컴포넌트만 쓰는거 아니였나?

리액트를 사용하다보면 컴포넌트를 필수적으로 만들어 사용하게 된다. 클래스 컴포넌트의 존재는 알고 있었지만 나는 사용을 한번도 해본적이 없다. 그냥 기본적으로 함수 컴포넌트를 사용해왔고 클래스 컴포넌트는 이미 레거시 기능이라고 생각했기 때문이다. 그래서 클래스 컴포넌트와 함수 컴포넌트에 대해서 알아볼 계획이다.

사실 함수 컴포넌트는 새롭게 등장한 기술이 아니다. 리액트 0.14버전부터 존재하는 기술이였다. 하지만 기능이 제한적인 무상태 함수 컴포넌트, stateless functional component였다. 즉 단순히 정적 요소를 렌더링하는 목적이였다.

var Aquarium = (props) => {
	var fish = getFish(props.species)
    return <Tank>{fish}</Tank>
}

var Aquarium = ({species}) => <Tank>{getFish(species)}</Tank>

이렇게 단순히 render만 사용하는 경우에만 사용하는 것이 함수형 컴포넌트였다. 하지만 리액트 16.8버전에서 Hook이라는 개념이 등장하면서 각광받기 시작했다. 그럼 이후에 함수 컴포넌트만 지원하는가? 그건 아니다. 이후에도 클래스 컴포넌트를 지원하고 있고 아직 클래스 컴포넌트를 사용하고 있는 코드도 존재한다. 그래서 우리는 함수형 컴포넌트가 익숙하지만 클래스 컴포넌트에 대해 알아야할 필요가 있다.

클래스 컴포넌트

클래스 컴포넌트의 구조 및 구성 요소

클래스 컴포넌트가 어떻게 생겼는지, 어떤 구조를 가지고 있는지 제대로 본적이 없지만 기본적인 구조를 알아보자

import React from 'react'

class SampleComponent extends React.Component{
  render(){
    return <h2>Sample Component</h2>
  }
}

클래스 컴포넌트를 만들려면 클래스를 선언하고 extends로 만들고 싶은 컴포넌트로 확장한다. 이때 React.Component 또는 React.PureComponent를 사용해서 extends할수 있다.

둘의 차이는 뒤에서 더 설명해보겠다.

그럼 props와 state, 메서드는 어떻게 사용하는지 보겠다.

import React from 'react';

interface SampleProps {
  required?: boolean;
  text: string;
}

interface SampleState {
  count: number;
}

class SampleComponent extends React.Component<SampleProps, SampleState> {
  static defaultProps = {
    required: false,
  };

  constructor(props: SampleProps) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  handleClick = () => {
    this.setState((prevState) => ({ count: prevState.count + 1 }));
  };

  public render() {
    const { required, text } = this.props;
    const { count } = this.state;
    const isLimited = count >= 10;

    return (
      <div>
        <h2>Sample Component</h2>
        <div>{required ? '필수' : '필수 아님'}</div>
        <div>문자: {text}</div>
        <div>count: {count}</div>
        <button onClick={this.handleClick} disabled={isLimited}>
          증가
        </button>
      </div>
    );
  }
}

export default SampleComponent;

컴포넌트 내부에 생성자 함수(constructor)가 있다면 컴포넌트가 초기화되는 시점에 호출된다. 그래서 이 생성자 함수를 통해 state를 초기화한다. 추가적으로 super()는 컴포넌트를 만들면서 상속받은 상위 컴포넌트인 React.Component 생성자 함수를 먼저 호출해 필요한 상위 컴포넌트에 접근할 수 있게 도와준다. 쉽게 얘기하면 컴포넌트가 처음 실행될때 state를 설정하기 위해 super를 사용해 Component의 생성자 함수를 받고, 상속받은 생성자 함수를 통해 state의 초기값을 세팅해주는 것이다.

constructor없이 state 초기화하는 방법도 있다. ES2022에서 추가된 클래스 필드를 사용하면 된다. 물론 최신 문법이기 때문에 ES2022 환경을 지원하는 브라우저에서 사용해야 한다.

...
class SampleComponent extends Component{
 state = {
   count: 1,
 }
}
...

props, state, 메서드는 우리가 알던 그대로이다. props는 컴포넌트에 전달할 속성, state는 변경될 값, 메서드는 내부에서 사용할 함수이다. 추가적으로 클래스 컴포넌트에서 메서드를 생성하는 방법은 3가지다. 첫번째로 일반 함수로 지정한 경우에는 this가 전역 객체를 바라보기 때문에 bind로 직접 this를 바인딩해야 한다. 두번째 방법은 위에서 사용한대로 화살표 함수를 사용해서 직전 this를 바인딩하는 방법으로 간단하게 메서드를 생성할 수 있다. 세번째 방법은 렌더링 함수 내부에서 새롭게 만들어 전달하는 방법이다. 즉, <button onClick={()=> this.handleClick()}></button>이런 식으로도 가능하지만 리렌더링이 이뤄질때마다 새롭게 생성되기때문에 성능이 저하된다. 그냥 간편하게 화살표 함수로 생성해주자. Image 이렇게 만든 컴포넌트를 렌더링한 결과이다. 증가 버튼을 누르면 리렌더링이 이뤄지며, count가 올라가는 모습을 볼수 있다.

클래스 컴포넌트의 생명주기 메서드

리액트 생명주기는 대부분 클래스 컴포넌트의 생명주기 메서드를 의미한다. 리액트의 생명주기 메서드가 실행되는 시점은 3가지다.

  • 마운트 : 컴포넌트가 마운팅(생성)되는 시점
  • 업데이트 : 이미 생성된 컴포넌트의 내용이 변경되는 시점
  • 언마운트 : 컴포넌트가 더 이상 존재하지 않는 시점

각 시점마다 실행되는 메서드가 존재하며, 그 종류를 알아보겠다.

Mount

constructor

위에서 언급했듯이 state를 초기화하는 용도이다. 유의해야할 점은 super()를 통해 React.Component의 생성자 함수를 호출함으로써this.propsthis.state를 상속받아야한다. 그렇지 않으면 contructor내부에서 오류가 발생하고 state 초기화를 진행하지 못한다.

class MyComponent() extends React.Component{
  constructor(props){
    super(props) // React.Component(부모 클래스)의 생성자 함수 호출
    //...
  }
}

render

컴포넌트가 UI를 렌더링하기 위해서 쓰인다. 그래서 리액트 클래스 컴포넌트의 유일한 필수 값으로 항상 쓰인다. 한가지 주의해야할 점은 render는 사이드 이펙트가 없어야 한다. 쉽게 말해서 input에 대해서 같은 output이 나와야한다는 것을 의미한다. render내부에서 state를 직접 업데이트하는 this.setState를 호출해서는 안된다.

마운트 과정에서만 이뤄지는 것이 아니라 업데이트 과정에서도 일어난다.

class Example extends React.Component {
  render() {
    return <div>...</div>
  }
}

static getDerivedStateFromProp

최근에 도입된 생성주기 메서드이다. 다음에 올 props를 바탕으로 state를 변경하는 용도이다. 컴포넌트가 마운트 될 때와 업데이트 될 때 호출된다. static으로 선언되기 때문에 this에 접근할 수 없다. 반환되는 객체의 내용이 state로 전달되며, null을 반환하면 아무 변화가 없다.

...
static getDerivedStateFromProp(nextProps, prevState){
  if(props.name !== state.name){
    return(
      name: props.name
    )
  }
  return null
}
...

componentDidMount

컴포넌트가 마운트된 직후에 호출된다. render와 다르게 이 메서드를 통해 this.setState로 state를 변경하는 것이 가능하다. 만약 state가 변경되었다면 즉시 다시 렌더링을 시도하지만 브라우저가 실제 UI를 사용자에게 업데이트되기 전에 실행되기 때문에 눈치챌 수 없게 해준다. 하지만 그렇다고해서 이 메서드에서 state를 조작하는 것을 권장하지 않는다. 만약 마운트 시점에 state를 조작하고 싶다면 constructor에서 하는 것이 좋다.

그렇다면 componentDidMount는 언제 사용하면 좋을까? 컴포넌트가 DOM에 추가된 이후에 수행해야 할 수 밖에 없는 작업을 하면 좋다. 그 예시로 API를 호출 후 업데이트, DOM에 의존적인 작업(이벤트 리스너 추가)등이다.

...
  componentDidMount() {
    fetch("https://api.example.com/data")
      .then((response) => response.json())
      .then((data) => this.setState({ data }));
  }
...

Update

shouldComponentUpdate

state나 props의 변경으로 리액트 컴포넌트가 리렌더링되는 것을 막기 위한 메서드이다. 이 메서드는 컴포넌트에 영향을 받지 않는 변화에 대해서 정의할 수 있다.

shouldComponentUpdate(nextProps, nextState) {
  // true인 경우(title이나 state의 input이 같지 않은 경우) 컴포넌트를 업데이트 한다.
  return this.props.title !== nextProps.title || this.state.input !== nextState.input
}

그래서 이 생명주기와 관련된 것이 위에서 잠깐 언급했던 PureComponent이다. 리액트에서 컴포넌트를 만드는 방법은 ComponentPureComponent두가지다. 큰 차이는 shouldComponentUpdate 생명주기 메서드를 처리하는 여부이다. PureComponent는 내부에서 shouldComponentUpdate 메서드가 구현된 클래스이고, Component는 아니다.

import React from 'react';

interface State {
  count: number;
}

type Props = Record<string, never>;

export class ReactComponent extends React.Component<Props, State> {
  private renderCounter = 0;

  constructor(props: Props) {
    super(props);
    this.state = {
      count: 1,
    };
  }
  private handleClick = () => {
    this.setState({ count: 1 });
  };
  public render() {
    console.log('ReactComponent', ++this.renderCounter);
    return (
      <>
        <h1>ReactComponent: {this.state.count}</h1>
        <button onClick={this.handleClick}>+</button>
      </>
    );
  }
}

export class ReactPureComponent extends React.PureComponent<Props, State> {
  private renderCounter = 0;

  constructor(props: Props) {
    super(props);
    this.state = {
      count: 1,
    };
  }

  private handleClick = () => {
    this.setState({ count: 1 });
  };

  public render() {
    console.log('ReactPureComponent', ++this.renderCounter); // eslint-disable-line no-console
    return (
      <>
        <h1>ReactPureComponent: {this.state.count}</h1>
        <button onClick={this.handleClick}>+</button>
      </>
    );
  }
}

export default function CompareComponent() {
  return (
    <>
      <h2>React.Component</h2>
      <ReactComponent />
      <h2>React.PureComponent</h2>
      <ReactPureComponent />
    </>
  );
}

PureComponent와 Component 두가지를 만들었다. 각 버튼을 클릭하면 setState에 의해 {count : 1}객체로 state값을 변경한다. 여기에서 renderCount는 렌더링되는 횟수를 알기 위한 변수이다. Image 버튼을 눌러보면 Component의 버튼은 다른 객체로 변경되면서 값이 바꼈다고 인식하기 때문에 매번 렌더링이 이뤄진다. 하지만 PureComponent는 얕은 비교를 통해 결과가 다른 경우에만 리렌더링하기 때문에 리렌더링이 이뤄지지 않는다. 즉, PureComponent는 클래스 내부에서 shouldComponentUpdate를 통해 변경된 값을 얕은 비교를 통해 확인한다. 그래서 PureComponent의 한계점이 깊은 비교는 하지 못하기 때문에 복잡한 객체 형태일 경우 PureComponent는 무의미하다. 그래서 필요한 곳에 적재적소에 활용하는 것이 좋다.

getSnapShotBeforeUpdate

이 메서드또한 최근에 도입된 메서드이다. DOM이 업데이트되기 직전에 호출되는 메서드로 여기에서 반환되는 값은 componentDidUpdate에 전달된다. 이 메서드는 업데이트 되기 직전에 snapshot(props & states)을 확보하는게 목적이다. 그래서 렌더링되기 전에 윈도우 크기를 조절하거나 스크롤 위치를 조정하는 등의 작업을 처리하면 좋다.

class Example extends React.Component {
  getSnapshotBeforeUpdate(prevProps, prevState) {
    if (prevProps.list.length < this.props.list.length) {
      const list = this.listRef.current
      return list.scrollHeight - list.scrollTop
    }
    return null
  }
}

componentDidUpdate

컴포넌트 업데이트가 일어난 이후 바로 실행되는 메서드이다. state나 props의 변화에 따라 DOM을 업데이트하는 상황에 쓰인다. 여기에서도 setState를 사용할 수 있다. 하지만 setState를 하게되면 업데이트가 이뤄지기 때문에 적절한 조건문을 넣어주지 않는다면 무한 루프가 발생할 수 있다.

...
componentDidUpdate(prevProps, prevState){
  if(this.props.userName !== prevProps.userName){
    this.fetchData(this.props.userName)
  }
}
...

UnMount

ComponentWillUnmount

언마운트시에 실행되는 메서드는 이것 하나 뿐이다. 언마운트되거나 더이상 사용하지 않기 직전에 호출된다. 메모리 누수나 불필요한 동작을 막기 위한 클린업 함수를 호출하기 위한 곳이라고 생각하면 된다.

...
componentWillUnmount() {
  window.removeEventListener('click', this.handleClick)
}
...

이렇게 각 생명주기마다 동작하는 메서드를 알아봤는데 그림으로 표현하면 이런 모습이다. Image render와 static getDerivedStateFromProps는 Mount와 Update에서 동작하는 것을 확인할 수 있다.

에러 상황에서 동작하는 메서드

생명주기에서 실행되지 않고 에러 상황에서 실행되는 메서드가 존재한다.

getDerivedStateFromError

이 메서드는 자식 컴포넌트에서 에러가 발생했을때 호출되는 에러 메서드이다. static 메서드로 error를 인수로 받는다.

import { Component, PropsWithChildren } from "react";

type Props = PropsWithChildren<{}>;

type State = {
  hasError: boolean;
  errorMessage: string;
};

export default class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false, errorMessage: "" };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, errorMessage: error.toString() };
  }

  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h1>에러 발생!</h1>
          <p>{this.state.errorMessage}</p>
        </div>
      );
    }
    return this.props.children;
  }
}

그리고 이 메서드는 무조건 정해둔 state를 리턴해야한다. 왜냐하면 에러가 발생했을때 어떻게 자식 컴포넌트를 렌더링할지 결정하는 용도이기 때문에 부수 효과를 발생하면 안된다. 물론 부수효과를 발생시킨다고 에러가 발생하지는 않는다. 하지만 render단계에서 실행되기 때문에 불필요한 부수 효과는 렌더링에 방해가 되기 때문이다.

componentDidCatch

그래서 에러 로깅과 같은 부수 효과를 위한 메서드가 있다. getDerivedStateFromError에서 에러는 잡고 state를 결정한 이후 실행된다.

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error("ErrorBoundary caught an error:", error, errorInfo);
  }

첫번째는 error를 인수로 받고 두번째는 에러가 발생한 컴포넌트 정보를 담는다.

이렇게 두가지 에러 메서드는 리액트에서 ErrorBoundary를 형성하는데 사용된다. 이 메서드는 아직 함수 컴포넌트에서 사용 가능한 훅으로 만들어지지 않았기 때문에 ErrorBoundary를 통해 에러를 잡고 싶다면 클래스 컴포넌트를 만들어줘야한다.

ErrorBoundary의 주의할점 : 개발 모드에서 에러가 발생하면 window까지 전파가 되지만 프로덕션 모드에서는 ErrorBoundary에 잡히지 않은 에러만 window로 전달된다.

클래스 컴포넌트의 한계

이렇게 기능을 세분화시켜서 공들여 만든 클래스 컴포넌트에서 왜 함수 컴포넌트로 넘어가게 되었을까?

  • 우선 데이터의 흐름을 추적하기 어렵다. 위의 코드를 보면 알겠지만 state는 여러 곳에서 수정될 수 있다. state가 변경된다는 것은 데이터가 변경되고 있다는 것이고, 리렌더링이 이뤄진다는 것인데 어떤 메서드에서 state가 수정되고 있는지, 이 state가 변경되면서 리렌더링이 이뤄지는지 일일히 찾아야하기 때문에 개발자가 한눈에 흐름을 추적하기 어려운 것이다.

  • 내부 로직의 재사용이 어렵다. 여러 컴포넌트에서 중복되는 로직이 존재할때 상위 컴포넌트에서 해당 메서드를 정의하고 props로 전달해주는 과정이 필수적이다. 하지만 클래스 컴포넌트 특성상 이 과정을 매끄럽게 처리하기 어려운 환경이다. 만약 전달해줬다 하더라도 그 흐름을 찾아가서 해당 함수를 수정해야하기 때문에 복잡할 수 밖에 없다. 그리고 이렇게 작성된 컴포넌트는 규모가 커질수 밖에 없다.

  • 클래스는 그냥 어렵다. 자바스크립트에서 클래스가 도입된 것은 비교적 최근이다. 자바스크립트는 프로토타입 기반 언어이지만 클래스 문법을 추가적으로 지원하면서 복잡도가 올라갔다. 나도 그렇지만 자바스크립트를 익힌지 얼마안됬다면 클래스 자체가 그렇게 익숙한 문법은 아닐 것이다.

  • 코드 크기를 최적화하기 어렵다. 클래스 컴포넌트는 번들 크기를 줄이는데 어려움을 겪는다.

  • hot reloading이 어렵다. hot reloading이란 코드를 수정했을때 앱을 다시 시작하지 않고 수정된 내용이 바로 적용되는 것이다. 클래스 컴포넌트는 instance를 생성하고 내부에서 state를 선언한다. 그 이후에 render가 수정되면 instance를 다시 생성해야만 하기 때문에 state가 초기화된다.

이러한 이유들로 클래스 컴포넌트의 단점을 극복하기 위해 훅을 도입하고 함수 컴포넌트가 각광받기 시작한 것이다.

클래스 컴포넌트와 함수 컴포넌트의 차이

  • 우선 가장 큰 차이는 생명주기 메서드의 부재이다. 생명주기 메서드는 React.Component의 메서드를 상속받아 사용하기 때문에 단순히 JSX를 리턴하는 함수 컴포넌트에서는 생명주기 메서드를 당연히 사용하지 못한다. 그래서 함수 컴포넌트에서 사용가능한 Hook을 통해서 생명주기 메서드를 비슷하게 구현할 수 있다.

useEffect를 사용하면 componentDidMount, componentDidUpdate, componentWillUnMount를 비슷하게 구현할 수 있다.

  • 렌더링되는 결과가 다르다. 클래스 컴포넌트는 this를 통해 props의 값을 가져온다. 그래서 props는 변경 가능한 값이고, 생명주기 메서드들은 변경된 props를 읽을 수 있게 된다. 반면에 함수 컴포넌트는 props를 인수로 받는다. 그래서 props를 받는 컴포넌트는 전달받은 props밖에 읽을 수 없는 것이다. 간단하게 정리하면 클래스 컴포넌트는 시간의 흐름에 따라 변화하는 this를 기준으로 렌더링이 이뤄지고, 함수 컴포넌트는 props와 state를 기준으로 렌더링이 이뤄진다.

이 내용은 setTimeout을 통해 로직을 작성해보면 확인이 된다. 클래스 컴포넌트에서 setTimeout에 지정한 시간이 흐르기 전에 props를 변경하면 setTimeout의 콜백 함수는 변경된 props값을 리턴한다. 반면에 함수 컴포넌트는 변경되기 전 props를 리턴한다.

마무리

나는 지금까지 함수 컴포넌트만 사용해왔고, 그 과정에서 클래스 컴포넌트의 필요성을 느끼지 못했다. 하지만 이렇게 정리를 해보면서 느낀 것은 이런 원래 클래스 컴포넌트가 시작이고 이후에 함수 컴포넌트로 개량된 것이라는 사실이다. 지금 쓰고 있는 것들이 어떤 원리로, 어떤 이유로 현재에 도달했는지 알아야 더 개선하고 성장할 수 있는 것이다. 아직 제대로 사용해본 적은 없는 클래스 컴포넌트이지만 알기 전보다는 성장했을 것이다.

ErrorBoundary와 같은 컴포넌트는 결국 클래스 컴포넌트로 만들어야하기 때문에 한번씩 코드도 작성해보고 복잡하지 않는 컴포넌트는 클래스로도 한번 만들어보는 것도 재미있을것 같다.

개의 댓글