본문 바로가기

공부 정리

23.01.11: dependency array에는 언제, 뭘 넣어주어야 할까 (useEffect, useMemo, useCallback)

반응형

React Hooks 중 useEffect, useCallback, useMemo를 사용하면서 언제 dependency array를 추가하고 또 어떤 것을 넣어주어야 할 지 어렴풋이 짐작은 하지만 확실하게 알고 있지 못한 것 같아 공부를 또 해야겠다 싶었습니다. 같은 내용을 몇 번을 쓰고 또 쓰는지 모르겠지만 이렇게 정리하다 보면 완전히 알게되는 날이 올 것이라는 믿음으루... 또 정리 해보겠습니다. 별코딩의 리액트 hooks 강의 예제 및 다른 블로그들의 예제들을 참고 하며 학습 및 정리한 내용입니다. 링크는 본문 하단에 있습니다.

 

# useEffect

 

useEffect는 컴포넌트가 화면에 첫 렌더링이 될 때(Mount), 다시 렌더링이 될 때(Update),  화면에서 사라질 때(Unmount) 특정 작업을 할 코드를 실행시키고 싶을 때 쓰는 hook이다. 인자로 콜백함수를 받는다. 콜백함수라는 것은 다른 함수에 인자로 전달된 함수를 의미한다.

useEffect는 다음과 같이 해야하는 작업을 콜백함수 내부에 작성한다. 

 

 

1. 인자로 하나의 콜백 함수만 받는 형태

 

