지금 주인장은 Nest.js 공부 중 ···
Sign In
프론트엔드

Nuxt4의 useFetch · useAsyncData · $fetch

현우
Last modified
레퍼런스
Empty
Nuxt를 처음 쓸 때 데이터를 가져오는 방법이 여러 개라는 게 헷갈렸다. useFetch도 있고, useAsyncData도 있고, $fetch도 있다. 셋 다 API를 호출하는 것 같은데 왜 따로 존재하는걸까?
$fetch가 네이티브 fetch나 axios 처럼 이벤트처럼 사용하는 것이고, 또 useFetch는 데이터 뿐만 아니라, 로딩 및 에러 상태를 받아볼 수 있고.. 이 세가지의 패칭 방식에 대해서 온전히 잘 모르다보니 어느 상황에서 사용해야하고 적용해야하는지 헷갈렸다.
Nuxt는 서버와 클라이언트 양쪽에서 코드가 실행되는 환경을 제공한다. 같은 컴포넌트 코드가 서버에서 한 번 실행되고, 브라우저에서 또 실행된다. 이 구조에서 데이터를 어떻게 가져오느냐에 따라 API 호출 횟수, 사용자 경험, 성능이 모두 달라진다. 세 가지 방법은 이 구조 안에서 각자 다른 역할을 위해 설계됐다.

먼저 Nuxt4의 SSR 방식 가볍게 알아보기

