[React] exhaustive-deps란 무엇인가

🌩 es·2022년 6월 8일
17

대략 1년 전에 회사에서 React로 짜여진 프로젝트를 리팩터하면서 공부했던 내용을 글로 다시 정리해보고자 한다.

목차

  1. exhaustive-deps란 무엇인가?
  2. hook을 이해하려면 리액트 라이프 사이클을 벗어나야 한다!
  3. 라이프사이클을 벗어나서 useEffect를 다른 관점으로 바라보기

1. exhaustive-deps란 무엇인가?


리액트로 개발 중에 이런 warning을 자주 본 적이 있다. 리액트 CRA(Create React App) 환경에서 기본적으로 설치되어있는 ESLint 플러그인이 표시해주는 warning이다. 이 React Hook useXXX has a missing dependency 라는 warning은, "hook에서 state, prop, 함수를 사용하고 있으면 의존성 배열에 넣어줘!" 라는 뜻이다.

리액트는 hook 내부에서 렌더 범위 안에 있는 값을 사용 중임에도 의도적으로 의존성 배열을 빈 배열로 두는 것을 권장하지 않는다. 하지만 warning이 알려주는대로 의존성 배열을 없애거나 필요한 의존성 배열을 추가해주면, 컴포넌트가 의도대로 동작하지 않거나 무한 루프를 경험하게 된다.

왜 그럴까?

  • useEffect의 경우라면, 보통 deps를 빈 배열로 두는 이유는 맨 처음 렌더링 될 때만 이펙트를 실행하고 싶다는 의도일 것이다.
  • 개발자가 자신의 의도에 맞춰서 hook을 쓰겠다는데 이런 린팅 규칙 자체가 왜 있는 걸까?
  • 리액트는 왜 exhaustive-deps 린팅 규칙을 사용하기를 권장하는가?

내가 useEffect를 이해했던 과정

  1. 🔎 : useEffect 검색
  2. 👀 : 아래와 같은 설명을 보게 됨
  3. 😯 : useEffect를 사용해서 마운트/언마운트/업데이트시에 특정 작업을 할 수 있구나!
  4. 🤔 : 마운트/언마운트/업데이트가 뭐지? 리액트 라이프 사이클을 알아야 hook을 이해할 수 있겠군. 리액트 라이프 사이클을 찾아보자.
  5. 😲 : componentWillMount, componentDidMount, componentDidUpdate 라는 메소드가 있구나. 이거를 hook으로 대체해서 쓰는 거였군.
  6. 리액트 라이프 사이클을 대략 이해했다고 생각하고 마음대로 useEffect 사용.
- deps 없음 : 렌더링마다 이펙트 실행 
- deps [ ] : 마운트할 때만 이펙트 실행
- deps 추가 : 추가한 값이 변경될 때만 이펙트 실행
  1. exhaustive-deps warning을 만남. warning에서 알려주는대로 deps 추가 ➡️ 원하는대로 동작을 안 하거나, 무한루프 발생.

2. hook을 이해하려면 리액트 라이프 사이클을 벗어나야 한다!

결론부터 이야기하자면,

  • useEffect와 같은 hook은 라이프 사이클 메서드가 아니다.
  • 함수형 컴포넌트의 hook으로 클래스형 컴포넌트의 라이프 사이클 메서드를 대체할 수 없다.
  • useEffectstate를 활용하여 동기적으로 부수 효과를 만들 수 있는 메커니즘이다.
  • 문제는 “이 이펙트가 언제 실행되는가" 가 아니라 “이 이펙트가 어떤 state와 동기화되는가" 이다.

useEffect을 가지고 hook을 이해해보자

  1. 모든 렌더링은 고유의 prop / state / 함수를 가지고 있다.

    • 각각의 렌더링마다 격리된 고유의 propstate보는 것이다.
    • 각각의 렌더링에서 함수 안의 stateprop은 상수이자 독립적인 값(특정 렌더링 시의 상태)으로 존재한다.
  2. 모든 렌더링은 고유의 이펙트를 가지고 있다.

    • 이펙트 함수 자체가 모든 렌더링마다 별도로 존재한다.

    • 매 렌더링마다의 이펙트는 해당 렌더링에 속한 state, prop바라보고 있다.

    • 그러면 클린업 함수(return ( ) => {...})는 뭔가?

      • 새로운 state로 렌더링 후,
      • 이전 state를 바라보고 있는 이펙트를 클린업
      • 새로운 state를 바라보고 있는 이펙트 실행

      새로 렌더링 된 후에 클린업을 하는데도 이전 state를 바라보고 있는 이유는 클린업 함수가 정의된 시점의 렌더링에 있던 값을 읽기 때문이다.

    • 추가적으로 알 수 있는 함수형 컴포넌트의 장점!

      클래스형 컴포넌트는 this.state가 최신 상태를 가리키도록 변경하기 때문에 비동기 함수 안에서 stateprop을 사용할 때 hook처럼 동작하길 원한다면(상수처럼 렌더링마다 유지되는 값으로 잡아두고 싶다면) 클로저를 사용해서 해결해야한다. 함수형 컴포넌트가 각 렌더링마다 변하지 않는 고유의 값을 가지고 있다는 점은 우리가 컴포넌트 작성할 때 좀 더 데이터의 흐름을 쉽게 생각할 수 있게 해준다.


