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, icignal-vite-demo 모두 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 { Foundation, iCignal, useDialog } 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<iCignal.ListColumnDef<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"> <Foundation.Button size="xs" onClick={() => onDetailClick?.(row.original)} > {dict.users.view} </Foundation.Button> <Foundation.Button variant="destructive" size="xs" onClick={() => handleDeleteClick(row.original)} > {dict.common.delete} </Foundation.Button> </div> ), }, ], [dict, onDetailClick, handleDeleteClick], ) if (error) return ( <div> {dict.errors.unknown}: {error.message} </div> ) return ( <iCignal.List 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