[React] 렌더링 최적화를 통한 useState와 useReducer 이해

Park U-yeong·2021년 10월 10일
3
post-thumbnail

State를 관리하려면?

React에서 함수형 컴포넌트를 개발하려면 hooks 사용은 필수인데, state를 관리하기 위한 hook은 useStateuseReducer가 있다.
useContext는 state라기 보다는 props에 가깝다. 내용 추가하기가 귀찮아서는 아니다.

본 글은 state를 관리할 때 렌더링 최적화를 위해 이 두 hooks를 어떻게 사용해야하는지 적어보려고 한다. 참고로 기본적인 정보나 사용법 등은 React 공식 문서나 자료가 많기 때문에 따로 언급하지는 않겠다. 이것도 귀찮아서는 아니다..

렌더링 최적화

React 렌더링 최적화의 궁극적인 목적은 대부분 리렌더링을 방지하는 것이다. 리렌더링을 방지한다는 것은 렌더링에 영향을 주는 값들이 변하지 않았을 때는 렌더링을 하지 않도록 하는 것이다. 뭔가.. 하늘은 파랗다 라는 당연한 소리 같은데...

React는 렌더링을 할 때 가상 돔에 적용한 내용을 비교해서 변화가 있는 것만 처리하는 방식인데, 애초에 데이터가 동일하면 이 비교를 할 필요가 없지만 부지런한 React는 묵묵히 이 과정을 수행한다.

그래서 배려심이 많은 우리들이 React가 이런 고생을 하지 않도록 사전에 차단을 해줘야 한다.

설명을 위한 예제

UI를 재사용하기 위해 컴포넌트를 나눠서 관리하는 예시를 만만한 스핀박스로 준비했다.

SpinBox

import React from 'react';
import Display from './Display';
import Btn from './Btn';

const SpinBox = () => {
  const [value, setValue] = useState(0);
  
  const onClcik = () => {
    setValue(value + 1);
  };
  
  return (
    <div>
      <Display value={value} />
      <Btn onClick={onClick} />
    </div>
  );
}

Display

import React from 'react';

const Display = ({value}) => {
  console.log('display');
  return <span>{value}</span>
};

export default Display;

Button

import React from 'react';

const Btn = ({onClick}) => {
  console.log('btn');
  return <button onClick={onClick}>증가</button>
};

export default Btn;

최적화를 위한 React.memo 사용

렌더링 최적화는 결국 값이 변했는지를 확인하는 과정의 싸움이기 때문에, 불필요하게 값이 변하지 않도록 항상 신경써줘야한다.

예제에서 <Btn>을 클릭하면 console에 'display'와 'btn'이 출력된다.
그런데 'display'는 표시할 내용이 바뀌니 출력 되는게 이해가 되는데, 'btn'은 버튼 모양이 바뀌지도 않는데 출력이 된다.

이는 <SpinBox>value가 변경되면서 렌더링하게 되고, 이때 자식 컴포넌트들도 같이 렌더링을 하게 된다. 그래서 자식 컴포넌트가 불필요하게 리렌더링이 되지 않도록React.memouseMemo 등으로 메모이징 한다.

const Btn = ({onClick}) => {
  //...
};

// React.memo 사용
export default React.memo(Btn);

참고로 메모이징을 이런 사소한 곳 까지 남용하는 것은 권장하지 않는다.
예제는 설명을 위한 것이므로, 실무에는 꼭 필요한 곳에서 메모리 점유CPU 사용의 등가교환을 판단해서 값지게 사용하자.

그러나 코드를 실행해보면 우리의 바램과 달리 여전히 'btn'이 출력되고 있다.

React.memoReact.PureComponent와 동일한 역할을 하는데, <Btn>의 props가 변경되었는지를 확인해서 기존 내용을 재사용 할지 새로 렌더링을 할지 결정한다.

따라서 'btn'이 출력된다는 것은 결국 새로 렌더링 하는 것이고, 이는 onClick이 변했다는 의미가 된다.

