[React][ESLint] useEffect 의존성 배열 에 대해: '오래된 클로저'는 무엇이고, 의존성 배열의 불편함을 무엇으로 대체할 수 있을까? (This is a new ESLint rule that verifies the list of dependencies for Hooks like useEffect and similar, protecting against the stale closure pitfalls. )

이전부터 개인적으로 리액트 프로젝트를 할 때마다 useEffect를 잘 이해하고 사용하고 있지 않다는 느낌을 받았다.

가장 큰 이유는 의존성 배열 때문이다.


🔹 useEffect를 사용하면서 아쉬웠던 점

의존성 배열을 제대로 사용하고 있지 않았다.

지금까지는 원하는 값이 바뀔 때 호출하도록 하기 위해 의존성 배열에 변경될 값을 추가했지만, 사실 이렇게 사용하는 것은 useEffect를 제대로 사용하는 것이 아니라고 한다. 의존성 배열은 useEffect 함수 내부에서 변경되는 모든 값을 알려주도록 하는 것인데, 이렇게 될 경우 불필요한 렌더링 혹은 무한루프가 발생하기 때문에 다른 방법을 함께 사용해주어야 한다.


의존성 배열은 useEffect()의 호출 시점을 정하기 위해 사용하는 것이 아니다.

다르게 말하면, 컴포넌트에 있는 모든 값들 중에서 useEffect 함수 내부에서 사용될 값은 의존성 배열에 모두 추가해야 한다. 이 때문에 데이터를 불러오는 로직이 무한 루프에 빠지거나 마운트 될 때만 이펙트를 실행하고 싶은데 이것이 불가능해질 수도 있다.

인용: A complte guide to useEffect


🔹 알고 싶었던 점

  • 왜 의존성 배열에 모든 값을 추가해야 하는 걸까?
  • 의존성 배열에 넣지 않은 값이 있다면 어떤 결과가 나오는 걸까?
  • 의존성 배열에 필요한 모든 값을 추가하는 대신 위에서 언급한 문제를 해결하기 위해 어떻게 다른 방법을 사용할 수 있을까?

마침, 프로젝트를 진행하면서 ESLint가 띄운 경고메시지에서 힌트를 얻었다.


💎 #1. ESLint Rule - stale closure pitfalls

ESLint의 이러한 경고 메시지를 보았다.

[ESLint] Feedback for 'exhaustive-deps' lint rule을 참고해보니, 이렇게 쓰여있었다.

This is a new ESLint rule that verifies the list of dependencies 
for Hooks like useEffect and similar, 
protecting against the stale closure pitfalls. 

stale closure pitfalls가 무엇인지부터 짚고 넘어가자.



💎 오래된 클로저 (stale closure)

오래된 클로저라는 것은 결국 클로저로 인해 갇혀있던 값이 업데이트 되지 않고 남아있는 것을 의미하는 것 같다.
정확히 어떤 의미인지, 어떤 상황에 왜 발생하는 건지 더 알아보자.

function MyComponent() {
  const [v1, setV1] = useState(0);
  const [v2, setV2] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => console.log(v1, v2), 1000);
    return () => clearInterval(id);
  }, [v1]);
  
  return <> ... </>
}

v1이 변경이 되면 렌더링이 될 때, useEffect 함수가 생성되고 그 함수가 나중에 비동기로 호출된다.
그러나 이후에 v2가 변경이 되었을 때도 useEffect 함수는 새로 생성이 되겠지만, 의존성 배열에는 v2가 없기 때문에 리액트는 방금 생성된 useEffect 함수를 무시하고 이전에 v1이 변경될 때 생성된 useEffect 함수를 계속 사용한다.

함수가 생성될 때는 실행컨텍스트가 생성되어 그 함수가 생성될 당시의 지역변수를 기억하고 있다.
v1이 변경되었을 때 실행컨텍스트가 생성되어 함수가 호출되며 선언된 그 당시의 v1, v2값을 기억하고 있다. 그리고 v2가 변경이 되었을 때는 useEffect함수가 생성되었더라도 의존성 배열에 v2가 없어 사용하지 않기 때문에, v1이 변경될 때 생성된 useEffect함수만 변경된 v1과 변경 이전의 v2값을 기억한 채로 사용된다.

따라서 이렇게 의존성 배열에 v2를 입력하지 않으면, useEffect 함수는 오래된 v2를 업데이트 하지 않은 채 사용하게 되는데, 이것을 오래된 클로저 라고 한다.



왜 useEffect의 의존성 배열에 사용하는 값들을 다 넣어주어야 한다는 것인지 이제 이해가 된다.
그렇다면 useEffect 함수 내부에서 값이 변경되는 변수가 있을 경우 모두 의존성 배열에 넣어주어야 하는데,
위에서 언급했던 문제들은 어덯게 해결해야 할까?.
어떤 경우에 어떤 문제가 발생할 수 있고, 어떻게 해결할 수 있는지 알아보자.


