useAsyncData 키 관리, 캐시 무효화, 낙관적 업데이트, Nitro 캐싱 계층.
데이터 패칭 심화와 캐싱 전략
입문편에서 useFetch와 useAsyncData의 기본 사용법, lazy·server 옵션, $fetch와의 차이를 살펴봤습니다. 이제 한 단계 더 나아가, 실제 프로덕션 환경에서 반드시 마주치는 문제들을 다룰 차례입니다. 같은 데이터를 여러 컴포넌트에서 중복 요청하거나, 서버에서 렌더링한 데이터가 클라이언트에서 재요청되거나, 업데이트 후 캐시가 오래된 상태로 남아 사용자에게 잘못된 UI를 보여주는 상황들입니다.
이번 레슨에서는 Nuxt의 데이터 캐싱 계층이 내부적으로 어떻게 동작하는지 이해하고, 캐시 무효화와 낙관적 업데이트 패턴, 그리고 Nitro를 활용한 서버 측 캐싱 전략까지 실무 관점에서 깊이 있게 다룹니다.
학습 목표
- 키 기반 캐시와 payload 공유가 SSR·CSR 경계에서 어떻게 동작하는지 설명할 수 있다.
refresh,clearNuxtData,refreshNuxtData로 캐시 무효화를 정밀하게 제어할 수 있다.getCachedData·transform·pick옵션으로 중복 패칭과 메모리 사용량을 줄일 수 있다.- 낙관적 업데이트와 요청 중복 제거(dedupe) 패턴을 구현할 수 있다.
- Nitro routeRules·
cachedFunction·cachedEventHandler로 서버 측 ISR/SWR 캐싱을 설계할 수 있다.
키 기반 캐시와 Payload 공유 원리
useAsyncData의 첫 번째 인수인 **키(key)**는 단순한 식별자가 아닙니다. 이 키는 Nuxt의 useNuxtApp().payload.data 객체에 데이터를 저장하고 참조하는 실제 주소 역할을 합니다.
SSR 단계에서 서버가 데이터를 가져오면, 그 결과는 __NUXT_DATA__ 스크립트 블록에 직렬화되어 HTML과 함께 클라이언트로 전달됩니다. 클라이언트는 하이드레이션 과정에서 동일한 키로 useAsyncData를 호출할 때 실제 HTTP 요청을 보내지 않고 payload에서 값을 꺼내 씁니다. 이것이 payload 공유입니다.
// pages/posts/[id].vue
// 키가 동적이므로 route.params.id를 포함해야 함
const route = useRoute()
const { data: post } = await useAsyncData(
`post-${route.params.id}`, // ✅ 동적 키 — 페이지별로 독립된 캐시
() => $fetch(`/api/posts/${route.params.id}`)
)
키를 고정 문자열로만 쓰면 다른 게시물 페이지에서 잘못된 캐시를 재사용하는 버그가 발생합니다.
// ❌ 키가 고정이면 /posts/1에서 가져온 데이터가 /posts/2에서도 그대로 보임
const { data } = await useAsyncData('post', () => $fetch(`/api/posts/${route.params.id}`))
useFetch는 URL 자체를 키로 사용하므로 이 문제가 자동으로 해결되지만, 동일한 URL에 쿼리 파라미터 조합이 다를 때는 key 옵션을 명시적으로 지정해야 합니다.
// useFetch에서 명시적 키 지정
const { data } = await useFetch('/api/posts', {
key: `posts-${JSON.stringify(query)}`,
query
})
💡 TIP
useAsyncData는 같은 키로 여러 컴포넌트에서 호출되면 데이터를 공유합니다. 공통 레이아웃과 페이지 컴포넌트 양쪽에서 같은 키를 쓰면 요청 한 번으로 둘 다 채워집니다.
캐시 무효화: refresh, clearNuxtData, refreshNuxtData
데이터를 한번 가져온 뒤 캐시를 어떻게 비우거나 갱신하느냐가 UX 품질을 결정합니다. Nuxt는 세 가지 방법을 제공하며, 각각 쓰임새가 다릅니다.
refresh — 즉시 재요청
useAsyncData / useFetch가 반환하는 refresh 함수는 현재 캐시를 버리고 동일한 fetcher를 즉시 재실행합니다. 로딩 상태(pending)가 자동으로 관리되므로 스피너 처리가 자연스럽습니다.
<script setup lang="ts">
const { data: posts, pending, refresh } = await useAsyncData(
'posts',
() => $fetch('/api/posts')
)
</script>
<template>
<button :disabled="pending" @click="refresh()">
{{ pending ? '불러오는 중...' : '새로 고침' }}
</button>
<PostList :posts="posts" />
</template>
clearNuxtData — payload 항목 삭제
clearNuxtData(key)는 payload에서 해당 키 항목을 제거합니다. 이후 해당 키의 useAsyncData가 다시 실행되면 캐시 없이 처음부터 요청합니다. 여러 키를 배열로 넘길 수도 있습니다.
// 로그아웃 처리: 사용자 관련 캐시 전체 삭제
function logout() {
clearNuxtData(['user-profile', 'user-settings', 'user-orders'])
navigateTo('/login')
}
refreshNuxtData — 백그라운드 갱신
refreshNuxtData(key)는 현재 화면을 유지하면서 지정한 키의 데이터를 백그라운드에서 다시 가져옵니다. 키를 생략하면 페이지의 모든 useAsyncData 항목을 갱신합니다.
// 폼 제출 완료 후 관련 데이터 백그라운드 갱신
async function submitForm(payload: FormData) {
await $fetch('/api/posts', { method: 'POST', body: payload })
// 목록 페이지를 이동하지 않고 같은 화면에서 캐시만 갱신
await refreshNuxtData('posts')
}
| 메서드 | 범위 | 즉시 로딩 표시 | 주요 용도 |
|---|---|---|---|
refresh() | 해당 composable 인스턴스 | 있음 (pending) | 버튼 클릭, 폴링 |
clearNuxtData(key) | payload 전역 | 없음 | 로그아웃, 상태 초기화 |
refreshNuxtData(key) | payload 전역 | 없음 | 제출 후 백그라운드 갱신 |
⚠️ 주의
clearNuxtData후 페이지를 이동하지 않으면 해당useAsyncData는 자동으로 재요청하지 않습니다. 명시적으로refresh()를 함께 호출하거나navigateTo로 페이지를 새로 마운트해야 합니다.
getCachedData, transform, pick으로 최적화하기
getCachedData — 커스텀 캐시 판단 로직
getCachedData 옵션은 매번 서버에 요청을 보내기 전에 "이미 유효한 캐시가 있는가?"를 판단하는 함수입니다. undefined를 반환하면 실제 fetcher를 실행하고, 값을 반환하면 그것을 data로 사용합니다.
// 5분 TTL 캐시 구현
const cache = new Map<string, { data: unknown; ts: number }>()
const { data } = await useAsyncData('config', () => $fetch('/api/config'), {
getCachedData(key) {
const entry = cache.get(key)
if (!entry) return undefined
const age = Date.now() - entry.ts
if (age > 5 * 60 * 1000) {
cache.delete(key)
return undefined // 만료 → 재요청
}
return entry.data // ✅ 유효한 캐시 반환
},
transform(result) {
cache.set('config', { data: result, ts: Date.now() })
return result
}
})
💡 TIP
getCachedData의 인수로 전달되는key는nuxtApp.payload.data[key]와 같은 형태로 접근하는 것이 가능합니다. payload에 이미 값이 있으면 SSR 데이터를 재사용하는 것이고, payload에 없으면 클라이언트에서 첫 요청입니다.
transform — 응답 가공과 메모리 절약
transform은 서버 응답을 받은 직후, 데이터가 캐시에 저장되기 전에 실행됩니다. 필요한 필드만 추출하거나 타입을 변환하는 데 사용합니다.
interface ApiResponse {
data: Post[]
meta: { total: number; page: number }
links: Record<string, string>
}
// ✅ 컴포넌트에서 필요한 배열만 추출 — 나머지 meta/links는 메모리에 저장 안 됨
const { data: posts } = await useAsyncData<Post[]>(
'posts',
() => $fetch<ApiResponse>('/api/posts'),
{
transform: (response) => response.data
}
)
pick — 객체 필드 선택
pick 옵션은 응답 객체에서 지정한 키만 남깁니다. transform과 달리 배열 응답에는 적용되지 않습니다.
// 전체 사용자 객체 중 화면에 필요한 필드만 캐시
const { data: user } = await useAsyncData(
'user-header',
() => $fetch('/api/me'),
{
pick: ['id', 'name', 'avatarUrl']
}
)
낙관적 업데이트와 요청 중복 제거
낙관적 업데이트(Optimistic UI)
낙관적 업데이트는 서버 응답을 기다리지 않고 즉시 UI를 업데이트한 뒤, 요청 결과에 따라 확정하거나 롤백하는 패턴입니다. 체감 성능을 크게 향상시킵니다.
<script setup lang="ts">
interface Post { id: number; title: string; liked: boolean; likeCount: number }
const { data: post, refresh } = await useAsyncData<Post>(
`post-${useRoute().params.id}`,
() => $fetch(`/api/posts/${useRoute().params.id}`)
)
async function toggleLike() {
if (!post.value) return
// 1. 즉시 UI 반영 (낙관적)
const previous = { ...post.value }
post.value = {
...post.value,
liked: !post.value.liked,
likeCount: post.value.liked ? post.value.likeCount - 1 : post.value.likeCount + 1
}
try {
// 2. 서버 요청
await $fetch(`/api/posts/${post.value.id}/like`, {
method: post.value.liked ? 'DELETE' : 'POST'
})
} catch (e) {
// 3. 실패 시 롤백
post.value = previous
console.error('좋아요 처리 실패:', e)
}
}
</script>
⚠️ 주의
post.value를 직접 변경하면useAsyncData의 내부 캐시와 불일치가 생길 수 있습니다. 롤백 로직을 반드시 구현하고, 중요한 작업은 완료 후refresh()로 서버 데이터를 재동기화하세요.
요청 중복 제거(dedupe)
같은 키의 useAsyncData가 동시에 여러 번 호출될 때, 기본값인 dedupe: 'cancel'은 새 요청이 들어오면 진행 중이던 기존 요청을 취소하고 새 요청을 실행합니다. 반대로 dedupe: 'defer'는 진행 중인 요청이 있으면 새 요청을 만들지 않고 기존 요청의 결과를 공유합니다(즉, 첫 번째 요청만 실행하고 나머지는 그 결과를 공유). dedupe 옵션으로 동작을 제어할 수 있습니다.
// dedupe: 'cancel' (기본값) — 새 요청이 들어오면 진행 중이던 기존 요청을 취소하고 새로 실행
const { data } = await useAsyncData('search', fetcher, { dedupe: 'cancel' })
// dedupe: 'defer' — 진행 중인 요청이 있으면 새 요청을 만들지 않고 그 결과를 공유
const { data } = await useAsyncData('search', fetcher, { dedupe: 'defer' })
검색 자동완성처럼 타이핑마다 요청이 발생하는 상황에서는 dedupe: 'cancel'이 적합합니다. 반면 동일한 초기화 데이터를 여러 컴포넌트가 동시에 요청할 때는 dedupe: 'defer'로 단 한 번만 요청합니다.
// composables/useAppConfig.ts — 전역 설정을 중복 없이 한 번만 요청
export function useAppConfig() {
return useAsyncData(
'app-config',
() => $fetch('/api/config'),
{
dedupe: 'defer', // ✅ 여러 컴포넌트가 동시에 호출해도 요청은 1회
getCachedData(key, nuxtApp) {
return nuxtApp.payload.data[key]
}
}
)
}
워터폴 제거와 병렬 패칭 설계
워터폴(waterfall)은 요청 A가 끝나야 요청 B가 시작되는 순차 패턴으로, 총 대기 시간이 개별 지연의 합이 됩니다. 독립적인 데이터는 반드시 병렬로 요청해야 합니다.
// ❌ 워터폴 — user 완료 후 posts 시작, 총 지연 = 300ms + 200ms
const { data: user } = await useAsyncData('user', () => $fetch('/api/me'))
const { data: posts } = await useAsyncData('posts', () => $fetch('/api/posts'))
// ✅ 병렬 패칭 — await 없이 호출한 뒤 함께 대기
const [{ data: user }, { data: posts }] = await Promise.all([
useAsyncData('user', () => $fetch('/api/me')),
useAsyncData('posts', () => $fetch('/api/posts'))
])
서버 컴포넌트나 server: true 환경에서는 $fetch를 직접 병렬로 묶는 것이 더 직관적입니다.
// pages/dashboard.vue
const [user, stats, notifications] = await Promise.all([
$fetch('/api/me'),
$fetch('/api/stats'),
$fetch('/api/notifications')
])
부모-자식 관계처럼 데이터가 실제로 의존 관계일 때만 순차 요청을 허용하고, 나머지는 모두 병렬로 처리하는 것이 기본 원칙입니다.
// ✅ 의존 관계가 있는 경우만 순차
const { data: user } = await useAsyncData('user', () => $fetch('/api/me'))
// user.id가 있어야 team 요청 가능 — 불가피한 워터폴
const { data: team } = await useAsyncData(
`team-${user.value?.teamId}`,
() => $fetch(`/api/teams/${user.value?.teamId}`),
{ watch: [user] }
)
Nitro 서버 측 캐싱: ISR/SWR 전략
Nuxt의 서버 엔진인 Nitro는 라우트 수준, 함수 수준, 이벤트 핸들러 수준에서 각각 캐싱을 제공합니다.
routeRules — 라우트 단위 캐시 선언
nuxt.config.ts의 routeRules로 특정 경로의 캐싱 정책을 선언적으로 설정합니다.
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
// 정적 자산: 장기 캐시
'/assets/**': { headers: { 'cache-control': 'max-age=31536000, immutable' } },
// 블로그 목록: SWR — 60초 후 백그라운드 재검증, 그 전까지는 캐시 응답
'/blog': { swr: 60 },
// 개별 포스트: ISR — 최초 요청 시 생성 후 3600초 동안 캐시
'/blog/**': { isr: 3600 },
// API 엔드포인트: 30초 캐시
'/api/public/**': { cache: { maxAge: 30 } },
// 관리자 페이지: 캐시 없음
'/admin/**': { cache: false }
}
})
**ISR(Incremental Static Regeneration)**은 isr 값(초) 동안 정적 응답을 제공하다가 만료되면 다음 요청 시 재생성합니다. **SWR(Stale-While-Revalidate)**은 캐시 만료 후에도 오래된(stale) 응답을 즉시 반환하면서 백그라운드에서 갱신합니다.
| 전략 | 옵션 | 만료 후 동작 | 적합한 콘텐츠 |
|---|---|---|---|
| ISR | isr: N | 다음 요청 시 stale 응답 후 백그라운드 재생성 | 블로그 포스트, 상품 상세 |
| SWR | swr: N | 즉시 stale 반환 + 백그라운드 재검증 | 뉴스 피드, 대시보드 |
| 단순 캐시 | cache: { maxAge: N } | 만료 후 항상 재요청 | API 응답 |
cachedFunction — 서버 함수 단위 캐시
cachedFunction은 서버 사이드에서 실행되는 함수의 결과를 캐싱합니다. 복잡한 DB 쿼리나 외부 API 호출에 적합합니다.
// server/utils/posts.ts
import { cachedFunction } from 'nitropack/runtime'
export const getCachedPosts = cachedFunction(
async (category: string) => {
// 비용이 큰 DB 쿼리
const posts = await db.query(
'SELECT * FROM posts WHERE category = ? ORDER BY created_at DESC LIMIT 20',
[category]
)
return posts
},
{
maxAge: 60 * 5, // 5분 캐시
name: 'posts', // 캐시 키 접두사
getKey: (category) => category // 인수로 캐시 키 결정
}
)
// server/api/posts/[category].get.ts
export default defineEventHandler(async (event) => {
const category = getRouterParam(event, 'category')!
return getCachedPosts(category)
})
cachedEventHandler — 이벤트 핸들러 단위 캐시
핸들러 전체를 캐싱하려면 cachedEventHandler를 사용합니다. 요청 URL을 기반으로 자동으로 캐시 키가 생성됩니다.
// server/api/stats.get.ts
export default cachedEventHandler(
async (event) => {
const stats = await computeExpensiveStats()
return stats
},
{
maxAge: 60 * 10, // 10분
name: 'api-stats',
staleMaxAge: 60 * 60, // SWR: 1시간 동안 stale 허용
swr: true
}
)
💡 TIP Nitro 캐시는 기본적으로 메모리(lruCache)를 사용합니다. 프로덕션에서 멀티 인스턴스로 운영한다면 Redis나 Cloudflare KV 같은 외부 스토리지를
storage옵션으로 연결해야 캐시가 인스턴스 간에 공유됩니다.
// nuxt.config.ts — Redis 캐시 스토리지 연결
export default defineNuxtConfig({
nitro: {
storage: {
cache: {
driver: 'redis',
url: process.env.REDIS_URL
}
}
}
})
요약
useAsyncData의 키는 payload의 저장 주소이자 캐시 ID다. 동적 라우트에서는 반드시 파라미터를 키에 포함시켜야 한다.- 캐시 무효화는
refresh()(즉시 재요청),clearNuxtData()(payload 항목 삭제),refreshNuxtData()(백그라운드 갱신) 세 가지 도구로 상황에 맞게 선택한다. getCachedData·transform·pick을 조합하면 불필요한 HTTP 요청과 메모리 낭비를 모두 줄일 수 있다.- 낙관적 업데이트는 즉시 UI를 반영하되 롤백 로직을 반드시 갖춰야 하고,
dedupe옵션으로 중복 요청을 제어한다. - 독립적인 데이터 요청은
Promise.all로 병렬 패칭하여 워터폴 지연을 제거한다. - Nitro의
routeRules(ISR/SWR),cachedFunction,cachedEventHandler로 서버 측 캐싱을 계층화하면 DB와 외부 API 부하를 크게 줄일 수 있다.
연습문제
-
현재 사용자의 프로필(
/api/me)과 공지사항 목록(/api/notices)을 페이지 진입 시 병렬로 가져오는<script setup>을 작성하세요. 두 요청 모두 완료된 뒤에야 페이지가 렌더링되어야 합니다.힌트
Promise.all과useAsyncData를 조합하세요. -
게시물 목록 페이지에서 "새로 고침" 버튼을 누르면 로딩 스피너가 표시되는 동안 목록을 다시 가져오고, 버튼은 로딩 중 비활성화되어야 합니다.
useFetch와 반환되는pending·refresh를 활용하세요.힌트
pending은Ref<boolean>타입입니다. -
댓글 작성 폼에서
POST /api/comments를 호출한 뒤, 서버 응답을 기다리지 않고 즉시 댓글 목록에 새 항목을 추가하는 낙관적 업데이트를 구현하세요. 요청 실패 시 추가한 항목을 롤백해야 합니다.힌트 요청 전에 원본 배열을 복사해 두고
catch블록에서 복원합니다. -
server/api/products/[category].get.ts에서 카테고리별 상품 목록을 5분간 캐싱하는 Nitro 이벤트 핸들러를 작성하세요. 캐시 키는 카테고리 값을 포함해야 합니다.힌트
cachedEventHandler의getKey옵션을 활용하세요.
💡 연습문제 풀이
불러오는 중…
댓글 0
“Nuxt.js 심화” 강좌에 대한 댓글입니다.