스무디 한 잔 마시며 끝내는 리액트 + TDD (13)

y_cat·2023년 1월 9일
0

클래스 컴포넌트

지금까지 할 일 목록 프로젝트를 진행했을 때, 함수 컴포넌트(Function component)를 사용했다. 이는 React 16.8 버전부터 함수 컴포넌트를 기본 컴포넌트로 사용하기 시작했지만, 그 이전에는 클래스 컴포넌트 기반으로 사용했었다. 그 이유는 함수 컴포넌트에서는 컴포넌트의 상태(State)를 사용할 수 없었기 때문이다. (리액트 훅이 없었음)

그러므로 지난번에 만든 할 일 목록 앱을 클래스 컴포넌트로 제작해보고 React의 클래스 컴포넌트를 이해해본다.


프로젝트 셋팅

다음과 같이 명령어를 차례차례 입력하였다.

// 새로운 React 프로젝트 생성
npx create-react-app class-todo-list --template=typescript

// styled-components와 Prettier 설치
cd class-todo-list
npm install --save styled-components
npm install --save-dev @types/styled-components jest-styled-components
npm install --save-dev husky lint-staged prettier

Prettier를 설정하기 위해 .prettierrc.js 파일을 생성하여 다음과 같이 입력한다.
module.exports = {
	jsxBracketSameLine: true,
    singleQuote: true,
    trailingComma: 'all',
    printWidth: 100,
};

lint-staged와 husky를 설정하기 위해 package.json 파일을 열어 다음과 같이 수정한다.
"scripts": {
	...
},
"husky": {
	"hooks": {
    	"pre-commit": "lint-staged"
    }
},
"lint-staged": {
	"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
    	"prettier --write"
    ]
},

마지막으로 절대 경로로 컴포넌트를 추가하기 위해 Typescript 설정 파일인 tsconfig.json을 열어 다음과 같이 수정한다.
{
	"compilerOptions": {
    	...
        "jsx": "react-jsx",
        "baseurl": "src"
    },
    ...
}

그리고 지난번에 만들었었던 할 일 목록 앱의 소스 코드를 그대로 사용하기 위해 src 폴더를 복사하여 현재 src폴더를 삭제하고 붙여넣는다. 추가적으로 index.tsx에서 에러 처리를 위해 document.getElementById('root')에 HTMLElement 타입을 붙여서 다음과 같이 수정한다.

터미널에서 npm run test를 입력하여 테스트 코드를 실행하여 문제없이 프로젝트가 설정되었는지 확인해본다. 그리고 npm start를 입력하여 앱이 잘 실행되는 지도 확인해본다.



클래스 컴포넌트로 리팩토링


Button 컴포넌트

import React, { Component } from 'react';

...

export class Button extends Component<Props> { 
  render() {
    const {
      label,
      backgroundColor = '#304FFE',
      hoverColor = '#1E40FF',
      onClick
    } = this.props;

    return (
      <Container backgroundColor={backgroundColor} hoverColor={hoverColor}
        onClick={onClick}>
        <Label>{label}</Label>
      </Container>
    );
  }
}

React에서 클래스 컴포넌트를 정의하기 위해서는 React의 Component 클래스(Component<Props>)로부터 상속받아 새로운 클래스 컴포넌트를 생성할 필요가 있다.

그리고 Typescript를 사용하는 경우, 부모 컴포넌트로부터 전달받을 Props와 컴포넌트 안에서 사용할 State의 타입을 다음과 같이 미리 지정할 필요가 있다.

interface Props {...}
interface State {...}

export class Button extends Component<Props, State> {
	...
}

하지만 여기 Button 컴포넌트에서는 State를 사용하지 않으므로 다음과 같이 State를 생각하고 Props 인터페이스만 지정하였다.


