티스토리 뷰

React

useEffect 완벽가이드 - 3편

심재철 2020. 4. 3. 16:59

useEffect완벽가이드 2편 읽고오기

 

지난 포스팅에서는 useEffect에서 의존성배열에 올바른 변수를 명시해주지 않으면 어떤 문제가 발생하는지 살펴봤습니다. 이번 포스팅에서는 useEffect에 올바르게 의존성을 명시하는 방법에 대해서 살펴보겠습니다.

useState의 함수형 업데이트 사용하기

useEffect(() => {

  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  
  return () => clearInterval(id);
  
}, [count]);

이런식으로 코드를 작성할 경우 매 렌더링 마다 타이머가 초기화되고 다시 세팅되는 과정이 반복되어 비효율적이라고 말했었습니다. 이 코드를 아래와 같이 변경해봅시다.

useEffect(() => {

  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  
  return () => clearInterval(id);
  
}, []);

1번 setCount(count + 1); 에서

2번 setCount(c => c + 1); 로 변경되었습니다.

 

useState를 사용하는 두가지 방법이 있습니다.

1번 방식에서는 내가 업데이트 시키고 싶은 값을 직접 명시합니다.

2번 방식에서는 기존 상태를 가지고 이런식으로 업데이트 해줘 라고 함수의 형태로 명시하는것입니다.

 

 

기존 상태가 뭐든 상관없이 그냥 거기에 1을 더해줘 라고 말하는것과 똑같은거죠. 이렇게 하면 기존 렌더링 스코프 안에 갇힌 count값을 계속 참조하는 일도 발생하지 않게 됩니다.

 

이제 우리가 의도했던대로 코드가 동작하기 시작했습니다. 첫번째 렌더링 직후에 이펙트가 실행되고 그안에서 타이머가 설정되며 타이머 안에서는 1초마다 카운트의 값을 기존 값에서 1을 더해줍니다. 상태가 변경되고 나면 리렌더링이 일어나지만 의존성 배열에 아무것도 명시되어 있지 않기 때문에 두번쨰 렌더링 부터는 이펙트가 실행되지 않습니다.

 

따라서, 1초마다 정확히 값이 1씩 증가하겠죠

 

이펙트 안에서는 최소한의 정보만 사용하라.

useEffect안에서 컴포넌트의 정보를 사용하는것을 최소화 해야합니다. 위 예제에서처럼, 이펙트 안에서 컴포넌트의 count상태를 직접 사용할게 아니라 함수형 업데이트를 사용해서 c => c + 1 해준것처럼 말이죠. 후자의 방식은 컴포넌트가 현재 어떤 상태를 갖고 있는지를 전혀 궁금해 하지 않습니다. 그냥 너가 갖고 있는값에서 1을 더해 라는 명령을 내릴뿐이죠.

 

useEffect안에서 컴포넌트 내부에 있는 값을 많이 가져다 사용할수록 렌더링 스코프에 갇힌 변수들이 많아진다는것이고 그 말은 결국, 디버깅하기 힘든 이슈가 발생할 가능성이 높아진다는것을 의미합니다.

 

하지만, 이런 useState의 함수형 업데이트 방법을 사용하는것도 완벽한 방법은 아닙니다. 할 수 있는 일이 굉장히 제한적이죠. 컴포넌트의 state에 따라 업데이트하는게 아니라 props에 따라 다음 상태를 계산하고 싶을때는 이 방법을 사용하지 못합니다. 더 좋은 방법을 살펴보죠.

 

useReducer사용하기

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

  useEffect(() => {
  
    const id = setInterval(() => {
      setCount(c => c + step);
    }, 1000);
    
    return () => clearInterval(id);
    
  }, [step]);
  
  return (
    <>
      <h1>{count}</h1>
      <input value={step} onChange={e => setStep(Number(e.target.value))} />
    </>
  );
}

이번에는 1씩 증가하는게 아니라 원하는 step값 만큼 카운터를 1초마다 증가시키는 예제를 살펴봅시다. step이 이펙트의 의존성으로 설정되어 있기 때문에 step을 바꿔주면 인터벌이 초기화되고 재설정됩니다. 근데 step이 바뀌더라도 인터벌을 재설정하고 싶지 않으면 어떤 방법을 사용해야 할까요?

 

두 상태 변수 count, step 모두 useReducer로 변경해주면 됩니다.

 

위 코드를 이렇게 바꿔보죠.

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

useEffect(() => {

  const id = setInterval(() => {
    dispatch({ type: 'tick' }); // setCount(c => c + step) 대신에
  }, 1000);
  
  return () => clearInterval(id);
  
}, [dispatch]);

