Data Fetching
데이터 fetching 및 캐싱 구현 패턴입니다.
목차
개요
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계층 역할
| 계층 | 역할 | 예시 |
|---|---|---|
| schemas | API 요청/응답 타입 정의 및 Zod 검증 | UserSchema, GetUsersParamsSchema |
| api | HTTP 요청 로직 | getUsers(), deleteUser() |
| models | DTO → Domain Model 변환 | userMapper.toUser(dto) |
| hooks | React Query 통합 | useUsers(), useDeleteUser() |
| lib | Query Key Factory, Axios 인스턴스 | userKeys.list(params) |
| components | UI 렌더링 | <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 Factory | userKeys.list(params) - 일관된 캐시 키 관리 |
| select | DTO → Domain Model 변환 + 메모이제이션 |
| invalidateQueries | Mutation 후 관련 쿼리 캐시 무효화 |
| 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()
}사용 시점: 일회성 요청, 폼 제출 등 캐싱이 불필요한 경우.
관련 패턴
- State Management - 전역 상태 관리
- Loading States - 로딩 UI
- Error Handling - API 에러 처리
참고 자료
Last updated on