🎯 학습 목표
- 훅의 두 가지 규칙을 이해하고 위반 시 발생하는 오류를 안다
- 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]처럼 배열로 계층적으로 설계하면 관련 캐시를 일괄 무효화할 수 있습니다.