728x90

useEffect Hook은 dependency 배열 내에 지정된 값의 변화가 일어났을 때 이펙트 함수가 실행됩니다. 이러한 특성으로 인해 컴포넌트가 마운트될 때 API를 통해 데이터를 가져오거나, state 값 또는 props 값이 변경될 때 특정 함수를 실행시키는 등의 작업을 하는 데 사용됩니다.

함수 컴포넌트에서 return 구문 밖에서의 함수 실행은 거의 모두 useEffect 내에 작성됩니다. 또한, 컴포넌트는 하나 이상의 useEffect Hook을 가질 수 있습니다. 이 게시글에서는 useEffect가 어떻게 동작하는지에 대해서 알아보도록 하겠습니다.

TLDR

  • useEffectcomponentDidMount 흉내내기
    • useEffectcomponentDidMount를 흉내내기 위한 방법으로 useEffect(fn, [])과 같이 사용할 수 있습니다. 하지만, useEffect의 동작 방법은 라이프사이클과 다르다는 것을 인지하고 있어야 합니다. useEffect의 동작 방법과 라이프사이클의 동작 방법은 다르므로 useEffect로 라이프사이클을 흉내내는 방법을 찾으려 한다면 혼란만 야기시킬 수 있습니다.
  • useEffect 내에서 이전 값을 참조하는 경우
    • useEffect 내에서 이전 값을 참조하는 경우는 의존성 배열에 이펙트 함수 내에서 사용하는 값을 생략했을 가능성이 큽니다. 이러한 이슈를 방지하기 위해 린트 규칙을 추가하는 것이 좋습니다.
  • 간혹 무한루프에 빠지는 경우
    • 무한루프에 빠지는 것은 의존성 배열을 전달하지 않았을 가능성이 큽니다. 이 경우에는 이펙트 함수 내에서 사용하는 값을 의존성 배열로 지정해주면 됩니다.
    • 의존성 배열이 지정되어 있음에도 무한루프에 빠지는 경우 이펙트 함수 내에서 의존성 배열 내의 값을 수정하고 있을 가능성이 큽니다. 이 경우 값을 지우는 것은 잘못된 해결 방법으로, 이 문제를 해결할 수 있는 근본적인 해결 방법을 찾아야 합니다.

useEffect vs LifeCycle

클래스 컴포넌트를 사용해본 경험이 있는 경우 LifeCycle과 useEffect의 차이를 확실히 알고 넘어가는 것이 좋습니다. 클래스 컴포넌트를 다뤄본 적이 없다면 해당 섹션은 넘어가도 괜찮습니다만, 알아두면 도움이 될 것이라고 생각됩니다.

클래스 컴포넌트의 라이프 사이클은 다음과 같습니다(UNSAFE 메소드 등의 라이프 사이클은 제외하였습니다.).

  • 컴포넌트가 마운트될 때: componentDidMount
  • 컴포넌트가 리렌더링될 때: componentDidUpdate
  • 컴포넌트가 마운트 해제되기 전에: componentWillUnmount
import React from 'react';
import ReactDOM from 'react-dom';

const LifeCycleTest = () => {
  React.useEffect(() => {
    // 해당 useEffect의 이펙트 함수는 렌더시에 동작합니다.
    console.log('rendering');

    // componentWillUnmount를 구현하려 하는 경우,
    // 이펙트 함수에 return 구문을 추가하면 됩니다.
    return () => console.log('unmounting');
  });

  return "Test LifeCycle";
};

