🌱 ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [React] useEffect : 오용을 경계하자
    Front-End/React 2024. 9. 29. 15:46
    🍀목차
    글의 목적
    useEffect 문서 읽어보기(Event vs Effect)
    1 - Effect는 동기화에만 사용하자
    2 - Effect의 생명주기는 컴포넌트의 생명주기와 다르다
    3 - 데이터 페칭 목적으로 사용 시 주의하자

     

    글의 목적

     React로 함수 컴포넌트를 만들다 보면 자연스럽게 React Hooks들을 사용하게 된다. 그중에서도 useEffect는 컴포넌트 렌더링 후 특정 작업을 수행하거나 외부 시스템과 컴포넌트를 동기화할 때 유용한 Hook이다. 그리고 오용을 주의해야 하는 Hook이기도 하다. 

     

    - Effect로 인해 무한루프에 빠질 때도

    - 의존성 배열에 경고가 뜰 때도

    - 혹은 useEffect 자체의 목적을 잊어버리는 경우도 있는데

    이번 글은 useEffect의 목적과 대표적인 주의점에 대해 정리해 보았다.


    useEffect 문서 읽어보기(Event vs Effect)

    문서

     

    useEffect(setup, dependencies?)

     

    Effect를 사용하면 렌더링 후 특정 코드를 실행하여 React 외부 시스템과 컴포넌트를 동기화할 수 있다. React는 useEffec를 통해 UI 렌더링과 데이터 페칭 등의 작업을 독립적으로 실행하게 하여 효율적이고 예측 가능한 렌더링 과정을 유지할 수 있다.

     

    렌더링 자체에 의해 발생하는 부수 효과를 특정하는 것으로, 특정 이벤트가 아니라 렌더링에 의해 직접 발생한다. 

     

    📌 cf. Event & Effect
    Event : 사용자가 특정 버튼을 클릭함에 따라 직접적으로 발생하는 것(채팅창에 메시지 보냄)
    Effect : 서버 연결 설정

     

    이 둘은 분리되는 개념이기에 코드도 분리되어야 한다. 구매 버튼 클릭 이벤트 핸들러는 정확히 무슨 일이 일어났는지 알 지만 Effect는 실행될 때까지 어떤 버튼을 클릭했는지 모르기 때문이다. 왜 그럴까?

     

     Effect는 DOM에 변경사항을 커밋한 후 화면 업데이트가 이루어지고 난 타이밍에 실행된다. 그때가 외부 시스템을 동기화하기 좋은 타이밍이기 때문이다.

     

    모든 커밋 이후에 실행된다는 것은 다시 말해,

     

    ➡️ useEffect는 화면에 렌더링이 반영될 때까지 코드 실행을 지연시킨다.

    ➡️ 렌더링 코드는 렌더링 중에 DOM 노드를 조작할 수 없다. (아직 DOM이 완전히 구성되지 않았음, 조작 시 부수 효과 발생 가능)

    ➡️ React는 컴포넌트가 JSX를 반환할 때까지 어떤 DOM을 생성할지 모른다.

    ➡️ 부수 효과를 렌더링 연산(순수한 작업)에서 분리하기 위해 useEffect로 감싸는 것

     

    과도 같다. 

     

     

    그럼에도 Event 핸들러에 있어야 하는지, Effect에 있어야 하는지 헷갈린다면 다음을 생각해 보자.

     

    • 코드가 실행되어야 하는 이유가 무엇인가?
      • 컴포넌트가 사용자에게 표시되었기 "때문에" 실행되는 코드 : Effect
      • 사용자가 "버튼을 눌렀기" 때문에(특정 상호작용으로 인해 발생) : Event 핸들러

     

    function ProductPage({ product, addToCart }) {
      // ✅ 좋습니다: 이벤트 핸들러에서 이벤트별 로직이 호출됩니다.
      function buyProduct() {
        addToCart(product);
        showNotification(`Added ${product.name} to the shopping cart!`);
      }
    
      function handleBuyClick() {
        buyProduct();
      }
    
      function handleCheckoutClick() {
        buyProduct();
        navigateTo('/checkout');
      }
      // ...
    }

     

    function Form() {
      const [firstName, setFirstName] = useState('');
      const [lastName, setLastName] = useState('');
    
      // ✅ 좋습니다: 컴포넌트가 표시되었으므로 이 로직이 실행되어야 합니다.
      useEffect(() => {
        post('/analytics/event', { eventName: 'visit_form' });
      }, []);
    
      // 🔴 피하세요: Effect 내부의 이벤트별 로직
      const [jsonToSubmit, setJsonToSubmit] = useState(null);
      useEffect(() => {
        if (jsonToSubmit !== null) {
          post('/api/register', jsonToSubmit);
        }
      }, [jsonToSubmit]);
    
      function handleSubmit(e) {
        e.preventDefault();
        setJsonToSubmit({ firstName, lastName });
      }
      // ...
    }
    
    // 대신
    
    function Form() {
      const [firstName, setFirstName] = useState('');
      const [lastName, setLastName] = useState('');
    
      // ✅ 좋습니다: 컴포넌트가 표시되었으므로 이 로직이 실행됩니다.
      useEffect(() => {
        post('/analytics/event', { eventName: 'visit_form' });
      }, []);
    
      function handleSubmit(e) {
        e.preventDefault();
        // ✅ 좋습니다: 이벤트별 로직은 이벤트 핸들러에 있습니다.
        post('/api/register', { firstName, lastName });
      }
      // ...
    }

     

    1 - Effect는 동기화에만 사용하자

     

    Effect의 목적은 컴포넌트 동기화이다. state 계산 및 다른 것들을 하지 말자. 아래 "무한 루프가 일어나는 코드"를 살펴보자.

    const [count, setCount] = useState(0);
    useEffect(() => {
      setCount(count + 1);
    });
    
    // 무한 루프가 일어나는 코드

     

     위에서 알아본 대로 Effect는 모든 렌더링 후에, 렌더링의 결과로 실행된다.

    그리고 state 설정은 렌더링이 트리거 되는 행위이다.

    다시 말해, 해당 코드는 불필요한 렌더링 패스(렌더링 -> Effect -> state 트리거 -> 렌더링 -> Effect ->...)가 발생한다. 외부 시스템이 없고 렌더링 중에 무언가를 계산할 수 있다면 Effect는 필요하지 않다.

     

    • Effect가 외부 시스템(DOM, 네트워크, 서드파티 위젯 등)에 연결되어 있지 않음 : Effect를 제거해 로직 단순화 고려.
    • 연결 중임 : state가 종속성 변경을 야기하지 않는지 주의(Object.is를 사용해 종속성 변경 디버깅 가능), 렌더링에 사용되지 않는 데이터를 추적해야 한다면 리렌더링을 야기하지 않는 ref 사용 고려.

     

     React에서 데이터는 부모 -> 자식으로 흐른다는 개념은 아주 중요하다. 만약, 자식 컴포넌트가 Effect에서 부모 컴포넌트 state를 업데이트하면 데이터 흐름을 추적하기도 매우 어려워진다. state를 잘못 사용하고 있지 않은지 생각하고, 컴포넌트 최상위 레벨에서 모든 데이터를 변환하자.

     

    function Form() {
      const [firstName, setFirstName] = useState('Taylor');
      const [lastName, setLastName] = useState('Swift');
    
      // 🔴 피하세요: 중복된 state 및 불필요한 Effect
      const [fullName, setFullName] = useState('');
      useEffect(() => {
        setFullName(firstName + ' ' + lastName);
      }, [firstName, lastName]);
      // ...
    }
    
    function Form() {
      const [firstName, setFirstName] = useState('Taylor');
      const [lastName, setLastName] = useState('Swift');
      // ✅ 좋습니다: 렌더링 중에 계산됨
      const fullName = firstName + ' ' + lastName;
      // ...
    }

     

     


    2 - Effect의 생명주기는 컴포넌트의 생명주기와 다르다

    의존성 배열을 명확하게 설정하자

     

    useEffect는 의존성 배열에 따라 실행 시점을 제어할 수 있다. React는 지정한 모든 종속성이 이전 렌더링과 정확히 동일한 값을 가진 경우(Object.is)에만 Effect를 다시 실행하지 않는다.

    useEffect(() => {
    	// 모든 렌더링 후에 실행
    });
    
    useEffect(() => {
    	// 마운트될 때만 실행
        // 매번 재렌더링 후에 실행되면 느린 경우 추가(채팅 서버 연결)
    }, []);
    
    useEffect(() => {
    	// 마운트될 때 실행, 렌더링 이후 a 또는 b 중 하나라도 변경된 경우 실행
    }, [a, b]);

     

     의존성 배열로 알 수 있듯이 Effect는 컴포넌트와 생명주기가 다르다. 그렇기에 컴포넌트 생명주기와 독립적으로 Effect를 생각해야 한다. 외부 시스템을 현재 props, state와 동기화하는 방법을 설명하는 것이 Effect이다. Effect는 컴포넌트 렌더링 후에 실행되며 컴포넌트 생명주기(마운트-업데이트-언마운트)와 흐름이 일치하지 않다. 그렇기에 의존성 배열을 명확하게 지정하지 않으면 Effect가 필요 이상으로 자주 실행되거나 한 번도 실행되지 않을 수도 있다.

     

     

    Clean up 함수 활용하기

     React는 Effect가 다시 실행되기 전마다 클린업 함수(정리 로직)를 호출하고, 컴포넌트가 마운트 해제(제거)될 때에도 마지막으로 호출한다.

     

    Effect로 어떤 요소를 애니메이션으로 표시했다면, 클린업 함수에서 애니메이션을 초기 값으로 재설정해야 하며(애니메이션 트리거), Effect가 어떤 데이터를 가져온다면, 클린업 함수에서는 fetch를 중단하거나 결과를 무시해야 한다(데이터 페칭). 

    다시 말해, 외부 시스템을 연결했다면 - 연결 해제가 필요하고 이벤트를 구독했다면 - 구독 취소가 필요하다.

      useEffect(() => {
        const connection = createConnection();
        connection.connect();
        return () => {
          connection.disconnect();
        };
      }, []);

     

     

    정리 로직은 설정 로직과 대칭이어야 하며 설정이 수행한 것을 중지하거나 되돌릴 수 있어야 한다.

    정리 함수의 목적은 구독과 같은 Effect를 되돌리는 것이다. 컴포넌트가 마운트 될 때 동기화를 시작하고 마운트 해제될 때 동기화를 중지할 것이라고 생각할 수 있지만(컴포넌트 라이프사이클 관점) 때로는 컴포넌트가 마운트 된 상태에서 동기화를 여러 번 시작하고 중지해야 할 수도 있다(effect가 사용하는 일부 데이터 변경되는 경우).

     

    📌 컴포넌트의 마운트 해제 관점보다는 연결 해제 관점(Effect 관점)으로 보는 것이 더 적합하다.

     


    3 - 데이터 페칭 목적으로 사용 시 주의하자

    경쟁 조건 주의

    function SearchResults({ query }) {
      const [results, setResults] = useState([]);
      const [page, setPage] = useState(1);
    
      useEffect(() => {
        // 🔴 피하세요: 정리 로직 없이 가져오기
        fetchResults(query, page).then(json => {
          setResults(json);
        });
      }, [query, page]);
    
      function handleNextPageClick() {
        setPage(page + 1);
      }
      // ...
    }

     

     위의 코드 기반으로 Hello를 빠르게 입력한다고 가정해 보자.

    Query는 h he hel hell hello로 바뀔 것이다. 그런데, 응답도 순서대로 도착할까?

    보장할 수 없다. 경쟁 조건이란 이렇게 서로 다른 두 요청이 서로 경쟁하여 예상과 다른 순서로 도착할 수 있음을 의미한다.

     

    Async/await 같은 비동기 호출에서 흔히 나타나며, 이럴 경우 정리 함수에서 비동기 함수를 취소할 수 있다.

    useEffect(() => {
      let ignore = false;
    
      async function startFetching() {
        const json = await fetchTodos(userId);
        if (!ignore) {
          setTodos(json);
        }
      }
    
      startFetching();
    
      return () => {
        ignore = true;
      };
    }, [userId]);

     

    이미 발생한 네트워크 요청은 실행 취소 할 수 없지만, 클린업 함수로 더 이상 관련이 없는 fetch가 애플리케이션에 계속 영향을 미치는 것은 방지할 수 있다.

    위의 코드에서 UserId가 Alice에서 Bob으로 변경되면 클린업 함수는 Alice가 Bob이후에 도착하더라도 응답을 무시하도록 보장한다.

     

     

    [ ]는 항상 돌파구가 될 수 없다

     

     왜 가끔씩 데이터 페칭이 무한루프에 빠지는 걸까? 의존성 배열을 전달하지 않았을 때 생길 수 있는 문제일 수 있다.

    [ ]는 Effect에 데이터 흐름에 관여하는 어떠한 값도 사용하지 않겠다는 뜻이다. 하지만 실제로는 그렇지 않을 경우, React에게 거짓말을 치는 것이 된다. 그렇기에 맹목적으로 [ ]을 지정하는 것은 좋지 않다.

     

    function SearchResults() {
    	async funtion fetchData() {
        	// ...
        }
        
        useEffect(() => {
        	fetchData();
        }, []); // []는 항상 돌파구가 될 수 없다.
        
        // ...
    }

     

     

     

     데이터 페칭에 대해서도 고민이 필요하다. 데이터 페칭은 엄밀히 말해 동기화의 문제가 아니다. 특히 명백히 이럴 때 deps도 [ ]가 되기 때문에 우리는 무엇을 동기화하고 있는지 모르게 된다. 언제나 Effect에 의존성을 솔직하게 전부 명시해야 하며, 데이터 페칭 시 린트 규칙 exhaustive-deps warning가 발생한다면 주의 깊게 지켜보자. 어떠한 함수를 Effect 안에서만 쓴다면, 그 함수를 직접 Effect안에 옮겨 의존성이 정말로 없음을 표시하는 것도 좋다.

     

    function SearchResults() {
        
        useEffect(() => {
        	async funtion fetchData() {
        	// ...
        	}
        
        	fetchData();
        }, []); // ok, 진짜로 Effect 안에서 컴포넌트 범위 바깥의 어떠한 것도 사용하지 않고 있다.
        
        // ...
    }

     

     그리고 Effect에서 데이터를 가져오는 것은 네트워크 폭포(동기적으로 데이터를 가져온다고 이해하면 쉽다)를 쉽게 만들 수 있다. 부모 컴포넌트 렌더링 후 필요 데이터를 가져온 후 자식 컴포넌트를 렌더링 한 다음 그들이 데이터를 가져오기 시작하기 때문이다. 이는 모든 데이터를 병렬적으로 가져오는 것보다 훨씬 느리다. 그리고 Effect 안에서 직접 데이터를 가져오는 것은 일반적으로 데이터를 미리 로드하거나 캐시 하지 않음을 의미하고 매 마운트 해체 후 다시 마운트 되면 데이터도 다시 가져온다. 리소스가 큰 행위라면 성능에도 좋지 못하다.

     

     해결법 중 하나는 서드파티 라이브러리(TanStack Query 등)를 사용하는 것이다. 기본적으로 이러한 단점(의존성 문제, 성능 문제)을 겪지 않는 효율적이고 통합적인 데이터 페칭 솔루션을 제공한다.

     

     

    참고자료

    https://ko.react.dev/learn/synchronizing-with-effects

    https://ko.react.dev/learn/you-might-not-need-an-effect

    https://ko.react.dev/learn/lifecycle-of-reactive-effects

    https://ko.react.dev/reference/react/useEffect#troubleshooting

    댓글

🍀 Y0ungZ dev blog.
스크롤 버튼