리액트 useEffect: 개발자가 알아야 할 네가지 팁


출처: React useEffect: 4 Tips Every Developer Should Know | by Helder Esteves | The Startup | Aug, 2020 | Medium

0*iGA2-Ocd8pGybk6z

리액트 훅의 useEffect를 사용할 때 꼭 알아야 할 네 가지를 공유하려고 한다.

단일 목적의 useEffect

리액트 훅을 사용할 때 복수의 useEffect 함수를 사용할 수 있다. 하지만 할 수 있다고 다 좋은 것은 아니다. 클린 코드의 관점에서는 함수는 한 가지 목적을 가지고 있어야 하기 때문이다. (글을 쓴다면 한 문장에 한 개의 아이디어만 전달해야 하는 이유와 같다)

useEffect를 작고 단순한 단일 목적의 함수로 분리한다면 의도치 않은 이펙트 함수의 실행을 방지할 수 있다. 물론 디펜던시 배열도 사용해야 한다.

우선, useEffect(그리고 setTimeout)를 이용해 재귀적인 카운터를 구현하는 나쁜 코드를 작성해보자. 내부에서는 서로 관련이 없는 varAvarB 변수를 사용한다.

우선 나쁜 코드를 먼저 작성한다.

function App() {
  const [varA, setVarA] = useState(0);
  const [varB, setVarB] = useState(0);

  // 이렇게 하면 안된다!
  useEffect(() => {
    const timeoutA = setTimeout(() => setVarA(varA + 1), 1000);
    const timeoutB = setTimeout(() => setVarB(varB + 2), 2000);

    return () => {
      clearTimeout(timeoutA);
      clearTimeout(timeoutB);
    };
  }, [varA, varB]);

  return (
    <span>
      Var A: {varA}, Var B: {varB}
    </span>
  );
}

보다시피, 한번 상태 변경으로 varAvarB 두 상태 변수에 업데이트가 발생하게 된다. 그래서 이 훅은 정상적으로 동작하지 않는다.

예제는 짧은 코드기 때문에 문제점이 명확하게 보이지만 더 많은 코드와 더 많은 변수를 갖고 있는 긴 함수였다면 문제를 발견하기 힘들 것이다. 그러니 옳은 방향으로 useEffect를 분리해 수정해보자.

이런 경우라면 코드는 아래와 같이 작업해야 한다.

function App() {
  const [varA, setVarA] = useState(0);
  const [varB, setVarB] = useState(0);

  // 옳은 방법
  useEffect(() => {
    const timeout = setTimeout(() => setVarA(varA + 1), 1000);
    return () => clearTimeout(timeout);
  }, [varA]);

  useEffect(() => {
    const timeout = setTimeout(() => setVarB(varB + 2), 2000);
    return () => clearTimeout(timeout);
  }, [varB]);
  return (
    <span>
      Var A: {varA}, Var B: {varB}
    </span>
  );
}

이 코드는 예제로 사용될 목적으로 만들어진 코드다. useEffect의 잘못된 사용에 쉽게 대해 이해시키고자 만들어진 코드다. 보통 변수가 이전 상태에 의존을 갖고 있다면 setVarA(varA => varA + 1)같은 방법을 이용해야 한다.(이 노트를 추가하도록 도움을 준 @Michael Landis 에게 감사의 뜻을 표한다)*

가능하다면 커스텀 훅을 사용한다

상단의 예제를 다시 살펴보자. 만약 varAvarB 가 완전히 독립적이라면 어땠을까?

커스텀 훅으로 만든다면 두 상태 변수를 완전히 독립적으로 만들 수 있다. 그리고 이 방법은 각 함수들이 어떤 변수를 사용하는지도 쉽게 파악할 수 있다.

자 커스텀 훅을 만들어 보자

function App() {
  const [varA, setVarA] = useVarA();
  const [varB, setVarB] = useVarB();

  return (
    <span>
      Var A: {varA}, Var B: {varB}
    </span>
  );
}

function useVarA() {
  const [varA, setVarA] = useState(0);

  useEffect(() => {
    const timeout = setTimeout(() => setVarA(varA + 1), 1000);

    return () => clearTimeout(timeout);
  }, [varA]);

  return [varA, setVarA];
}

