Skip to Content
PatternsData Fetching

Data Fetching

데이터 fetching 및 캐싱 구현 패턴입니다.

목차

  1. 개요
  2. 권장 방식: React Query
  3. Feature 기반 구조
  4. 실제 구현 예시 (icignal-demo)
  5. 대안적 방법
  6. 관련 패턴

개요

Data Fetching 패턴은 서버로부터 효율적으로 데이터를 가져오고, 캐싱하며, 사용자에게 최적의 경험을 제공하는 검증된 구현 방법입니다.

권장 라이브러리

라이브러리권장도설명
React Query⭐ 기본 권장캐싱, 재검증, 에러 처리 자동화
SWR대안가벼운 대안, Vercel 프로젝트
fetch/axios단순 케이스캐싱 불필요 시 직접 사용

💡 icignal-demo, vite-icignal 모두 React Query를 사용합니다.

사용 사례

  • REST API 데이터 가져오기
  • 실시간 데이터 동기화
  • 무한 스크롤 구현
  • 캐싱 및 재검증
  • 낙관적 업데이트

사용하지 말아야 할 때

  • 정적 데이터 (빌드 타임에 생성)
  • 로컬 상태만 필요
  • 단순 폼 제출

권장 방식: React Query

왜 React Query인가?

// ❌ 직접 구현 시 - 복잡하고 에러 발생 가능 const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) useEffect(() => { setLoading(true) fetch("/api/users") .then((res) => res.json()) .then(setData) .catch(setError) .finally(() => setLoading(false)) }, []) // ✅ React Query - 간결하고 강력함 const { data, isLoading, error } = useQuery({ queryKey: ["users"], queryFn: () => fetch("/api/users").then((res) => res.json()), })

React Query의 장점:

  • 자동 캐싱: 동일한 요청 중복 방지
  • 백그라운드 재검증: stale 데이터 자동 갱신
  • 에러 재시도: 네트워크 에러 시 자동 재시도
  • DevTools: 캐시 상태 시각화
  • TypeScript: 완벽한 타입 지원

Best Practices

✅ 권장 사항

영역권장 사항
캐싱staleTime: 5 * 60 * 1000 (5분), 윈도우 포커스 재검증
에러명확한 메시지, 재시도 3회, 네트워크 상태 감지
성능페이지네이션, 디바운싱, 필요한 데이터만 요청
UX로딩 상태 표시, 낙관적 업데이트
타입Zod 스키마 검증, TypeScript 타입 정의

⚠️ 피해야 할 것

  • 캐싱 없이 반복 요청
  • 로딩/에러 상태 미표시
  • 과도한 API 호출

Feature 기반 구조

Feature 기반 아키텍처를 사용하여 관심사를 명확히 분리합니다.

src/features/users/ ├── api/ # API 호출 함수 │ ├── get-users.ts │ ├── get-user.ts │ ├── update-user.ts │ └── delete-user.ts ├── hooks/ # React Query 훅 │ ├── queries/ # useQuery 훅 │ │ ├── use-users.ts │ │ └── use-user.ts │ └── mutations/ # useMutation 훅 │ ├── use-update-user.ts │ └── use-delete-user.ts ├── schemas/ # Zod 스키마 (API 타입 검증) │ └── user.schema.ts ├── models/ # Domain 모델 & Mapper │ └── user.model.ts ├── lib/ # Feature 내부 유틸리티 │ ├── api.ts # Feature별 Axios 인스턴스 │ └── query-keys.ts # Query Key Factory └── components/ # UI 컴포넌트 ├── user-list.tsx └── user-detail-modal.tsx

계층 역할

계층역할예시
schemasAPI 요청/응답 타입 정의 및 Zod 검증UserSchema, GetUsersParamsSchema
apiHTTP 요청 로직getUsers(), deleteUser()
modelsDTO → Domain Model 변환userMapper.toUser(dto)
hooksReact Query 통합useUsers(), useDeleteUser()
libQuery Key Factory, Axios 인스턴스userKeys.list(params)
componentsUI 렌더링<UserList />

실제 구현 예시 (icignal-demo)

📁 examples/icignal-demo/src/features/users/ 참조

1. Zod 스키마 (schemas/user.schema.ts)

import { z } from "zod" export const UserSchema = z.object({ id: z.number(), firstName: z.string(), lastName: z.string(), email: z.string().email(), role: z.enum(["admin", "moderator", "user"]), }) export type UserDto = z.infer<typeof UserSchema> export const GetUsersParamsSchema = z.object({ sortBy: z.string().optional(), order: z.enum(["asc", "desc"]).optional(), limit: z.number().optional(), skip: z.number().optional(), }) export type GetUsersParams = z.infer<typeof GetUsersParamsSchema> export const GetUsersResponseSchema = z.object({ users: z.array(UserSchema), total: z.number(), skip: z.number(), limit: z.number(), }) export type GetUsersResponse = z.infer<typeof GetUsersResponseSchema>

2. Query Key Factory (lib/query-keys.ts)