1초마다 tick이라는 액션을 dispatch하는 코드로 변경되었습니다. 리액트가 dispatch를 항상 고정된 값으로 유지한다는걸 보장하므로 사실 deps에서 dispatch를 빼도 무방하지만 일관성을 위해서 넣어주도록 합시다. 이펙트는 무슨일이 일어났는지만 명시합니다. 직접 상태를 가져다가 사용하지 않습니다. 이렇게하면, 이펙트가 컴포넌트 내부의 그 어떤값도 사용하지 않게 만들수있습니다.

const initialState = {
  count: 0,
  step: 1,
};

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

컴포넌트 상단에 이런식으로 리듀서를 정의해주면 됩니다. 이렇게 되면 이펙트안에서 렌더링 스코프에 갇힌 변수를 사용하지 않아서 편리하고 모든것이 코드를 작성할때 예상한대로 작동합니다.

 

리듀서에서 props가 필요한 경우?

아래와 같이 Counter컴포넌트가 step을 props로 받고 리듀서에서는 그 props를 사용해서 다음 상태를 계산한다고 해봅시다.

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

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

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

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

tick이라는 액션이 dispatch되면 props로 받아온 step을 참조해서 다음 상태를 계산합니다.

이 패턴은 몇가지 최적화를 무효화하기 때문에 남발하면 좋지 않습니다. 하지만 이렇게하면 리듀서 안에서 props에 접근할 수 있습니다.

위 코드는 이렇게 동작합니다.

 

 

첫번째 렌더링이 끝난 뒤, 이펙트에서 인터벌이 설정되고 1초뒤에 tick이라는 액션이 dispatch 됩니다. 

두번째 렌더링 도중, 이전 렌더링에서 dispatch된 액션(tick)에 해당하는 reducer가 실행됩니다. 이때 reducer는 현재 상태의 props를 참조합니다.

 

한마디로, 리액트는 그전 이펙트에서 dispatch된 액션을 기억해놨다가 그 다음 렌더링에서 리듀서를 실행합니다.

 

이제, 업데이트 로직(reducer)무엇이 일어나는지 서술(dispatch)하는것을 분리할 수 있게 되었습니다. 그리고 이펙트에서 불필요한 의존성을 제거함으로써 이펙트가 과도하게 재실행되는것도 막을 수 있게 되었습니다.

 

함수를 이펙트 안으로 옮기기

