React|서버 상태와 비동기 데이터

useEffect vs TanStack Query: 왜 데이터를 useEffect로 가져오면 안 될까?

78
useEffect vs TanStack Query: 왜 데이터를 useEffect로 가져오면 안 될까?

크리스는 리액트를 처음 배울 때, 데이터를 가져오는 방법을 이렇게 배웠다.

"컴포넌트가 마운트 될 때 API를 호출하려면 useEffect를 쓰세요."

그래서 크리스의 코드는 항상 이런 식이었다.

// UserList.tsx
function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let ignore = false; // 경쟁 상태(Race Condition) 방지용 플래그

    const fetchUsers = async () => {
      setIsLoading(true);
      try {
        const response = await fetch('/api/users');
        const data = await response.json();
        if (!ignore) setUsers(data);
      } catch (err) {
        if (!ignore) setError(err as Error);
      } finally {
        if (!ignore) setIsLoading(false);
      }
    };

    fetchUsers();

    return () => { ignore = true; }; // 클린업
  }, []);

  if (isLoading) return <div>로딩 중...</div>;
  if (error) return <div>에러 발생!</div>;
  return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>;
}

작동은 한다. 하지만 모든 페이지마다 이런 코드를 복사해서 붙여넣고 있다.

더 큰 문제는 사용자가 페이지를 나갔다가 다시 들어오면 로딩 스피너가 또 빙글빙글 돈다는 점이다. 데이터는 1초 전과 똑같은데도 말이다.

이런 비효율을 해결하기 위해 등장한 것이 바로 TanStack Query (구 React Query)다. 오늘은 왜 우리가 useEffect를 버리고 서버 상태 관리 라이브러리를 써야 하는지 알아본다.

1. useEffect는 데이터 패칭 도구가 아니다

리액트 공식 문서도 인정한다. useEffect외부 시스템과 동기화를 하기 위한 도구지, 데이터를 가져오기 위한 도구가 아니다. useEffect로 데이터를 가져올 때 발생하는 치명적인 문제점들이 있다.

❌ 경쟁 상태 (Race Condition)

사용자가 'User A' 버튼을 누르고, 바로 'User B' 버튼을 눌렀다고 치자.

네트워크 상황에 따라 'User B'의 응답이 먼저 오고, 'User A'의 응답이 나중에 도착할 수 있다. 그러면 화면에는 'User B'를 선택했는데 'User A'의 정보가 덮어씌워지는 끔찍한 버그가 발생한다. (이를 막으려면 위 예시처럼 ignore 플래그나 AbortController를 써야 하는데, 코드가 매우 복잡해진다.)

❌ 캐싱의 부재 (No Caching)

useEffect는 상태를 컴포넌트 내부에(useState) 저장한다. 컴포넌트가 언마운트(Unmount) 되면 상태도 함께 사라진다.

뒤로 가기를 했다가 다시 돌아오면? 처음부터 다시 다운로드한다. 사용자의 데이터와 배터리를 낭비하는 셈이다.

2. 서버 상태(Server State) vs 클라이언트 상태(Client State)

우리는 그동안 서버에서 가져온 데이터를 useState에 넣고 내 것처럼 다뤘다. 하지만 엄밀히 말하면 그건 내 것이 아니다.

  • 클라이언트 상태: 내 브라우저가 소유한다. 동기적으로 바뀐다. (예: 다크 모드 토글, 입력 폼, 모달 열림)
  • 서버 상태: 서버가 소유한다. 내 화면에 있는 건 서버 데이터의 스냅샷(캐시)일 뿐이다. 언제든 다른 사람이 수정해서 옛날 데이터(Stale)가 될 수 있다.
  • 서버 상태는 "가져오고(Fetch)", "캐시하고(Cache)", "동기화(Sync)"하는 것이 핵심이다. 이걸 useEffect로 직접 구현하는 건 바퀴를 재발명하는 것과 같다.

    3. TanStack Query: 선언적인 데이터 관리

    TanStack Query를 도입하면 크리스의 코드는 이렇게 바뀐다.

    // UserList.tsx (Refactored)
    import { useQuery } from '@tanstack/react-query';
    
    function UserList() {
      // ✅ 3가지 상태(data, isPending, error)를 알아서 관리해준다.
      const { data: users, isPending, error } = useQuery({
        queryKey: ['users'], // 데이터의 고유 이름표
        queryFn: async () => {
          const res = await fetch('/api/users');
          return res.json();
        },
      });
    
      if (isPending) return <div>로딩 중...</div>;
      if (error) return <div>에러 발생!</div>;
      
      return <div>{users?.map((u: User) => <div key={u.id}>{u.name}</div>)}</div>;
    }

    코드가 획기적으로 줄었다. 하지만 진짜 장점은 보이지 않는 곳에 있다.

    ✨ 자동 캐싱과 중복 제거 (Deduplication)

    만약 UserList 컴포넌트와 UserSidebar 컴포넌트가 동시에 렌더링 된다면?

    useEffect 방식이라면 API 요청이 두 번 날아간다.

    하지만 useQuery는 queryKey가 ['users']로 같다는 것을 감지하고, API 요청을 딱 한 번만 보낸다. 그리고 받아온 데이터를 두 컴포넌트에 나눠준다.

    ✨ Stale-While-Revalidate

    사용자가 다른 페이지를 갔다가 다시 돌아왔다. TanStack Query는 캐시 된 데이터(Stale)를 즉시 보여줘서 로딩 스피너를 없앤다. 그리고 뒤에서 몰래(Background) 최신 데이터를 받아와서 쓱 바꿔치기한다.

    사용자는 "이 사이트 엄청 빠른데?"라고 느끼게 된다.

    핵심 정리

  • useEffect의 한계: 경쟁 상태 처리, 캐싱, 중복 요청 제거, 에러 재시도 등을 직접 구현해야 한다. 코드가 복잡해지고 버그가 생기기 쉽다.
  • 서버 상태의 특성: 서버 데이터는 내가 소유한 게 아니라 빌려온 캐시다. 관점의 전환이 필요하다.
  • TanStack Query의 이점:
  • 이제 TanStack Query를 써야 하는 이유는 알았다. 그런데 언제 다시 요청을 보내야 할까? 어떤 데이터는 5초 만에 상하고, 어떤 데이터는 1시간 동안 신선하다.

    이 유효기간을 설정하는 핵심 옵션인 staleTime과 gcTime의 차이를 명확히 구분하는 것이 리액트 쿼리 마스터의 첫걸음이다.

    "TanStack Query의 라이프사이클: staleTime과 gcTime의 차이점" 편에서 계속된다.


    🔗 참고 링크

  • TanStack Query - Why React Query?
  • React Docs - You Might Not Need an Effect