효율적인 데이터 패칭과 비동기 상태 관리
SWR(Stale-While-Revalidate)
HTTP 캐싱 표준의 하나로, 서버가 응답 헤더를 통해 브라우저 캐시나 프록시 서버가 데이터를 먼저 캐시에서 제공하도록 하고, 백그라운드에서 최신 데이터를 요청하여 자동으로 갱신하는 방식.
SWR HTTP 설정 방식
HTTP에서 SWR을 설정할 때는 Cache-Control 헤더에 stale-while-revalidate 지시어를 추가하여 사용함.
예시 설정.
Cache-Control: max-age=60, stale-while-revalidate=120
이 설정은:
-
max-age=60: 데이터가 캐시에 저장된 후 60초 동안 신선한 상태를 유지. -
stale-while-revalidate=120: 60초 이후에도 120초 동안 캐시 데이터를 사용할 수 있으며, 동시에 백그라운드에서 최신 데이터를 요청.
HTTP 서버 설정 예시 (Nginx)
Nginx에서 SWR을 설정하려면 Cache-Control 헤더를 다음과 같이 추가.
location /api {
add_header Cache-Control "max-age=60, stale-while-revalidate=120";
}
이 설정으로 /api 경로에 대한 데이터는 60초 동안 신선하게 유지되며,
이후 120초 동안 캐시 데이터를 제공하고 백그라운드에서 갱신 요청.
SWR의 작동 단계 (React 등 프레임워크에서의 활용)
프론트엔드 라이브러리에서 SWR 패턴을 구현할 때는 유효 시간(staleTime)과 캐시 유지 시간(cacheTime)을 설정하여 캐시 데이터를 최적화하고 필요할 때만 서버로 요청을 보냄.
아래와 같은 단계로 작동:
-
0~
staleTime동안: 캐시된 데이터를 바로 표시해 사용자가 빠르게 볼 수 있도록 함. -
staleTime이후cacheTime내: 캐시된 데이터를 유지하면서 백그라운드에서 새로운 데이터 요청으로 최신 상태로 갱신. -
cacheTime이후: 캐시가 만료되며 새 데이터를 요청해loading상태를 표시함.
SWR 구현 예시 (React Query 사용)
Next.js의 useSWR, React Query 등이 SWR 개념을 활용 중.
const { data, isLoading } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 유효 시간 (1분)
cacheTime: 1000 * 60 * 5, // 캐시 유지 시간 (5분)
});
data fetching과 서버 상태관리
(feat. React Query)
1. 컴포넌트 간 상태 공유와 자동 업데이트
일반적인 방법
-
props 로 전달.
-
Context API 활용.
-
Redux 등 외부 상태관리 라이브러리.
React Query
-
React Query는 **
queryKey**를 기반으로 데이터를 캐싱. -
동일한
queryKey를 사용하는 컴포넌트들끼리는 상태를 자동으로 공유하고 최신 상태로 동기화. -
useQuery는 구독을 의미. 처음 실행할때는 옵저버 생성하는 개념.
-
const { data, error, isLoading, isFetching, refetch } = useQuery( ['todos'], fetchTodos );-
옵저버는 쿼리 객체를 구독하고, 쿼리의 상태 변화를 감시.
컴포넌트는 옵저버를 구독하여 쿼리의 상태 변화를 반영하고, 필요할 때 재렌더링.
-
-
queryKey 배열의 두번째 요소는 해당 데이터의 의존성.
-
이값이 변경되면 queryFn이 다시 실행되어 리패칭 함.
-
useQuery({ queryKey: ['todos', state], queryFn: () => fetchTodos(state), }); -
useQuery를 한 결과를 다시 클라상태(useState)로 보관할 필요 없음. (업데이트 즉시 안될 수 있음)
-
예시)
import { useQuery, useMutation, useQueryClient } from 'react-query'; //구독하는 TodoList function TodoList() { const { data: todos } = useQuery(['todos'], fetchTodos); return ( <ul> {todos?.map((todo) => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ); } //TodoApp function TodoApp() { const queryClient = useQueryClient(); const mutation = useMutation(addTodo, { onSuccess: () => { queryClient.invalidateQueries(['todos']); // 'todos' 키를 가진 쿼리들 무효화 }, }); const handleAddTodo = () => { mutation.mutate({ title: 'New Todo' }); }; return <button onClick={handleAddTodo}>Add Todo</button>; }
2. 캐싱(Caching)
서버에 반복적으로 데이터를 요청하면 네트워크 리소스가 불필요하게 소비되고, 성능이 저하될 수 있음.
동일한 데이터가 자주 필요한 경우, 캐싱을 사용하면 성능을 개선하고 네트워크 비용을 절약할 수 있음.
실제로 대부분의 성능개선은 캐싱기법.
fetching 결과 캐시하기 🤔
-
리액트의 데이터 통신 결과를 어떻게 캐시할 수 있지?
-
캐싱을 구현하기 위해
useEffect와useState를 조합하여 데이터를 가져온 후 상태로 저장함.
const cache = {};
function useCachedData(key, fetchFunction) {
const [data, setData] = useState(cache[key] || null);
const [loading, setLoading] = useState(!cache[key]);
useEffect(() => {
if (!cache[key]) {
setLoading(true);
fetchFunction()
.then(result => {
cache[key] = result;
setData(result);
})
.finally(() => setLoading(false));
}
}, [key, fetchFunction]);
return { data, loading };
}
-
컴포넌트의 언마운트 시 데이터를 캐시에 저장하고, 컴포넌트가 다시 마운트되면 캐시에서 데이터를 가져오도록 코드로 관리.
-
timestamp 를 추가해서 시간값을 활용해 캐시타임을 설정도 가능.
React Query 예시
React Query는 queryKey를 기반으로 데이터를 자동으로 캐싱하고, staleTime 옵션을 통해 캐시 유효 시간을 설정 관리.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 1000 * 60, // 1분 동안 데이터를 fresh 상태로 유지
cacheTime: 1000 * 60 * 5, // 5분 동안 캐시 유지
});
3. 비동기 상태 관리(Async State Management)
비동기 요청에서는 로딩 중, 성공, 실패 등의 상태를 수동으로 관리해야 함.
번거롭고 코드가 길어질 수 있으나, seamless 한 UX측면에서 중요함.
React에서 일반적으로 구현하는 방법
데이터를 요청할 때 useState로 로딩 및 오류 상태를 관리하고, 요청 성공 시 데이터를 업데이트함.
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/data')
.then((res) => res.json())
.then((result) => setData(result))
.catch((err) => setError(err))
.finally(() => setLoading(false));
}, []);
useFetch 와 같은 커스텀 훅으로 개발해서 재사용.
React Query 예시
const { data, isLoading, isError, error } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
useActionState (React 19 API)
useActionState 폼처리를 용이하게 하면서, 비동기 상태를 쉽게 지원.
function ChangeName({ name, setName }) {
const [error, submitAction, isPending] = useActionState(
updateNameAction,
null
);
...
......
}
4. 에러 핸들링(Error Handling)
비동기 처리과정에서 에러핸들링은 필수이나, 컴포넌트를 복잡하게 만들 수도 있음.
React에서 일반적으로 구현하는 방법
useState로 에러 상태를 설정.
const [error, setError] = useState(null);
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.catch((err) => setError(err));
}, []);
if (error) {
return <p>Error: {error.message}</p>;
}
ErrorBoundary ? 🤔
일반적으로 라이브러리 표준처럼 사용.
import { ErrorBoundary } from 'react-error-boundary';
//에러핸들링
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>에러가 발생했군요:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>재시도?</button>
</div>
);
}
//App 컴포넌트
function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => {
// 필요시 에러 후 초기화 로직 작성 가능
}}
>
<DataComponent />
</ErrorBoundary>
);
}
//View 컴포넌트.
import { useState, useEffect } from 'react';
function DataComponent() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('/api/data');
if (!response.ok) throw new Error('Failed to fetch data');
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
}
};
fetchData();
}, []);
if (error) {
throw error; // ErrorBoundary로 전달
}
return <div>Data: {data}</div>;
}
- ErrorBoundary 를 사용 했을 때 장점은??
React Query 예시
error 필드와 onError 옵션.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
onError: (error) => {
console.error("Query error:", error);
},
});
참고. React Query 에서 ErrorBoundary 연동 예
const { data } = useQuery(['todos'], fetchTodos, {
suspense: true,
throwOnError: true, // 에러 발생 시 ErrorBoundary로 전달
});
...
// App 컴포넌트에서 ErrorBoundary 적용
function App() {
return (
<QueryClientProvider client={new QueryClient()}>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<React.Suspense fallback={<p>Loading...</p>}>
<Todos />
</React.Suspense>
</ErrorBoundary>
</QueryClientProvider>
);
}
5. 자동 리페칭(Auto Refetching)
-
최신 데이터가 아니면 사용자 경험에 문제가 발생할 수 있으므로 특정 조건에서 자동으로 데이터를 리페칭이 필요한 경우가 있음.
-
뉴스사이트의 새로운 뉴스, 인스타그램의 피드내용…등
-
그밖에 네트워크 상태나 브라우저 focus 변화로 인해 데이터를 갱신할 필요도 있음.
React에서 일반적으로 구현하는 방법 🤔
포커스가 변경되거나 네트워크가 재연결될 때(?)마다 데이터를 수동으로 갱신하도록 설정.
const [data, setData] = useState(null);
function fetchData() {
fetch('/api/data')
.then(response => response.json())
.then(data => setData(data));
}
useEffect(() => {
fetchData();
window.addEventListener('focus', fetchData);
window.addEventListener('online', fetchData);
return () => {
window.removeEventListener('focus', fetchData);
window.removeEventListener('online', fetchData);
};
}, []);
React Query 예시
refetchOnWindowFocus와 refetchOnReconnect 옵션 제공.
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
refetchOnWindowFocus: true,
retry: 3, // 요청 실패 시 최대 3번 재시도
refetchOnReconnect: true, // 네트워크 재연결 시 리페칭
});
6. lazy loading
대량의 데이터를 한꺼번에 가져오는 것은 성능을 저하시킬 수 있음.
페이징 및 무한 스크롤 등과 같은 UX를 통해 필요한 데이터만 부분적으로 불러오는 것이 중요함.
React에서 일반적으로 구현하는 방법 🤔
현재 페이지 상태를 관리하고, 페이지가 스크롤이나 페이징에 따라 변경되면 새로운 데이터를 가져와 이전 데이터에 추가.
예)
.....
const observerRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
// element 보일때 페이지 증가
if (entries[0].isIntersecting) {
setPage((prev) => prev + 1);
}
});
observer.observe(observerRef.current);
return () => observer.disconnect();
}, []);
....
관련 추가 키워드.
-
IntersectionObserver
-
Throttling (스크롤이벤트 제어)
React Query 예시
useInfiniteQuery 훅 제공.
fetchNextPage를 호출하여 데이터를 추가로 호출.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['todos'],
queryFn: ({ pageParam = 1 }) => fetchTodos(pageParam),
getNextPageParam: (lastPage) => lastPage.nextPage,
});
7. 서버 상태 업데이트와 동기화
클라이언트에서 데이터를 추가/삭제 등 수정할 경우, 서버와의 동기화 필요.
React에서 일반적으로 구현하는 방법
데이터 변경 후 수동으로 서버 상태를 업데이트하기 위해 새로고침을 하거나, 컴포넌트 내에서 추가적인 로직을 작성.
function addTodo() {
fetch('/api/addTodo', { method: 'POST', body: JSON.stringify(newTodo) })
.then(() => {
// 데이터가 갱신되었으므로 수동으로 새 데이터를 요청
fetchTodos();
});
}
React Query 예시
React Query는 invalidateQueries를 통해 특정 쿼리를 무효화하여 서버 상태와 동기화.
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries(['todos']);
},
});
8. Prefetching
사용자가 데이터를 필요로 하기 전에 미리 불러와 네트워크 대기 시간을 줄일 수 있음.
사용자가 페이지를 전환할 때 데이터를 사전에 가져와 빠르게 화면을 렌더링할 수 있음.
React에서 일반적으로 구현하는 방법
페이지 이동이나 특정 이벤트 발생 시 데이터를 미리 요청하고, 요청이 완료되면 캐시에 저장하여 다음 사용 시 이를 활용함.
const [cachedData, setCachedData] = useState(null);
useEffect(() => {
// 페이지 로드 시 데이터를 미리 가져와 캐시에 저장
fetch('/api/data')
.then((res) => res.json())
.then((result) => setCachedData(result));
}, []);
...
const getData = async () => {
// 캐시에 데이터가 있는지 확인
if (cachedData) {
return cachedData;
} else {
const response = await fetch('/api/data');
const data = await response.json();
setCachedData(data);
return data;
}
};
React Query 예시
React Query의 prefetchQuery 메서드를 사용하면 특정 쿼리를 사전에 패칭하여, 이후 사용 시 빠르게 데이터를 제공할 수 있음.
const App = () => {
useEffect(() => {
// 페이지 로드와 동시에 데이터를 미리 가져옴
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
}, []);
return <HomePage />;
};
이후에 같은 queryKey를 사용해서 프리페칭된 캐시데이터를 활용.
9. (참고) 서버컴포넌트 기반 상태 데이터 패칭
React 19에서는 useActionState 등의 훅이 새롭게 추가
서버와 직접적으로 상호작용하는 폼 데이터를 클라이언트 컴포넌트 내에서 효율적으로 처리할 수 있음.
이 방법을 통해 fetch 호출 없이 폼 제출과 서버 액션을 간단히 연동가능.
- 예제:
'use server';
export async function submitForm(previousState, formData) {
return await updateData(formData);
}
import { useActionState } from "react";
import { submitForm } from "./serverActions";
function MyForm() {
const [state, formAction, isPending] = useActionState(submitForm, null);
return (
<form>
<button formAction={formAction} disabled={isPending}>
{isPending ? "Submitting..." : "Submit"}
</button>
</form>
);
}
export default MyForm;
10. 클라이언트 비동기 상태 관리 : zustand
Zustand는 경량 상태 관리 라이브러리.
컴포넌트 간의 상태를 중앙에서 관리. 컴포넌트가 필요시 상태를 직접 구독.
주요 특징과 사용 예시
-
create함수 내부에서 비동기호출. -
데이터 로드 이후
set메서드를 통해 상태를 업데이트
상태 저장 및 구독 예시
- 상태를 정의하고 비동기 데이터를 받아오는 함수를 Zustand 스토어에 설정
import create from "zustand";
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
- 상태를 구독하는 컴포넌트에서는
useStore를 호출.
function CounterDisplay() {
const count = useStore((state) => state.count); // 상태 구독
return <p>Count: {count}</p>;
}
function IncrementButton() {
const increment = useStore((state) => state.increment); // 액션 구독
return <button onClick={increment}>Increment</button>;
}