function SearchResults() {
  const [data, setData] = useState({ hits: [] });

  async function fetchData() {
    const result = await axios(
      'https://hn.algolia.com/api/v1/search?query=react',
    );
    setData(result.data);
  }

  useEffect(() => {
    fetchData();
  }, []); // 이거 괜찮은가?
  // ...

이펙트 안에서 fetchData()를 호출하고 있습니다. 위 코드는 실제로 잘 동작하지만 버그를 유발하기 좋은 코드입니다.

코드가 길어져서 api 호출 함수를 분리했다고 해봅시다.

function SearchResults() {
  const [query, setQuery] = useState('react');
  
  // 이 함수가 길다고 상상해 봅시다
  function getFetchUrl() {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }

  // 이 함수도 길다고 상상해 봅시다
  async function fetchData() {
    const result = await axios(getFetchUrl());
    setData(result.data);
  }

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

  // ...
}

fetchData에서 호출하는 getFetchUrl함수내에서 query state를 사용하고 있는데 deps에 query state를 명시하는걸 깜빡했습니다. 위 예제 코드에서 의도한바는 query가 변경될때마다 api호출을 새롭게 하는것인데 deps에 query를 누락해버리는 바람에 첫 렌더링때 딱 한번만 api호출이 일어나게 됩니다.

 

위와 같이 코드를 작성해두면 코드를 읽을때 아래 흐름을 거쳐야합니다.

 

fetchData 내부를 살펴보고 그 내부에 있는 getFetchUrl을 살펴보고 그 안에 query라는 state가 사용되고 있네!

그러니까, 이펙트의 depsquery를 명시해줘야겠다.

 

그래서, 차라리 이렇게 코드를 작성하는것보다 두 함수를 이펙트 내부로 옮기는것이 더 명확합니다.

function SearchResults() {
  const [query, setQuery] = useState('react');

  useEffect(() => {
    function getFetchUrl() {
      return 'https://hn.algolia.com/api/v1/search?query=' + query;
    }

    async function fetchData() {
      const result = await axios(getFetchUrl());
      setData(result.data);
    }

    fetchData();
  }, [query]); // ✅ Deps는 OK
  // ...
}

이렇게 하면 두 함수가 이펙트내에서 사용되고 있는 함수이고 각 함수에서 사용되고 있는 외부 state나 props를 바로바로 확인할 수 있게 되었습니다. 실수를 유발 가능성을 줄여줬죠.

 

이펙트 내부에 함수를 넣을 수 없으면요?

만약에 아래와 같이 컴포넌트 내부에 여러개의 이펙트가 있는 경우 함수를 복붙하지 않으려면 밖으로 꺼내야 합니다.

function SearchResults() {

  // 🔴 매번 랜더링마다 모든 이펙트를 다시 실행한다
  function getFetchUrl(query) {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }
  
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // 🚧 Deps는 맞지만 너무 자주 바뀐다

  // ...
}

그리고 나서 꺼내진 getFetchUrl을 이펙트의 deps에 명시를 해주는것까진 맞습니다. 근데 사실 리액트 컴포넌트 내부에 있는 함수들은 렌더링이 새로 될때마다 매번 새롭게 생성됩니다. 그래서 이 함수를 deps에 명시하는경우 이펙트도 매번 실행되죠. 결국에는 그냥 deps를 없앤거나 마찬가지 상황이 발생합니다.

 

그러면 getFetchUrl을 그냥 deps에서 빼면 되지 않냐고 물어보실수도 있는데, 그렇게 되면 아까 위에서 얘기했듯이, 함수 내부에서 언젠가 사용될 state, props를 추적하기 어려워져서 버그 발생 가능성을 높이게 된다는 문제가 또 생깁니다.

 

해결책

사실 getFetchUrl이라는 함수는 컴포넌트 내부에 있는 그 어떠한 값도 사용하지 않는 함수입니다. 따라서, 굳이 컴포넌트 안에 정의해서 렌더링 스코프에 갇히게 하는것보다 컴포넌트 바깥으로 뺴버리면 되는것이죠.

// ✅ 데이터 흐름에 영향을 받지 않는다
function getFetchUrl(query) {
  return 'https://hn.algolia.com/api/v1/search?query=' + query;
}

function SearchResults() {
  useEffect(() => {
    const url = getFetchUrl('react');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // ✅ Deps는 OK

  useEffect(() => {
    const url = getFetchUrl('redux');
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, []); // ✅ Deps는 OK

  // ...
}

이러면 리액트의 데이터 흐름에 영향을 받지 않는 함수가 되었습니다.

또 다른 해결책 : useCallback

function SearchResults() {
  const [query, setQuery] = useState('react');

  // ✅ 이 함수는 query가 변경되어야 새로 생성된다.
  const getFetchUrl = useCallback(() => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  }, [query]);  // ✅ 콜백 deps는 OK
  
  useEffect(() => {
    const url = getFetchUrl();
    // ... 데이터를 불러와서 무언가를 한다 ...
  }, [getFetchUrl]); // ✅ 이펙트의 deps는 OK

  // ...
}

getFetchUrl을 useCallback으로 감쌌습니다. 그리고 deps에 query를 명시했죠.

 

query가 변경되면 getFetchUrl에 새로운 익명 함수가 할당됩니다. 그 익명함수는 최신 상태의 현재 query값을 가지고 있을테고, getFetchUrl을 deps로 명시해둔 이펙트가 재실행되면서 api 호출이 일어나게 됩니다.

 

이렇게 만들면, 실수로 deps를 누락할 가능성을 줄여주고 데이터 흐름에 맞춰 동작하는 리액트 컴포넌트를 만들 수 있습니다. query가 변하지 않으면 getFetchUrl에는 새로운 함수가 할당되지 않고 그럼 마찬가지로 이펙트도 실행되지 않습니다.

 

함수를 props로 자식에게 내려주는 경우에도 이 방식을 활용할 수 있습니다.

function Parent() {
  const [query, setQuery] = useState('react');

  // ✅ query가 바뀌면 새로운 함수가 할당됩니다.
  const fetchData = useCallback(() => {
    const url = 'https://hn.algolia.com/api/v1/search?query=' + query;
    // ...
  }, [query]);  // ✅ 콜백 deps는 OK
  
  return <Child fetchData={fetchData} />
}

function Child({ fetchData }) {
  let [data, setData] = useState(null);

  useEffect(() => {
    fetchData().then(setData);
  }, [fetchData]); // ✅ 이펙트 deps는 OK

  // ...
}

부모에서 자식으로 fetchData라는 함수를 내려주고 있고 useCallback의 deps query로 감싸져있습니다. 따라서, query가 바뀌면 fetchData가 바뀌고 그럼, Child로 새로운 fetchData props를 내려주고, Child에서는 fetchData props가 변경되어 이펙트가 실행되어, 자식에서 api 호출이 일어나게 됩니다.

 

부모의 query state가 변경될때만 자식에서 api호출이 일어나는 코드입니다.

 

그렇다고 useCallback 을 어디든지 사용하는 것은 꽤 투박한 방법이라고 강조하고 싶습니다. useCallback 은 꽤 좋은 돌파구이며 함수가 전달되어 자손 컴포넌트의 이펙트 안에서 호출되는 경우 유용합니다. 

 

 

 

 

 

 

 

출처

https://rinae.dev/posts/a-complete-guide-to-useeffect-ko

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함