React 클래스 컴포넌트는 화면에 표시하기 위해 render 함수를 사용한다.
함수 컴포넌트에서는 함수 매개변수를 통해 부모 컴포넌트로부터 Props를 전달받았지만, 클래스 컴포넌트에서는 this.props를 사용하여 부모 컴포넌트부터 전달받은 Props에 접근한 후 JS의 구조 분할 할당을 통해 데이터를 할당하여 사용한다.

...

export class Button extends Component<Props> { 
  render() {
    const {
      label,
      backgroundColor = '#304FFE',
      hoverColor = '#1E40FF',
      onClick
    } = this.props;

    return (
      <Container backgroundColor={backgroundColor} hoverColor={hoverColor}
        onClick={onClick}>
        <Label>{label}</Label>
      </Container>
    );
  }
}

Input 컴포넌트

import React, { Component } from 'react';

...

export class Input extends Component<Props> {
	render() {
		const { placeholder, value, onChange } = this.props;
		return (<InputBox 
			placeholder={placeholder} 
			value={value}
			onChange={(event) => {
				if(onChange) {
					onChange(event.target.value);
				}
			}}/>
		);
	}
}

Button 컴포넌트와 동일한 방법으로 수정하였다.


ToDoItem 컴포넌트

import { Component } from 'react';

...

export class ToDoItem extends Component<Props> {
	render() {
		const { label, onDelete } = this.props
		return (
			<Container>
				<Label>{label}</Label>
				<Button
					label='삭제'
					backgroundColor='#FF1744'
					hoverColor='#F01440'
					onClick={onDelete} />
			</Container>
		)
	}
}



App 컴포넌트

import { Component } from 'react';

...

interface Props {}

interface State {
  readonly toDo: string;
  readonly toDoList: string[];
}

class App extends Component<Props, State> {

  constructor(props: Props) {
    super(props)

    this.state = {
      toDo: '',
      toDoList: [],
    }
  }

  private addToDo = (): void => {
    const { toDo, toDoList } = this.state
    if (toDo) {
      this.setState({
        toDo: '',
        toDoList: [...toDoList, toDo],
      })
    }
  }

  private deleteToDo = (index: number): void => {
    let list = [...this.state.toDoList];
    list.splice(index, 1);
    this.setState({
      toDoList: list,
    })
  }

  render() {
    const { toDo, toDoList } = this.state

    return (
      <Container>
        <Contents>
          <ToDoListContainer data-testid='toDoList'>
            {toDoList.map((item, index) =>
              <ToDoItem key={item} label={item} onDelete={() => this.deleteToDo(index)} />)}
          </ToDoListContainer>
          <InputContainer>
            <Input placeholder='할 일을 입력해 주세요' value={toDo} onChange={(text) => this.setState({toDo: text})} />
            <Button label="추가" onClick={this.addToDo} />
          </InputContainer>
        </Contents>
      </Container>
    );
  }
 
}

export default App;

App 컴포넌트는 부모 컴포넌트로부터 전달받는 Props는 없지만, 클래스 컴포넌트를 만들기 위해 Empty Props를 선언했다. 함수 컴포넌트에서 useState를 사용하여 만든 State 데이터에 관한 타입을 정의했다.

함수 컴포넌트에서는 useState를 사용하여 필요할 때마다 State를 정의하여 사용하였지만, 클래스 컴포넌트에서는 컴포넌트에서 사용하는 모든 State를 하나의 State로 관리한다. 따라서 한 타입에 모든 State 변수를 정의했다.

클래스 컴포넌트에서 State를 사용하기 위해서는 클래스의 생성자(Constructor)에서 State의 값을 초기화해야 한다. 또한 생성자에서는 super 함수를 호출하여 전달받은 Props를 상속받은 부모 클래스에 꼭 전달해야 한다.

State에 데이터를 갱신할 때는 Set 함수인 setState 함수를 사용하지만, 생성자에서 초기화할 때는 바로 값을 대입할 수 있다.

