React의 useReducer, useCallback, useMemo 제대로 알고 사용하기

ashnamuh·2020년 5월 8일
30

최근 React를 사용하면서 hooks api를 적극적으로 사용하고 있습니다. 더 나은 코드와 최적화를 위해 useReducer, useCallback, useMemo를 사용하곤 있지만, 아직 헷갈리는 개념이 있어서 제대로 알아보자는 차원에서 정리해보겠습니다.

다룰 내용

  • useReducer
  • useCallback
  • useMemo

useReducer

React 공식 문서에선 useReduceruseState를 대체할 수 있다고 설명하고 있습니다. 우선 useState만으로 아이디와 비밀번호를 입력받는 로그인 화면 컴포넌트를 구현해보겠습니다.

function Signin() {
  const [id, setId] = React.useState('')
  const [password, setPassword] = React.useState('')
  return (
    <form>
      <h1>로그인</h1>
      <input name="id" type="text" placeholder="아이디 입력"
        value={id} onChange={event => setId(event.target.value)} />
      <input name="password" type="password" placeholder="비밀번호 입력"
        value={password} onChange={event => setPassword(event.target.value)} />
    </form>
  )
}

useState를 두번 사용해서 구현했습니다.

이를 useReducer를 사용하면 다음과 같이 구현할 수 있습니다.

const reducer = (state, newState) => ({ ...state, ...newState })

function Signin() {
  const [inputValue, setInputValue] = React.useReducer(reducer, { id: '', password: '' })

  return (
    <form>
      <h1>로그인</h1>
      <input name="id" type="text" placeholder="아이디 입력"
        value={inputValue.id}
       onChange={event => setInputValue({ [event.target.name]: event.target.value })} />
      <input name="password" type="password" placeholder="비밀번호 입력"
        value={inputValue.password}
        onChange={event => setInputValue({ [event.target.name]: event.target.value })} />
    </form>
  )
}

사용법은 기본 Javascript의 reduce와 유사하니 사용법을 알고있다면, 이해하기 쉽다고 생각합니다. reduce mdn 문서 링크.

React 공식 사이트의 useReducer로 카운터를 구현한 예제를 보면, action, dispatch 등의 개념을 도입해서 Redux의 reducer와 매우 유사한 구조로 구현할 수도 있습니다.

React 공식 useReducer 예제

const initialState = {count: 0};

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

useReducer를 어떻게 활용해야할까요? useState를 많이 사용하는 경우에 사용하면 코드가 깔끔해지겠지만, 사실 다음 예시처럼 한번만 사용해도 구현 가능합니다.

function Signin() {
  const [inputValue, setInputValue] = React.useState({ id: '', password: '' })

  return (
    <form>
      <h1>로그인</h1>
      <input name="id" type="text" placeholder="아이디 입력"
        value={inputValue.id}
        onChange={event => setInputValue({ ...inputValue, [event.target.name]: event.target.value })} />
      <input name="password" type="password" placeholder="비밀번호 입력"
        value={inputValue.password}
        onChange={event => setInputValue({ ...inputValue, [event.target.name]: event.target.value })} />
    </form>
  )
}

물론 전개 구문 사용한 ...inputValue 을 중복해서 사용하는 것은 코드가 깔끔해보이지는 않습니다. 하지만 이것은 이벤트 핸들러 함수를 분리한다던지, 커스텀 훅을 만든다던지 등으로 어떻게 구현하냐에 따라 다르게 느껴질거 같습니다. 또한 나중에는 context API와 연동해서 redux를 완전히 대체하는데 쓰일 수 있지 않을까란 생각이 듭니다.

useCallback

이는 최적화를 위한 훅입니다. React에서 이벤트를 핸들링 할 때 보통 다음 코드처럼 컴포넌트 내부에 함수를 선언해서 사용합니다.

function Component() {
  const handleClick = () => console.log('clicked!')

  return (
    <button onClick={handleClick}>클릭해보세요!</button>
  )
}

위 코드는 별 문제가 되지 않습니다. 하지만 컴포넌트가 렌더링 될 때 마다 함수를 새로 생성한다는 단점이 있습니다. 부모 컴포넌트가 렌더링되거나, 상태(state)가 변경되는 경우, React 컴포넌트는 렌더링을 유발합니다. 다음 코드를 살펴봅시다.

function Component() {
  const [count, setCount] = React.useState(0)
  const handleClick = () => console.log('clicked!')

  return (
    <>
      <button onClick={() => setCount(count + 1)}>카운트 올리기</button>
      <button onClick={handleClick}>클릭해보세요!</button>
    </>
  )
}

버튼을 클릭해서 count값을 변경하면 컴포넌트 내부의 상태를 변경하고, 재렌더링을 유발합니다. 함수 컴포넌트의 렌더링이란, 다음 코드처럼 컴포넌트 함수가 새로 호출됨을 의미하고 이는 렌더링 때마다 새로 handleClick 함수를 생성합니다.

Component() // count는 0. 최초 렌더링

// setCount(count + 1)
Component() // count는 1. 두번째 렌더링

// setCount(count + 1)
Component() // count는 2. 세번째 렌더링