함수 재사용을 위한 useCallback

그래서 우리는 onClick의 참조가 변하는 문제를 해결하기 위해 useCallback을 사용하게 될 것이다.

import React, { useCallback } from 'react';
//...

const SpinBox = () => {
  //...
  
  // useCallback을 이용해 value 값이 바뀌지 않을 때 함수 재사용
  const onClcik = useCallback(() => {
    setValue(value + 1);
  }, [value]);
  
  //...
}

useCallback은 재사용 판단을 위해 관찰할 값을 설정해야 하는데, onClicksetValue를 이용해 value에 1을 더해서 값을 변경하는 처리를 하기 때문에 value를 관찰해야 된다.

엄밀히는 setValue도 확인해야 하지만 setValue는 참조가 변하지 않기 때문에 value를 관찰한다면 따로 추가하지 않아도 된다.

그런데 value는 매번 바뀌는 값이기 때문에 위 코드는 아무런 의미가 없다.

state를 직접 사용하지 않는 useState 사용법

앞에서 본 예제의 문제를 해결하려면, 매번 바뀌는 state에 직접 접근하지 않도록 코드 작성이 필요하다. 그래서 useState는 이에 대한 방법을 제공하고 있다.

공식 문서에서 친절히 알려주고는 있지만, 공식 문서는 보통 초반에 React Hooks가 익숙하지 않을때 보게 되는데, 뭐래는거야? 하고 넘어가게 된다.

// 현재 state를 받아서 새로운 state로 반환하는 함수
const mutateState = (prevState) => nextState;

// setState에 이 함수를 반환하는 함수를 전달
setState(mutateState);

렌더 함수 내에서 state를 직접 접근하는 방식과 callback 함수를 이용하는 방법의 차이는 다음과 같다.

현재 state를 사용하는 방법

// state는 현재 렌더링을 위한 값이며
// setState는 다음 렌더링을 위한 state를 변경한다.
const [state, setState] = useState(0);

// 현재 렌더링 값인 0에 1을 더하므로 다음 렌더링 값은 1이 된다.
setState(state + 1);

// 한번 더 실행하더라도 현재 렌더링 값인 0에서 1을 더한 값이 설정된다.
setState(state + 1);

callback 함수를 전달하는 방법

// state는 현재 렌더링을 위한 값이며
// setState는 다음 렌더링을 위한 state를 변경한다.
const [state, setState] = useState(0);

// prevState는 다음 렌더링에 사용될 state가 전달된다.
const increase = (prevState) => prevState + 1;

// 최초의 prevState는 state와 동일하므로,
// prevState는 0 에서 1을 더한 값이 된다.
setState(increase);

// prevState 값은 앞에서 1로 변경한 상태이므로,
// 이 값에서 다시 1을 더해서 2가 된다.
setState(increase);

예제에서 본 것 처럼 state를 가공하는 함수를 전달하는 방식으로 구현하면 state에 직접 접근하지 않고도 기존 state를 기반으로 변경을 할 수 있게 된다.

useState의 이 내용을 모른다면, 함수 자체를 state로 관리하려다가 눈물의 삽질을 하게 된다. 내 이야기는 아니고..

따라서 기존 useCallback 예제는 다음과 같이 수정해야 된다.

import React, { useCallback } from 'react';
//...

// 현재 value를 받아서 값을 1을 더해서 반환하는 함수.
// 사이드 이펙트가 없는 순수 함수이므로 컴포넌트 외부에 둔다.
const increase = (state) => state + 1;

const SpinBox = () => {
  //...
    
  // onClick은 value는 알 필요 없이 setValue만 관찰한다.
  // setValue는 참조가 변하지 않으므로,
  // onClick도 기존에 생성한 함수를 계속 재사용한다.
  const onClcik = useCallback(() => {
    setValue(increase);
  }, [setValue]);
  
  //...
}

