dev.syw

useAsyncData 키 관리, 캐시 무효화, 낙관적 업데이트, Nitro 캐싱 계층.

데이터 패칭 심화와 캐싱 전략

입문편에서 useFetchuseAsyncData의 기본 사용법, 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의 인수로 전달되는 keynuxtApp.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.tsrouteRules로 특정 경로의 캐싱 정책을 선언적으로 설정합니다.

// 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) 응답을 즉시 반환하면서 백그라운드에서 갱신합니다.

전략옵션만료 후 동작적합한 콘텐츠
ISRisr: N다음 요청 시 stale 응답 후 백그라운드 재생성블로그 포스트, 상품 상세
SWRswr: 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 부하를 크게 줄일 수 있다.

연습문제

  1. 현재 사용자의 프로필(/api/me)과 공지사항 목록(/api/notices)을 페이지 진입 시 병렬로 가져오는 <script setup>을 작성하세요. 두 요청 모두 완료된 뒤에야 페이지가 렌더링되어야 합니다.

    힌트 Promise.alluseAsyncData를 조합하세요.

  2. 게시물 목록 페이지에서 "새로 고침" 버튼을 누르면 로딩 스피너가 표시되는 동안 목록을 다시 가져오고, 버튼은 로딩 중 비활성화되어야 합니다. useFetch와 반환되는 pending·refresh를 활용하세요.

    힌트 pendingRef<boolean> 타입입니다.

  3. 댓글 작성 폼에서 POST /api/comments를 호출한 뒤, 서버 응답을 기다리지 않고 즉시 댓글 목록에 새 항목을 추가하는 낙관적 업데이트를 구현하세요. 요청 실패 시 추가한 항목을 롤백해야 합니다.

    힌트 요청 전에 원본 배열을 복사해 두고 catch 블록에서 복원합니다.

  4. server/api/products/[category].get.ts에서 카테고리별 상품 목록을 5분간 캐싱하는 Nitro 이벤트 핸들러를 작성하세요. 캐시 키는 카테고리 값을 포함해야 합니다.

    힌트 cachedEventHandlergetKey 옵션을 활용하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Nuxt.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.