데이터 페칭 — useEffect 패턴, TanStack Query, SWR
useEffect + fetch 기본 패턴
가장 기본적인 데이터 페칭 방식입니다. 하지만 직접 구현하면 많은 문제가 생깁니다.
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUsers() {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/users');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!cancelled) setUsers(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUsers();
return () => { cancelled = true; };
}, []);
if (loading) return <p>로딩 중...</p>;
if (error) return <p>오류: {error}</p>;
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
useEffect 데이터 페칭의 문제점
| 문제 | 설명 |
|---|---|
| 레이스 컨디션 | 여러 요청이 겹칠 때 순서 보장 어려움 |
| 캐싱 없음 | 같은 URL을 여러 컴포넌트에서 호출 시 중복 요청 |
| 리페치 어려움 | 데이터 갱신 로직을 직접 구현해야 함 |
| 로딩/에러 관리 | 모든 컴포넌트에서 반복 구현 |
| SSR 비호환 | 서버 렌더링 시 복잡한 처리 필요 |
TanStack Query (React Query)
서버 상태(Server State) 관리의 사실상 표준 라이브러리입니다.
설치 및 설정
npm install @tanstack/react-query
// main.jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5분간 fresh 상태 유지
retry: 3, // 실패 시 3번 재시도
},
},
});
createRoot(document.getElementById('root')).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
);
useQuery
import { useQuery } from '@tanstack/react-query';
async function fetchUsers() {
const res = await fetch('/api/users');
if (!res.ok) throw new Error('사용자 목록을 불러올 수 없습니다.');
return res.json();
}
function UserList() {
const {
data: users,
isLoading,
isError,
error,
isFetching, // 백그라운드 재요청 중
refetch, // 수동 리페치
} = useQuery({
queryKey: ['users'], // 캐시 키
queryFn: fetchUsers,
staleTime: 1000 * 60 * 5, // 5분
gcTime: 1000 * 60 * 10, // 10분 후 캐시 삭제 (구 cacheTime)
});
if (isLoading) return <p>로딩 중...</p>;
if (isError) return <p>오류: {error.message}</p>;
return (
<div>
{isFetching && <span>업데이트 중...</span>}
<ul>
{users.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
<button onClick={() => refetch()}>새로고침</button>
</div>
);
}
동적 쿼리 (queryKey 의존성)
function UserDetail({ userId }) {
const { data: user, isLoading } = useQuery({
queryKey: ['users', userId], // userId가 바뀌면 새 요청
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
enabled: !!userId, // userId가 있을 때만 실행
});
if (isLoading) return <p>로딩 중...</p>;
return <h1>{user?.name}</h1>;
}
useMutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const createUser = useMutation({
mutationFn: async (userData) => {
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData),
});
if (!res.ok) throw new Error('사용자 생성 실패');
return res.json();
},
onSuccess: (newUser) => {
// 캐시 무효화 → 자동 리페치
queryClient.invalidateQueries({ queryKey: ['users'] });
// 또는 캐시에 직접 추가 (리페치 없이)
queryClient.setQueryData(['users'], (old) => [...(old ?? []), newUser]);
},
onError: (error) => {
alert(`오류: ${error.message}`);
},
});
const [name, setName] = useState('');
function handleSubmit(e) {
e.preventDefault();
createUser.mutate({ name, email: `${name}@example.com` });
}
return (
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} />
<button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? '생성 중...' : '사용자 추가'}
</button>
</form>
);
}
낙관적 업데이트 (Optimistic Update)
const toggleTodo = useMutation({
mutationFn: (todo) =>
fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
body: JSON.stringify({ done: !todo.done }),
}),
onMutate: async (todo) => {
// 진행 중인 리페치 취소
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 이전 데이터 스냅샷
const previousTodos = queryClient.getQueryData(['todos']);
// 낙관적으로 캐시 업데이트
queryClient.setQueryData(['todos'], (old) =>
old.map(t => t.id === todo.id ? { ...t, done: !t.done } : t)
);
return { previousTodos };
},
onError: (err, todo, context) => {
// 에러 시 이전 데이터로 복원
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
// 성공/실패 무관하게 최신 데이터 동기화
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
SWR
Vercel이 개발한 데이터 페칭 라이브러리로, "Stale-While-Revalidate" 전략을 사용합니다.
설치
npm install swr
기본 사용법
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then(r => r.json());
function UserProfile({ userId }) {
const { data: user, error, isLoading, mutate } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher,
{
revalidateOnFocus: true, // 탭 포커스 시 재검증
revalidateOnReconnect: true, // 네트워크 재연결 시 재검증
refreshInterval: 30000, // 30초마다 자동 갱신
}
);
if (isLoading) return <p>로딩 중...</p>;
if (error) return <p>오류 발생</p>;
return (
<div>
<h1>{user.name}</h1>
<button onClick={() => mutate()}>새로고침</button>
</div>
);
}
뮤테이션 (SWR)
import useSWRMutation from 'swr/mutation';
async function createUser(url, { arg }) {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(arg),
});
return res.json();
}
function NewUserForm() {
const { trigger, isMutating } = useSWRMutation('/api/users', createUser);
async function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
await trigger({ name: formData.get('name') });
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<button disabled={isMutating}>
{isMutating ? '처리 중...' : '추가'}
</button>
</form>
);
}
TanStack Query vs SWR 비교
| 항목 | TanStack Query | SWR |
|---|---|---|
| 번들 크기 | ~50KB | ~15KB |
| mutation 지원 | ✅ 완전한 useMutation | ⚠️ useSWRMutation (제한적) |
| 낙관적 업데이트 | ✅ 내장 지원 | ⚠️ 수동 구현 필요 |
| 무한 스크롤 | ✅ useInfiniteQuery | ✅ useSWRInfinite |
| DevTools | ✅ 강력한 UI | ❌ 없음 |
| 쿼리 취소 | ✅ | ❌ |
| 학습 곡선 | 중간 | 낮음 |
| 적합한 경우 | 복잡한 서버 상태 | 간단한 READ 중심 |
실전 예제: 무한 스크롤
import { useInfiniteQuery } from '@tanstack/react-query';
import { useRef, useEffect } from 'react';
async function fetchPosts({ pageParam = 1 }) {
const res = await fetch(`/api/posts?page=${pageParam}&limit=10`);
const data = await res.json();
return { posts: data.posts, nextPage: data.hasMore ? pageParam + 1 : undefined };
}
function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextPage,
initialPageParam: 1,
});
const loadMoreRef = useRef(null);
// IntersectionObserver로 무한 스크롤 구현
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage]);
if (isLoading) return <p>로딩 중...</p>;
const posts = data.pages.flatMap(page => page.posts);
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
<div ref={loadMoreRef}>
{isFetchingNextPage ? '더 불러오는 중...' : ''}
{!hasNextPage ? '모든 게시물을 불러왔습니다.' : ''}
</div>
</div>
);
}
고수 팁
1. queryKey를 배열로 구조화
// ✅ 계층적 queryKey 구조
queryKey: ['users'] // 사용자 목록
queryKey: ['users', userId] // 특정 사용자
queryKey: ['users', userId, 'posts'] // 특정 사용자의 게시물
queryKey: ['users', { active: true }] // 필터 조건 포함
2. Custom Query Hook 패턴
// 재사용 가능한 커스텀 훅으로 추출
function useUsers(options = {}) {
return useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(r => r.json()),
...options,
});
}
// 사용
const { data: users } = useUsers({ staleTime: Infinity });
3. 에러 경계와 Suspense 활용
function UserList() {
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
throwOnError: true, // Suspense + ErrorBoundary 지원
suspense: true,
});
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// 사용
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Skeleton />}>
<UserList />
</Suspense>
</ErrorBoundary>