React study/React study

[React] Hook (1)

카누가 좋아요 2023. 10. 22. 02:00

📌 참고 자료

https://ko.legacy.reactjs.org/docs/hooks-intro.html

 

Hook의 개요 – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

 

https://ko.legacy.reactjs.org/docs/hooks-overview.html

 

Hook 개요 – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

 

 

 

📋 Hook

📍 기존 클래스 기반 컴포넌트의 문제점

다음은 클래스 기반 Counter 컴포넌트 이다.

Increment 버튼을 누를 때마다 count가 1씩 증가하는 것을 화면에서 볼 수 있다.

 

import React, { Component } from "react";

// Counter 컴포넌트는 React.Component 클래스를 상속받은 자식 클래스
class Counter extends Component {
  constructor(props) {
    super(props);
    // 클래스 기반 컴포넌트에서는 상태 관리를 위해 this.state를 사용해 상태 정의
    this.state = {
      count: 0
    };
  }
  
  // 클래스 기반 컴포넌트에서는 상태 변경을 위해 this.setState 메서드를 사용함.
  increment = () => {
    this.setState({ count: this.state.count + 1 });
  };

  // 화면에 렌더링하기 위한 render 메서드
  render() {
    return (
      <div>
        <!-- 현재 count State를 화면에 렌더링하기 위해 this.state.count 사용 -->
        <p>Count: {this.state.count}</p>
        <!-- 버튼을 클릭하면 Counter 클래스(this) 내에 정의된 increment 메서드 작동 -->
        <button onClick={this.increment}>Increment</button>
      </div>
    );
  }
}

export default Counter;

 

 this.state를 초기화하려면 생성자(constructor) 메서드 내에서 설정해야 한다. 그렇지 않으면 JS 엔진은 state를 클래스의 멤버 변수로 인식하지 못하고 런타임 에러가 발생하게 된다.

 

❗ super(props)를 호출하지 않으면 ES6 클래스에서의 생성자 내에서 this를 사용할 수 없다. 생성자 내에서 this를 사용하기 전에 부모 클래스의 생성자를 호출하는 것은 JS 클래스 상속 메커니즘에서 필수적인 단계로, 건너뛴다면 JS는 this가 초기화되지 않았다는 오류를 발생시킨다.

 

 

Hook이 도입되기 이전, 클래스 기반 컴포넌트에서 발생하는 문제점은 다음과 같았다.

 

 1️⃣ 복잡성

클래스 기반 컴포넌트를 사용하면 여러 개의 상태가 있는 경우 각 상태를 개별적으로 관리해야 해서 코드의 가독성이 떨어지고, 유지 보수가 어렵게 되었었다.

3번 문제와 결합한 예시로, componentDidMount와 componentDidUpdate는 컴포넌트 안에서 데이터를 가져오는 작업을 수행할 때 사용되어야 하는데, 같은 componentDidMount에서 이벤트 리스너를 설정하는 것과 같은 관계 없는 로직이 포함되기도 하며, componentWillUnmount에서 cleanup 로직을 수행하기도 한다.

위와 같은 경우 상태 관련 로직은 여러 라이프 사이클 메서드들에 의해 한 공간 안에 묶여 있고, 이들은 서로 의존적일 수 있기 때문에 컴포넌트들을 작게 분리하는 것은 어려워 React를 별도의 상태 관리 라이브러리와 함께 결합하여 사용해 왔다.

 

 2️⃣ 중복되는 코드

위와 같이 복잡성 등의 이유로 모듈화가 어려워져 같은 로직을 여러 컴포넌트에서 사용해야 할 수 있다. 이때 상태 관리 로직을 중복해서 작성해야 했어서 코드의 재사용성이 저하되는 문제가 있었다.

 

3️⃣ 생명주기 메서드

컴포넌트의 생명주기 메서드(componentDidMount, componentDidUpadate, componentWillUnmount 등)를 사용해서 작업을 해야 하는 경우 코드가 복잡해지고, 이해도가 떨어지는 문제도 있었다. 이는 버그 발생 가능성을 높이고 코드의 예측을 어렵게 만들었다.

 

4️⃣ this 문제

JS에서의 this 키워드가 대부분의 다른 언어에서와는 다르게 작동하여 혼란을 주었고, 코드의 재사용성과 구성을 매우 어렵게 만들었다.

 

but Class 컴포넌트들을 없애지는 않고 계속 지원할 예정이다.

 

 

📍 그렇다면 Hook과 Hook의 장점은?

함수 컴포넌트에서 React state와 생명주기 기능(lifecycle features)을 연동할 수 있게 해주는 함수이다.

Hook은 class 안에서는 동작하지 않는다.

Hook은 다음과 같은 특징을 가진다.

 

1️⃣ 계층의 변화 없이 상태 관련 로직을 재사용할 수 있도록 도와준다.

Hook을 사용하면 이전에는 여러 생명 주기 메서드에 나누어 작성해야 했던 로직들을 컴포넌트 바깥에 있는 함수로 분리하여 관리할 수 있게 되어 코드를 좀 더 모듈화하고 재사용 가능하게 만들 수 있다.

 

'계층의 변화' 란 컴포넌트의 구조가 변경되거나 중첩된 구조가 변화하는 것을 말하는데, 조건부로 컴포넌트를 렌더링 하는 경우(컴포넌트의 구조 변경), 부모 컴포넌트와 자식 컴포넌트가 서로 중첩되어 있는 경우 등이있다. (부모 컴포넌트 내에서 반환하는 JSX에 자식 컴포넌트가 포함되는 경우)