// setCount(count + 1)
Component() // count는 3. 네번째 렌더링

이러면 불필요한 메모리를 낭비하고 최적화도 좋지 않습니다. 특정 상태의 변경과 상관없는 함수의 경 useCallback을 사용하면 매번 새로 생성되는 것을 방지할 수 있습니다.

function Component() {
  const [count, setCount] = React.useState(0)
  const handleClick = React.useCallback(
    () => console.log('clicked!'),
  []) // useCallback 사용

  return (
    <>
      <button onClick={() => setCount(count + 1)}>카운트 올리기</button>
      <button onClick={handleClick}>클릭해보세요!</button>
    </>
  )
}

위 코드에선 useCallback으로 감싸기만 했을 뿐인데, 이전에 생성한 함수를 저장해두고 재사용합니다. 함수의 동작은 똑같지만 좀 더 최적화가 좋습니다. 이는 메모제이션 패턴을 이용한 것입니다. 잠깐, 그런데 두번째 인자로 넘긴 [] 은 무엇일까요?

useCallback의 두번째 인자?

두번째 인자의 배열은 의존성을 의미합니다. 여태 작성한 handleClick 함수는 아무런 의존성이 없기에 문제가 되지 않습니다. 코드를 조금 변경해서 handleClick 함수가 count값을 출력하게 해보겠습니다.

const handleClick = React.useCallback(
  () => console.log('current count :' + count),
[])

다음과 같은 순서로 이벤트를 발생시켜보겠습니다.

  1. handleClick()
  2. setCount(count + 1)
  3. handleClick()

출력 결과

handleClick() // 실제 count 값: 0, 출력 결과: current count :0
setCount(count + 1) // 실제 count 값: 1
handleClick() // 실제 count 값: 1, 출력 결과: current count :0

두번째 호출에서 실제 count 값이 1증가해서 변경되었음에도 최초값인 0을 출력합니다. useCallback 내부에서 count값을 의존하지만, 이를 제대로 인지하지 못하고 이전 값을 출력하는 것입니다.

따라서 다음과 같이 useCallback의 두번째 인자 배열에 의존하는 상태값을 명시해야 제대로 동작합니다.

const handleClick = React.useCallback(
  () => console.log('current count :' + count),
[count]) // 의존하는 상태 명시

출력 결과

handleClick() // 실제 count 값: 0, 출력 결과: current count :0
setCount(count + 1) // 실제 count 값: 1
handleClick() // 실제 count 값: 1, 출력 결과: current count :1

이처럼 useCallback 함수 내부에서 의존하는 상태값이 있다면, 반드시 두번째 인자 배열에 명시해야합니다.

useMemo

useMemo 또한 useCallback과 매우 유사하게 최적화에 사용됩니다. useCallback이 함수를 반환하는 반면, 이것은 값을 반환합니다. count값의 두배를 계산한 상태를 예시로 들어보겠습니다.

function Component() {
  const [count, setCount] = React.useState(0)
  const doubleCount = count * 2

  console.log(doubleCount) // 두배로 계산한 값 출력

  return (
    <>
      <button onClick={() => setCount(count + 1)}>카운트 올리기</button>
    </>
  )
}

버튼을 클릭할 때 마다 두배로 계산한 값을 출력합니다. 하지만 count값과 무관하게 컴포넌트가 재렌더링 되었을 경우, 불필요한 연산을 하게 됩니다. 컴포넌트의 상태값이 많고 복잡한 연산의 경우 최적화가 좋지 않습니다.

const doubleCount = React.useMemo(() => count * 2, [count])

위처럼 useMemo를 사용하면 의존하는 값이 변경될 때에만 연산하므로 최적화가 개선됩니다. useCallback과 마찬가지로 두번째 인자 배열에 의존하는 값을 반드시 명시해야합니다.

참고로 이전에 useCallback으로 작성한 handleClick 함수를 useMemo를 사용해서 똑같이 구현할 수 있습니다. 내부에서 함수만 반환하게 하면 됩니다.

const handleClick = React.useMemo(
  () => () => console.log('current count :' + count),
[count]) // useMemo로 useCallback 구현

useMemo는 상태값을 반환하고, useCallback은 함수를 반환하는 차이를 제외하곤 없습니다. 이를 적절히 사용하면 컴포넌트 렌더링 최적화에 큰 도움이 될 수 있다고 생각합니다. 한가지 주의할 점은 useCallbackuseMemo를 무분별하게 사용한다면, 이를 사용하는 코드와 메모제이션용 메모리가 추가로 필요하게 되므로 적절하게 사용해야합니다.

profile
프론트엔드 개발자입니다

3개의 댓글

comment-user-thumbnail
2020년 7월 15일

유용한 글 감사합니다! 이 글 덕분에 헷갈렸던 useCallback, useMemo가 이해되었습니다 :)

답글 달기
comment-user-thumbnail
2021년 4월 12일

깔끔하고 유익한 글 감사합니다 ! :) 큰 도움이 됐어요 !

답글 달기
comment-user-thumbnail
2021년 5월 9일

감사합니다 :)

답글 달기