3. 라이프 사이클을 벗어나서 useEffect를 다른 관점으로 바라보기

useEffect의 deps를 동기화의 관점에서 다시 이해해보자.

라이프 사이클의 관점동기화의 관점
deps 없음렌더링마다 이펙트 실행state, prop이 변경될 때마다 실행
deps 빈 배열마운트할 때만 이펙트 실행데이터 흐름에 관여하는 어떠한 값도 사용하지 않음.
의존성 배열이 같으므로(없으므로) 이펙트 Skip
deps 추가추가한 값이 변경될 때만 실행추가한 state, prop가 변경될 때마다 실행

리액트는 이펙트 함수를 호출해보지 않고서는 함수가 어떤 일을 하는지 알 수 없다. 따라서 deps의 존재 의미는 다음과 같다.

  • 리액트에게 렌더링 스코프 안에서 deps에 추가한 이외의 값은 쓰지 않겠다는 약속
  • 어떤 렌더링 스코프에서 나온 값 중 이펙트에 쓰이는 것을 전부 알려주는 힌트

deps를 언제 이펙트를 다시 실행해야 할 지정할 때 쓰인다는 기존의 직관은 잠시 잊어버려야 한다.

🤔 그래도 이펙트를 마운트했을 때만 실행하고 싶다면?

함수형 컴포넌트로 작성시, 특정 라이프사이클에만 무언가를 실행하고 싶다는 생각 자체를 다르게 전환해야 할 필요가 있다. 참고한 레퍼런스에 의하면, 리액트에게 의존성으로 거짓말을 하면 안 된다고 한다. 즉, 이펙트 내에서 렌더 범위 안의 값을 사용하는데도 불구하고, 기존의 직관대로 처음 한 번만 이펙트를 실행하고자 deps를 빈 배열로 두게 되면 버그가 발생할 확률이 높다.

예를 들면, 아래와 같이 Counter라는 컴포넌트를 작성했다고 치자.

function Counter() {
  const [ count, setCount ] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, []);
  
  return <h1>{count}</h1>;
}

기존의 직관대로라면 개발자의 원래 의도는 컴포넌트를 마운트할 때 인터벌을 한 번만 설정하고 한 번만 제거하는 것이다. 그러나 deps를 빈 배열로 설정했기 때문에 항상 같은 값의 state, prop을 참조하게 되고, 화면에서 count는 계속 1로 표시된다.

이 코드를 만약에 클래스형 컴포넌트로 작성한다면, 인터벌 설정을 componentDidMount에 넣고, 인터벌 해제를 componentWillUnmount에 넣어주면 의도대로 동작하게 된다. hook을 라이프 사이클 관점으로 이해하면 안 되는 게 분명해 보인다.

어떻게 해결하면 될까?

그러면 함수형 컴포넌트에서 hook을 어떻게 사용하면 좋을까? 방법은 의존성을 솔직하게 적는 것이다.

4. 의존성을 솔직하게 적는 방법 두 가지

1. 이펙트 내에서 사용되면서 렌더 범위에 있는 값을 의존성 배열 안에 추가하기

위의 Counter 컴포넌트를 리팩터 해보자.

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

  useEffect(() => {
    const id = setInterval(() => setCount(count + 1), 1000);
    return () => clearInterval(id);
  }, [count]);  // deps에 count 추가

  return <h1>{count}</h1>;
}

이 예제의 경우에는 count가 변경될 때마다 인터벌이 다시 설정/해제되므로 의도와 다르게 동작할 수 있다.

2. 이펙트가 자주 바뀌는 값을 요구하지 않도록 만들기(의존성 제거)