function useVarB() {
  const [varB, setVarB] = useState(0);

  useEffect(() => {
    const timeout = setTimeout(() => setVarB(varB + 2), 2000);

    return () => clearTimeout(timeout);
  }, [varB]);

  return [varB, setVarB];
}

이제 각 변수들은 고유의 훅을 갖게 되었다. 훨씬 더 유지 보수가 쉽고 읽기도 쉽다.

조건부 useEffect의 옳은 방법

setTimeout을 사용했던 예제에서 다음의 코드를 살펴보자.

function App() {
  const [varA, setVarA] = useState(0);

  useEffect(() => {
    const timeout = setTimeout(() => setVarA(varA + 1), 1000);

    return () => clearTimeout(timeout);
  }, [varA]);

  return <span>Var A: {varA}</span>;
}

특정한 이유로 카운터를 최대 5개까지로 제한한다고 해보자. 이를 구현하는 옳은 방법과 잘못된 방법을 각각 살펴보자.

먼저 잘못된 방법부터 살펴보자.

function App() {
  const [varA, setVarA] = useState(0);

  // 이렇게 하면 안된다.
  useEffect(() => {
    let timeout;
    if (varA < 5) {
      timeout = setTimeout(() => setVarA(varA + 1), 1000);
    }

    return () => clearTimeout(timeout);
  }, [varA]);

  return <span>Var A: {varA}</span>;
}

비록 위의 코드가 동작하긴 하지만 setTimeout은 조건적으로 실행되는 반면 clearTimeoutvarA가 변경될 때마다 매번 실행된다. 큰 문제는 없겠지만 정상적인 작업은 아니다.

useEffect를 조건적으로 사용하는 이상적인 방법은 함수의 초기에 바로 반환해버리는 것이다. 아래와 같이 말이다.

function App() {
  const [varA, setVarA] = useState(0);

  useEffect(() => {
    if (varA >= 5) return;

    const timeout = setTimeout(() => setVarA(varA + 1), 1000);

    return () => clearTimeout(timeout);
  }, [varA]);

  return <span>Var A: {varA}</span>;
}

이 방법은 Material UI에서도(그리고 다른 많은 곳에서) 사용하고 있으며 의도치 않은 useEffect 의 실행을 막을 수 있다.

useEffect 안에서 사용하는 모든 변수들을 디펜던시 배열에 추가한다

ESLint를 사용하고 있다면 아마도 exhaustive-deps 규칙의 경고를 본 적이 있을 것이다.

매우 중요한 규칙이다. 앱이 점점 커질질수록 useEffect에는 더 많은 디펜던시가 추가될 수 있다. 모든 디펜던시의 변화를 감지해 깨진 클로저가 만들어지는 것을 피하려면 모든 디펜던시를 디펜던시 배열에 추가해야 한다.(이 주제에 관한 공식 문서)

다시 한번 setTimeout을 사용했던 예제에서 setTimeout을 단 한 번만 실행해 varA를 증가시킨다고 해보자.

아마 아래와 같은 잘못된 예제의 코드를 작성할 수도 있다.

function App() {
  const [varA, setVarA] = useState(0);

  useEffect(() => {
    const timeout = setTimeout(() => setVarA(varA + 1), 1000);

    return () => clearTimeout(timeout);
  }, []); // 피해야한다: varA 가 디펜던시 배열에 없다.

  return <span>Var A: {varA}</span>;
}

위 코드가 비록 의도했던 대로 동작한다 하더라도 잠깐 생각해보자. "만약 코드가 더 커진다면?" 혹은 "위의 코드를 변경해야 한다면?" 어떨까 말이다.

그런 상황을 위해서, 사용되는 모든 변수들을 디펜던시 배열에 추가해야 한다. 그래야 테스트하기 수월해지고 발생할 수 있는 문제를 쉽게 탐지할 수 있다. (깨진 프롭이나 클로저 등)

옳은 방법은 아래와 같다.

function App() {
  const [varA, setVarA] = useState(0);

  useEffect(() => {
    if (varA > 0) return;

    const timeout = setTimeout(() => setVarA(varA + 1), 1000);

    return () => clearTimeout(timeout);
  }, [varA]); // 이렇게 해야한다. 모든 디펜더시가 배열에 들어가 있다.

  return <span>Var A: {varA}</span>;
}

이상이다. 질문이나 제안사항이 있다면, 댓글로 남겨주길 바란다. 꼭 확인하겠다.