# Nuxt4의 useFetch · useAsyncData · $fetch

Nuxt를 처음 쓸 때 데이터를 가져오는 방법이 여러 개라는 게 헷갈렸다. `useFetch`도 있고, `useAsyncData`도 있고, `$fetch`도 있다. 셋 다 API를 호출하는 것 같은데 왜 따로 존재하는걸까?

$fetch가 네이티브 fetch나 axios 처럼 이벤트처럼 사용하는 것이고, 또 useFetch는 데이터 뿐만 아니라, 로딩 및 에러 상태를 받아볼 수 있고.. 이 세가지의 패칭 방식에 대해서 온전히 잘 모르다보니 어느 상황에서 사용해야하고 적용해야하는지 헷갈렸다.

Nuxt는 서버와 클라이언트 양쪽에서 코드가 실행되는 환경을 제공한다. 같은 컴포넌트 코드가 서버에서 한 번 실행되고, 브라우저에서 또 실행된다. 이 구조에서 데이터를 어떻게 가져오느냐에 따라 API 호출 횟수, 사용자 경험, 성능이 모두 달라진다. 세 가지 방법은 이 구조 안에서 각자 다른 역할을 위해 설계됐다.

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

브라우저에서만 돌아가는 React 앱이라면 데이터 페칭은 단순하다. 컴포넌트가 마운트되면 API를 호출하고, 응답이 오면 화면에 표시한다. 그게 전부다.

Nuxt의 SSR(서버 사이드 렌더링)은 다르다. 같은 페이지를 두 번 렌더링한다.

```javascript
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 안에 직렬화해서 담아두고, 브라우저에서 하이드레이션할 때 그 데이터를 재사용한다.

```javascript
<!-- 서버가 보내는 HTML 안에 이런 내용이 포함된다 -->
<script>
  window.__nuxt = {
    data: {
      "fetch:0:0": { id: 1, name: "김현우", ... }  // ← 서버에서 가져온 데이터
    }
  }
</script>
```

`useFetch`와 `useAsyncData`는 이 메커니즘을 활용한다. 서버에서 실행하고, 데이터를 페이로드에 담고, 클라이언트에서는 API를 다시 호출하지 않는다. `$fetch`는 이 메커니즘을 사용하지 않는다.

## 세 가지 방법의 동작 원리

### $fetch — 그냥 HTTP 요청

`$fetch`는 Nuxt가 내장하는 HTTP 클라이언트다. 브라우저의 `fetch`와 같은 역할이지만 더 편리한 기능(JSON 자동 파싱, 에러 핸들링 등)이 추가됐다.

> **ofetch**

Nuxt가 내부적으로 사용하는 HTTP 클라이언트 라이브러리. `$fetch`는 이 라이브러리의 인스턴스다. 브라우저 `fetch`를 기반으로 만들어졌다.

`$fetch`는 SSR 페이로드 메커니즘을 전혀 사용하지 않는다. 그냥 HTTP 요청을 보내고 응답을 반환한다.

내가 사용하면서 이해했던 바 로는  그냥 브라우저의 네이티브 fetch 또는 axios 느낌이었다.

```javascript
// $fetch 사용 예시
const user = await $fetch('/api/user/1')
// → 결과값이 바로 반환된다 (ref가 아님)
// → pending, error 상태가 없다
// → 자동으로 화면이 업데이트되지 않는다
```

`<script setup>` 안에서 `await $fetch(...)`를 쓰면 어떻게 될까?

```javascript
// ❌ 이렇게 쓰면 서버 + 클라이언트 양쪽에서 API가 호출된다
<script setup>
const user = await $fetch('/api/user/1')  // 서버에서 한 번, 브라우저에서 한 번
</script>
```

서버에서 실행될 때 `$fetch`가 한 번 호출된다. 그런데 브라우저에서 하이드레이션할 때 `<script setup>` 코드가 다시 실행되면서 `$fetch`가 또 한 번 호출된다. 서버가 이미 가져온 데이터를 클라이언트가 또 가져오는 것이다.

`$fetch`가 적합한 상황은 **사용자 행동에 의해 트리거되는 경우**다.

```javascript
// ✅ $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`로 시작하는 이름이 관례다.

```javascript
// useAsyncData 기본 사용법
const { data, pending, error, refresh } = await useAsyncData(
  'user-1',           // ← 고유 키 (필수)
  () => $fetch('/api/user/1')  // ← 실행할 비동기 함수
)
```

여기서 첫 번째 인자인 **고유 키**가 중요하다. 이 키가 페이로드 메커니즘의 핵심이다.

```javascript
서버 실행:
  useAsyncData('user-1', 비동기함수)
    → 비동기함수 실행
    → 결과를 payload['user-1']에 저장
    → HTML에 payload 포함해서 전송

클라이언트 실행:
  useAsyncData('user-1', 비동기함수)
    → payload['user-1']이 있는지 확인
    → 있으면 비동기함수 실행 안 함, payload 데이터 사용
    → 없으면 비동기함수 실행
```

```javascript
// 반환값들이 모두 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`와의 차이다.

```javascript
// 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를 다시 호출하지 않도록 할 수 있다.

```javascript
// 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의 단축형

`useFetch`는 `useAsyncData`와 `$fetch`를 합친 것이다. 내부적으로 `useAsyncData(() => $fetch(url, options))`와 동일하게 동작한다. 차이는 키를 자동으로 생성한다는 점이다.

```javascript
// 이 두 코드는 동일하게 동작한다
const { data } = await useFetch('/api/user/1')

const { data } = await useAsyncData(
  '/api/user/1',                    // ← URL이 키가 된다
  () => $fetch('/api/user/1')
)
```

URL과 옵션을 조합해서 키를 자동으로 만들기 때문에, 같은 URL을 여러 컴포넌트에서 호출해도 하나의 요청으로 합쳐진다.

```javascript
// 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` 옵션으로 반응형 값이 바뀔 때 자동으로 데이터를 다시 가져오는 기능이 더 직관적으로 동작한다.

```javascript
// 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을 **함수**로 넣어야 반응형으로 동작한다 ^,^ ..

```javascript
// ❌ 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에서 데이터를 가져와서 화면에 표시하는 것.

```javascript
<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`가 더 적합하다.

```javascript
<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`가 적합하다.

```javascript
<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>` 컨텍스트에서만 동작하기 때문이다.

```javascript
// 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 페이로드 메커니즘에 대해 알아보면서 각각이 서로 다른 문제를 해결하기 위해 존재한다는 게 보였다.

`useFetch`와 `useAsyncData`는 서버-클라이언트 이중 실행 환경에서 데이터를 안전하게 다루기 위한 것이고, `$fetch`는 그 환경과 무관하게 단순히 HTTP 요청을 보내는 것이다. 앞으로 Nuxt에서 데이터 페칭 방법을 고를 때 "이 코드가 언제 실행되는가"를 먼저 생각해보아야될 것 같다.

For the site tree, see the [root Markdown](https://slashpage.com/timmy.md).
