중급

React 강좌 07강 — 커스텀 훅 + API 통신: useFetch 만들기

🎯 학습 목표

  • 훅의 두 가지 규칙을 이해하고 위반 시 발생하는 오류를 안다
  • AbortController로 fetch를 취소하는 패턴을 구현할 수 있다
  • loading/error/data 세 가지 상태를 관리하는 useFetch 커스텀 훅을 완성할 수 있다
  • TanStack Query(React Query)가 무엇인지, 왜 실무에서 useFetch 대신 쓰는지 이해한다
  • 커스텀 훅 설계 원칙을 안다

📖 핵심 개념 1 — 훅의 두 가지 규칙

React 훅은 반드시 지켜야 할 두 가지 규칙이 있습니다. 위반하면 런타임 오류가 발생합니다.

규칙 1: 최상위에서만 호출

// ❌ 조건문 안에서 훅 호출 — 금지
function BadComponent({ isLoggedIn }) {
  if (isLoggedIn) {
    const [user, setUser] = useState(null); // 훅 호출 순서가 조건에 따라 달라짐
  }
  // 에러: React Hook "useState" is called conditionally.
}

// ❌ 반복문 안에서 훅 호출 — 금지
function BadList({ items }) {
  return items.map(item => {
    const [selected, setSelected] = useState(false); // 반복문 안 — 금지
    return <li key={item.id}>{item.name}</li>;
  });
}

// ✅ 올바른 방법 — 항상 컴포넌트 최상위에서 호출
function GoodComponent({ isLoggedIn }) {
  const [user, setUser] = useState(null); // 항상 실행

  // 조건은 훅 호출 이후 로직에서 처리
  useEffect(() => {
    if (isLoggedIn) {
      fetchUser().then(setUser);
    }
  }, [isLoggedIn]);

  return <div>{user?.name}</div>;
}

React는 훅 호출 순서로 각 훅의 상태를 추적합니다. 조건에 따라 순서가 바뀌면 상태가 엉킵니다.

규칙 2: React 함수에서만 호출

// ❌ 일반 JavaScript 함수 안에서 훅 호출 — 금지
function regularFunction() {
  const [count, setCount] = useState(0); // 에러
}

// ❌ 클래스 컴포넌트 — 금지
class MyClass extends React.Component {
  render() {
    const [x] = useState(0); // 에러
  }
}

// ✅ 함수형 컴포넌트에서만
function MyComponent() {
  const [count] = useState(0); // OK
  return <div>{count}</div>;
}

// ✅ 커스텀 훅(use로 시작하는 함수)에서도 호출 가능
function useMyHook() {
  const [data, setData] = useState(null); // OK — 커스텀 훅
  return data;
}

📖 핵심 개념 2 — useFetch 커스텀 훅 완성 구현

// hooks/useFetch.js
import { useState, useEffect } from 'react';

