[꿀팁] 왜 useEffect에서 데이터 패칭을 하면 안될까?

in-ch·2023년 12월 21일
1

꿀팁

목록 보기
5/14
post-thumbnail
post-custom-banner

서론


최근에 이런 Article을 읽은 적이 있다.

링크: https://2024recruit-allpositions.daangn.com/

리액트 문서에서 제공하는 새로운 9가지 지침서라는 글이다.

해당 문서의 7번째 항목에 이런 내용이 있다.

When you need to fetch data, prefer using a library over useEffect
데이터 패칭을 useEffect에서 호출하지 말고 react-query같은 데이터 패칭 라이브러리를 사용하라는 내용이다.

음,, 사실 당연하게도 react-query를 사용하면서 프로젝트를 진행 중이다.

그래서 만약에 누군가가 왜 useEffect를 사용하지 말아야 하죠? 라고 물어본다면 아마

reactStrictMode에서 useEffect가 두 번 호출되고 의존성 배열 때문에 의도치 않은 결과값을 리턴할 수 있으니깐 그렇죠..?

라고 뭉뚱그려 대답할 것 같다.
하지만 나, 이런 건 데이터를 기반하며 논리적인 개발자인 내가 허용할 수 없다.
정확히 왜 그래야 하는지 한번 useEffect를 먼저 뜯어보자.

useEffect 심층 해부해보기


useEffect를 설명할 때 다들 클래스 컴포넌트의 라이프 사이클을 대체한다고 말해주는 것 같다.

예를 들어, 클래스 컴포넌트의 라이프 사이클 중 마운트, 업데이트, 언마운트를 대체할 수 있다.
첫번째 인자로 콜백 함수를 받고, 두번째 인자로 의존성 배열을 받게 된다.

  • 컴포넌트가 마운트될 때 실행: componentDidMount와 유사하게 컴포넌트가 처음으로 렌더링될 때 한 번 실행
  • 의존성 배열에 있는 값들이 변경될 때 실행: componentDidUpdate와 유사하게 특정 상태나 프롭스가 변경될 때마다 실행
  • 컴포넌트가 언마운트되기 전이나 업데이트 직전에 실행: componentWillUnmount와 유사하게 컴포넌트가 소멸되기 전에 실행

여기서 궁금한 게 useEffect는 어떻게 의존성 배열이 변경된 것을 알고 콜백 함수를 실행하는 것일까?

한가지 알아야하는 것은 함수형 컴포넌트는 리렌더링이 진행될 때 매번 함수를 새로 그리며 실행하여 렌더링을 수행한다는 것이다.

즉, useEffect가 의존성 배열이 변경된 것을 감지하는 원리는 다음과 같다.

  1. 함수형 컴포넌트가 렌더링될 때마다 useEffect 안에 있는 콜백 함수가 새로 생성
  2. 이전 렌더링에서 생성된 useEffect의 의존성 배열과 현재 렌더링에서 생성된 useEffect의 의존성 배열을 비교
  3. 만약 두 의존성 배열이 서로 다르다면, 의존성 배열에 있는 값들이 변경된 것으로 간주하고 해당 콜백 함수를 실행
import { useEffect, useState } from 'react';

function ExampleComponent() {
  const [count, setCount] = useState(0);
  const [dependency, setDependency] = useState(0);

  useEffect(() => {
    console.log('Effect 실행 !!');
  }, [dependency]);

  return (
    <div>
      <p>숫자: {count}</p>
      <p>의존성: {dependency}</p>
      <button onClick={() => setCount(count + 1)}>숫자 변경</button>
      <button onClick={() => setDependency(dependency + 1)}>의존성 변경</button>
    </div>
  );
}

export default ExampleComponent;

즉, useEffect는 데이터 바인딩, 옵저버 같은 특별한 기능을 통해 값의 변화를 관찰하는 것이 아니다. 또한 클래스 컴포넌트의 라이프 사이클도 완벽히 대체하는 것도 아니다.

단지, 리렌더링될 때마다 의존성에 있는 값을 비교하고 변경점이 있을 시 콜백 함수를 실행시키는
함수 컴포넌트에서의 부수 효과(side effects)를 수행할 수 있게 해주는 평범한 훅이라고 할 수 있다.