그리고 Java에서와 비슷하게 클래스 컴포넌트 내부에서 함수를 정의할 때 클래스 함수로 정의해야 하며 클래스 외부로 공개(public)할 지, 공개하지 말 지(private)를 결정할 수 있다. 특별한 이유가 없다면 클래스 함수는 private로 설정한다.

State는 불변 데이터이다. 클래스 컴포넌트에서도 역시 State 값을 변경하기 위해서는 Set 함수를 사용해야 한다. 하지만, 우리가 임의로 함수명을 설정할 수 있었던 함수 컴포넌트와는 다르게 클래스 컴포넌트에서는 this.setState 함수만을 사용한다.



라이프 사이클 함수

클래스 컴포넌트는 함수 컴포넌트와 다르게 라이프 사이클 함수들을 가지고 있다. 라이프 사이클 함수를 잘 이해하면 클래스 컴포넌트를 좀 더 효율적으로 활용할 수 있다.
다음은 React의 모든 라이프 사이클 함수를 App 컴포넌트에 적용한 예제이다.

import type{ IScriptSnapshot } from 'typescript';

...

class App extends Component<Props, State> {
	constructor(props: Props) {
    	super(props);
        
        this.state = {
        	toDo: '',
            toDoList: [],
        };
    }
    
    private addToDo = (): void => {
    	const { toDo, toDoList } = this.state;
        if (toDo) {
        	this.setState({
            	toDo: '',
                toDoList: [...toDoList, toDo],
            });
        }
    };
    
    private deleteToDo = (index: number): void => {
    	let list = [...this.state.toDoList];
        list.splice(index, 1);
        this.setState({
        	toDoList: list,
        });
    };
    
    render() {
    	const { toDo, toDoList } = this.state;
        
        return (
        	<Container>
            	<Contents>
                	<ToDoListContainer data-testid="toDoList">
                    	{toDoList.map((item, index) => (
                        	<ToDoItem key={item} label={item} onDelete{() => this.deleteToDo(index)} />
                    	))}
                    </ToDoListContainer>
                    <InputContainer>
                    	<Input
                        	placeholder="할 일을 입력해 주세요"
                            value={toDo}
                            onChange={(text) => this.setState({toDo: text})}
                        />
                        <Button label="추가" onClick={this.addToDo} />
                    </InputContainer>
                </Contents>
            </Container>
        );
    }
    
    static getDerivedStateFromProps(nextProps: Props, prevState: State) {
    	console.log('getDerivedStateFromProps');
        
        return null;
    }
    
    componentDidMount() {
    	console.log('componentDidMount');
    }
    
    getSnapshotBeforeUpdate(prevProps: Props, prevState: State) {
    	console.log('getSnapshotBeforeUpdate');
        
        return {
        	testData: true,
        };   
    }
    
    componentDidUpdate(prevProps: Props, prevState: State, snapshot: IScriptSnapshot) {
    	console.log('componentDidUpdate');
    }
    
    shouldComponentUpdate(nextProps: Props, nextState: State) {
    	console.log('shouldComponentUpdate');
        return true;
    }
    
    componentWillUnmount() {
    	console.log('componentWillUnmount');
    }
    
    componentDidCatch(error: Error, info: React.ErrorInfo) {
    	// this.setState({
        //	 error: true,
        // });
    }
    
}

export default App;

하나씩 자세히 살펴본다.

constructor

클래스 컴포넌트는 클래스이기에 생성자 함수가 존재한다. 하지만 클래스 컴포넌트에서 State를 사용하지 않아 State의 초기값 설정이 필요하지 않다면 생성자 함수도 생략이 가능하다. 생성자 함수를 사용할 때는 반드시 super(props) 함수를 호출하여 부모 클래스의 생성자를 호출해야 한다. 생성자 함수는 해당 컴포넌트가 생성될 때 한 번만 호출된다.


render

클래스 컴포넌트가 렌더링되는 부분을 정의한다. 리턴값이 화면에 표시하게 되며, render 함수는 부모 컴포넌트로부터 받는 Props 값이 변경되거나 this.setState에 의해 State의 값이 변경되어 화면을 갱신할 필요가 있을 때마다 호출된다.