이렇게 수정하고 나서 <Btn>을 클릭하면 우리가 의도한 대로 console에 'display'만 출력될 것이다.

useState를 보완 하는 useReducer

만약 1씩 증가하는 버튼과 10씩 증가하는 버튼이 있다고 생각해보자. useState를 사용하는 상황이라면 아래와 같이 코드가 작성된다.

//...

// 1씩 증가하는 함수
const increase = (state) => state + 1;

// 10씩 증가하는 함수
const increase10 = (state) => state + 10;

const SpinBox = () => {
  //...
  
  // 1씩 증가 버튼 클릭
  const onClcik = useCallback(() => {
    setValue(increase);
  }, [setValue]);
  
  // 10씩 증가 버튼 클릭
  const onClcik10 = useCallback(() => {
    setValue(increase10);
  }, [setValue]);
  
  //...
}

increaseincrease10state와 더할 값을 추가로 받는 함수로 만들면 굳이 모든 케이스를 일일이 만들 필요가 없을 것이다.

const increase = (state, num) => state + num;

그래서 이런 동적인 state의 변화(액션)를 처리하기 위해서 useReducer를 사용하게 된다. 그리고 이런 액션을 처리하는 함수를 reducer라고 한다.

//...

// 단일 액션을 처리하는 경우
const reducer = (state, num) => state + num;

const SpinBox = () => {
  // increase는 dispatch 함수이다.
  const [value, increase] = useReducer(reducer, 0);
  
  //...
  
  // 1씩 증가 버튼 클릭
  const onClcik = useCallback(() => {
    // increase(dispatch)는 increase 함수에 전달하는 값과 state를
    // reducer 함수에 전달해서 호출한다.
    increase(1);
  }, [increase]); // increase(dispatch)를 관찰한다.
  
  // 10씩 증가 버튼 클릭
  const onClcik10 = useCallback(() => {
    increase(10);
  }, [increase]);
  
  //...
}

이렇게 기존 state에 직접 접근하지 않고 함수의 인자를 통해 받아서 새로운 상태를 반환하는 형태로 관리하게 되므로, 매번 상태가 변하는 state에 영향을 받지 않고 callback 함수들을 정의할 수 있게 된다.

그러나 공식 문서에서는, state가 다양한 상황에서 변형이 이뤄지기 때문에 확장성을 고려해서 아래와 같이 구현한다.

//...

// action은 action type과 해당 action에 필요한 전달 인자를 가진다.
const reducer = (state, action) => {
  switch(action.type) {
    case 'increase':
      return state + action.num;
    default :
      throw new Error('invalid action');
  }
};

const SpinBox = () => {
  // value는 setValue 대신 dispatch를 이용해 다양한 형태로 제어
  const [value, dispatch] = useReducer(reducer, 0);
  
  //...
  
  // 1씩 증가 버튼 클릭
  const onClcik = useCallback(() => {
    dispatch({type: 'increase', num: 1});
  }, [dispatch]); // dispatch를 관찰한다.
  
  // 10씩 증가 버튼 클릭
  const onClcik10 = useCallback(() => {
    dispatch({type: 'increase', num: 10});
  }, [dispatch]);
  
  //...
}

따라서 dispatch 함수는 변화가 없기 때문에 useCallback를 이용해 dispatch를 관찰하는 onClickonClick10은 함수를 언제나 새로 생성하지 않고 재사용하게 되고, React.memo로 메모이징 한 <Btn>onClick 참조가 변하지 않으므로 재렌더링을 하지 않게 된다.

마치며

Hooks가 익숙하지 않은 개발자가 공식 문서나 기타 문서들에서 useStateuseReducer에 대해서 학습을 하더라도, 왜 이런 형태로 사용하는지에 대해서 이해하기 쉽지 않다.

그래서 렌더링을 최적화 하는 과정을 통해 useStateuseReducer를(더불어 React.memouseCallback도..) 이해하고 어떻게 사용하는지 살펴보았다.

profile
What 12 9oing on?

0개의 댓글