Nuxt 3에서 useAsyncData 예시를 찾아보면 두 가지 코드가 섞여 나온다. 하나는 브라우저 표준 fetch를, 다른 하나는 $fetch를 쓴다. 둘 다 useAsyncData의 두 번째 인자로 들어가 있어서 얼핏 보면 같은 일을 하는 것 같다.

두 코드를 나란히 놓고 무엇이 다른지, $fetch를 왜 useAsyncData로 감싸야 하는지 정리한다.

같은 자리에 들어간 두 함수

먼저 fetch를 쓴 예시다.

// Nuxt 3 정식판에서는 useAsyncData가 auto-import되므로 이 import 줄은 없어도 된다
import { useAsyncData } from 'nuxt3'

export default {
  setup() {
    const { data, pending, error, refresh } = useAsyncData('user', () =>
      fetch('http://localhost:8080/api/user/1')
        .then(response => response.json())  // JSON 파싱을 직접 한다
    )

    return { data, pending, error, refresh }
  }
}

$fetch 쪽은 이렇다.

const { data, pending, error, refresh } = await useAsyncData(
  'mountains', // key
  () => $fetch('https://api.nuxtjs.dev/mountains')  // 파싱된 결과가 바로 온다
)

두 코드 모두 useAsyncData의 두 번째 인자로 함수를 넘긴다. 이 함수가 데이터를 가져오는 비동기 작업을 맡고, 함수가 돌려준 Promise의 결과값이 useAsyncData의 data가 된다.

차이는 응답을 다루는 부분에 있다. 첫 번째 코드는 fetch로 요청을 보낸 뒤 .then에서 response.json()을 직접 부른다. response.json()은 Fetch API가 제공하는 메서드로, 응답 본문을 JSON으로 파싱한다. 두 번째 코드에는 이 호출이 없다. $fetch는 내부적으로 fetch를 쓰면서 응답을 JSON으로 파싱하는 일까지 대신 한다.

fetch는 표준, $fetch는 그 위에 얹은 것

fetch는 웹 브라우저와 Node.js에서 모두 쓸 수 있는 HTTP 요청 함수다. 서버에 데이터를 요청하거나 보낼 때 쓰고, Promise를 반환하므로 async/await로 다룰 수 있다.

$fetch는 Nuxt 3가 전역으로 제공하는 함수다. 내부에서 fetch로 요청을 보내고, 그 위에 몇 가지 처리를 더한다. 자동 JSON 파싱이 그중 하나이고, 에러 처리도 들어 있다. 표준 fetch는 서버가 404나 500을 돌려줘도 Promise를 reject하지 않아서 response.ok를 직접 확인해야 한다. $fetch는 이런 응답을 에러로 던져 준다.

서버에서 부를지 브라우저에서 부를지는 SSR이 정한다

API를 어디서 부르느냐에 따라 페이지가 만들어지는 흐름이 달라진다.

  • 브라우저에서 부르는 경우: Nuxt 웹서버가 클라이언트에게 페이지 제공 → 브라우저에서 API 호출 → API 결과로 CSR

  • SSR 중 서버에서 부르는 경우: Nuxt 웹서버가 직접 API 호출 → API 결과값으로 SSR된 페이지를 클라이언트에 제공

두 번째 흐름에서는 클라이언트가 API를 따로 부를 필요 없이 데이터가 채워진 페이지를 받으니 첫 로딩이 빠르다.

다만 서버에서 호출되는 건 $fetch만의 성질이 아니다. SSR 동안 <script setup>은 서버에서 먼저 실행되니, 그 안에 있는 fetch든 $fetch든 서버에서 한 번 돈다. $fetch가 서버 쪽에서 더 해 주는 일은 따로 있다. 서버에서 /api/... 같은 Nuxt 내부 API 경로를 부르면 HTTP 요청을 새로 보내지 않고 해당 핸들러 함수를 바로 호출한다.

useAsyncData로 감싸지 않으면 두 번 호출된다

문제는 SSR이 끝난 뒤다. 컴포넌트에서 $fetch를 useAsyncData로 감싸지 않고 쓰면, 처음에는 서버에서 한 번, 하이드레이션 중에 클라이언트에서 또 한 번 데이터를 가져온다. $fetch는 서버에서 가져온 결과를 클라이언트로 넘기지 않기 때문이다. 서버에서 가져온 결과를 페이지에 실어 클라이언트로 넘기는 일은 useAsyncData가 한다.

sequenceDiagram
  participant S as Nuxt 서버
  participant B as 브라우저
  participant A as API
  S->>A: $fetch (SSR)
  A-->>S: 데이터
  S->>B: 렌더링된 HTML
  Note over B: 하이드레이션
  B->>A: $fetch 한 번 더
  A-->>B: 같은 데이터

컴포넌트에서 $fetch를 쓸 때는 useAsyncData로 감싼다. useFetch는 이 조합의 줄임말이다.

<script setup lang="ts">
// SSR 동안 데이터를 서버에서 한 번, 클라이언트에서 한 번, 총 두 번 가져온다
const dataTwice = await $fetch('/api/item')

// SSR 중에는 서버에서만 가져오고, 결과를 클라이언트로 넘긴다
const { data } = await useAsyncData('item', () => $fetch('/api/item'))

// useAsyncData + $fetch의 줄임말인 useFetch를 써도 된다
// (한 파일에 그대로 두면 data가 중복 선언되니, 셋 중 하나만 쓴다)
const { data } = await useFetch('/api/item')
</script>

useFetch와 useAsyncData가 서로 어떻게 다른지는 앞선 글에서 따로 다뤘다.

정리하면,

  • $fetch는 fetch 위에 JSON 파싱과 에러 처리를 얹은 함수라서 response.json()을 따로 부르지 않아도 된다.

  • SSR 중 서버에서 호출되는 건 setup이 서버에서 먼저 돌기 때문이고, 결과를 클라이언트로 넘기는 건 useAsyncData의 몫이다.

  • 컴포넌트에서는 $fetch를 useAsyncData로 감싸거나 useFetch를 쓴다. 그냥 쓰면 같은 요청이 두 번 나간다.