const App = () => {
  // 리렌더링되는 상황을 만들어주기 위한 state를 생성합니다.
  const [random, setRandom] = React.useState(0);

  // LifeCycleTest 컴포넌트의 표시 여부를 결정하는 state를 생성합니다.
  const [mounted, setMounted] = React.useState(true);

  // 기존 state 값에 1을 더해 의도적으로 리렌더링을 발생시킵니다.
  const reRender = () => setRandom(curr => curr + 1);

  const toggle = () => setMounted(curr => !curr);

  return (
    <div>
      <button onClick={reRender}>re render</button>
      <button onClick={toggle}>toggle life cycle test</button>
      {mounted && <LifeCycleTest />}
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById("root"));

위 코드를 실행해서 나온 결과물을 확인해보겠습니다.

'toggle life cycle test' 버튼을 클릭했을 때는 다음과 같이 동작합니다.

버튼을 눌러 mounted state가 false가 되면 컴포넌트가 화면에서 사라지면서 useEffect 내의 이펙트 함수의 return 값을 실행시킵니다.

그렇지만 're rendering' 버튼을 클릭했을 때는 무언가 이상합니다. 리렌더링을 발생시키는데 왜 마운트 해제 이벤트가 발생하는 것일까요?

useEffect의 이펙트 함수에서 반환되는 cleanup 함수는 컴포넌트가 unmount되는 시점에만 실행되지는 않습니다. cleanup 함수는 이펙트가 실행되기 전에 매번 호출되며, 컴포넌트가 unmount될 때 역시 실행될 때 정리됩니다. 필요에 따라 모든 렌더링 전후에 사이드 이펙트를 발생시킬 수 있으므로 componentWillUnmount보다 더 자주 호출됩니다.

LifeCycle과 다릅니다

useEffect Hook은 매 렌더링 후에 실행되며, 필요에 따라 cleanup 함수를 반환하도록 작성해 이펙트 실행 이전에 실행시킬 수 있습니다.

useEffect는 하나의 함수가 라이프사이클 3개의 역할(componentDidMount, componentDidUpdate, componentWillUnmount)을 한다고 하는 것보다 단순히 렌더링 후 사이드 이펙트를 실행하는 방법으로만 생각하는 것이 더 유용할 수 있습니다.

매 렌더링마다 실행되는 것 방지

이펙트가 매 렌더링마다 실행되는 것을 방지하기 위해 두 번째 인자에 값을 넘길 수 있습니다. 이 배열은 이펙트가 실행되기 위한 의존성 배열입니다. 배열 내에 작성된 값 중 하나가 변경된 경우 이펙트가 실행됩니다.

const [value, setValue] = useState(0);

React.useEffect(() => {
  // 이펙트에서 'value' 값을 사용 중입니다.
  // dependency 배열에 'value' 값을 추가해야 합니다.
  console.log(value);
}, [value]);  // 'value' 값이 변경될 때 이펙트를 실행합니다.

dependency 배열에 포함되어야 하는 값

dependency 배열에는 이펙트 함수 내에서 사용되는 모든 값(state, props)이 포함되어야 합니다. ref, setValue 등의 값은 변경되지 않는다는 것을 리액트가 보장해주므로 생략할 수 있습니다.

마운트시에 한 번만 실행

컴포넌트가 마운트될 때 한 번만 실행하고, 언마운트될 때 정리시키기 위한 방법으로 useEffect의 dependency 배열에 빈 값([])을 전달할 수 있습니다.

React.useEffect(() => {
  console.log('component mounted');

  return () => console.log('component unmounting');
}, []);

컴포넌트는 최초 렌더링 이후 'component mounted' 텍스트를 출력할 것이고, 컴포넌트가 언마운트되는 시점에 'component unmounting' 텍스트가 출력될 것입니다.

이펙트 함수 내에서 사용하는 값이 있음에도 의존성 배열을 비워놓는 경우 의도치 않은 버그가 생길 수 있음에 유의해야 합니다.

useEffect 경고

가장 흔히 경고가 발생하는 상황은 이펙트 함수 내에서 사용하는 값이 있다 하더라도 의존성 배열에 빈 배열을 넘기는 것입니다. 이 경우, CRA를 사용해 프로젝트를 생성하였거나 React Eslint를 추가한 경우 경고가 발생합니다.

useEffect는 함수를 전달받으며, 이 함수는 모든 변수 값에 'latches on' 되어 있는 클로저를 생성합니다. 빈 배열은 '이 이펙트 함수는 변경될 변수를 참조하고 있지 않으므로 클로저를 다시 생성하지 않을 것'을 요구합니다.

이를 정리하면, 의존성 배열의 요점은 '사용하고 있는 값 중 일부가 수정되었으니 클로저를 다시 생성할 것'을 요구합니다. 따라서, 경고를 띄우는 경우 경고에 표시된 모든 변수를 배열에 추가하는 것이 좋습니다. 일반적으로 의존성 배열에 값이 채워져 있더라도 이펙트 함수는 잘 동작합니다.

useEffect가 무한루프에 빠졌다

이펙트 함수에서 사용하는 값이 의존성 배열에 추가되었을 때 정상적으로 동작하는 것이 일반적입니다. 그렇지만 의도치 않게 이펙트 함수가 계속해서 실행되는 무한루프에 빠지는 경우가 간혹 있습니다.

const [value, setValue] = React.useState([]);

const addData = React.useCallback(() => {
  setValue(value.map(item => {
    return [
      ...item,
      'added item',
    ];
  }));
}, [value]);

React.useEffect(() => {
  addData();
}, [addData]);

위와 같은 형태의 코드가 있다고 가정을 할 떄, 위 Hook은 의존성 배열을 잘 지키고 있지만 계속해서 아이템이 추가되는 문제가 발생할 것입니다.

위 코드는 다음과 같이 동작합니다.

  • 컴포넌트가 마운트된 후, useEffect에 의해 value state에 값이 추가됩니다.
  • value state 값이 변경되었으므로 이를 의존성 배열로 사용하는 값을 다시 계산합니다.
    • 따라서 addData 함수가 다시 생성됩니다.
  • addData 함수가 새로 생성되었으므로 이를 의존성 배열로 사용하는 값을 다시 계산합니다.
    • 따라서 addData 값을 의존성 배열로 갖고 있는 이펙트 함수가 다시 실행됩니다.
  • 위 과정이 반복됩니다..

따라서 위의 Hook은 계속해서 value state에 값을 추가할 것입니다.

해결 방법

위 문제를 해결하기 위해서는 value값이 변경되었을 때 addData 함수가 새로 생성되는 연결고리를 제거해야 합니다. 그리고 위 문제를 해결하기 위해 우리는 setState(setValue) 함수에 함수를 인자로 넘길 수 있습니다.

setValue(value.map(...));
setValue(item => item.map(...));

위 두 함수는 동일한 실행 결과를 반환하며, 차이점으로는 setValue 내에서 이전 값을 사용하기 위해 value state 값을 직접 참조하지 않아도 된다는 것입니다. 그리고 이것은 addData 함수에 value 의존성을 제거할 수 있다는 말이 되기도 합니다.

위에서 알아본 것을 바탕으로 수정하면 다음과 같이 작성할 수 있습니다.

const [value, setValue] = React.useState([]);

const addData = React.useCallback(() => {
  setValue(curr => curr.map(item => {
    return [
      ...item,
      'added item',
    ];
  }));
}, []);  // 의존성 배열이 비어있습니다.

React.useEffect(() => {
  addData();
}, [addData]);

useEffect는 언제 실행될까?

일반적으로 컴포넌트의 렌더링 뒤에 이펙트가 실행됩니다.

import React from "react";
import "./styles.css";

function Top() {
  React.useEffect(() => {
    console.log("top");
  });

  return (
    <div>
      <p>Top</p>
      <Bottom />
    </div>
  );
}

function Bottom() {
  React.useEffect(() => {
    console.log("bottom");
  });

  return <div>Bottom</div>;
}

function App() {
  return (
    <div className="App">
      <Top />
    </div>
  );
}

export default App;

CodeSandbox에서 보기

위의 예시에서 App 컴포넌트는 'Bottom 컴포넌트를 호출하는 Top 컴포넌트'를 호출하고 있습니다. 또한 각 컴포넌트의 useEffect로 인해 콘솔이 출력될 것입니다.

React 컴포넌트는 기본적으로 자식 컴포넌트부터 렌더링됩니다. 따라서 위의 예시에서는 Bottom -> Top -> App 순서로 렌더링될 것입니다. 부모 컴포넌트는 모든 자식 컴포넌트가 렌더링될 때까지 완료되지 않으므로 useEffect의 실행 순서도 Bottom -> Top 순으로 실행되는 것입니다.

state 또는 props 값이 변경되었을 때

기본적으로 useEffect는 의존성 배열에 값을 설정해 이펙트가 실행되는 시점을 제한할 수 있습니다. 이 의존성 배열에는 모든 종류의 변수(state, props, 그 외 값)가 포함될 수 있습니다.

function CountWatch({ number }) {
  React.useEffect(() => {
    console.log(number);
  }, [number]);
}

function Counter() {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    console.log('count changed');
  }, [count]);

  return (
    <div>
      <CountWatch number={count} />
      <button onClick={() => setCount(curr => curr + 1)}>
        Increment
      </button>
    </div>
  );
}