따라서 이 함수에서 this.setState를 사용하여 State 값을 직접 변경할 경우, 무한 루프에 빠질 수 있으므로 주의해야 한다. 예제에서는 render 함수에서 this.setState를 직접 호출하지 않고 클릭 이벤트와 연결하였다. 따라서, 클릭 이벤트가 발생할 때만 this.setState가 호출되므로 무한 루프에 빠지지 않는다.


getDerivedStateFromProps

부모로부터 받은 Props와 State를 동기화할 때 사용된다. 부모로부터 받은 Props로 State에 값을 설정하거나 State 값이 Props에 의존하여 결정될 때 이 함수를 사용한다.

State에 설정하고 싶은 값을 리턴하게 되며, 동기화할 State가 없으면 "null"을 반환하면 된다.

컴포넌트가 생성될 때 한번 호출되며 Props와 State를 동기화해야 하므로 Props가 변경될 때마다 호출된다.


componentDidMount

클래스 컴포넌트가 처음으로 화면에 표시된 이후에 호출된다. Render 함수가 처음 호출된 후 이 함수가 호출된다. ajax를 통한 데이터 습득이나 다른 JS 라이브러리와의 연동을 수행할 때 주로 사용된다.

이 함수는 부모로부터 받는 Prop 값이 변경되어도 this.setState로 State 값이 변경되어도, 다시 호출되지 않는다. 따라서 render 함수와는 다르게 이 함수에 this.setState를 직접 호출할 수 있으며 ajax를 통해 서버로부터 전달받은 데이터를 this.setState를 사용하여 State에 설정하기 가장 적합하다.


shouldComponentUpdate

클래스 컴포넌트는 기본적으로 부모 컴포넌트로부터 전달받은 Props가 변경되거나 컴포넌트 내부에서 this.setState로 State를 변경하면 리렌더링되어 화면을 다시 그리게 된다. Props 또는 State의 값이 변경되었지만, 다시 화면을 그리고 싶지 않으면 이 함수를 사용하여 렌더링을 제어할 수 있다.

이 함수에서 false를 반환하면 화면을 다시 그리는 리렌더링을 수행하지 않도록 막을 수 있다. 앞의 예제에서는 true를 사용하여 항상 리렌더링되게 하였지만, 다음과 같이 특정값을 비교하여 리렌더링을 방지할 수 있다.

리렌더링을 방지하는 이유는 화면 렌더링 최적화를 위해서이다. 화면을 다시 그리는 리렌더링은 React 컴포넌트에서 가장 코스트가 많이 드는 부분이다. 따라서, 이 함수를 사용하여 데이터를 비교하고 불필요한 리렌더링을 방지하면 성능이 좀 더 좋은 앱을 제작할 수 있다.


getSnapshotBeforeUpdate

Props 또는 State가 변경되어 화면을 다시 그리기 위해 render 함수가 호출된 후 실제로 화면이 갱신되기 바로 직전에 이 함수가 호출된다. 이 함수에서 반환하는 값은 componentDidUpdate의 세번째 매개변수(snapshot)로 전달된다.

이 라이프 사이클 함수는 많이 활용되지는 않지만, 화면을 갱신하는 동안 수동으로 스크롤 위치를 고정해야 하는 경우 등에 사용될 수 있다.

getSnapshotBeforeUpdate를 선언한 후 반환값을 반환하지 않는 경우 또는 getSnapshotBeforeUpdate를 선언하고 componentDidUpdate를 선언하지 않는 경우 warning이 발생함으로 주의해서 사용해야 한다.


componentDidUpdate

componentDidMount 함수는 컴포넌트가 처음 화면에 표시된 후 실행되고 두 번 다시 호출되지 않는 함수이다. 반대로 componentDidUpdate 함수는 컴포넌트가 처음 화면에 표시될 때는 실행되지 않지만, Props 또는 State가 변경되어 화면이 갱신될 때마다 render 함수가 호출된 후 호출되는 함수이다.