useEffect(() => { // 해야 할 작업 })

 

--> 이러한 형태는 첫 렌더링이 될 때, 다시 렌더링이 될 때마다 매번 콜백함수가 실행된다.

 

 

2. useEffect의 첫번째 인자로는 콜백함수, 두번째 인자로는 배열을 받는 아래와 같은 형태도 있다. 

 

useEffect(()=>{
 // 해야 할 작업 
},[value])

 

--> 이 경우엔 화면에 처음으로 렌더링이 될 때, value의 값이 바뀔 때(ComponentDidUpdate) 실행된다. 만약 value가 없는 빈 배열이라면 맨 처음 렌더링이 될 때만 해야 할 작업의 코드가 실행 되게 된다.

 

3. useEffect의 Clean up (정리) 

 

어떤 것을 구독하는 코드를 실행 할 때,구독을 해제 하는 작업을 해야 한다. (혹은 이벤트리스너를 등록했다면 등록 해제 해주는 작업을 해야함) 이 때, 다음과 같이 코드를 작성 한다.

 

useEffect(() => {
  // 구독
  
  return() => {
    
  // 구독 해제
  
  }

},[]);

 

# useEffect의 dependency array

 

1. useEffect dependency array에 state를 넣어주는 경우

 

useEffect를 dependency array 없이 쓰게 된다면 렌더링이 일어날 때 마다 계속 콜백 함수가 실행이 된다. 뭔가 콜백 함수 내부에서 헤비한 작업을 실행 시킨다면 상태가 변경될 때 마다 그 로직이 실행 되면서 성능이 저하 되는 문제가 발생 하게된다. 

그 특정 작업을 첫 렌더링에서만 실행 시키고 싶다면 빈 dependency array를 useEffect의 두번째 인자로 추가해주면 되고, 외부의 특정 값이 변경 될 때 콜백 함수 내부의 작업을 실행 시키고 싶다면 그 특정 값을 dependency array에 다음과 같이 넣어주면 된다.

 

useEffect(()=>{
 // 해야 할 작업 
},[value])

 

그러면 useEffect 함수 내부 로직에 의해서 dependency array를 캐싱하고 이전의 각 element를 반복하면서 dependency array 내부의 element와 비교 할 것이고, 이 둘이 다르다면 첫 번째 파라미터인 콜백을 호출 한다. 

 

2.  useEffect 내부에 함수를 정의한 경우

 

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

useEffect(() => {
  fetchAndSetUser(false);
},[fetchAndSetUser]);

 

useEffect 훅 내부에서 fetchAndSetUser 함수를 사용하므로 해당 함수를 dependency array에 넣는다. fetchAndSetUser 함수는 렌더링을 할 때마다 갱신되므로 결과적으로 fetchAndSetUser 함수는 렌더링이 될 때마다 호출 된다. 이를 해결하기 위해서는 함수가 필요할 때만 갱신이 되도록 만들어야 한다. 이 경우 아래와 같이 useCallback 훅을 이용하면 userId가 변경 될 때만 fetchAndSetUser 함수가 갱신된다.

 

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

 

 

# useMemo 

 

useMemo의 memo는 memoization을 뜻한다. memoization이란 동일한 값을 리턴 하는 함수를 반복적으로 호출 한다면 맨 처음 값을 메모리에 저장 해서 값이 필요할 때 캐싱된 데이터를 가져와 사용하는 것을 뜻 한다. 우리가 사용하는 함수형 컴포넌트는 하나의 함수이다. 함수형 컴포넌트가 렌더링 된다는 것은 그 함수가 호출 된다는 것을 뜻한다. 함수는 호출될 때마다 함수 내부의 모든 변수들이 초기화 된다. 따라서 함수에 무겁고 복잡한 연산이 들어있을 경우에 무의미하게 반복적인 연산을 하게되는 비 효율적 상황들을 useMemo를 통해 해결 할 수 있다.

 

# useMemo의 dependency array

 

아래와 같이 dependency array가 비어있을 경우엔 초기 렌더링 당시에 기억된 값을 메모리에 저장하게 된다. 따라서 value라는 변수는 

초기 렌더링 이후 상태가 변하더라도 내부의 로직이 다시 실행되지 않기 때문에 초기 렌더링 때 메모이제이션 된 값을 계속 사용하게 되어 버그를 초래 할수 있다.

 

const value = useMemo(() => {
  return calculate();
},[])

 

아래 코드와 같이 deps에 state를 넣어 주게되면 컴포넌트 내부에 state라는 값이 useState로 선언 되어있고, 연산을 하는 함수에 의해서 state값이 바뀔 때 useMemo가 변화를 감지하여 값을 다시 메모이제이션 해주게 된다.

 

  const secondValue = useMemo(() => {
    const local = state;
    return local;
  }, [state]);

 

# useMemo를 언제 쓰면 좋을까?

 

아래의 코드 같은 경우엔 첫 렌더링시 useEffect의 콜백함수 내부의 console.log('useEffect 호출')이 출력되고, 그 후 gym 버튼을 누르면  버튼 상태 변화를 추적해서 상태가 바뀔때만 componentDidUpdate로 바뀌어 리렌더링이 일어날 때 useEffect 호출이 일어난다. 

 

import React, { useEffect, useState } from 'react';

function App() {
  const [number, setNumber] = useState(0);
  const [isGym, setIsGym] = useState(true);
  
  const gym = isGym ? '다님' : '안다님';
  
  useEffect(() => {
    console.log('useEffect 호출');
  }, [gym]);
  
  return (
    <div>
      <h2>하루에 몇끼 먹어요?</h2>
      <input 
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <hr />
      <h2>피트니스 클럽 다니심?</h2>
      <p>gym: {gym}</p>
      <button onClick={() => setIsGym(!isGym)}>Gym</button>
    </div>
    
  );
}

export default App;

 

그럼 gym이란 변수가 string이 아닌 객체라면? 

 

import React, { useEffect, useState } from 'react';

function App() {
  const [number, setNumber] = useState(0);
  const [isGym, setIsGym] = useState(true);
  
  const gym = {
    workout: isGym ? '다님' : '안다님'
  };
  
  useEffect(() => {
    console.log('useEffect 호출');
  }, [gym]);
  
  return (
    <div>
      <h2>하루에 몇끼 먹어요?</h2>
      <input 
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <hr />
      <h2>피트니스 클럽 다니심?</h2>
      <p>gym: {gym.workout}</p>
      <button onClick={() => setIsGym(!isGym)}>Gym</button>
    </div>
    
  );
}

export default App;

 

이런 경우에는 gym 버튼을 누를때 똑같이 useEffect가 호출이 된다, 하지만 하루에 몇끼 먹어요 input 의 숫자가 변하면 useEffect가 호출이 된다. useEffect에서 의존성 배열에 gym은 그대로인데, 왜일까? 

 

그 이유는 gym이 객체 타입이기 때문이다. 자바스크립트에서 객체는 어떤 메모리상의 공간에 할당되어서 메모리 안에 저장이 되고 gym이라는 변수 안에는 객체가 담긴 메모리의 주소가 할당 된다. input의 number가 증가, 감소될 때 마다 App 컴포넌트는 리렌더링이 일어나고, 그때마다 gym이란 객체는 매번 다른 메모리 주소가 할당 되기 때문에 React의 입장에서는 gym이라는 객체는 변경된 것으로 인식된다. 따라서 useEffect가 계속 해서 호출 된다.

 

이때 useMemo로 gym을 Memoization 해주면 된다. 다음 코드와 같이 useMemo의 의존성 배열에 콜백에서 사용하는 isGym이란 state를 넣어주어 state가 변경되지 않으면 기억 했던 값을 꺼내와 사용하고, state가 변경되면 변경 된 값을 기억 해 놓았다가 쓰게 되어 App 컴포넌트를 최적화 할 수 있다. 

 

import React, { useEffect, useState } from 'react';

function App() {
  const [number, setNumber] = useState(0);
  const [isGym, setIsGym] = useState(true);
  
  const gym = useMemo(() => {
   return {
     workout: isGym ? '다님' : '안다님',
   }
  }, [isGym]);
  
  useEffect(() => {
    console.log('useEffect 호출');
  }, [gym]);
  
  return (
    <div>
      <h2>하루에 몇끼 먹어요?</h2>
      <input 
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <hr />
      <h2>피트니스 클럽 다니심?</h2>
      <p>gym: {gym.workout}</p>
      <button onClick={() => setIsGym(!isGym)}>Gym</button>
    </div>
    
  );
}

export default App;

 

 

# useCallback 

 

useCallback 또한 메모이제이션 도구로 컴포넌트를 최적화 시키는 도구 중 하나이다. useCallback은 인자로 전달한 콜백 함수 자체를 메모이제이션 해 주어 필요할 때 마다 함수를 메모리에서 가져와서 재사용하는 것이다. 다시 한 번 우리가 기억 해야 할 것. 리액트에서 함수형 컴포넌트는 말 그대로 함수이다. 함수형 컴포넌트가 렌더링 된다는 것은 컴포넌트 함수가 호출이 된다는 말이고, 즉, 모든 컴포넌트 내부 변수가 초기화 된다는 말이다. 

 

아래 예제 코드에서 input에서 변경 된 숫자는 상태가 변할 때 마다 number라는 state에 저장이 되고 Call someFunc이라는 버튼을 누를 때 someFunction이라는 함수를 호출하여 number에 저장 된 숫자를 console.log로 출력 해 줄 것이다.

 

import { useState } from 'react';

function App() {
  const [number, setNumber] = useState(0);
  
  const someFunction = () => {
    console.log(`someFunc: number: ${number}`);
    return;
  };
  
  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <br />
      <button onclick={someFunction}>Call someFunc</button>
    </div>
  );
};

export default App;

 

여기에 someFunction이라는 함수가 호출될 때마다 어떤 로직을 실행시키고 싶어서 useEffect를 추가 한다면, 

 

import { useState } from 'react';

function App() {
  const [number, setNumber] = useState(0);
  
  const someFunction = () => {
    console.log(`someFunc: number: ${number}`);
    return;
  };
  
  useEffect(() => {
    console.log('someFunction이 변경되었습니다.');  
  }, [someFunction])
  
  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <br />
      <button onclick={someFunction}>Call someFunc</button>
    </div>
  );
};