a. 함수형 업데이트로 수정

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount((prevCount) => prevCount + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);  // deps가 빈 배열

  return <h1>{count}</h1>;
}

이렇게 하면 이펙트 함수가 현재 state를 알 필요가 없어진다. 그러나 count 값에 prop으로 전달 받은 값으로 계산하고 싶다거나, 여러가지 상태값으로 계산해야할 때는 쓰기 어려울 것이다. 할 수 있는 일이 제한적이다.

b. useReducer 사용하기
이펙트 안에서 연관된 state를 업데이트하거나 업데이트에 prop도 필요한 경우 useReducer로 업데이트 로직 분리할 수 있다.
Counter 컴포넌트를 useReducer를 사용해서 다시 리팩터 해보자.

const initialState = {
	count: 0;
}

function reducer(state, action) {
  if (action.type === 'tick') {
    return state + step;
  } else {
    throw new Error();
  }
}

function Counter({ step }) {
  const [count, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, [dispatch]);

  return <h1>{count}</h1>;
}

이펙트 내부에서는 상태값을 알 필요 없이 dispatch로 액션 타입만 알려주면 되고, 업데이트 로직은 리듀서에 작성한다. 다음 렌더링 중에 리듀서가 호출되면 새로운 state, prop이 렌더 범위 안으로 들어온다. 이때 리액트는 컴포넌트가 유지되는 한 dispatch 함수가 항상 같다는 것을 보장하므로, depsdispatch를 추가하든 말든 상관없다.

c. 이펙트 안에서만 쓰이는 함수라면 그 함수를 이펙트 안으로 옮기기

  • 위에서 언급했듯이, 리액트는 각 렌더링마다 고유의 함수를 가지고 있다.
  • 따라서 렌더 범위 안의 함수를 이펙트 안에서 쓰고 있다면 deps에 포함될 수 있다.
  • 그 함수를 이펙트 안에서만 사용한다면, 아예 이펙트 안으로 옮겨서 해당 함수를 deps에 포함되지 않게 만들 수 있다.

만약에 함수가 이펙트 내부 뿐만 아니라 여러군데에서 반복적으로 사용된다면 어떨까? 이럴 때도 이펙트 안으로 함수를 옮겨야 할까?

d. 컴포넌트 외부로 끌어올리거나 useCallback으로 감싸기
이펙트 안으로 함수를 옮기고 싶지 않다면,

  • 함수를 아예 컴포넌트 외부로 끌어올려서 컴포넌트의 렌더 범위에 들어오지 않게 만들 수 있다.
  • 또는 useCallback으로 함수를 감싸서 함수 자체가 필요할 때만 바뀔 수 있도록 만든다.
    • useCallback의 의존성 배열에 들어가는 값이 같다면 해당 함수 또한 같은 함수이며, 이펙트는 다시 실행되지 않을 것이다.
    • 부모 컴포넌트로부터 자식 컴포넌트로 함수 prop을 내려보내는 것 또한 같은 해결책이 적용된다. useCallback은 함수가 하위 컴포넌트로 전달되어 그 컴포넌트의 이펙트 안에서 호출되는 경우 유용하다. 혹은 하위 컴포넌트의 메모이제이션이 깨지지 않도록 방지할 때도 쓰인다.

결론

  1. 컴포넌트가 첫 번째로 렌더링 할 때와 그 후에 다르게 동작하는 이펙트를 작성하는 것은
    함수형 컴포넌트의 데이터 흐름을 거스르는 것이다.
  2. 리액트는 우리가 지정한 prop, state에 따라 DOM과 동기화하려고 하기 때문이다.
  3. 함수형 컴포넌트는 라이프 사이클이 아니라 동기화의 관점으로 바라보아야 한다. 렌더링 시 마운트, 업데이트의 구분이 없다.

느낀점

exhaustive-deps에 대한 궁금점으로 시작해서 리액트 훅의 메커니즘을 이해하게 되었다. 나는 리액트 시작을 함수형 컴포넌트로 시작했지만, 많은 설명들이 클래스형 컴포넌트의 라이프 사이클을 기준으로 쓰여있어서 훅에 대해 잘못 이해하고 쓴 적이 많았다. 리액트 개발자인 댄 아브라모프의 글이 많은 도움이 되었다.

📎 References
1. useEffect는 라이프 사이클 메소드가 아니다.
2. useEffect 완벽가이드

profile
완벽주의가 아닌 완성주의(블로그 이동 중...)

0개의 댓글

관련 채용 정보