1. 사용자가 URL 접속
↓
2. 서버에서 Vue 컴포넌트 실행
→ API 호출
→ 응답 데이터로 HTML 생성
→ HTML을 브라우저로 전송
↓
3. 브라우저가 HTML을 표시 (첫 화면이 빠르게 보임)
↓
4. 브라우저에서 Vue 앱 초기화 (하이드레이션)
→ <script setup> 코드가 다시 실행됨
→ 여기서 또 API를 호출하면 이중 호출 발생!

<!-- 서버가 보내는 HTML 안에 이런 내용이 포함된다 -->
<script>
window.__nuxt = {
data: {
"fetch:0:0": { id: 1, name: "김현우", ... } // ← 서버에서 가져온 데이터
}
}
</script>
// $fetch 사용 예시
const user = await $fetch('/api/user/1')
// → 결과값이 바로 반환된다 (ref가 아님)
// → pending, error 상태가 없다
// → 자동으로 화면이 업데이트되지 않는다// ❌ 이렇게 쓰면 서버 + 클라이언트 양쪽에서 API가 호출된다
<script setup>
const user = await $fetch('/api/user/1') // 서버에서 한 번, 브라우저에서 한 번
</script>// ✅ $fetch가 적합한 경우 — 버튼 클릭, 폼 제출 등
async function submitForm() {
const result = await $fetch('/api/submit', {
method: 'POST',
body: formData
})
// → 사용자가 버튼을 눌렀을 때 실행 → SSR 이중 호출 문제가 없음
}
// useAsyncData 기본 사용법
const { data, pending, error, refresh } = await useAsyncData(
'user-1', // ← 고유 키 (필수)
() => $fetch('/api/user/1') // ← 실행할 비동기 함수
)서버 실행:
useAsyncData('user-1', 비동기함수)
→ 비동기함수 실행
→ 결과를 payload['user-1']에 저장
→ HTML에 payload 포함해서 전송
클라이언트 실행:
useAsyncData('user-1', 비동기함수)
→ payload['user-1']이 있는지 확인
→ 있으면 비동기함수 실행 안 함, payload 데이터 사용
→ 없으면 비동기함수 실행// 반환값들이 모두 ref로 감싸져 있다
const {
data, // → Ref<결과값> — 가져온 데이터
pending, // → Ref<boolean> — 로딩 중 여부
error, // → Ref<Error|null> — 에러 정보
refresh, // → () => void — 데이터 다시 가져오기
execute // → () => void — 수동으로 실행 (lazy 옵션과 함께 사용)
} = await useAsyncData('user-1', () => $fetch('/api/user/1'))// HTTP가 아닌 비동기 작업도 가능
const { data: config } = await useAsyncData(
'app-config',
async () => {
// 서버에서만 실행되는 코드 (파일 읽기 등)
if (process.server) {
const fs = await import('fs/promises')
return JSON.parse(await fs.readFile('./config.json', 'utf-8'))
}
return null
}
)// Nuxt 4 — getCachedData로 캐싱 제어
const { data } = await useAsyncData(
'user-list',
() => $fetch('/api/users'),
{
getCachedData(key, nuxtApp) {
// nuxtApp.payload.data에 이미 데이터가 있으면 그걸 사용
return nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]
}
}
)// 이 두 코드는 동일하게 동작한다
const { data } = await useFetch('/api/user/1')
const { data } = await useAsyncData(
'/api/user/1', // ← URL이 키가 된다
() => $fetch('/api/user/1')
)// useFetch 반환값 — useAsyncData와 동일
const {
data, // → Ref<결과값>
pending, // → Ref<boolean>
error, // → Ref<Error|null>
refresh, // → () => void — 다시 가져오기
execute, // → () => void — 수동 실행
status // → Ref<'idle'|'pending'|'success'|'error'>
} = await useFetch('/api/users')// Nuxt 4 — 반응형 파라미터 변경 시 자동 재요청
const userId = ref(1)
const { data } = await useFetch(() => `/api/user/${userId.value}`, {
watch: [userId] // ← userId가 바뀌면 자동으로 다시 호출
})
// 또는 URL 자체를 함수로 만들면 watch 옵션 없이도 반응형으로 동작
const { data } = await useFetch(() => `/api/user/${userId.value}`)// ❌ userId가 바뀌어도 재요청 안 됨
const { data } = await useFetch(`/api/user/${userId.value}`)
// → 처음 실행 시점의 userId.value가 문자열로 고정됨
// ✅ userId가 바뀌면 재요청됨
const { data } = await useFetch(() => `/api/user/${userId.value}`)
// → 매번 실행 시점의 userId.value를 읽음$fetch | useAsyncData | useFetch | |
SSR 이중 호출 | 있음 | 없음 (페이로드 재사용) | 없음 (페이로드 재사용) |
반환 형태 | 데이터 직접 | { data, pending, error, ... } | { data, pending, error, ... } |
고유 키 | 없음 | 직접 지정 (필수) | URL 기반 자동 생성 |
HTTP 전용 여부 | HTTP 전용 | 어떤 비동기든 가능 | HTTP 전용 |
반응형 URL | 없음 | 없음 | 함수로 넘기면 가능 |
사용 위치 | 어디서든 | <script setup>, 컴포저블 | <script setup>, 컴포저블 |
Nuxt 4 캐싱 | 없음 | getCachedData 지원 | getCachedData 지원 |
<script setup>
// 상품 목록 페이지
const { data: products, pending, error } = await useFetch('/api/products')
</script>
<template>
<div v-if="pending">로딩 중...</div>
<div v-else-if="error">오류가 발생했습니다.</div>
<ul v-else>
<li v-for="product in products" :key="product.id">
{{ product.name }}
</li>
</ul>
</template><script setup>
const { data } = await useAsyncData(
'dashboard-stats', // ← 키를 명시적으로 지정
async () => {
// 여러 API 호출을 병렬로 실행하고 조합
const [users, orders, revenue] = await Promise.all([
$fetch('/api/users/count'),
$fetch('/api/orders/today'),
$fetch('/api/revenue/month')
])
return { users, orders, revenue }
}
)
</script><script setup>
async function handleDelete(productId) {
try {
await $fetch(`/api/products/${productId}`, { method: 'DELETE' })
// 삭제 후 목록 갱신
await refresh()
} catch (e) {
console.error('삭제 실패:', e)
}
}
async function handleLogin(formData) {
const result = await $fetch('/api/auth/login', {
method: 'POST',
body: formData
})
await navigateTo('/dashboard')
}
</script>// server/api/users.get.ts
export default defineEventHandler(async (event) => {
// 여기서는 $fetch만 사용 가능
const data = await $fetch('https://external-api.com/users')
return data
})
const oFetchData = await $fetch<{ todos: Todo[]; achievement_rate: number | null; }>(`/api/todos?date=${date}`); console.log(oFetchData); const useFetchData = await useFetch(`/api/todos?date=${date}`); console.log(useFetchData);