architecture
데이터 페칭 패턴: fetch, Axios, React Query, SWR 완전 비교
데이터 페칭은 모든 프론트엔드 애플리케이션의 핵심입니다. 7년차라면 단순히 라이브러리 사용법이 아니라, 서버 상태와 클라이언트 상태의 개념적 차이, 캐싱 전략, 낙관적 업데이트의 동작 원리를 설명할 수 있어야 합니다. "왜 useEffect 안에서 fetch를 쓰면 안 되는가"는 면접 단골 질문입니다.
학습 개요
탄생 배경
해결하려 했던 문제
초기 React 애플리케이션에서는 useEffect 안에서 직접 fetch를 호출했습니다. 이 방식은 로딩/에러 상태 관리, 중복 요청 방지, 캐싱, 리페치, 경쟁 조건(Race Condition) 처리를 모두 개발자가 직접 구현해야 했습니다. 서버에서 가져오는 데이터(서버 상태)는 클라이언트 상태와 근본적으로 다른 특성을 가지는데, 이를 동일하게 useState/useReducer로 관리하는 것 자체가 문제였습니다.
역사적 맥락
2015년 fetch API 등장 → Axios로 편의성 개선 → 2019년 SWR 등장(Vercel, 서버 상태 개념 도입) → 2020년 React Query(현 TanStack Query) 등장 → 2022년 TanStack Query v5로 gcTime 개념 정립 → 2023년 Next.js App Router에서 fetch 캐싱을 프레임워크 레벨로 통합
이전에는 어떻게 했나
이전에는 Redux + redux-thunk/redux-saga로 비동기 데이터를 전역 상태로 관리했습니다. API 응답을 Redux store에 저장하고, 로딩/에러 상태도 모두 Redux로 관리했습니다. 이는 서버 상태를 클라이언트 상태로 취급하는 근본적인 오류였고, 엄청난 보일러플레이트를 만들었습니다.
멘탈 모델
동작 원리
[fetch API 동작 방식] fetch는 브라우저의 Web API로, XMLHttpRequest의 현대적 대체재입니다. Promise 기반으로 동작하며, Response 스트림을 반환합니다. 중요: fetch는 4xx, 5xx 응답도 reject하지 않습니다. 네트워크 오류만 reject됩니다. 응답 body는 ReadableStream이며, .json()/.text()로 파싱하면 새 Promise를 반환합니다. [Axios가 fetch 위에서 제공하는 것] - 자동 JSON 파싱 (fetch는 수동으로 .json() 호출 필요) - HTTP 에러 자동 throw (4xx, 5xx를 reject로 처리) - 요청/응답 인터셉터 - 요청 취소 (CancelToken → AbortController) - 자동 요청 타임아웃 - XSRF 보호 헤더 자동 설정 - Node.js 환경 지원 [React Query 캐싱 동작 원리] React Query는 QueryKey를 기준으로 인메모리 캐시를 관리합니다. staleTime: 데이터가 "신선(fresh)"한 상태로 유지되는 시간. → staleTime 이내에는 캐시를 그대로 반환하고 네트워크 요청 없음. → staleTime이 지나면 "stale" 상태가 되어 다음 기회에 백그라운드 리페치. gcTime(구 cacheTime): 쿼리가 사용되지 않을 때 캐시를 메모리에 유지하는 시간. → 컴포넌트가 언마운트되면 즉시 삭제가 아니라 gcTime 후 가비지 컬렉션. → 같은 쿼리 키로 다시 마운트 시 gcTime 이내면 캐시 데이터를 먼저 보여주고 리페치. Background Refetch: stale 상태의 쿼리가 있을 때 다음 트리거 시 자동 실행. 트리거: 컴포넌트 마운트, 윈도우 포커스, 네트워크 재연결, refetchInterval. Window Focus Refetch: 사용자가 탭을 전환했다가 돌아오면 자동으로 최신 데이터를 가져옵니다. 실무에서 가장 유용한 기능 중 하나입니다. [Optimistic Update 동작 방식] 1. 뮤테이션 실행 전, 예상 결과로 캐시를 즉시 업데이트 (UI 즉각 반영) 2. 실제 API 요청 전송 3. 성공 시: 서버 응답 데이터로 캐시를 다시 정확히 업데이트 4. 실패 시: onError 콜백에서 이전 캐시 상태로 롤백 [useEffect 안에서 fetch를 쓰면 안 되는 이유] 1. Race Condition: 빠르게 파라미터가 바뀔 때 이전 요청의 응답이 나중에 도착해 UI를 덮어씀 2. 중복 요청: StrictMode에서 useEffect가 두 번 실행되어 요청 중복 발생 3. 캐싱 없음: 같은 데이터를 매번 새로 요청 4. 로딩/에러 상태 관리 복잡성 5. 서버 사이드에서 실행 불가 (useEffect는 클라이언트 전용)
핵심 구성 요소
QueryKey
React Query 캐시의 식별자. 배열 형태로 의존성을 표현 (URL + 파라미터)
staleTime
데이터가 신선한 상태로 유지되는 시간. 기본값 0 (즉시 stale)
gcTime
미사용 캐시가 메모리에 유지되는 시간. 기본값 5분
QueryClient
전역 캐시 저장소이자 React Query의 핵심 인스턴스
Mutation
데이터를 변경하는 작업(POST/PUT/DELETE). Optimistic Update 지원
Invalidation
뮤테이션 성공 후 관련 쿼리를 stale 처리하여 리페치 트리거
흐름 설명
[React Query 기본 데이터 페칭 흐름]
1. useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) 호출
2. QueryClient에서 ['todos'] 키로 캐시 조회
3. 캐시 없음 → 즉시 queryFn 실행, isLoading: true
4. API 응답 → 캐시에 저장, isLoading: false, data: 응답값
5. staleTime이 지남 → status: stale
6. 윈도우 포커스 이벤트 → 백그라운드 리페치 시작 (isRefetching: true)
7. 새 데이터 도착 → 캐시 업데이트, UI 자동 업데이트
[Optimistic Update 흐름]
1. 사용자: "좋아요" 버튼 클릭
2. onMutate: 현재 캐시를 저장(snapshot) + 즉시 좋아요 수 +1로 캐시 업데이트
3. UI: 즉시 좋아요 반영 (서버 응답 대기 없음)
4. API 요청: POST /api/likes
5a. 성공: onSuccess에서 캐시 무효화 → 서버 데이터로 동기화
5b. 실패: onError에서 snapshot으로 롤백 → 원래 상태 복원
코드 예제
// 잘못된 패턴: useEffect 안에서 fetch (Race Condition 발생 가능)
function BadComponent({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
// userId가 빠르게 바뀌면 이전 요청의 응답이 나중에 도착할 수 있음!
}, [userId]);
}
// 올바른 패턴: React Query 사용
function GoodComponent({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId], // userId가 바뀌면 자동으로 새 요청
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000, // 5분간 캐시 신선 유지
});
}
// Optimistic Update 패턴
function LikeButton({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (postId: string) => fetch(`/api/posts/${postId}/like`, { method: 'POST' }),
onMutate: async (postId) => {
// 진행 중인 리페치 취소 (Optimistic Update 덮어쓰기 방지)
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// 현재 캐시 스냅샷 저장 (롤백용)
const previousPost = queryClient.getQueryData(['post', postId]);
// 즉시 낙관적 업데이트
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
likes: old.likes + 1,
}));
return { previousPost }; // context로 전달
},
onError: (err, postId, context) => {
// 실패 시 롤백
queryClient.setQueryData(['post', postId], context?.previousPost);
},
onSettled: (postId) => {
// 성공/실패 모두 서버와 동기화
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
}
// staleTime vs gcTime 차이 이해
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분: 이 시간 동안은 캐시를 그대로 사용
gcTime: 5 * 60 * 1000, // 5분: 컴포넌트 언마운트 후 5분간 캐시 유지
},
},
});
비교 분석
데이터
vsSWR
데이터
vsfetch API (직접 사용)
트레이드오프
이상적인 사용 사례