export const userKeys = { all: ["users"] as const, list: (params?: unknown) => [...userKeys.all, "list", params ?? {}] as const, detail: (id?: number | string) => [...userKeys.all, "detail", id ? String(id) : ""] as const, }

3. API 함수 (api/get-users.ts)

import { userApi } from "../lib/api" import { type GetUsersParams, GetUsersParamsSchema, GetUsersResponseSchema, } from "../schemas" export async function getUsers(params: GetUsersParams) { const validatedParams = GetUsersParamsSchema.parse(params) const res = await userApi.get("/users", { params: validatedParams }) return GetUsersResponseSchema.parse(res.data) }

4. Query Hook (hooks/queries/use-users.ts)

import { useQuery } from "@tanstack/react-query" import { userKeys } from "../../lib/query-keys" import { getUsers } from "../../api" import { userMapper } from "../../models" import { type GetUsersParams } from "../../schemas" export function useUsers(params?: GetUsersParams) { return useQuery({ queryKey: userKeys.list(params), queryFn: () => getUsers(params), select: (data) => data.users.map((user) => userMapper.toUser(user)), staleTime: 5 * 60 * 1000, }) }

5. Mutation Hook (hooks/mutations/use-delete-user.ts)

import { useMutation, useQueryClient } from "@tanstack/react-query" import { userKeys } from "../../lib/query-keys" import { deleteUser } from "../../api" export function useDeleteUser() { const queryClient = useQueryClient() return useMutation({ mutationFn: deleteUser, onSuccess: () => { queryClient.invalidateQueries({ queryKey: userKeys.all }) }, }) }

6. 컴포넌트 (components/user-list.tsx)

import { useState, useMemo, useCallback } from "react" import { useUsers, useDeleteUser } from "../hooks" import { Button, useDialog, DataTable, type DataTableColumnDef, } from "@vortex/ui-icignal" import { type User } from "../models" export function UserList({ dict, onDetailClick }) { const [sortBy, setSortBy] = useState("firstName") const [order, setOrder] = useState<"asc" | "desc">("asc") const [limit, setLimit] = useState(10) const [skip, setSkip] = useState(0) const { confirm, alert } = useDialog() // Query: 사용자 목록 조회 const { data: users = [], error } = useUsers({ sortBy, order, limit, skip }) // Mutation: 사용자 삭제 const { mutateAsync: deleteUser } = useDeleteUser() const handleDeleteClick = useCallback( async (user: User) => { const confirmed = await confirm({ title: dict.common.delete, description: dict.users.deleteConfirm, }) if (!confirmed) return try { await deleteUser(user.id) alert(dict.users.deleteSuccess) } catch (e) { alert(dict.users.deleteFailed) } }, [confirm, deleteUser, dict, alert], ) const columns = useMemo<DataTableColumnDef<User>[]>( () => [ { id: "id", header: "ID", accessorKey: "id", size: 60 }, { id: "name", header: dict.users.name, accessorKey: "name", size: 160 }, { id: "email", header: dict.users.email, accessorKey: "email", size: 220, }, { id: "role", header: dict.users.role, accessorKey: "role", size: 100 }, { id: "actions", header: dict.users.actions, size: 160, cell: ({ row }) => ( <div className="flex gap-2"> <Button size="xs" onClick={() => onDetailClick?.(row.original)}> {dict.users.view} </Button> <Button variant="destructive" size="xs" onClick={() => handleDeleteClick(row.original)} > {dict.common.delete} </Button> </div> ), }, ], [dict, onDetailClick, handleDeleteClick], ) if (error) return ( <div> {dict.errors.unknown}: {error.message} </div> ) return ( <DataTable columns={columns} data={users} getRowId={(user) => String(user.id)} manualPagination manualSorting /> ) }

핵심 패턴 요약

패턴설명
Zod 스키마z.infer로 타입 자동 추론 + 런타임 검증
Query Key FactoryuserKeys.list(params) - 일관된 캐시 키 관리
selectDTO → Domain Model 변환 + 메모이제이션
invalidateQueriesMutation 후 관련 쿼리 캐시 무효화
useDialog삭제 확인 다이얼로그 + 결과 알림

대안적 방법

React Query 외에도 다음 방법들을 사용할 수 있습니다.

SWR (Vercel)

import useSWR from "swr" const fetcher = (url: string) => fetch(url).then((res) => res.json()) function UserList() { const { data, error, isLoading } = useSWR("/api/users", fetcher) if (isLoading) return <div>Loading...</div> if (error) return <div>Error</div> return ( <div> {data.map((user) => ( <div key={user.id}>{user.name}</div> ))} </div> ) }

특징: React Query보다 가볍고 API가 단순함. Vercel/Next.js 프로젝트에서 자주 사용.

직접 fetch (단순 케이스)

// 캐싱이 필요 없는 단순한 경우 async function submitForm(data: FormData) { const res = await fetch("/api/submit", { method: "POST", body: JSON.stringify(data), }) return res.json() }

사용 시점: 일회성 요청, 폼 제출 등 캐싱이 불필요한 경우.


관련 패턴


참고 자료

Last updated on