일반적인 사용 사례

useEffect를 일반적으로 사용하는 사례에 대해 살펴보도록 하겠습니다.

useEffect로 데이터 가져오기

useEffect를 사용할 수 있는 사례 중 하나는 페이지에 접근시 데이터를 가져오는 것입니다. 클래스 컴포넌트에서 해당 기능을 구현한다면 componentDidMount 라이프사이클에 작성하게 될 것입니다.

또한, 위 기능은 Suspense 기능을 사용해 데이터를 가져올 수도 있습니다(해당 기능은 아직 실험 단계입니다). useEffect를 사용하는 부분은 일부 놓칠 수 있는 부분이 있어서 Suspense 기능이 정식 배포된다면 해당 기능을 사용하는 것이 더 좋을 것이라 생각됩니다.

import React from "react";

export default function App() {
  const [image, setImage] = React.useState({});

  React.useEffect(() => {
    async function fetchImage() {
      const res = await fetch("https://dog.ceo/api/breeds/image/random");
      const json = await res.json();

      setImage(json);
    }

    fetchImage();
  });

  console.log("image", image);

  return (
    <div className="App">
      <img src={(image || {}).message} />
    </div>
  );
}

CodeSandbox에서 보기

useEffect에 의존성 배열을 비워두었다는 것을 알 수 있습니다. 이것은 매우 좋지 않은 행동이며, 이렇게 할 경우 매 렌더링마다 이펙트 함수가 실행될 것입니다.

  • 이펙트 함수 내에서 image state 값이 변경됨
  • 값이 변경됨을 인지하고 이펙트 함수가 실행됨
  • 위 과정이 반복

그렇다면, 위의 useEffect에서 의존성 배열에는 어떤 값이 포함되어야 할까요? 현재 이펙트 함수 내에서 사용되는 변수는 setImage가 전부입니다. 따라서 [setImage] 값을 전달해야 합니다. 또한, setImage 값은 useState에서 반환하는 setter로, 매 렌더링시에 다시 생성되지 않아서 이펙트 함수가 한 번만 실행됩니다.

Tips
useState를 통해 반환되는 setter 함수는 한 번만 생성됩니다. 컴포넌트가 렌더링되더라도 동일한 함수 인스터스를 반환하므로 이펙트 함수 내의 setter 함수를 비워도 경고가 노출되지 않습니다.이는 useReducer가 반환하는 dispatch에도 동일하게 적용됩니다.

 

 

728x90
728x90