Data Fetching
데이터 fetching 및 캐싱 구현 패턴입니다.
개요
Data Fetching 패턴은 서버로부터 효율적으로 데이터를 가져오고, 캐싱하며, 사용자에게 최적의 경험을 제공하는 검증된 구현 방법입니다. React Query, SWR, 무한 스크롤, 실시간 업데이트 등 프로덕션 환경에서 필요한 모든 데이터 fetching 시나리오를 다룹니다.
사용 사례:
- REST API 데이터 가져오기
- 실시간 데이터 동기화
- 무한 스크롤 구현
- 캐싱 및 재검증
- 낙관적 업데이트
사용하지 말아야 할 때:
- 정적 데이터 (빌드 타임에 생성)
- 로컬 상태만 필요
- 단순 폼 제출
기본 패턴
1. 기본 Fetch 패턴
기본 fetch API를 사용한 데이터 로딩 패턴입니다.
"use client";
import { useState, useEffect } from "react";
import { Card, Button } from "@vortex/ui-foundation";
interface User {
id: number;
name: string;
email: string;
}
export default function BasicFetch() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "데이터를 불러오는데 실패했습니다"
);
} finally {
setIsLoading(false);
}
};
if (isLoading) {
return <div className="p-4">로딩 중...</div>;
}
if (error) {
return (
<div className="p-4">
<p className="text-red-600 mb-4">{error}</p>
<Button onClick={fetchUsers}>다시 시도</Button>
</div>
);
}
return (
<div className="grid gap-4 p-4">
{users.map((user) => (
<Card key={user.id} className="p-4">
<h3 className="font-bold">{user.name}</h3>
<p className="text-sm text-gray-600">{user.email}</p>
</Card>
))}
</div>
);
}2. Custom Hook 패턴
재사용 가능한 데이터 fetching hook 패턴입니다.
"use client";
import { useState, useEffect } from "react";
interface UseFetchOptions {
method?: string;
headers?: HeadersInit;
body?: BodyInit;
}
interface UseFetchResult<T> {
data: T | null;
isLoading: boolean;
error: string | null;
refetch: () => void;
}
export function useFetch<T>(
url: string,
options?: UseFetchOptions
): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : "오류가 발생했습니다");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return { data, isLoading, error, refetch: fetchData };
}
// 사용 예시
export default function UseFetchExample() {
const { data, isLoading, error, refetch } = useFetch<User[]>("/api/users");
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<div>
<button onClick={refetch}>새로고침</button>
{data?.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}고급 패턴
3. React Query 통합
React Query를 사용한 강력한 데이터 관리 패턴입니다.
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, Button } from "@vortex/ui-foundation";
interface User {
id: number;
name: string;
email: string;
}
// API 함수
const fetchUsers = async (): Promise<User[]> => {
const response = await fetch("/api/users");
if (!response.ok) throw new Error("Failed to fetch users");
return response.json();
};
const updateUser = async (user: User): Promise<User> => {
const response = await fetch(`/api/users/${user.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(user),
});
if (!response.ok) throw new Error("Failed to update user");
return response.json();
};
export default function ReactQueryExample() {
const queryClient = useQueryClient();
// 데이터 조회
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // 5분 동안 fresh
cacheTime: 10 * 60 * 1000, // 10분 동안 캐시
refetchOnWindowFocus: true, // 윈도우 포커스 시 재검증
retry: 3, // 실패 시 3번 재시도
});
// 데이터 수정
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: () => {
// 성공 시 캐시 무효화
queryClient.invalidateQueries({ queryKey: ["users"] });
},
onError: (error) => {
console.error("Update failed:", error);
},
});
const handleUpdate = (user: User) => {
mutation.mutate(user);
};
if (isLoading) return <div className="p-4">로딩 중...</div>;
if (error) return <div className="p-4 text-red-600">에러 발생</div>;
return (
<div className="grid gap-4 p-4">
{users?.map((user) => (
<Card key={user.id} className="p-4">
<h3 className="font-bold">{user.name}</h3>
<p className="text-sm text-gray-600 mb-2">{user.email}</p>
<Button
onClick={() =>
handleUpdate({ ...user, name: user.name + " (Updated)" })
}
disabled={mutation.isPending}
>
{mutation.isPending ? "업데이트 중..." : "업데이트"}
</Button>
</Card>
))}
</div>
);
}4. SWR 패턴
SWR을 사용한 stale-while-revalidate 패턴입니다.
"use client";
import useSWR from "swr";
import { Card } from "@vortex/ui-foundation";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function SWRExample() {
const { data, error, isLoading, mutate } = useSWR<User[]>(
"/api/users",
fetcher,
{
refreshInterval: 3000, // 3초마다 재검증
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 2000, // 2초 내 중복 요청 방지
fallbackData: [], // 초기 데이터
}
);
const handleRefresh = () => {
mutate(); // 수동 재검증
};
if (error) return <div>에러 발생: {error.message}</div>;
if (isLoading) return <div>로딩 중...</div>;
return (
<div className="p-4">
<button
onClick={handleRefresh}
className="mb-4 px-4 py-2 bg-blue-600 text-white rounded"
>
새로고침
</button>
<div className="grid gap-4">
{data?.map((user) => (
<Card key={user.id} className="p-4">
<h3 className="font-bold">{user.name}</h3>
<p className="text-sm text-gray-600">{user.email}</p>
</Card>
))}
</div>
</div>
);
}5. 무한 스크롤
무한 스크롤을 위한 페이지네이션 패턴입니다.
"use client";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { Card } from "@vortex/ui-foundation";
interface PageData {
items: User[];
nextCursor: number | null;
}
const fetchPage = async ({ pageParam = 0 }): Promise<PageData> => {
const response = await fetch(`/api/users?cursor=${pageParam}&limit=10`);
return response.json();
};
export default function InfiniteScrollExample() {
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
useInfiniteQuery({
queryKey: ["users-infinite"],
queryFn: fetchPage,
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.5 }
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) return <div className="p-4">로딩 중...</div>;
return (
<div className="p-4">
<div className="grid gap-4">
{data?.pages.map((page, pageIndex) => (
<div key={pageIndex}>
{page.items.map((user) => (
<Card key={user.id} className="p-4 mb-4">
<h3 className="font-bold">{user.name}</h3>
<p className="text-sm text-gray-600">{user.email}</p>
</Card>
))}
</div>
))}
</div>
<div ref={loadMoreRef} className="h-20 flex items-center justify-center">
{isFetchingNextPage && (
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
)}
{!hasNextPage && (
<p className="text-gray-500">모든 데이터를 불러왔습니다</p>
)}
</div>
</div>
);
}6. 실시간 데이터 동기화
WebSocket을 사용한 실시간 데이터 업데이트 패턴입니다.
"use client";
import { useState, useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Card } from "@vortex/ui-foundation";
export default function RealtimeSync() {
const [messages, setMessages] = useState<string[]>([]);
const queryClient = useQueryClient();
useEffect(() => {
// WebSocket 연결
const ws = new WebSocket("ws://localhost:3001");
ws.onopen = () => {
console.log("WebSocket 연결됨");
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 새 메시지 추가
setMessages((prev) => [...prev, data.message]);
// React Query 캐시 무효화
if (data.type === "user-update") {
queryClient.invalidateQueries({ queryKey: ["users"] });
}
};
ws.onerror = (error) => {
console.error("WebSocket 에러:", error);
};
ws.onclose = () => {
console.log("WebSocket 연결 종료");
};
// 클린업
return () => {
ws.close();
};
}, [queryClient]);
const sendMessage = (message: string) => {
// WebSocket으로 메시지 전송 로직
};
return (
<div className="p-4">
<Card className="p-4">
<h3 className="font-bold mb-4">실시간 메시지</h3>
<div className="space-y-2 max-h-96 overflow-y-auto">
{messages.map((msg, index) => (
<div key={index} className="p-2 bg-gray-100 rounded">
{msg}
</div>
))}
</div>
</Card>
</div>
);
}Best Practices
✅ 권장 사항
-
캐싱 전략
- React Query/SWR로 자동 캐싱
- stale time 설정 (5분 권장)
- 윈도우 포커스 시 재검증
-
에러 처리
- 명확한 에러 메시지
- 재시도 로직 (3번 권장)
- 네트워크 상태 감지
-
성능 최적화
- 페이지네이션 또는 무한 스크롤
- 디바운싱/쓰로틀링
- 필요한 데이터만 요청
-
사용자 경험
- 로딩 상태 명확히 표시
- 낙관적 업데이트
- 백그라운드 재검증
-
타입 안전성
- TypeScript 타입 정의
- API 응답 검증 (Zod)
- 에러 타입 처리
⚠️ 피해야 할 것
-
성능 문제
- 불필요한 재렌더링
- 과도한 API 호출
- 캐싱 없이 반복 요청
-
UX 문제
- 로딩 상태 미표시
- 에러 무시
- 느린 응답 시간
-
보안 문제
- 민감한 데이터 노출
- CORS 설정 오류
- 토큰 미검증
성능 고려사항
Request Deduplication
// React Query는 자동으로 중복 요청 제거
const { data } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});Prefetching
// 사용자가 hover 시 데이터 프리페칭
const queryClient = useQueryClient();
const handleHover = (userId: number) => {
queryClient.prefetchQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
};Foundation 예제
범용 데이터 리스트
Foundation 컴포넌트로 구현한 데이터 리스트입니다.
import { useFetch } from "@/hooks/useFetch";
import { Card } from "@vortex/ui-foundation";
export default function FoundationList() {
const { data, isLoading } = useFetch<User[]>("/api/users");
if (isLoading) return <div>로딩 중...</div>;
return (
<div className="grid gap-4">
{data?.map((user) => (
<Card key={user.id} className="p-4">
<h3 className="font-bold">{user.name}</h3>
</Card>
))}
</div>
);
}iCignal 예제
Analytics 데이터 대시보드
iCignal Blue 브랜드를 적용한 실시간 데이터 대시보드입니다.
import "@vortex/ui-icignal/theme";
import { useQuery } from "@tanstack/react-query";
import { Card } from "@vortex/ui-icignal";
export default function ISignalDashboard() {
const { data } = useQuery({
queryKey: ["analytics"],
queryFn: fetchAnalytics,
refetchInterval: 5000, // 5초마다 갱신
});
return (
<Card className="p-6 border-blue-500">
<h3 className="text-xl font-bold text-blue-600">실시간 지표</h3>
<p className="text-3xl font-bold">{data?.visitors}</p>
</Card>
);
}Cals 예제
예약 목록 실시간 업데이트
Cals Pink 브랜드를 적용한 실시간 예약 목록입니다.
import "@vortex/ui-cals/theme";
import { useQuery } from "@tanstack/react-query";
import { Card, Badge } from "@vortex/ui-cals";
export default function CalsBookings() {
const { data } = useQuery({
queryKey: ["bookings"],
queryFn: fetchBookings,
refetchInterval: 3000,
});
return (
<div className="space-y-4">
{data?.map((booking) => (
<Card key={booking.id} className="p-4 border-pink-500">
<div className="flex items-center justify-between">
<h3 className="font-bold">{booking.customer}</h3>
<Badge variant="confirmed">확정</Badge>
</div>
</Card>
))}
</div>
);
}CodeSandbox
CodeSandbox 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-data-project --template next-app cd my-data-project -
React Query 설치
pnpm add @tanstack/react-query -
컴포넌트 추가
npx @vortex/cli add card button --package foundation -
코드 복사 및 실행
pnpm dev
관련 패턴
- State Management - 전역 상태 관리
- Loading States - 로딩 UI
- Error Handling - API 에러 처리
Last updated on