FrontEnd/React.js

useEffect 이해하기

SambaLim 2021. 10. 28. 00:07

서론

Warning: Maximum update depth exceeded.
This can happen when a component calls setState inside useEffect,
but useEffect either doesn’t have a dependency array,
or one of the dependencies changes on every render.

React를 사용하여 App을 만들던 도중 위와 같은 오류를 만나게 되었고, 이를 useCallback Hook API를 사용하여 해결하였습니다. 해결은 했지만 위와같은 Warning이 왜 발생했는지 그리고 어떻게 해결할 수 있는지 이해를 하지 못하여 공부하며 이 글을 적어봅니다.

제가 공부한 흐름에 따라 정리해놓은 부족한 글로 글 하단의 참고문서들을 읽어보시는 것을 매우 추천합니다.

useEffect

useEffect 가 어떤 기능을 하는지 모르니 왜 Warning이 발생했는지 알 수 없습니다. 따라서 useEffect Hook API에 대해 알아보려고 합니다.

Side Effect

useEffect Hook API는 함수 컴포넌트에서 Side Effect를 수행할 수 있게 합니다.

React가 DOM을 업데이트한 후, 추가로 코드를 실행해야하는 경우가 있습니다. 예를들어 데이터 가져오기, 구독(subscription) 설정하기, 수동으로 React 컴포넌트의 DOM을 조작하는 행위가 있는데 이는 모두 Side effects 입니다.

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

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

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

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

export default Counter;

useEffect Hook API는 함수 컴포넌트가 렌더링 된 후에 어떤 일을 수행할 것인지 우리가 지정할 수 있게 해줍니다. React는 우리가 useEffect 내에 넘긴 함수(effect)를 기억했다가 DOM 업데이트를 수행한 이후에 불러낼 것입니다. useEffect 로 전달한 effect는 처음 렌더링과 이후 모든 업데이트에서 수행됩니다.

기존의 마운팅(componentDidMount)과 업데이트(componentDidUpdate)라는 개념으로 생각하기보다, effect를 렌더링 이후에 발생시킨다고 생각하는 것이 이해하기 좋습니다.

React는 effect가 수행되는 시점에는 이미 DOM이 업데이트 되어있음을 보장합니다.

렌더링 따라가보기

더 자세하게 이해할 수 있도록, 첫 번째 랜더링을 되짚어 보겠습니다.

  • 리액트: state가 0 일 때의 UI를 보여줘.
  • 컴포넌트
    • 여기 랜더링 결과물로 <p>You clicked 0 times</p> 가 있어.
    • 그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마: () => { document.title = 'You clicked 0 times' }.
  • 리액트: 좋아. UI를 업데이트 하겠어. 이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해.
  • 브라우저: 좋아, 화면에 그려줄게.
  • 리액트: 좋아 이제 컴포넌트 네가 준 이펙트를 실행할거야.
    • () => { document.title = 'You clicked 0 times' } 를 실행하는 중.

그럼 버튼을 클릭하면 어떤 일이 벌어지는지 복습해 보시죠.

  • 컴포넌트: 이봐 리액트, 내 상태를 1 로 변경해줘.
  • 리액트: 상태가 1 일때의 UI를 줘.
  • 컴포넌트
    • 여기 랜더링 결과물로 <p>You clicked 1 times</p> 가 있어.
    • 그리고 모든 처리가 끝나고 이 이펙트를 실행하는 것을 잊지 마: () => { document.title = 'You clicked 1 times' }.
  • 리액트: 좋아. UI를 업데이트 하겠어. 이봐 브라우저, 나 DOM에 뭘 좀 추가하려고 해.
  • 브라우저: 좋아, 화면에 그려줄게.
  • 리액트: 좋아 이제 컴포넌트 네가 준 이펙트를 실행할거야.
    • () => { document.title = 'You clicked 1 times' } 를 실행하는 중.

성능 최적화

렌더링이 일어날때마다 effect가 일어나는 것은 성능 저하를 발생시킬 수 있습니다. 따라서 useEffect 는 특정 값들이 리렌더링 시에 변경되지 않는다면, effect를 실행하지 않도록 할 수 있습니다. useEffect의 두 번째 인수로 배열을 넘기면 됩니다.

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