💎 의존성 배열 사용시 유의할 점

A complete guide to useEffect의 내용을 참고해 정리했다.
아직 정말로 체득한 내용이라고 할 수는 없지만, 정리한 내용을 계속 찾아보면서
이를 기준으로 useEffect의 의존성 배열을 잘 관리하고 있는지 점검하고 적용해보려고 한다.


🔹 1. API 호출 함수

useEffect() 내에서 실행 시점 조절하기

userId가 변경될 때마다 fetch함수를 호출해야 한다고 가정해보자.
의존성 배열을 입력하지 않고 useEffect가 매 렌더링마다 호출되면, 내부에서 조건문을 추가해 userid가 변경되었을 때만 fetch 함수를 호출하도록 코드를 작성해준다.

function Profile({ userId }) {
  const [user, setUser] = useState();
  async function fetchAndSetUser() {
    const data = await fetchUser(userId);
    setUser(data);
  }

  useEffect(() => {
    if(!user || user.id !== userId) {
      fetchAndSetUser();
    }
  });
  
  // ...
}

🔹 2. 이전 상태값을 사용하는 경우

예) 이전 상태값을 기반으로 다음 상태값을 계산하는 경우

function MyComponent() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    function onClick() {
      setCount(count + 1);
    }
    window.addEventListener('click', onClick);
    return () => window.removeEventListener('click', onClick);
  }, [count]);
  // ...
}

상태값 변경함수의 매개변수로 함수 사용하기

의존성 배열을 사용하지 않기 위해 상태값을 변경하는 setState()자체에 콜백함수를 전달해준다.


function MyComponent() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    function onClick() {
      setCount(prev => prev + 1);
    }
    window.addEventListener('click', onClick);
    return () => window.removeEventListener('click', onClick);
  });
  // ...
}

🔹 3. 여러 상태값을 사용하는 경우

hour,minute, second 3개의 값을 의존성 배열에 추가해야 하는 경우,


function Timer({ initialTotalSeconds }) {
  const [hour, setHour] = useState(Math.floor(initialTotalSeconds / 3600));
  const [minute, setMinute] = useState(Math.floor((initialTotalSeconds % 3600) / 60));
  const [second, setSecond] = useState(Math.floor(initialTotalSeconds % 60);
  
  useEffect(() => {
    const id = setInterval(() => {
      if (second) {
        setSecond(second -1);
      } else if (minute) {
        setMinute(minute -1);
        setSecond(59);
      } else if (hour) {
        setHour(hour -1);
        setMinute(59);
        setSecond(59);
      }
    }, 1000);
    return () => clearInterval(id);
  }, [hour, minute, second]);
  
  //...
}

useReducer 사용하기

dispatch함수는 변하지 않는 요소이기 때문에 의존성 배열에 별도로 값을 써주지 않아도 된다.

function Timer({ initialTotalSeconds }) {
  const [state, dispatch] = useReducer(reducer, {
    hour: initialTotalSeconds / 3600
    minute: (initialTotalSeconds % 3600) / 60
    second: initialTotalSeconds % 60
  });
  const { hour, minute, second } = state;
  useEffect(() => {
    const id = setInterval(dispatch, 1000);
    return () => clearInterval(id);
  });
  //...
}

function reducer(state) {
  const { hour, minute, second } = state;
  if(second) {
    return { ..state, second: second - 1 };
  } else if(second) {
    return { ..state, minute: minute - 1, second: 59 };
  } else if(second) {
    return { ..state, hour: hour - 1, minute: 59, second: 59 };
  } else {
    return state;
  }
}

🔹 4. 속성값으로 전달되는 함수

아래와 같이 함수 자체를 props로 받아왔을 때는 함수의 내용이 변경되지는 않았지만 렌더링 시마다 새로운 참조값이 부여되면서 변경된 것으로 인식해 계속해서 useEffect가 불필요하게 호출될 수 있다.


function MyComponent({ onClick }) {
  useEffect(() => {
    window.addEventListener('click', () => {
      onClick();
      // ...
    })
  }, [onClick]);
  // ...
}

useRef

렌더링 될 때마다 ref 객체에 onClick함수를 넣어주고, ref객체를 이용해 함수를 호출하는 방식이다.

function MyComponent({ onClick }) {
  const onClickRef = useRef();
  
  useEffect(() => {
    onClickRef.current = onClick;
  });
  
  useEffect(() => {
    window.addEventListener('click', () => {
      onClickRef.current();
      // ...
    })
    // ...
  });
  // ...
}


Reference

좋은 웹페이지 즐겨찾기