기존 클래스 기반에서는 상태 관련 로직이 컴포넌트 자체에 내장되어 있어 상위 컴포넌트의 구조를 변경하면 하위 컴포넌트의 구조나 로직도 변경되어야 했다.

but Hook을 사용하면 상태 관련 로직을 커스텀 훅과 같은 방식으로 컴포넌트와 별도로 분리하여 관리할 수 있으므로 컴포넌트의 구조가 변경되어도 상태 관련 로직은 영향을 받지 않게 만들 수도 있다.

(물론 Hook을 사용한다고 모든 경우 상태 관련 로직이 컴포넌트와 분리되는 것은 아니다.)

 

이렇게 더 유연하고 모듈화된 코드를 작성할 수 있게 된다.

 

2️⃣ Hook을 통해 비슷한 작업을 하는 작은 함수의 묶음으로 컴포넌트를 나눌 수 있다.

클래스 기반 컴포넌트에서는 주로 생명주기 메서드를 기반으로 컴포넌트를 분리했었다.

but Hook을 사용하면 비슷한 작업을 하는 로직을 함수로 묶어 컴포넌트로 나눌 수 있고, useState, useReducer 같은 것들을 활용해 컴포넌트의 지역 상태 값을 관리할 수도 있다.

 

3️⃣ Hook은 Class 없이 React 기능들을 사용할 수 있게 한다.

클래스를 사용하면 위에서 언급한 this 바인딩 문제 등이 발생할 수 있는데 Hook을 사용하면 컴포넌트를 함수로 선언함으로써 개발자가 느끼는 복잡성이 더 줄어든다.

 

 

📍 Hook 사용 규칙

➡️ 최상위 레벨에서만 Hook을 호출해야 한다.

반복문, 조건문, 중첩된 함수 내에서 Hook을 실행하지 않도록 한다.

이 규칙을 따라야 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것이 보장이 된다.

 

다음 예시를 통해 순서 보장에 대해 살펴보자.

 

import React, { useState, useEffect } from 'react';

const ExampleComponent = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]);

  const handleClick = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={handleClick}>Click me</button>
    </div>
  );
};

export default ExampleComponent;

 

위의 경우 최상위 레벨에서 useState와 useEffect Hook을 작성해 주었다.

따라서 버튼을 클릭했을 때 handleClick 함수가 호출되어 setCount(count + 1)을 통해 count state가 1 증가한 상태로 바뀌게 되고, 그 후에 useEffect는 count state 변경을 감지하여 document.title을 변화시키는 작업을 하는 순서가 보장이 되게 된다.

 

but 아래 예시와 같이 최상위 레벨에서 Hook을 사용하지 않는 경우 순서 보장이 되지 않을 수 있다.

 

import React, { useState, useEffect } from 'react';

const ExampleComponent = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
  };

  // 주목!! useEffect Hook을 최상위 레벨이 아닌 if 문 안에 작성함.
  if (count === 5) {
    useEffect(() => {
      document.title = `Count is now 5!`;
    }, []);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      {count < 5 && <button onClick={handleClick}>Click me</button>}
    </div>
  );
};

export default ExampleComponent;

 

count 버튼을 4번 클릭하는 것 까지는 정상적으로 작동하는데, 5번째부터 에러가 발생한다.

Rendered more hooks than during the previous render.

React에서는 컴포넌트의 각 렌더링 시에 호출된 훅의 수가 이전 렌더링과 일치해야 한다는 규칙이 있는데 위 코드에 따르면 count state의 값이 5가 되었을 때에만 useEffect가 비로소 실행될 수 있어 useEffect의 호출 수가 일관되지 않아 발생하는 에러이다. (count가 5가 되기 전에는 useEffect가 아예 호출되지 않지만 5가 되면 1번 호출되므로 이전 렌더링과 호출된 hook의 수가 일치하지 않게 된다.)

 

이 문제는 다음과 같이 조건문을 useEffect의 안에 넣어 해결해 줄 수 있다. (이것은 곧 최상위 레벨에서 hook을 작성해야 한다는 뜻이다.)

count state가 변경될 때마다(버튼을 클릭할 때마다) useEffect가 호출이 되고, count가 5가 되었을 때 document.title이 변경되게 된다.

 

useEffect(() => {
  if (count === 5) {
    document.title = `Count is now 5!`;
  }
}, [count]);

 

➡️ 오직 React 함수 내에서 Hook을 호출해야 한다.

Hook을 일반적인 JS 함수 내에서 호출하는 것이 아닌 React 함수 컴포넌트나 커스텀 훅에서 호출해야 한다.

이는 React가 Hook을 호출하는 컴포넌트를 기반으로 내부적으로 Hook의 상태를 관리하고, 컴포넌트의 상태 변화에 따라 이를 감지하고 업데이트하기 때문이다.

만약 React 함수 컴포넌트가 커스텀 훅 내부가 아닌 다른 곳에서 Hook을 호출한다면, React는 해당 Hook의 호출을 인식하지 못하게 된다. 

 

위 2가지 규칙은 ESLint 플러그인인 eslint-plugin-react-hooks를 통해 강제될 수 있다. (CRA에 기본적으로 포함되어 있다.)