예시와 같이 두 번째 인수로 배열을 넘기는 것이 의미하는 것은 다음과 같습니다. count 의 값이 5 이고 컴포넌트가 리렌더링된 후의 값이 변함없이 5 라면 effect를 건너뜁니다. 이런식으로 최적화가 가능합니다.

배열내에 여러개의 값이 있다면 그중에 단 하나만 다를지라도 React는 effect를 실행합니다.

 

eslint-plugin-react-hooks 패키지의 일부로 exhaustive-deps ESLint 규칙을 제공합니다.
업데이트를 일관되게 처리하지 않는 컴포넌트를 찾는 데 도움이 됩니다.

 

만약 두 번째 인수로 빈배열을 넘긴다면, effect를 실행하고 이를 정리(clean-up)하는 과정을 (마운트와 마운트 해제 시에)딱 한 번씩만 실행하게 됩니다.

정리

useEffect 는 함수 컴포넌트에서 자신의 첫 번째 인수인 effect를 기억했다가 렌더링 후, effect를 실행합니다. 하지만 렌더링이 일어날때마다 effect가 발생하는 것은 비효율적입니다. 따라서 useEffect 는 두 번째 인수로 배열을 받아 특정 값들이 리렌더링 시에 변하지 않는다면 effect를 실행하지 않도록 합니다.

useCallback

Maximum update depth exceeded. 을 해결하는 방법중 하나로 useCallback Hook API를 사용하기 때문에 간단하게 보고갑니다.

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

두 번째 인수로 전달된 배열의 값들이 변경되지 않았다면, 메모이제이션된 콜백(첫 번째 인수)를 반환합니다.

Maximum update depth exceeded.

예시를 통해 Maximum update depth exceeded. 를 해결하는 방법을 보도록 합시다.

예시

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

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

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

    useEffect(() => {
        incrementCount();
    }, [incrementCount]);

    return <>Content</>;
};

export default Counter;

위의 예시는 Maximum update depth exceeded. 가 일어나는 예시입니다.

Counter 컴포넌트는 리렌더링될때마다 새로운 incrementCount 함수를 만들게 되고, useEffect 애서 새로운 참조값을 받기 때문에 effect내의 incrementCount 함수를 실행하게 됩니다. 따라서 Maximum update depth exceeded. 에러를 볼 수 있습니다.

해결방법

앞선 과정을 보면서 우리는 결국 문제가 "Counter 컴포넌트가 리렌더링될 때마다 incrementCount 함수를 새로 만들어 useEffect 내부의 effect가 계속 실행되는 것"임을 알 수 있었습니다.

따라서 우리는 incrementCount 가 새로 만들어지지 않는 두 가지 방법을 통해 Maximum update depth exceeded. 가 일어나지 않도록 합니다.

useEffect 내부로 함수를 옮기기

함수가 리렌더링될때마다 새로운 incrementCount 함수를 만드는 것이 문제가 된다면, effect내로 함수를 옮겨 이를 막는 방법이 있습니다.

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

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

    useEffect(() => {
        const incrementCount = () => {
            setCount((count) => count + 1);
        }
        incrementCount();
    }, []);

    return <>Content</>;
};

export default Counter;

effect 내에서 incrementCount 가 정의되었기 때문에 컴포넌트가 마운트된 후, 한번만 실행되게 됩니다.

useCallback 사용하기

메모이제이션된 incrementCount 를 사용하기 위해 useCallback 을 사용합니다.

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

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

    const incrementCount = useCallback(() => {
        setCount((count) => count + 1);
    }, []);

    useEffect(() => {
        incrementCount();
    }, [incrementCount]);

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

export default Counter;

useCallback 의 두 번째 인수인 배열에서 수정된 값이 없으므로 incrementCount 함수를 새로 생성하지 않습니다.

정리

서론에 이야기했던 Warning은 결국 useEffect 의 effect가 원하지 않을때 실행되어 발생하였습니다. 따라서 useEffect 의 두 번째 인수인 dependency array에 적절한 값을 주어 이를 해결하거나 인수로 전달한 값이 변경되지 않도록 하여 이를 해결하였습니다.

참고문서