Nuxt useFetch를 await하면 페이지 전환이 멈추는 이유: useLazyFetch로 2.4초 대기 없애기
목록에서 상세를 눌러도 2.4초 동안 화면이 그대로였다. 원인은 await useFetch와 응답에 묶인 느린 AI 호출이었다. useLazyFetch, server: false, 스켈레톤으로 고친 과정과 직접 잰 결과를 정리했다.
눌렀는데 2.4초 동안 아무 일도 안 일어난다
관광 정보 서비스를 Nuxt로 만들던 중이었다. 목록에서 관광지 하나를 누르면 상세 페이지로 가는데, 누른 뒤 2.4초 동안 화면이 그대로였다. 로딩 표시도 없이 목록이 계속 보이니, 사용자 입장에서는 클릭이 안 된 것처럼 느껴진다.
상세 페이지 코드는 이랬다.
// pages/spots/[id].vue
const { data: spot } = await useFetch(() => `/api/spots/${id.value}`)
그리고 상세 API는 관광공사 API에서 정보를 가져온 뒤, Claude API로 소개글 요약까지 만들어서 한 번에 돌려주고 있었다. 이 요약 호출이 1~2초 걸렸다.
원인은 두 가지가 겹친 것이었다.
| 원인 | 결과 |
|---|---|
| 상세 API가 느린 AI 요약까지 끝내고 응답 | 상세 응답 자체가 1~2초 늦어짐 |
| 페이지에서 await useFetch | 그 응답이 올 때까지 페이지 이동이 멈춤 |
await useFetch는 페이지 이동을 막는다
useFetch를 await하면, Nuxt는 데이터가 올 때까지 다음 페이지로 넘어가지 않는다. 그동안 사용자는 이전 페이지를 계속 보고 있다.
말로만 들으면 와닿지 않아서, 2초 걸리는 API를 만들어 직접 재 봤다. 목록 페이지에서 링크를 누르고, 다음 페이지 제목이 보일 때까지의 시간이다.
// 2초 뒤에 응답하는 테스트 API
export default defineEventHandler(async () => {
await new Promise((r) => setTimeout(r, 2000))
return { ok: true }
})
| 페이지 코드 | 다음 페이지가 보이기까지 | 데이터가 오기까지 |
|---|---|---|
| await useFetch(...) | 약 2,400ms | 약 2,400ms |
| useLazyFetch(...) | 약 100ms | 약 2,100ms |
데이터가 오는 시간은 둘 다 2초 남짓으로 비슷하다. 다른 건 화면이 바뀌는 시점이다. await useFetch는 데이터가 올 때까지 이전 페이지에 머물고, useLazyFetch는 바로 다음 페이지로 넘어간 뒤 데이터를 기다린다. (Nuxt 3.21 개발 서버, Chromium에서 각각 두 번씩 잰 값이다.)
useLazyFetch는 useFetch에 lazy를 켠 것
// 둘은 같다
const { data } = useLazyFetch('/api/spots/1')
const { data } = useFetch('/api/spots/1', { lazy: true })
lazy를 켜면 Nuxt가 데이터를 기다리지 않고 페이지부터 보여준다. 대신 처음에는 data가 비어 있다는 걸 화면에서 처리해야 한다.
첫 접속에서는 둘 다 기다린다. 주소를 직접 입력하거나 새로고침해서 들어오면, 서버가 HTML을 만들 때 데이터를 가져온다(서버 렌더링). 이때는 useLazyFetch도 데이터를 기다린 뒤 HTML을 보낸다. 같은 테스트 페이지를 curl로 요청해 보니 둘 다 약 2초가 걸렸고, HTML에는 데이터가 채워져 있었다. lazy가 줄여 주는 건 앱 안에서 페이지를 이동할 때의 대기다.
고친 방법 1: 느린 AI 요약을 별도 API로 분리
useLazyFetch로 바꾸기 전에 먼저 할 일이 있었다. 응답 자체를 빠르게 만드는 것이다. 상세 API에서 AI 요약 호출을 빼고, 요약만 따로 돌려주는 API를 만들었다. (아래 코드는 설명에 필요한 부분만 남기고 줄였다.)
// server/api/spots/[id].get.ts — 상세: AI 요약 호출을 뺐다
const detail = toSpotDetail(item, intro, images)
// AI 요약은 여기서 부르지 않는다. Claude 호출이 1~2초라 상세 전체가 그만큼 늦어진다.
return detail
// server/api/spots/[id]/summary.get.ts — 요약 전용
export default defineEventHandler(async (event) => {
const contentId = getRouterParam(event, 'id')
try {
const { name, overview, summary } = await getSpot(contentId) // 상세와 같은 캐시를 쓴다
if (!overview) return { summary: null }
const aiSummary = await summarizeWithAI(contentId, name, overview)
return { summary: aiSummary ?? summary ?? null }
} catch {
return { summary: null } // 요약은 없어도 페이지는 성립하므로 에러를 올리지 않는다
}
})
이것만으로 상세 API 응답이 0.1~0.4초로 줄었다. 요약 API는 상세 API와 같은 캐시 키로 원본 데이터를 읽어서, 관광공사 API를 다시 부르지 않는다.
느린 작업이 "없어도 페이지가 성립하는 부가 정보"라면, 메인 응답에 묶지 말고 따로 가져오게 한다.
고친 방법 2: 페이지는 useLazyFetch, 요약은 server: false
// pages/spots/[id].vue
// 상세: 기다리지 않고 페이지부터 보여준다
const { data: spot, error, status } = useLazyFetch(() => `/api/spots/${id.value}`, {
key: () => `spot-${id.value}`,
})
const pending = computed(() => status.value === 'pending')
// 요약: 브라우저에서만 가져온다
const { data: aiSummary, status: summaryStatus } = useLazyFetch(
() => `/api/spots/${id.value}/summary`,
{ key: () => `spot-summary-${id.value}`, server: false },
)
| 옵션 | 효과 |
|---|---|
| useLazyFetch (= lazy: true) | 앱 안에서 이동할 때 데이터를 기다리지 않는다 |
| server: false | 서버 렌더링 때는 아예 부르지 않고 브라우저에서만 가져온다 |
요약에 server: false를 준 이유는 위의 경고 때문이다. lazy만 주면 첫 접속에서는 서버가 요약까지 기다린다. 요약은 1~2초 걸리니, 첫 접속 HTML이 그만큼 늦어진다. server: false면 서버는 요약 없이 HTML을 바로 보내고, 요약은 브라우저가 나중에 채운다.
다만 server: false로 가져온 내용은 서버 HTML에 없다. 검색엔진이 꼭 읽어야 하는 본문에는 쓰면 안 되고, 이번 AI 요약처럼 부가 정보에만 쓰는 게 맞다.
고친 방법 3: 기다리는 동안 보여줄 화면
useLazyFetch로 바꾸면 페이지가 먼저 뜨고 spot은 잠시 비어 있다. 그동안 빈 화면이 보이면 안 되니, 실제 레이아웃과 같은 크기의 스켈레톤을 그렸다.
<template>
<div v-if="error">관광지 정보를 불러오지 못했어요.</div>
<!-- 실제 화면과 같은 골격. 데이터가 와도 레이아웃이 밀리지 않는다 -->
<div v-else-if="pending" class="skeleton" aria-hidden="true">
<div class="skeleton__title" />
<div class="skeleton__media" />
<div v-for="n in 5" :key="n" class="skeleton__row" />
</div>
<template v-else-if="spot">
<h1>{{ spot.name }}</h1>
...
</template>
</template>
스켈레톤의 크기를 실제 화면과 맞춘 이유는, 데이터가 도착했을 때 글과 사진이 아래로 밀리는 현상을 막기 위해서다.
요약은 "확정된 뒤 한 번만" 그린다
처음에는 AI 요약이 오기 전까지 간단한 규칙 기반 요약을 먼저 보여주려 했다. 그런데 그러면 읽고 있던 글이 1~2초 뒤 AI 요약으로 갑자기 바뀐다. 그래서 요약이 오는 동안에는 로딩 표시만 보여주고, 결과가 확정되면 한 번만 그리게 했다. AI 요약이 실패했을 때만 규칙 기반 요약으로 대신한다.
const summaryPending = computed(() => summaryStatus.value === 'pending')
const summary = computed(() => {
if (summaryPending.value) return '' // 오는 중: 로딩 표시
return aiSummary.value?.summary || spot.value?.summary || '' // 실패 시 규칙 기반
})
await가 없어지면 바뀌는 것
await를 빼면 setup 안에서 spot.value를 바로 읽을 수 없다. 처음에는 비어 있기 때문이다. 원래 코드에는 setup에서 console.log(spot.value...)로 응답을 찍는 부분이 있었는데, useLazyFetch로 바꾸면 항상 undefined가 찍힌다. 데이터가 도착할 때 실행되도록 watch로 옮겨야 한다.
watch(spot, (value) => {
if (!value) return
console.log('[상세] 받은 데이터', value)
}, { immediate: true })
결과
| 항목 | 고치기 전 | 고친 뒤 |
|---|---|---|
| 목록 → 상세 클릭 후 화면이 바뀌기까지 | 2.4초 (이전 화면 그대로) | 바로 (스켈레톤) |
| 상세 API 응답 | AI 요약(1~2초)까지 기다림 | 0.1~0.4초 |
| AI 요약 | 상세와 함께 도착 | 브라우저에서 따로, 도착하면 한 번만 그림 |
정리
| 상황 | 쓸 것 |
|---|---|
| 데이터 없이는 페이지가 의미 없고, 응답이 빠르다 | await useFetch (기본) |
| 응답이 느릴 수 있고, 먼저 화면을 보여주고 싶다 | useLazyFetch + 로딩/스켈레톤 |
| 느린 부가 정보 (AI 요약, 추천, 통계) | 별도 API로 분리 + server: false |
| 검색엔진이 읽어야 하는 본문 | server: false는 쓰지 않는다 |
await useFetch는 코드가 가장 간단해서 기본으로 쓰기 쉽다. 하지만 응답이 느린 순간, 그 시간만큼 페이지 이동이 통째로 멈춘다는 걸 알고 써야 한다. 먼저 느린 부분을 응답에서 떼어 내고, 남은 대기는 lazy와 스켈레톤으로 사용자에게 보이게 하는 게 순서다.
useFetch/useAsyncData의 key를 잘못 정해서 생기는 문제는 useAsyncData 캐싱 문제와 Key 동적 설정 글에 정리했다.