데이터 페칭
데이터 페칭 (Data Fetching)
SSR 프레임워크에서 데이터를 불러올 때 가장 신경 쓰이는 게 "같은 요청이 서버와 클라이언트에서 두 번 나가지 않게 하는 것"이에요. Nuxt는 이를 해결하기 위해 useFetch, useAsyncData, $fetch 세 가지를 제공해요. 언제 어떤 걸 골라 써야 하는지, 그리고 공통 옵션의 동작까지 정리할게요.
출처: 공식문서
본문
한 줄로 요약하면 이렇게 돼요.
$fetch— 네트워크 요청을 보내는 가장 단순한 방법useFetch— 유니버설 렌더링에서 데이터를 한 번만 가져오도록 하는$fetch래퍼useAsyncData—useFetch와 비슷하지만 더 세밀한 제어 제공
useFetch와 useAsyncData는 공통된 옵션과 패턴을 공유해요.
왜 useFetch / useAsyncData가 필요한가
Nuxt는 서버와 클라이언트 어디서든 도는 동형(유니버설) 코드를 실행할 수 있어요. Vue 컴포넌트의 setup 함수에서 $fetch로 데이터를 가져오면, 서버(HTML 렌더링)에서 한 번, 클라이언트(하이드레이션)에서 한 번이라 두 번 가져올 수 있어요. 이러면 하이드레이션 문제가 생기고, 상호작용 가능 시점까지의 시간이 늘어나며 예측할 수 없는 동작이 벌어질 수 있어요.
useFetch와 useAsyncData는 서버에서 API 호출이 일어나면 그 데이터를 페이로드로 클라이언트에 전달해서 이 문제를 풀어요. 페이로드는 useNuxtApp().payload로 접근할 수 있는 자바스크립트 객체로, 하이드레이션 중 브라우저에서 같은 데이터를 다시 가져오지 않도록 해 줘요. Nuxt DevTools의 Payload 탭에서 이 데이터를 확인할 수 있어요.
Suspense
Nuxt는 내부적으로 Vue의 <Suspense> 컴포넌트를 써서, 모든 비동기 데이터가 뷰에 준비되기 전에는 내비게이션을 막아요. 데이터 페칭 컴포저블이 이 기능을 활용하게 해 주는 거죠. 페이지 전환 사이에 진행 바를 넣고 싶다면 <NuxtLoadingIndicator>를 추가할 수 있어요.
await에 대한 메모
문서의 예시는 보통 useFetch와 useAsyncData를 await 하지만, 항상 그럴 필요는 없어요. await는 서버 렌더링된 HTML을 바꾸지 않아요. 서버 렌더링 중에는 <Suspense>와 onServerPrefetch 덕분에 어차피 요청이 해석될 때까지 기다렸다가 페이지를 직렬화하거든요. await가 바꾸는 건 그다음 script setup에서 일어나는 일과 클라이언트 내비게이션의 동작이에요.
await를 쓰면 데이터가 준비될 때까지 실행이 멈춰서 그 뒤 코드는 데이터가 채워진 상태를 전제할 수 있어요. 클라이언트 내비게이션에서는 데이터가 해석될 때까지 내비게이션이 막혀, 현재 페이지에 머물다가(선택적으로<NuxtLoadingIndicator>표시) 완전히 채워진 페이지에 도달해요. 기본 동작이에요await를 안 쓰면 요청이 백그라운드에서 도는 동안 즉시 실행을 이어가고, 데이터는 기본값으로 시작했다가 요청이 해석되면 채워져요. 클라이언트 내비게이션도 즉시 일어나므로, 로딩/에러 상태는 돌려받은status와errorref로 직접 다뤄야 해요
둘 중 어느 쪽이 절대적으로 낫다는 건 없고, 그 경로에 원하는 경험에 따라 고르면 돼요.
await를 안 쓰는 건 lazy 옵션과 사용자에게 보이는 효과가 비슷하지만, 완전히 같진 않아요. lazy는 요청을 컴포넌트 마운트까지 미루는 명시적 플래그이고, 단순히 await를 안 쓰는 건 setup 동안 요청을 시작하는 거예요. 논블로킹 동작을 원한다면 의도를 명확히 하려고 lazy(또는 useLazyFetch / useLazyAsyncData)를 선호해요.
$fetch
Nuxt는 ofetch 라이브러리를 포함하며, $fetch 별칭으로 전역 자동 임포트돼요.
<!-- pages/todos.vue -->
<script setup lang="ts">
async function addTodo () {
const todo = await $fetch('/api/todos', {
method: 'POST',
body: {
// My todo data
},
})
}
</script>
주의할 점은 $fetch만 쓰면 네트워크 호출 중복 제거와 내비게이션 방지가 제공되지 않아요. $fetch는 클라이언트 상호작용(이벤트 기반)이나, 초기 컴포넌트 데이터를 가져올 때 useAsyncData와 함께 쓰는 걸 권장해요.
useFetch
useFetch 컴포저블은 내부에서 $fetch를 써서 setup 함수에서 SSR-안전한 네트워크 호출을 만들어요.
<!-- app/app.vue -->
<script setup lang="ts">
const { data: count } = await useFetch('/api/count')
</script>
<template>
<p>Page visits: {{ count }}</p>
</template>
이 컴포저블은 useAsyncData 컴포저블과 $fetch 유틸의 래퍼예요.
useAsyncData
useAsyncData 컴포저블은 비동기 로직을 감싸고, 해석되면 결과를 돌려주는 역할을 해요. useFetch(url)은 사실상 useAsyncData(url, () => event.$fetch(url))과 같아요 — 가장 흔한 사용 사례를 위한 개발자 경험상의 단맛(syntactic sugar)이죠.
CMS나 서드파티가 자체 쿼리 레이어를 제공하는 경우처럼 useFetch가 적합하지 않을 때 useAsyncData로 호출을 감싸고도 컴포저블의 이점을 유지할 수 있어요.
<!-- app/pages/users.vue -->
<script setup lang="ts">
const { data, error } = await useAsyncData('users', () => myGetFunction('users'))
// 이것도 가능
const { data, error } = await useAsyncData(() => myGetFunction('users'))
</script>
useAsyncData의 첫 번째 인자는 두 번째 인자(쿼리 함수)의 응답을 캐시하는 고유 키예요. 쿼리 함수를 직접 넘기면 자동 생성되지만, 자동 생성 키는 호출 위치만 반영하므로 커스텀 컴포저블로 감쌀 때는 원치 않은 동작을 피하려고 직접 키를 만드는 걸 권장해요. 키는 useNuxtData로 컴포넌트 간 같은 데이터를 공유하거나 특정 데이터를 리프레시하는 데도 유용해요.
useAsyncData는 여러 $fetch 요청이 모두 끝나기를 기다렸다가 결과를 처리할 때도 좋아요.
<script setup lang="ts">
const { data: discounts, status } = await useAsyncData('cart-discount', async (_nuxtApp, { signal }) => {
const [coupons, offers] = await Promise.all([
$fetch('/cart/coupons', { signal }),
$fetch('/cart/offers', { signal }),
])
return { coupons, offers }
})
// discounts.value.coupons
// discounts.value.offers
</script>
useAsyncData는 데이터를 가져오고 캐시하기 위한 것이지, Pinia 액션 호출 같은 사이드 이펙트를 트리거하기 위한 게 아니에요. 그렇게 쓰면 nullish 값으로 반복 실행되는 등 원치 않는 동작이 생길 수 있어요. 사이드 이펙트가 필요하면 callOnce 유틸을 쓰세요.
반환 값
useFetch와 useAsyncData는 같은 반환 값을 가져요.
data— 전달한 비동기 함수의 결과refresh/execute— 핸들러 함수가 돌려준 데이터를 갱신하는 함수clear—data를undefined(또는options.default()값),error를undefined,status를idle로 만들고 대기 중인 요청은 취소 처리하는 함수error— 데이터 페칭 실패 시의 에러 객체status— 요청 상태를 나타내는 문자열("idle","pending","success","error")
data, error, status는 Vue ref라서 <script setup>에서 .value로 접근해요. 기본적으로 Nuxt는 refresh가 끝나기 전까지는 다시 실행되지 않도록 기다려요. 서버에서 데이터를 가져오지 않았다면(server: false처럼) 하이드레이션이 끝나기 전까지는 데이터가 없으므로, 클라이언트에서 useFetch를 await해도 <script setup> 안에서 data는 undefined로 남아요.
옵션
useAsyncData와 useFetch는 같은 객체 타입을 반환하고 마지막 인자로 공통 옵션을 받아요. 내비게이션 차단, 캐싱, 실행 같은 컴포저블 동작을 제어할 수 있어요.
Lazy
기본적으로 데이터 페칭 컴포저블은 Vue의 Suspense를 사용해 새 페이지로 내비게이션하기 전에 비동기 함수가 해석되기를 기다려요. 클라이언트 내비게이션에서는 lazy 옵션으로 이 기능을 끌 수 있는데, 그러면 status 값으로 로딩 상태를 직접 다뤄야 해요. useLazyFetch/useLazyAsyncData를 쓰면 같은 동작을 간편하게 할 수 있어요.
<!-- app/app.vue -->
<script setup lang="ts">
const { status, data: posts } = useFetch('/api/posts', {
lazy: true,
})
</script>
<template>
<!-- 로딩 상태를 직접 다뤄야 해요 -->
<div v-if="status === 'pending'">
Loading ...
</div>
<div v-else>
<div v-for="post in posts">
<!-- do something -->
</div>
</div>
</template>
클라이언트 전용 페칭
기본적으로 데이터 페칭 컴포저블은 클라이언트와 서버 양쪽에서 비동기 함수를 실행해요. server 옵션을 false로 두면 클라이언트에서만 호출돼요. 초기 로드에서는 하이드레이션이 끝나기 전에 데이터를 가져오지 않으므로 pending 상태를 다뤄야 하고, 이후 클라이언트 내비게이션에서는 페이지 로드 전에 데이터가 await돼요. lazy와 함께 쓰면 첫 렌더링에 필요 없는(예: SEO에 민감하지 않은) 데이터에 유용해요.
/* 이 호출은 하이드레이션 전에 수행 */
const articles = await useFetch('/api/article')
/* 이 호출은 클라이언트에서만 수행 */
const { status, data: comments } = useFetch('/api/comments', {
lazy: true,
server: false,
})
useFetch는 setup 메서드 안이나 라이프사이클 훅의 함수 최상위에서 직접 호출하도록 설계됐고, 그 외에는 $fetch를 쓰는 게 맞아요.
페이로드 크기 줄이기
pick 옵션은 HTML 문서에 저장되는 페이로드에서 원하는 필드만 선택해 크기를 줄여 줘요.
/* 템플릿에서 쓰는 필드만 선택 */
const { data: mountain } = await useFetch('/api/mountains/everest', {
pick: ['title', 'description'],
})
더 제어가 필요하거나 여러 객체를 매핑한다면 transform 함수로 쿼리 결과를 바꿀 수 있어요. pick과 transform 모두 처음에 불필요한 데이터가 가져와지는 걸 막지는 못하지만, 서버→클라이언트로 전송되는 페이로드에 들어가는 건 막아 줘요.
캐싱과 리페치 — 키
useFetch와 useAsyncData는 키를 써서 같은 데이터를 다시 가져오지 않게 해요. useFetch는 URL, fetch 옵션, 소스 코드 내 호출 위치로 키를 생성하므로, 다른 컴포넌트에서 같은 URL의 useFetch 두 개는 서로 다른 키를 갖고 각자 요청을 수행해요. 여러 컴포넌트가 같은 데이터를 공유하려면 마지막 인자로 넘기는 옵션 객체에 같은 명시적 키를 주면 돼요. useAsyncData는 첫 번째 인자가 문자열이면 그걸 키로 쓰고, 쿼리 함수면 호출 위치 기준 고유 키가 생성돼요. 키로 캐시된 데이터를 얻으려면 useNuxtData를 쓰세요.
같은 키를 쓰는 여러 컴포넌트는 같은 data, error, status ref를 공유해요. 단, 같은 키의 모든 호출에서 일관돼야 하는 옵션이 있어요 — handler 함수, deep 옵션, transform 함수, pick 배열, getCachedData 함수, default 값이요. 반대로 server, lazy, immediate, dedupe, watch는 달라도 안전해요.
반응형 키
키로 computed ref, 일반 ref, getter 함수를 쓸 수 있어 의존성이 바뀌면 자동으로 데이터가 다시 fetch되는 동적 페칭이 가능해요. reactivity가 바뀌면 데이터가 자동 리페치되고, 다른 컴포넌트가 안 쓰면 예전 데이터는 정리돼요.
refresh / execute
데이터를 수동으로 가져오거나 갱신하려면 컴포저블이 제공하는 execute나 refresh 함수를 써요. execute는 refresh의 별칭으로 동작은 같지만, fetch가 즉시가 아닐 때 더 의미론적으로 어울려요. 캐시된 데이터를 전역으로 다시 가져오거나 무효화하려면 clearNuxtData와 refreshNuxtData를 참고하세요.
clear
어떤 이유든 특정 키를 몰라도 데이터를 비우고 싶다면 clear 함수를 쓸 수 있어요.
<script setup lang="ts">
const { data, clear } = await useFetch('/api/users')
const route = useRoute()
watch(() => route.path, (path) => {
if (path === '/') {
clear()
}
})
</script>
watch
다른 반응형 값이 바뀔 때마다 페칭 함수를 다시 실행하려면 watch 옵션을 써요. 여러 개를 watch할 수도 있어요. 단, 반응형 값을 watch한다고 fetch되는 URL이 바뀌는 건 아니에요 — URL은 함수가 호출되는 시점에 만들어지기 때문이에요. 반응형 값에 따라 URL을 바꿔야 한다면 computed URL을 쓰는 게 좋아요. 반응형 fetch 옵션을 주면 자동으로 watch되어 리페치가 트리거되는데, watch: false로 이 동작을 끌 수도 있어요.
computed URL
반응형 값에서 URL을 계산하고 바뀔 때마다 데이터를 갱신해야 한다면, 각 파라미터를 반응형 값으로 붙이면 돼요. Nuxt가 반응형 값을 자동으로 사용하고 바뀔 때마다 다시 fetch해요. 더 복잡한 URL 구성은 computed getter 콜백으로 URL 문자열을 반환하면 되고, 여기에 immediate: false를 조합하면 반응형 값이 바뀌기 전까지는 fetch를 기다릴 수 있어요.
not immediate
useFetch는 호출되는 즉시 데이터를 가져오기 시작해요. 사용자 상호작용을 기다리려면 immediate: false로 막을 수 있어요. 그러면 fetch 라이프사이클을 다루는 status와 fetch를 시작하는 execute가 모두 필요해요. status는 미세 제어를 위해 idle(시작 안 함), pending(시작했지만 완료 안 됨), error(실패), success(성공) 값을 가져요.
헤더와 쿠키 전달
브라우저에서 $fetch를 호출하면 cookie 같은 사용자 헤더가 API에 직접 전송돼요. 서버 사이드 렌더링 중에는 보안상 이유로 $fetch가 사용자의 브라우저 쿠키를 포함하지 않고, fetch 응답의 쿠키도 전달하지 않아요. 다만 서버에서 상대 URL로 useFetch를 호출하면 Nuxt가 useRequestFetch로 헤더와 쿠키를 프록시해요(host처럼 전달되면 안 되는 헤더 제외).
반대 방향, 즉 내부 요청의 쿠키를 클라이언트로 되돌려 보내고 싶다면 직접 처리해야 해요.
// app/composables/fetch.ts
import { appendResponseHeader } from 'h3'
import type { H3Event } from 'h3'
export const fetchWithCookie = async (event: H3Event, url: string) => {
/* 서버 엔드포인트에서 응답 가져오기 */
const res = await $fetch.raw(url)
/* 응답에서 쿠키 가져오기 */
const cookies = res.headers.getSetCookie()
/* 각 쿠키를 들어오는 Request 에 붙이기 */
for (const cookie of cookies) {
appendResponseHeader(event, 'set-cookie', cookie)
}
/* 응답의 데이터 반환 */
return res._data
}
외부 API로 헤더를 프록시할 땐 꼭 필요한 것만 포함해야 해요. 모든 헤더가 우회해도 안전한 건 아니고 원치 않는 동작을 일으킬 수 있어요. 프록시하면 안 되는 흔한 헤더는 host, accept, content-length, content-md5, content-type, x-forwarded-host, x-forwarded-port, x-forwarded-proto, cf-connecting-ip, cf-ray예요.
Options API 지원
Nuxt는 Options API에서도 asyncData 페칭을 제공해요. 컴포넌트 정의를 defineNuxtComponent로 감싸야 해요. 다만 <script setup>(또는 lang="ts")을 쓰는 게 권장 방식이에요.
서버→클라이언트 데이터 직렬화
useAsyncData/useLazyAsyncData로 서버에서 가져온 데이터를 클라이언트로 전달할 때(Nuxt 페이로드를 쓰는 그 밖의 것도 마찬가지) 페이로드는 devalue로 직렬화돼요. 그래서 기본 JSON뿐 아니라 정규표현식, Date, Map, Set, ref, reactive, shallowRef, shallowReactive, NuxtError 같은 고급 데이터 타입도 직렬화·복원할 수 있어요. Nuxt가 지원하지 않는 타입은 직접 직렬화기/역직렬화기를 정의할 수도 있어요. 단 이건 서버 라우트에서 $fetch/useFetch로 가져올 때는 적용되지 않아요.
API 라우트 데이터 직렬화
server/ 디렉터리에서 데이터를 가져오면 응답은 JSON.stringify로 직렬화돼요. 직렬화가 JS 원시 타입에만 국한되므로, Nuxt는 $fetch/useFetch의 반환 타입을 실제 값에 맞추려고 최선을 다해요. 예를 들어 Date 객체를 돌려주는 API를 useFetch('/api/foo')로 가져오면 data의 타입이 Date가 아니라 string으로 추론돼요.
직렬화 동작을 커스터마이즈하려면 반환 객체에 toJSON 함수를 정의하면 돼요. toJSON 메서드를 정의하면 Nuxt는 그 함수의 반환 타입을 존중하고 타입을 변환하지 않아요.
// server/api/bar.ts
export default defineEventHandler(() => {
const data = {
createdAt: new Date(),
toJSON () {
return {
createdAt: {
year: this.createdAt.getFullYear(),
month: this.createdAt.getMonth(),
day: this.createdAt.getDate(),
},
}
},
}
return data
})
Nuxt는 현재 JSON.stringify를 대체할 직렬화기를 지원하지 않아요. 대신 페이로드를 일반 문자열로 반환하고 toJSON 메서드로 타입 안전성을 유지할 수 있어요. 아래 예시는 superjson을 직렬화기로 쓰는 경우예요.
// server/api/superjson.ts
import superjson from 'superjson'
export default defineEventHandler(() => {
const data = {
createdAt: new Date(),
// 타입 변환 우회
toJSON () {
return this
},
}
// superjson 으로 출력을 문자열로 직렬화
return superjson.stringify(data) as unknown as typeof data
})
레시피
SSE를 POST 요청으로 소비하기
GET으로 SSE를 소비한다면 EventSource나 VueUse 컴포저블 useEventSource를 쓰면 돼요. POST 요청으로 SSE를 소비하려면 연결을 직접 다뤄야 해요.
// SSE 엔드포인트에 POST 요청
const response = await $fetch<ReadableStream>('/chats/ask-ai', {
method: 'POST',
body: {
query: 'Hello AI, how are you?',
},
responseType: 'stream',
})
// TextDecoderStream 으로 데이터를 텍스트로 받는 새 ReadableStream 생성
const reader = response.pipeThrough(new TextDecoderStream()).getReader()
// 데이터 조각을 받는 대로 읽기
while (true) {
const { value, done } = await reader.read()
if (done) { break }
console.log('Received:', value)
}
병렬 요청
서로 의존하지 않는 요청은 Promise.all()로 병렬 처리하면 성능을 높일 수 있어요.
const { data } = await useAsyncData((_nuxtApp, { signal }) => {
return Promise.all([
$fetch('/api/comments/', { signal }),
$fetch('/api/author/12', { signal }),
])
})
const comments = computed(() => data.value?.[0])
const author = computed(() => data.value?.[1])
더 알아보기
useFetch,useAsyncData,useLazyFetch,useLazyAsyncData,useNuxtData세부 옵션은 API 문서를 참고하세요- 데이터 페칭으로 서버의 사용자 쿠키/헤더를 다루는 더 깊은 내용은
useRequestFetch·useRequestHeaders문서를 참고하세요