15.3 데이터 페칭 — useFetch, useAsyncData, $fetch, 캐싱 전략
데이터 페칭 개요
웹 애플리케이션의 핵심은 데이터를 가져오고 표시하는 것입니다. Nuxt 3는 SSR/SSG/CSR 환경 모두에서 올바르게 동작하는 데이터 페칭 솔루션을 제공합니다.
Nuxt 3의 세 가지 데이터 페칭 방법
| 방법 | 특징 | 사용 시기 |
|---|---|---|
useFetch | 가장 간단, URL 기반 | 대부분의 API 요청 |
useAsyncData | 더 유연한 커스텀 로직 | 복잡한 비동기 로직 |
$fetch | 일반 HTTP 클라이언트 | 이벤트 핸들러, 서버 코드 |
SSR에서의 데이터 페칭 흐름
서버 클라이언트
│ │
│ 1. 페이지 요청 │
│◄──────────────────────────────│
│ │
│ 2. useFetch 실행 (서버) │
│ ↓ API 호출 │
│ ↓ 데이터 수신 │
│ │
│ 3. 데이터 포함 HTML 전송 │
│──────────────────────────────►│
│ │
│ 4. 하이드레이션
│ (중복 요청 없음)
SSR에서는 서버에서 데이터를 미리 받아 HTML에 포함시키므로, 클라이언트에서 중복 요청이 발생하지 않습니다.
useFetch
useFetch는 Nuxt 3에서 가장 많이 사용하는 데이터 페칭 컴포저블입니다.
기본 사용법
<template>
<div>
<!-- 로딩 상태 -->
<div v-if="pending">로딩 중...</div>
<!-- 에러 상태 -->
<div v-else-if="error">
에러: {{ error.message }}
</div>
<!-- 데이터 표시 -->
<div v-else>
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
</div>
</div>
</template>
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
const { data: user, pending, error, refresh } = await useFetch<User>('/api/users/1')
</script>
반환 값 상세
const {
data, // Ref<T | null> — 응답 데이터
pending, // Ref<boolean> — 로딩 상태
error, // Ref<Error | null> — 에러 정보
refresh, // () => Promise<void> — 재요청 함수
execute, // () => Promise<void> — 수동 실행 (lazy 모드에서 사용)
status, // Ref<'idle' | 'pending' | 'success' | 'error'>
} = await useFetch('/api/data')
옵션 설정
const { data } = await useFetch('/api/users', {
// HTTP 메서드
method: 'POST',
// 요청 바디
body: { name: '김철수', email: 'kim@example.com' },
// 쿼리 파라미터
query: { page: 1, limit: 10 },
// 헤더
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
// 기본 URL (runtimeConfig의 public.apiBase 사용 권장)
baseURL: 'https://api.example.com',
// 키 (캐시 식별자)
key: 'my-unique-key',
// 지연 실행 (execute()로 수동 실행)
lazy: false,
// 서버에서만 실행
server: true,
// 응답 데이터 변환
transform: (response) => response.data,
// 캐시 전략 ('no-cache', 'default' 등)
getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key],
// 요청 전 훅
onRequest({ request, options }) {
console.log('요청:', request)
},
// 응답 후 훅
onResponse({ response }) {
console.log('응답:', response.status)
},
// 에러 처리 훅
onResponseError({ response }) {
console.error('에러:', response.status)
}
})
동적 URL과 반응형 파라미터
useFetch는 반응형 값을 자동으로 감지하고 데이터를 재요청합니다.
<template>
<div>
<input v-model="searchQuery" placeholder="검색어 입력..." />
<select v-model="currentPage">
<option v-for="p in 10" :key="p" :value="p">{{ p }}페이지</option>
</select>
<div v-if="pending">검색 중...</div>
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const searchQuery = ref('')
const currentPage = ref(1)
interface User {
id: number
name: string
}
// query 객체 내의 ref는 자동으로 감지됨
// searchQuery나 currentPage가 변경되면 자동 재요청
const { data: users, pending } = await useFetch<User[]>('/api/users', {
query: {
search: searchQuery, // ref를 직접 전달
page: currentPage, // ref를 직접 전달
limit: 10,
},
// 빈 검색어일 때 요청 방지
immediate: true,
})
</script>
POST 요청과 폼 처리
<template>
<form @submit.prevent="submitForm">
<input v-model="form.name" placeholder="이름" required />
<input v-model="form.email" type="email" placeholder="이메일" required />
<button type="submit" :disabled="pending">
{{ pending ? '처리 중...' : '제출' }}
</button>
<p v-if="error" class="error">{{ error.message }}</p>
</form>
</template>
<script setup lang="ts">
const form = reactive({
name: '',
email: '',
})
// lazy: true로 수동 실행 설정
const { data, pending, error, execute } = await useFetch('/api/users', {
method: 'POST',
body: form,
lazy: true, // 즉시 실행하지 않음
immediate: false,
})
const submitForm = async () => {
await execute()
if (!error.value) {
// 성공 처리
navigateTo('/success')
}
}
</script>
useAsyncData
useAsyncData는 useFetch보다 유연하게 비동기 로직을 처리할 수 있습니다. 단순 HTTP 요청이 아닌 복잡한 로직을 다룰 때 사용합니다.
기본 사용법
const { data, pending, error } = await useAsyncData(
'unique-key', // 캐시 키 (필수)
async () => { // 비동기 함수
// 어떤 비동기 로직도 사용 가능
const response = await $fetch('/api/users')
return response
}
)
useFetch vs useAsyncData
// 이 두 코드는 동일하게 동작합니다
const { data } = await useFetch('/api/users')
const { data } = await useAsyncData('users', () => $fetch('/api/users'))
복잡한 비동기 로직
// 여러 API를 동시에 호출
const { data } = await useAsyncData('dashboard', async () => {
// Promise.all로 병렬 요청
const [users, products, orders] = await Promise.all([
$fetch('/api/users'),
$fetch('/api/products'),
$fetch('/api/orders'),
])
// 데이터 조합 및 가공
return {
totalUsers: users.length,
totalProducts: products.length,
pendingOrders: orders.filter(o => o.status === 'pending').length,
recentOrders: orders.slice(0, 5),
}
})
서버 전용 로직
// 서버에서만 실행 (DB 직접 쿼리 등)
const { data } = await useAsyncData('server-data', async () => {
// server: true는 이 코드가 서버에서만 실행됨을 보장
if (process.server) {
// DB 직접 쿼리 (서버에서만 안전)
const db = useDatabase()
return await db.query('SELECT * FROM users LIMIT 10')
}
return null
}, { server: true })
의존성 기반 재실행
const userId = ref(1)
const tab = ref('posts')
const { data, refresh } = await useAsyncData(
() => `user-${userId.value}-${tab.value}`, // 동적 키
async () => {
if (tab.value === 'posts') {
return await $fetch(`/api/users/${userId.value}/posts`)
} else if (tab.value === 'comments') {
return await $fetch(`/api/users/${userId.value}/comments`)
}
},
{
// userId 또는 tab이 변경되면 자동 재실행
watch: [userId, tab],
}
)
$fetch
$fetch는 Nuxt의 저수준 HTTP 클라이언트입니다. ofetch 라이브러리를 기반으로 하며, 이벤트 핸들러, 서버 코드, 플러그인 등에서 사용합니다.
기본 사용법
// GET 요청
const users = await $fetch('/api/users')
// POST 요청
const newUser = await $fetch('/api/users', {
method: 'POST',
body: { name: '김철수', email: 'kim@example.com' },
})
// PUT 요청
const updated = await $fetch('/api/users/1', {
method: 'PUT',
body: { name: '김영희' },
})
// DELETE 요청
await $fetch('/api/users/1', { method: 'DELETE' })
$fetch는 언제 사용하나?
<template>
<button @click="deleteUser(user.id)">삭제</button>
<button @click="likePost(post.id)">좋아요</button>
</template>
<script setup lang="ts">
// 이벤트 핸들러에서는 $fetch 사용 (useFetch는 setup에서만)
const deleteUser = async (id: number) => {
try {
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
await refresh() // 목록 새로고침
} catch (error) {
console.error('삭제 실패:', error)
}
}
const likePost = async (id: number) => {
const result = await $fetch(`/api/posts/${id}/like`, {
method: 'POST',
})
console.log('좋아요 수:', result.likes)
}
</script>
$fetch와 useFetch의 차이
| 구분 | useFetch | $fetch |
|---|---|---|
| 반환값 | { data, pending, error, refresh } | 직접 응답 데이터 |
| SSR 중복 요청 방지 | 자동 | 수동 처리 필요 |
| 캐싱 | 자동 | 없음 |
| 사용 위치 | <script setup>, setup() | 어디서든 |
| 에러 처리 | error ref로 | try/catch |
| 반응형 파라미터 | 자동 감지 | 없음 |
전역 $fetch 설정
// plugins/api.ts
export default defineNuxtPlugin((nuxtApp) => {
const $customFetch = $fetch.create({
// 기본 URL 설정
baseURL: useRuntimeConfig().public.apiBaseUrl,
// 모든 요청에 인증 토큰 추가
onRequest({ options }) {
const token = useCookie('auth_token')
if (token.value) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token.value}`,
}
}
},
// 401 에러 시 로그인 페이지로 리다이렉트
onResponseError({ response }) {
if (response.status === 401) {
navigateTo('/login')
}
},
})
// 글로벌 헬퍼로 제공
return {
provide: {
api: $customFetch,
}
}
})
<!-- 사용법 -->
<script setup lang="ts">
const { $api } = useNuxtApp()
// 자동으로 baseURL + 인증 헤더 적용
const users = await $api('/users')
</script>
캐싱 전략
기본 캐싱 동작
Nuxt는 useFetch와 useAsyncData의 결과를 자동으로 **페이로드(payload)**에 저장합니다. 페이지 이동 후 돌아와도 기본적으로 캐시된 데이터를 사용합니다.
// 기본: 페이지 이동 후 돌아와도 캐시 사용
const { data } = await useFetch('/api/users')
// 캐시 무효화: 항상 새로 요청
const { data } = await useFetch('/api/users', {
getCachedData: () => null, // 항상 null 반환 = 캐시 없음
})
getCachedData로 캐시 제어
const { data } = await useFetch('/api/products', {
// 5분 내 동일 요청은 캐시 사용
getCachedData(key, nuxtApp) {
const cachedData = nuxtApp.payload.data[key]
if (!cachedData) return undefined // 캐시 없으면 새로 요청
// 캐시 만료 시간 체크 (5분)
const expiresAt = new Date(cachedData._cachedAt)
expiresAt.setMinutes(expiresAt.getMinutes() + 5)
if (expiresAt < new Date()) {
return undefined // 만료됨 → 새로 요청
}
return cachedData // 유효한 캐시 반환
},
// 응답에 타임스탬프 추가
transform(response) {
return {
...response,
_cachedAt: new Date().toISOString(),
}
}
})
useState로 전역 캐시 구현
// composables/useProducts.ts
export const useProducts = () => {
// 전역 상태에 캐시 저장
const products = useState('products-cache', () => null)
const lastFetched = useState('products-last-fetched', () => 0)
const fetchProducts = async (force = false) => {
const now = Date.now()
const CACHE_DURATION = 5 * 60 * 1000 // 5분
// 캐시가 있고 만료되지 않았으면 재사용
if (!force && products.value && (now - lastFetched.value) < CACHE_DURATION) {
return products.value
}
// 새로 요청
const data = await $fetch('/api/products')
products.value = data
lastFetched.value = now
return data
}
const invalidateCache = () => {
products.value = null
lastFetched.value = 0
}
return { products, fetchProducts, invalidateCache }
}
서버 사이드 캐시 (Nitro)
// server/api/products/index.get.ts
export default defineCachedEventHandler(async (event) => {
// 이 핸들러의 결과는 서버에서 1시간 동안 캐시됩니다
const products = await fetchProductsFromDatabase()
return products
}, {
maxAge: 60 * 60, // 1시간 (초 단위)
name: 'products-list',
// 캐시 키 생성 (쿼리 파라미터 포함)
getKey: (event) => {
const query = getQuery(event)
return `products-${query.category || 'all'}-${query.page || 1}`
},
// 스테일 타임 (캐시 만료 후 백그라운드 갱신 동안 보여줄 시간)
staleMaxAge: 60 * 5, // 5분
})
실전 예제: 상품 목록 페이지
페이지네이션, 검색, 필터링이 포함된 완전한 상품 목록 페이지입니다.
서버 API
// server/api/products/index.get.ts
interface Product {
id: number
name: string
price: number
category: string
stock: number
}
export default defineCachedEventHandler(async (event) => {
const query = getQuery(event)
const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 12
const search = (query.search as string) || ''
const category = (query.category as string) || ''
const sortBy = (query.sortBy as string) || 'id'
const order = (query.order as string) || 'asc'
// 실제로는 DB 쿼리
let products: Product[] = [
{ id: 1, name: '무선 키보드', price: 79000, category: 'electronics', stock: 50 },
{ id: 2, name: '마우스', price: 45000, category: 'electronics', stock: 30 },
{ id: 3, name: '모니터 스탠드', price: 35000, category: 'accessories', stock: 100 },
{ id: 4, name: '웹캠', price: 89000, category: 'electronics', stock: 20 },
{ id: 5, name: '노트북 가방', price: 65000, category: 'accessories', stock: 45 },
]
// 검색 필터
if (search) {
products = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
)
}
// 카테고리 필터
if (category) {
products = products.filter(p => p.category === category)
}
// 정렬
products.sort((a, b) => {
const valA = a[sortBy as keyof Product]
const valB = b[sortBy as keyof Product]
if (order === 'asc') return valA > valB ? 1 : -1
return valA < valB ? 1 : -1
})
// 페이지네이션
const total = products.length
const offset = (page - 1) * limit
const paginatedProducts = products.slice(offset, offset + limit)
return {
products: paginatedProducts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
}
}
}, {
maxAge: 60, // 1분 캐시
getKey: (event) => {
const q = getQuery(event)
return `products-${JSON.stringify(q)}`
},
})
상품 목록 페이지
<!-- pages/products/index.vue -->
<template>
<div class="products-page">
<!-- 검색 및 필터 -->
<div class="filters">
<input
v-model="searchQuery"
type="search"
placeholder="상품 검색..."
class="search-input"
/>
<select v-model="selectedCategory">
<option value="">전체 카테고리</option>
<option value="electronics">전자제품</option>
<option value="accessories">액세서리</option>
</select>
<select v-model="sortBy">
<option value="id">기본 순서</option>
<option value="price">가격 낮은 순</option>
<option value="name">이름 순</option>
</select>
</div>
<!-- 로딩 스켈레톤 -->
<div v-if="pending" class="product-grid">
<div
v-for="i in 12"
:key="i"
class="product-skeleton"
/>
</div>
<!-- 에러 -->
<div v-else-if="error" class="error-state">
<p>상품을 불러오지 못했습니다.</p>
<button @click="refresh()">다시 시도</button>
</div>
<!-- 상품 목록 -->
<template v-else>
<p class="result-count">
총 {{ result?.pagination.total }}개 상품
</p>
<div v-if="result?.products.length === 0" class="empty-state">
검색 결과가 없습니다.
</div>
<div v-else class="product-grid">
<ProductCard
v-for="product in result?.products"
:key="product.id"
:product="product"
@add-to-cart="addToCart"
/>
</div>
<!-- 페이지네이션 -->
<Pagination
:current-page="currentPage"
:total-pages="result?.pagination.totalPages || 1"
@change="currentPage = $event"
/>
</template>
</div>
</template>
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
category: string
stock: number
}
interface ProductsResponse {
products: Product[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}
// 필터 상태
const searchQuery = ref('')
const selectedCategory = ref('')
const sortBy = ref('id')
const currentPage = ref(1)
// 검색어 변경 시 페이지 초기화
watch(searchQuery, () => { currentPage.value = 1 })
watch(selectedCategory, () => { currentPage.value = 1 })
// 데이터 페칭 (반응형 쿼리 파라미터)
const { data: result, pending, error, refresh } = await useFetch<ProductsResponse>(
'/api/products',
{
query: {
search: searchQuery,
category: selectedCategory,
sortBy: sortBy,
page: currentPage,
limit: 12,
},
// 클라이언트에서만 실행 (서버는 정적 캐시 사용)
server: true,
}
)
// 장바구니 추가
const cartStore = useCartStore()
const addToCart = async (product: Product) => {
await cartStore.add(product)
// 토스트 알림
useToast().success(`${product.name}을 장바구니에 담았습니다.`)
}
// SEO
useHead({
title: '상품 목록',
meta: [
{ name: 'description', content: '다양한 상품을 검색하고 구매하세요.' }
]
})
</script>
상품 상세 페이지 (병렬 데이터 요청)
<!-- pages/products/[id].vue -->
<template>
<div v-if="product" class="product-detail">
<div class="product-images">
<NuxtImg
v-for="image in product.images"
:key="image"
:src="image"
:alt="product.name"
preset="product"
/>
</div>
<div class="product-info">
<h1>{{ product.name }}</h1>
<p class="price">{{ formatPrice(product.price) }}</p>
<div class="reviews-summary">
<span>⭐ {{ reviewStats?.averageRating.toFixed(1) }}</span>
<span>({{ reviewStats?.totalReviews }}개 리뷰)</span>
</div>
<button
@click="addToCart"
:disabled="product.stock === 0"
>
{{ product.stock === 0 ? '품절' : '장바구니 담기' }}
</button>
</div>
<!-- 리뷰 섹션 -->
<section class="reviews">
<h2>리뷰 ({{ reviewStats?.totalReviews }})</h2>
<div v-for="review in reviews" :key="review.id" class="review">
<strong>{{ review.author }}</strong>
<p>{{ review.content }}</p>
</div>
</section>
<!-- 관련 상품 -->
<section class="related">
<h2>관련 상품</h2>
<div class="product-grid">
<ProductCard
v-for="p in relatedProducts"
:key="p.id"
:product="p"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const productId = route.params.id as string
// 여러 API를 병렬로 호출
const [
{ data: product },
{ data: reviews },
{ data: reviewStats },
{ data: relatedProducts },
] = await Promise.all([
useFetch(`/api/products/${productId}`),
useFetch(`/api/products/${productId}/reviews`, {
query: { limit: 10 }
}),
useFetch(`/api/products/${productId}/reviews/stats`),
useFetch(`/api/products/${productId}/related`, {
query: { limit: 4 }
}),
])
// 404 처리
if (!product.value) {
throw createError({ statusCode: 404, message: '상품을 찾을 수 없습니다.' })
}
const formatPrice = (price: number) =>
new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(price)
const cartStore = useCartStore()
const addToCart = () => {
if (product.value) {
cartStore.add(product.value)
}
}
// 동적 SEO
useSeoMeta({
title: product.value?.name,
description: product.value?.description,
ogImage: product.value?.images[0],
ogTitle: product.value?.name,
})
</script>
고수 팁
1. useAsyncData로 낙관적 업데이트 구현
// composables/useLike.ts
export const useLike = (postId: number) => {
const isLiked = ref(false)
const likeCount = ref(0)
const { data: post } = await useFetch(`/api/posts/${postId}`)
if (post.value) {
isLiked.value = post.value.isLiked
likeCount.value = post.value.likeCount
}
const toggleLike = async () => {
// 낙관적 업데이트: UI를 먼저 변경
const previousState = isLiked.value
isLiked.value = !isLiked.value
likeCount.value += isLiked.value ? 1 : -1
try {
await $fetch(`/api/posts/${postId}/like`, {
method: 'POST',
})
} catch (error) {
// 실패 시 롤백
isLiked.value = previousState
likeCount.value += isLiked.value ? 1 : -1
console.error('좋아요 처리 실패')
}
}
return { isLiked, likeCount, toggleLike }
}
2. 무한 스크롤 구현
<script setup lang="ts">
const page = ref(1)
const items = ref<any[]>([])
const hasMore = ref(true)
const loading = ref(false)
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
const data = await $fetch('/api/items', {
query: { page: page.value, limit: 20 }
})
items.value.push(...data.items)
hasMore.value = data.items.length === 20
page.value++
loading.value = false
}
// Intersection Observer로 무한 스크롤 감지
const sentinel = ref(null)
const { stop } = useIntersectionObserver(sentinel, ([entry]) => {
if (entry.isIntersecting) {
loadMore()
}
})
// 컴포넌트 언마운트 시 Observer 정리
onUnmounted(stop)
// 초기 데이터 로드
await loadMore()
</script>
<template>
<div>
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
<!-- 로딩 sentinel -->
<div ref="sentinel" class="sentinel">
<span v-if="loading">로딩 중...</span>
<span v-else-if="!hasMore">모든 항목을 불러왔습니다.</span>
</div>
</div>
</template>
3. 에러 처리 표준화
// composables/useApi.ts
export const useApi = () => {
const toast = useToast()
const handleError = (error: any) => {
if (error?.status === 401) {
toast.error('로그인이 필요합니다.')
navigateTo('/login')
} else if (error?.status === 403) {
toast.error('접근 권한이 없습니다.')
} else if (error?.status === 404) {
toast.error('요청한 리소스를 찾을 수 없습니다.')
} else if (error?.status >= 500) {
toast.error('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.')
} else {
toast.error(error?.message || '알 수 없는 오류가 발생했습니다.')
}
}
const safeRequest = async <T>(fn: () => Promise<T>): Promise<T | null> => {
try {
return await fn()
} catch (error) {
handleError(error)
return null
}
}
return { safeRequest, handleError }
}
<script setup lang="ts">
const { safeRequest } = useApi()
const updateProfile = async (data: ProfileData) => {
const result = await safeRequest(() =>
$fetch('/api/profile', { method: 'PUT', body: data })
)
if (result) {
// 성공 처리
useToast().success('프로필이 업데이트되었습니다.')
}
}
</script>
4. 서버-클라이언트 데이터 하이드레이션
// server/api/config.get.ts — 서버에서 설정 가져오기
export default defineEventHandler(() => {
return {
featureFlags: {
newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
darkMode: true,
},
maintenance: false,
}
})
// plugins/config.ts — 앱 전체에서 사용할 수 있도록 제공
export default defineNuxtPlugin(async () => {
const { data: config } = await useFetch('/api/config')
return {
provide: {
config: config.value,
}
}
})
<!-- 모든 컴포넌트에서 접근 가능 -->
<script setup lang="ts">
const { $config } = useNuxtApp()
const isNewCheckout = $config?.featureFlags.newCheckout
</script>
정리
Nuxt 3의 데이터 페칭은 세 가지 도구의 적절한 조합으로 이루어집니다.
useFetch: 가장 많이 사용. URL 기반의 간단한 데이터 페칭, 자동 SSR 지원useAsyncData: 복잡한 비동기 로직, 여러 소스의 데이터 조합$fetch: 이벤트 핸들러, 수동 실행이 필요한 경우, 서버 사이드 코드
캐싱 전략:
- 클라이언트 캐시:
getCachedData,useState로 관리 - 서버 캐시:
defineCachedEventHandler로 Nitro 레벨 캐싱 routeRules의swr옵션으로 페이지 레벨 캐싱
올바른 데이터 페칭 전략을 선택하면 성능과 사용자 경험을 동시에 향상시킬 수 있습니다.