Skip to main content
Advertisement

데이터 페칭 — 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 QuerySWR
번들 크기~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>
Advertisement