클래스 컴포넌트의 라이프 사이클을 완벽히 대체하는 것은 아니다.

컴포넌트가 언마운트되기 전이나 업데이트 직전에 실행: componentWillUnmount와 유사하게 컴포넌트가 소멸되기 전에 실행한다고 위에서 언급하였는데, 어떻게 하는 걸까?

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

function TimerComponent() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    // 컴포넌트가 마운트될 때 타이머 시작
    const timerId = setInterval(() => {
      setSeconds((prevSeconds) => prevSeconds + 1);
    }, 1000);

    // clean-up 함수를 반환하여 컴포넌트가 언마운트되기 전에 타이머 중지
    return () => {
      clearInterval(timerId);
      console.log('Timer stopped before unmounting');
    };
  }, []); // 빈 배열을 전달하여 컴포넌트가 마운트될 때만 실행

  return (
    <div>
      <p>{seconds}</p>
    </div>
  );
}

export default TimerComponent;

useEffect 클린업 함수는 생명주기 메서드의 언마운트 개념과 차이가 있다.
언마운트는 컴포넌트가 DOM에서 사라지는 것을 의미하는데, 클린업 함수는 언마운트보다는 함수형 컴포넌트가 리렌더링되고 의존성 배열의 변화가 생겼을 때 단지, 청소해 주는 개념으로 보는 게 더 정확하다.

그렇다면 useEffect의 콜백 함수에 왜 데이터 패칭을 하면 안 좋을까?


예를 들어 다음과 같은 코드가 있다고 해보자.

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