export default App;

 

useEffect의 두번째 인자인 의존성 배열에 someFunction을 명시해 주었으니 someFunction이 변경될 때만 useEffect 콜백 안의 console.log가 출력 될 것이다.

 

하지만 이대로 App 컴포넌트를 실행시켜 개발자 도구를 보면 "someFunction이 변경되었습니다." 가 input의 number가 변경될 때 마다 계속해서 출력 되는 것을 볼 수 있다. 

 

그 이유는 앞서 말 했듯, React 컴포넌트에서 state를 변경할 때 마다 App 컴포넌트는 재 호출이 되기 때문이다. 그러면서 컴포넌트 내부의 모든 변수들이 초기화 되고, someFunction이라는 변수에 담긴 함수 객체 또한 초기화 되면서 객체를 메모리에 저장하고 변수에 할당 된 메모리 주소가 초기화 되어 someFunction이 변경 되었다고 React는 인식하게 된다.

 

그럼 이 someFunction이란 변수에 할당 된 메모리 주소를 메모이제이션 해 주면 불필요한 리렌더링을 막을 수 있지 않을까? 

함수를 메모이제이션 해 주는 useCallback을 이때 쓸 수 있다. 

 

import { useState } from 'react';

function App() {
  const [number, setNumber] = useState(0);
  
  const someFunction = useCallback(() => {
    console.log(`someFunc: number: ${number}`);
    return;
  }, [number]);
  
  useEffect(() => {
    console.log('someFunction이 변경되었습니다.');  
  }, [someFunction])
  
  return (
    <div>
      <input
        type="number"
        value={number}
        onChange={(e) => setNumber(e.target.value)}
      />
      <br />
      <button onclick={someFunction}>Call someFunc</button>
    </div>
  );
};