function useFetch(url) {
  // 세 가지 상태: 로딩 중 / 에러 / 데이터
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // url이 없으면 실행하지 않음
    if (!url) {
      setLoading(false);
      return;
    }

    // AbortController로 fetch 취소 준비
    const controller = new AbortController();

    async function fetchData() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP 에러: ${response.status} ${response.statusText}`);
        }

        const json = await response.json();
        setData(json);
      } catch (err) {
        if (err.name === 'AbortError') {
          // 컴포넌트 언마운트 또는 URL 변경으로 인한 취소 — 무시
          return;
        }
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }

    fetchData();

    // cleanup: URL 변경 또는 언마운트 시 이전 fetch 취소
    return () => controller.abort();
  }, [url]); // url이 바뀔 때마다 새로 fetch

  return { data, loading, error };
}

export default useFetch;
// useFetch 사용 예시
import useFetch from './hooks/useFetch';

function UserList() {
  const { data: users, loading, error } = useFetch('https://jsonplaceholder.typicode.com/users');

  if (loading) return <p>로딩 중...</p>;
  if (error) return <p>에러: {error}</p>;
  if (!users?.length) return <p>사용자가 없습니다.</p>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name} ({user.email})</li>
      ))}
    </ul>
  );
}

📖 핵심 개념 3 — TanStack Query(React Query) 소개

위의 useFetch는 간단한 케이스에는 충분하지만, 실무에서는 다음과 같은 요구사항이 생깁니다.

  • 동일 URL을 여러 컴포넌트에서 호출할 때 중복 요청 방지
  • 데이터 캐싱 및 백그라운드 갱신
  • 네트워크 에러 시 자동 재시도
  • 페이지 포커스 시 자동 갱신
  • 낙관적 업데이트(Optimistic Update)
  • 무한 스크롤 페이지네이션

이 모든 것을 직접 구현하면 수백 줄이 됩니다. TanStack Query는 이 문제를 해결하는 서버 상태 관리 라이브러리입니다.

// 설치
// npm install @tanstack/react-query

// main.jsx — QueryClient 설정
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5분간 데이터를 신선하다고 간주
      retry: 2,                  // 에러 시 2번 재시도
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserList />
    </QueryClientProvider>
  );
}
// useQuery로 데이터 페칭 — useFetch보다 훨씬 강력
import { useQuery } from '@tanstack/react-query';

async function fetchUsers() {
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  if (!res.ok) throw new Error('사용자 목록을 불러오지 못했습니다');
  return res.json();
}

function UserListWithQuery() {
  const {
    data: users,
    isLoading,
    isError,
    error,
    refetch,           // 수동으로 다시 가져오기
    isFetching,        // 백그라운드 갱신 중
  } = useQuery({
    queryKey: ['users'],         // 캐시 키
    queryFn: fetchUsers,         // 데이터 페칭 함수
    staleTime: 1000 * 60,        // 1분간 캐시 유지
  });

  if (isLoading) return <p>로딩 중...</p>;
  if (isError) return <p>에러: {error.message}</p>;

  return (
    <div>
      {isFetching && <span>백그라운드 갱신 중...</span>}
      <button onClick={refetch}>새로고침</button>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

패널에서 “useFetch는 학습 목적으로 구현해보는 것에 의미가 있지만, 실제 프로젝트에서는 첫 날부터 TanStack Query를 도입하라”고 권고했습니다. 서버 상태 관리의 복잡성을 혼자 해결하려 하지 마세요.

💻 코드 예제 — 파라미터가 있는 커스텀 훅

// hooks/useLocalStorage.js — 로컬 스토리지와 동기화되는 state
import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    // 초기화 시 로컬 스토리지에서 읽기 (함수형 초기화 — 최초 1회만 실행)
    try {
      const stored = localStorage.getItem(key);
      return stored !== null ? JSON.parse(stored) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // value가 변경될 때마다 로컬 스토리지 동기화
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      console.warn('localStorage에 저장 실패');
    }
  }, [key, value]);

  return [value, setValue];
}

// 사용 예시
function ThemeToggle() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');

  return (
    <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
      현재 테마: {theme}
    </button>
  );
}

⚠️ 흔한 실수 (よくあるミス)

  • 커스텀 훅 이름이 use로 시작하지 않음: fetchData()처럼 use로 시작하지 않으면 ESLint가 훅 규칙을 검사하지 않아 버그를 놓칩니다. 반드시 useFetchData()처럼 use로 시작하세요.
  • 훅에서 너무 많은 일을 함: 하나의 커스텀 훅은 하나의 관심사만 다뤄야 합니다. useUserDashboard처럼 여러 API를 모두 호출하는 훅은 useUser, useUserStats로 분리하세요.
  • AbortController 없이 fetch: 사용자가 페이지를 빠르게 이동하거나 검색어를 빠르게 바꾸면 이전 요청 결과가 나중에 도착해 UI가 덮어써집니다. 반드시 AbortController를 사용하세요.
  • useFetch를 실무 프로젝트에 직접 사용: 학습용으로는 훌륭하지만, 실무에서는 TanStack Query를 사용하세요.

💡 실무 팁

  • 커스텀 훅 폴더 구조: src/hooks/ 폴더에 모든 커스텀 훅을 모아두세요. 파일명은 훅 이름과 동일하게(useFetch.js).
  • TanStack Query DevTools: @tanstack/react-query-devtools를 설치하면 캐시 상태, 요청 상태를 시각적으로 확인할 수 있습니다. 개발 중 필수 도구입니다.
  • useMutation: TanStack Query의 useMutation은 POST/PUT/DELETE 요청을 위한 훅입니다. 낙관적 업데이트와 에러 롤백도 간단하게 구현할 수 있습니다.
  • queryKey 설계: ['users', userId]처럼 배열로 계층적으로 설계하면 관련 캐시를 일괄 무효화할 수 있습니다.