Skip to Content
PatternsData Fetching

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

✅ 권장 사항

  1. 캐싱 전략

    • React Query/SWR로 자동 캐싱
    • stale time 설정 (5분 권장)
    • 윈도우 포커스 시 재검증
  2. 에러 처리

    • 명확한 에러 메시지
    • 재시도 로직 (3번 권장)
    • 네트워크 상태 감지
  3. 성능 최적화

    • 페이지네이션 또는 무한 스크롤
    • 디바운싱/쓰로틀링
    • 필요한 데이터만 요청
  4. 사용자 경험

    • 로딩 상태 명확히 표시
    • 낙관적 업데이트
    • 백그라운드 재검증
  5. 타입 안전성

    • TypeScript 타입 정의
    • API 응답 검증 (Zod)
    • 에러 타입 처리

⚠️ 피해야 할 것

  1. 성능 문제

    • 불필요한 재렌더링
    • 과도한 API 호출
    • 캐싱 없이 반복 요청
  2. UX 문제

    • 로딩 상태 미표시
    • 에러 무시
    • 느린 응답 시간
  3. 보안 문제

    • 민감한 데이터 노출
    • 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 예제는 곧 제공될 예정입니다.

로컬에서 실행하기

  1. 프로젝트 생성

    npx @vortex/cli init my-data-project --template next-app cd my-data-project
  2. React Query 설치

    pnpm add @tanstack/react-query
  3. 컴포넌트 추가

    npx @vortex/cli add card button --package foundation
  4. 코드 복사 및 실행

    pnpm dev

관련 패턴

Last updated on