잘 활용되지 않지만, getSnapshotBeforeUpdate 함수와 함께 사용하여 스크롤을 수동으로 고정할 때 활용되기도 한다.

render 함수와 마찬가지로 이 함수는 State 값이 변경될 때도 호출이 되므로 State 값을 변경하는 this.setState를 직접 호출한다면 무한 루프에 빠질 수 있으므로 주의해서 사용해야 한다.


componentWillUnmount

해당 컴포넌트가 화면에서 완전히 사라진 후 호출되는 함수이다. 이 함수에서는 보통 componentDidMount에서 연동한 JS 라이브러리를 해지하거나 setTimeout, setInterval 등의 타이머를 clearTimeout, clearInterval을 사용하여 해제할 때 사용된다.

이 함수는 클래스 컴포넌트가 화면에서 완전히 사라진 후 호출되는 함수이다. 따라서 컴포넌트의 State 값을 변경하기 위한 this.setState 호출하면 갱신하고자 하는 컴포넌트가 사라진 후이기 때문에 Warning이 발생할 수 있다.


componentDidCatch

React는 JS이므로 비즈니스 로직에서 에러의 예외 처리로 try-catch를 사용할 수 있다. 하지만 render 함수에서 JSX 문법을 사용하여 컴포넌트를 렌더링하는 부분에서는 발생하는 에러를 처리하기 위해 try-catch를 사용할 수 없다. 이처럼 render 함수의 JSX에서 발생하는 에러를 예외 처리할 수 있게 도와준다.

render 함수의 return 부분에서 에러가 발생하면 componentDidCatch 함수가 실행된다. 이때, 다음과 같이 State를 사용하여 에러가 발생했을 때 자식 컴포넌트를 표시하지 않게하거나 에러 화면을 표시함으로써 사용자 경험을 개선할 수도 있다.

interface State {
	...
    readonly error: boolean;
}

class App extends Component<Props, State> {
	constructor(props: Props) {
    	super(props);
        
        this.state = {
        	...
            error: false,
        };
    }


    ...

    render() {
        const { ..., error } = this.state;

        return (
            <Container>
                {!error && (
                    <Contents>
                        ...
                    </Contents>
                )}
            </Container>
        );
    }

    ...

    componentDidCatch(error: Error, info: React.ErrorInfo) {
        this.setState({
            error: true,
        });
    }
}

모든 라이프 사이클 함수는 render 함수를 제외하고 모두 생략이 가능하며 필요할 때 재정의하여 사용할 수 있다.



호출 순서

컴포넌트가 생성될 때

constructor -> getDerivedStateFromProps -> render -> componentDidMount

컴포넌트의 Props가 변경될 때

getDerivedStateFromProps -> shouldComponentUpdate -> render -> getSnapshotBeforeUpdate -> componentDidUpdate

컴포넌트의 State가 변경될 때

shouldComponentUpdate -> render -> getSnapshotBeforeUpdate -> componentDidUpdate

컴포넌트의 렌더링 중 에러가 발생할 때

componentDidCatch

컴포넌트가 화면에서 제거될 때

componentWillUnmount



테스트

테스트는 건들 건 없다.
npm run test를 입력하여 잘 되는지 확인해본다.



결론

React의 예전 버전의 매운맛을 느끼기 좋았던 챕터이다. 클래스 컴포넌트가 React의 메인 컴포넌트로 사용되었을 때, 클래스의 개념과 라이프 사이클의 이해가 어렵다. 함수 컴포넌트에서도 라이프 사이클 함수와 비슷한 기능이 대부분 존재하며, 최적화 기법도 존재한다. React 레거시 코드 같은 것들을 분석할 때 아주 유용하다고 생각이 들었다.



Github Repo


profile
토이 프로젝트와 기술들 정리하는 블로그

0개의 댓글