export default App;

 

위와 같이 useCallback을 사용하면 input의 number를 변경 해도 'someFunction이 변경되었습니다'는 Call someFunc 버튼을 누르지 않는 이상 출력 되지 않는 것을 볼 수 있다.  하지만 useCallback을 쓸 때 의존성 배열에 number가 없었다면? App 컴포넌트가 최초 렌더링 될 때의 함수를 메모이제이션 해 주기 때문에 아무리 number state를 변경해도 Call someFunc 버튼으로 number를 출력하면 초기 값인 '0' 만을 보여주게 될 것이다. 그러므로 의존성 배열에는 꼭 콜백 내에서 사용하는 외부 변수를 명시 해 주어야 한다.

 

# deps에 대하여 공부 하면서

여러가지 예제를 살펴 보면서 deps에 어떤 변수를 혹은 함수를 명시 해 주어야 할지 이해 해 볼수 있었고 어떤 경우에 useMemo, useCallback을 쓸 수 있는지를 다시 한 번 머리에 입력 시킬 수 있었다. 또한 deps에 적절한 변수나 함수의 명시가 없다면 버그가 발생하여 의도치 않은 결과로 이어질 수 있으므로 주의 해야 한다. 특히 useEffect는 특정 로직을 콜백 내에서 실행시켜 주는 훅이기 때문에 잘못된 deps 관리로 인하여 버그 뿐 아니라 성능상의 이슈로 연결 될 수도 있어서 deps를 잘 관리 해 주어야 한다고 한다.

최근 이미 몇 번 사용도 해 보았지만 이렇게 다시 차근히 정리 해보니 훨씬 더 깊이 이해 할 수 있었던 공부였다 :)

 

References

useEffect 깔끔하게 마스터하기 - 별코딩

[react] useEffect 훅에서 async await 함수 사용하기

exhaustive-deps-warning 해결법

반응형