브라우저에서만 돌아가는 React 앱이라면 데이터 페칭은 단순하다. 컴포넌트가 마운트되면 API를 호출하고, 응답이 오면 화면에 표시한다. 그게 전부다.
Nuxt의 SSR(서버 사이드 렌더링)은 다르다. 같은 페이지를 두 번 렌더링한다.
1. 사용자가 URL 접속 ↓ 2. 서버에서 Vue 컴포넌트 실행 → API 호출 → 응답 데이터로 HTML 생성 → HTML을 브라우저로 전송 ↓ 3. 브라우저가 HTML을 표시 (첫 화면이 빠르게 보임) ↓ 4. 브라우저에서 Vue 앱 초기화 (하이드레이션) → <script setup> 코드가 다시 실행됨 → 여기서 또 API를 호출하면 이중 호출 발생!
💬
SSR(Server-Side Rendering)
브라우저가 아닌 서버에서 HTML을 미리 만들어서 보내주는 방식. 첫 화면이 빠르게 보이고, 검색엔진 최적화(SEO)에 유리하다.
💬
하이드레이션(Hydration)
서버에서 만든 정적 HTML에 Vue가 달라붙어서 상호작용 가능한 앱으로 만드는 과정. 이 시점에 <script setup> 코드가 브라우저에서 다시 실행된다.
이 이중 호출 문제를 해결하기 위해 Nuxt는 페이로드(payload) 메커니즘을 사용한다. 서버에서 가져온 데이터를 HTML 안에 직렬화해서 담아두고, 브라우저에서 하이드레이션할 때 그 데이터를 재사용한다.
<!-- 서버가 보내는 HTML 안에 이런 내용이 포함된다 --> <script> window.__nuxt = { data: { "fetch:0:0": { id: 1, name: "김현우", ... } // ← 서버에서 가져온 데이터 } } </script>
useFetchuseAsyncData는 이 메커니즘을 활용한다. 서버에서 실행하고, 데이터를 페이로드에 담고, 클라이언트에서는 API를 다시 호출하지 않는다. $fetch는 이 메커니즘을 사용하지 않는다.

세 가지 방법의 동작 원리

$fetch — 그냥 HTTP 요청

$fetch는 Nuxt가 내장하는 HTTP 클라이언트다. 브라우저의 fetch와 같은 역할이지만 더 편리한 기능(JSON 자동 파싱, 에러 핸들링 등)이 추가됐다.
💬
ofetch
Nuxt가 내부적으로 사용하는 HTTP 클라이언트 라이브러리. $fetch는 이 라이브러리의 인스턴스다. 브라우저 fetch를 기반으로 만들어졌다.
$fetch는 SSR 페이로드 메커니즘을 전혀 사용하지 않는다. 그냥 HTTP 요청을 보내고 응답을 반환한다.
내가 사용하면서 이해했던 바 로는 그냥 브라우저의 네이티브 fetch 또는 axios 느낌이었다.
// $fetch 사용 예시 const user = await $fetch('/api/user/1') // → 결과값이 바로 반환된다 (ref가 아님) // → pending, error 상태가 없다 // → 자동으로 화면이 업데이트되지 않는다
<script setup> 안에서 await $fetch(...)를 쓰면 어떻게 될까?
// ❌ 이렇게 쓰면 서버 + 클라이언트 양쪽에서 API가 호출된다 <script setup> const user = await $fetch('/api/user/1') // 서버에서 한 번, 브라우저에서 한 번 </script>
서버에서 실행될 때 $fetch가 한 번 호출된다. 그런데 브라우저에서 하이드레이션할 때 <script setup> 코드가 다시 실행되면서 $fetch가 또 한 번 호출된다. 서버가 이미 가져온 데이터를 클라이언트가 또 가져오는 것이다.
$fetch가 적합한 상황은 사용자 행동에 의해 트리거되는 경우다.
// ✅ $fetch가 적합한 경우 — 버튼 클릭, 폼 제출 등 async function submitForm() { const result = await $fetch('/api/submit', { method: 'POST', body: formData }) // → 사용자가 버튼을 눌렀을 때 실행 → SSR 이중 호출 문제가 없음 }

useAsyncData — 모든 비동기 작업의 SSR 래퍼

useAsyncData는 어떤 비동기 함수든 SSR-safe하게 실행해주는 Nuxt 컴포저블이다. HTTP 호출뿐 아니라 데이터베이스 직접 조회, 파일 읽기, 어떤 비동기 작업이든 감쌀 수 있다.
💬
컴포저블(Composable)
Vue 3의 Composition API를 활용한 재사용 가능한 함수. use로 시작하는 이름이 관례다.
// 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 요청이 아닌 것도 감쌀 수 있다는 점이 useFetch와의 차이다.
// 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 옵션이 추가됐다. 이미 캐시된 데이터가 있으면 비동기 함수를 아예 실행하지 않는다. 페이지 이동 후 뒤로 가기 했을 때 API를 다시 호출하지 않도록 할 수 있다.
// 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] } } )

useFetch — useAsyncData + $fetch의 단축형

useFetchuseAsyncData$fetch를 합친 것이다. 내부적으로 useAsyncData(() => $fetch(url, options))와 동일하게 동작한다. 차이는 키를 자동으로 생성한다는 점이다.
// 이 두 코드는 동일하게 동작한다 const { data } = await useFetch('/api/user/1') const { data } = await useAsyncData( '/api/user/1', // ← URL이 키가 된다 () => $fetch('/api/user/1') )
URL과 옵션을 조합해서 키를 자동으로 만들기 때문에, 같은 URL을 여러 컴포넌트에서 호출해도 하나의 요청으로 합쳐진다.
// 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에서 달라진 점: watch 옵션으로 반응형 값이 바뀔 때 자동으로 데이터를 다시 가져오는 기능이 더 직관적으로 동작한다.
// 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}`)
tanstack-query는 queryKey가 변경되면 자동으로 패칭을 진행하게 되는데, useFetch도 동일한 줄 알고 내부 반응형 값만 바뀌면 되는 줄 알았다. 알고보니 URL을 문자열로 넣으면 userId가 바뀌어도 URL이 고정돼서 재요청이 안 된다. URL을 함수로 넣어야 반응형으로 동작한다 ^,^ ..
// ❌ 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 지원

어떤 상황에서 패칭 메서드를 각각 사용해야할까?

페이지/컴포넌트 초기 데이터 로딩 → useFetch

가장 일반적인 경우다. 페이지가 열릴 때 API에서 데이터를 가져와서 화면에 표시하는 것.
<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>

복잡한 조건 로직이 필요하거나 HTTP가 아닌 비동기 작업 → useAsyncData

키를 직접 지정해야 할 때, 또는 조건에 따라 다른 API를 호출해야 할 때 useAsyncData가 더 적합하다.
<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>

버튼 클릭, 폼 제출 등 사용자 행동 → $fetch

페이지 로딩과 무관하게 사용자가 특정 동작을 했을 때 실행되는 API 호출에는 $fetch가 적합하다.
<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 디렉토리) → $fetch

Nuxt의 server/api/ 안에 있는 API 라우트는 서버에서만 실행된다. 여기서는 useAsyncData, useFetch를 쓸 수 없다. 이 컴포저블들은 Vue 컴포넌트나 <script setup> 컨텍스트에서만 동작하기 때문이다.
// server/api/users.get.ts export default defineEventHandler(async (event) => { // 여기서는 $fetch만 사용 가능 const data = await $fetch('https://external-api.com/users') return data })
useFetch, useAsyncData, $fetch를 처음엔 그냥 "API 호출하는 세 가지 방법" 정도로만 가볍게 봤다. 막상 프로젝트에서 적용하다보니 상황에 따라 어떤 메서드를 사용해야할지 너무 궁금점이 생겼다. SSR 페이로드 메커니즘에 대해 알아보면서 각각이 서로 다른 문제를 해결하기 위해 존재한다는 게 보였다.
useFetchuseAsyncData는 서버-클라이언트 이중 실행 환경에서 데이터를 안전하게 다루기 위한 것이고, $fetch는 그 환경과 무관하게 단순히 HTTP 요청을 보내는 것이다. 앞으로 Nuxt에서 데이터 페칭 방법을 고를 때 "이 코드가 언제 실행되는가"를 먼저 생각해보아야될 것 같다.
Subscribe to '悠悠自適'
Subscribe to my site to be the first to receive notifications and emails about the latest updates, including new posts.
Join Slashpage and subscribe to '悠悠自適'!
Subscribe
1
👍
현우
이런 경우가 있음, 아래와 같은 경우 조금 신기함
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);
이런 경우에는 oFetchData는 이중 호출이 되며(SSR, CSR) useFetchData는 신기하게 서버 로그에 찍히지 않고, 클라이언트 로그에 찍히게 된다. 그 이유는 Nuxt가 Vue의 getCurrentInstance()를 통해 현재 컴포넌트 인스턴스에 컨텍스트를 붙여놓는다. 그런데 await 이후 JavaScript 실행 흐름이 마이크로태스크 큐로 넘어가면서 "현재 실행중인 컴포넌트"가 누구인지 추적이 끊긴다.
Nuxt는 SSR 컨텍스트 없이 useFetch가 호출되면 서버에서 실행 자체를 건너뛴다. 에러를 던지는 것이 아니라 조용히 클라이언트 전용으로 fallback 하게 된다.
script setup 시작 → Nuxt가 현재 인스턴스 등록
└─ await $fetch() → 비동기 대기
└─ 다른 태스크 실행 가능 구간
└─ 재개 시 "현재 인스턴스"가 null
└─ useFetch → getCurrentInstance() === null → SSR 컨텍스트 없음
See latest comments