function ExampleComponent() {
  const [data, setData] = useState(null);
  const [id, setId] = useState(0);
 
  useEffect(() => {
    // 컴포넌트가 마운트될 때 데이터 로딩
    fetchData(id);
  }, [id]); // 빈 배열을 전달하여 컴포넌트가 마운트될 때만 실행

  const fetchData = async (id) => {
    // 데이터를 가져오는 비동기 작업 수행
    try {
      // 예시: API 호출
      const response = await fetch(`https://example.com/todos/${id}`);
      const result = await response.json();
      setData(result);
    } catch (error) {
      console.error('Error fetching data:', error);
    }
  };

  return (
    <div>
      {data ? (
        <div>
          <p>Data: {data.title}</p>
    	  <input type="button" onClick={() => setId(1)} value="id 변경" />
        </div>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
}

export default ExampleComponent;

코드를 간략하게 설명하면 다음과 같다.

  1. 마운트 시 데이터 로딩: useEffect의 첫 번째 매개변수로 전달된 함수는 컴포넌트가 마운트될 때 실행. fetchData 함수를 호출하여 비동기적으로 데이터를 가져와서 상태인 data에 값을 할당한다.

  2. 비동기 데이터 로딩: fetchData 함수는 fetch를 사용하여 예시 API에서 데이터를 가져오고, response.json()을 통해 JSON 형태로 변환한다. 성공적으로 데이터를 가져오면 setData를 사용하여 컴포넌트의 상태를 업데이트한다.

  3. 로딩 상태 표시: 데이터가 로딩 중인 동안은 "Loading..."이라는 문구가 출력된다.

    위의 코드는 왜 문제가 될까??
    왜냐하면 콜백 함수로 들어온 비동기 함수의 응답 속도에 따라 최종 결과가 이상하게 나타날 수 있기 때문이다.

예를 들어 이전 비동기 함수가 100s 이상 걸리는 와중에 id가 바꿨다고 가정해보자.
그렇다면 useEffect가 다시 실행될 거고 이 때는 데이터 패칭이 1s 걸렸다고 하자.

이러면 의도한 결과가 제대로 표시되지 않을 것이고 이를 경쟁 상태라고 한다.

경쟁 상태

경쟁 상태(Race Condition)은 보다 일반적으로 여러 프로세스나 스레드가 공유된 자원에 동시에 접근하거나 수정하려고 할 때 발생하는 상태를 나타낸다. 이는 예상치 못한 결과를 가져올 수 있는데, 여러 사용자의 입력이나 다수의 프로세스/스레드가 동시에 공유 자원에 영향을 주면서 일어나기도 한다.

여러 사용자의 입력이 결과에 영향을 주는 상황에서, 경쟁 상태는 다양한 형태로 나타날 수 있다.
예를 들어, 두 사용자가 동시에 같은 데이터를 수정하거나, 두 이벤트 핸들러가 동시에 동작하여 예상치 못한 상태를 만들어낼 수 있다.

간단한 예시로, 두 사용자가 동시에 같은 데이터를 증가시키는 상황을 살펴보자.

let sharedValue = 0;

function increaseSharedValue() {
  // 공유 변수에 1을 더함
  sharedValue += 1;
  console.log(`Current value: ${sharedValue}`);
}

// 두 사용자가 동시에 이벤트를 발생시키는 상황
setTimeout(increaseSharedValue, 1000); // 입력 1
setTimeout(increaseSharedValue, 1000); // 입력 2

위의 코드에서 increaseSharedValue 함수는 공유 변수 sharedValue에 1을 더하는데, 입력 1과 2에 의해 두번 실행된다. 이 경우, 두 이벤트 핸들러가 동시에 실행되면서 공유 변수에 대한 경쟁 상태가 발생하고, 최종 결과가 예상치 못한 값이 될 수 있다.

이를 해결하려면 적절한 동기화 매커니즘을 사용하거나 락(Lock) 등을 활용하여 경쟁 상태를 방지해 안전한 동시 접근을 보장해야 한다.

예를 들어 위의 예제를 다음과 같이 수정할 수 있다.

import { useEffect, useState } from 'react';

function ExampleComponent() {
  const [data, setData] = useState(null);
  const [id, setId] = useState(0);

  // 락을 통해 동기화를 유지할 변수
  const lock = { isLocked: false };

  useEffect(() => {
    // 컴포넌트가 마운트될 때 데이터 로딩
    fetchData(id);

    // clean-up 함수를 반환하여 컴포넌트가 언마운트되기 전에 실행
    return () => {
      // 락 해제
      lock.isLocked = false;
      console.log('클린업');
    };
  }, [id]); // id가 변경될 때마다 실행

  const fetchData = async (id) => {
    // 락이 걸려있는 경우 무시
    if (lock.isLocked) {
      console.log('락이 걸려있어서 무시');
      return;
    }

    // 락 설정
    lock.isLocked = true;

    // 데이터를 가져오는 비동기 작업 수행
    try {
      // 예시: API 호출
      const response = await fetch(`https://example.com/todos/${id}`);
      const result = await response.json();
      setData(result);
    } catch (error) {
      console.error('Error fetching data:', error);
    } finally {
      // 락 해제
      lock.isLocked = false;
    }
  };

  return (
    <div>
      {data ? (
        <div>
          <p>Data: {data.title}</p>
          <input type="button" onClick={() => setId(1)} value="id 변경" />
        </div>
      ) : (
        <p>Loading...</p>
      )}
    </div>
  );
}

export default ExampleComponent;

딱봐도 복잡하다..

만약에 react-query를 사용하면 라이브러리에서 제공해주는 다양한 기능을 사용할 수 있을 뿐만 아니라 코드가 더 줄어들어 직관적이게 변경이 가능하다.

import { useQuery } from '@tanstack/react-query';

function ExampleComponent() {
  const { data, isLoading } = useQuery(['todos', id], () =>
    fetchData(id)
  );

  const fetchData = async (id) => {
    try {
      //  API 호출
      const response = await fetch(`https://example.com/todos/${id}`);
      const result = await response.json();
      return result;
    } catch (error) {
      throw new Error(`Error fetching data: ${error}`);
    }
  };

  const handleButtonClick = () => {
    // id 변경
    // useQuery는 자동으로 캐시를 활용하여 데이터를 가져옴
    // 변경된 id에 대한 데이터가 없으면 다시 데이터를 가져옴
    setId(1);
  };

  return (
    <div>
      {isLoading ? (
        <p>Loading...</p>
      ) : (
        <div>
          <p>Data: {data?.title}</p>
          <button onClick={handleButtonClick}>id 변경</button>
        </div>
      )}
    </div>
  );
}

export default ExampleComponent;

결론


사실 어렴풋이 알고 있는 내용이긴 한데 정리하면서 리마인드 시키니깐 또 새롭게 다가온다.
이렇게 하나씩 정리해 나가야 좀 더 깊은 이해를 할 수 있지 않을까 싶다.

끝 .. !!

profile
인치
post-custom-banner

0개의 댓글