Skip to content

데이터 로더

할 일 목록

아직 문서에 추가되지 않은 항목 목록:

  • [ ] vue-apollo, vuefire, vue-query 같은 데이터 가져오기 라이브러리를 위한 확장 가능한 API
  • [ ] non-lazy loader가 데이터 없이 사용되면 경고하기: 즉, 페이지 컴포넌트에서 export되지 않았는데 컴포넌트에서 사용된 경우. lazy로 만들거나 export해야 함

요약

데이터 가져오기에는 만능 해결책이 없습니다. 다양한 데이터 가져오기 전략이 존재하고, 그것들이 애플리케이션의 아키텍처와 UX를 규정할 수 있기 때문입니다. 하지만 저는 애플리케이션의 데이터 가져오기를 좋은 방식으로 유도 하고 복잡성을 줄일 만큼 충분히 유연한 해법을 찾을 수 있다고 생각합니다. 이 RFC의 목표는 vue-router에서 데이터 가져오기를 표준화하고 개선하는 것입니다:

  • 데이터 가져오기를 내비게이션 사이클에 통합
    • 가져오는 동안 내비게이션을 막거나, 덜 중요한 데이터는 지연 합니다(Nuxt에서는 lazy 라고 부름)
  • 요청 중복 제거
  • 모든 데이터 로더가 resolve될 때까지 데이터 업데이트 지연
    • 일부만 최신 상태인 데이터나 일관되지 않은 상태가 표시되는 것을 방지
    • commit 옵션으로 구성 가능
  • 최적의 데이터 가져오기
    • 기본적으로 병렬 가져오기
    • 필요 시 의미론적 순차 가져오기
  • <Suspense> 회피
    • 계단식 로딩 상태 없음
    • 이중 마운트 없음
    • 더 보기...
  • 로딩/오류 상태에 대한 원자적이고 전역적인 접근 제공
  • 구현 가능한 인터페이스 집합을 정립하여 서드파티 라이브러리가 로더 기능을 확장할 수 있게 함. 이는 VueFire, @pinia/colada, vue-apollo, @tanstack/vue-query 같은 라이브러리가 자신들의 사용 사례에 맞는 캐싱, 페이지네이션 등의 기능을 제공할 수 있도록 하기 위함입니다.

이 제안은 Vue Router 4를 대상으로 하며 unplugin-vue-router 아래에 구현되어 있습니다. 이를 통해 데이터 로더에서 타입을 사용할 수 있지만 필수는 아닙니다. 이 기능은 플러그인의 다른 부분과 독립적이며, 즉 파일 기반 라우팅 없이도 사용할 수 있습니다.

TIP

이 RFC에서는 data loader를 줄여서 loader 라고 자주 부릅니다. API 이름도 간결성을 위해 data loader 대신 loader 를 사용합니다.

💡 일부 예제는 상호작용 가능합니다. 코드 위에 마우스를 올리거나 탭하면 타입과 기타 정보를 볼 수 있습니다.

기본 예제

데이터 로더는 어떤 컴포넌트에서든 사용할 수 있는 composable 을 반환하는 defineLoader() 함수로 만듭니다(페이지 컴포넌트에만 국한되지 않음).

그 다음 이 로더는 내비게이션 가드가 수집합니다. 페이지 컴포넌트에 연결하는 방법은 두 가지입니다:

  • 연결할 페이지 컴포넌트에서 로더를 export합니다. 이 페이지 컴포넌트는 lazy load되어야 합니다(() => import('~/pages/users-details.vue'))
  • 라우트 정의의 meta.loaders[]에 로더를 수동으로 추가합니다

페이지 컴포넌트의 non-setup <script>에서 export하는 예:

vue
<script lang="ts">
// ---cut-start---
import { defineComponent } from 'vue'
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
// ---cut-end---
// @moduleResolution: bundler
import { getUserById } from '../api'

// 원하는 이름을 붙인 뒤 **export합니다**
export const useUserData = defineLoader(async route => {
  const user = await getUserById(route.params.id as string)
  // ...
  // 노출하고 싶은 어떤 값이든 반환합니다
  return user
})

// 선택 사항: 다른 컴포넌트 옵션을 정의합니다
export default defineComponent({
  name: 'custom-name',
  inheritAttrs: false,
})
</script>

<script lang="ts" setup>
// 사용자 데이터는 `data`와 기타 속성으로 가져옵니다
const { data: user, isLoading, error, reload } = useUserData()
// data는 항상 존재하며, '/users/2'에서 '/users/3'으로 갈 때 isLoading이 바뀝니다
</script>

페이지 컴포넌트에서 로더를 export하면 해당 라우트가 lazy load 되어 있는 한(이는 모범 사례입니다) 자동으로 수집됩니다. 라우트가 lazy load되지 않는다면 meta.loaders의 데이터 로더 배열에 직접 정의할 수 있습니다:

ts
import './shims-vue.d'
// ---cut---
// @moduleResolution: bundler
import { createRouter, createWebHistory } from 'vue-router'
import UserList from './pages/UserList.vue'
// 어디에 있어도 됩니다
import { useUserList, useUserData, type User } from './loaders/users'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/users',
      component: UserList,
      meta: {
        // 컴포넌트가 lazy load되지 않을 때 필요합니다
        loaders: [useUserList],
      },
    },
    {
      path: '/users/:id',
      // export된 모든 로더를 자동으로 수집합니다
      component: () => import('./pages/UserDetails.vue'),
    },
  ],
})

useUserData()가 반환하는 값은 다음과 같습니다:

  • data(여기서는 user로 alias됨), isLoading, error는 shallow ref이므로 반응형입니다.
  • reload는 새 내비게이션 없이 데이터를 강제로 다시 불러오는 함수입니다.

useUserData()는 페이지 컴포넌트뿐 아니라 어떤 컴포넌트에서든 사용할 수 있습니다. 다른 composable처럼 함수를 import하고 <script setup> 안에서 호출하면 됩니다

기본적으로 데이터 로더는 내비게이션을 막습니다. 즉 SSR과도 자연스럽게 동작하며, 오류는 라우터 수준(router.onError())으로 전파됩니다. 그뿐 아니라 데이터 로드는 중복 제거되므로, 얼마나 많은 페이지 컴포넌트가 같은 로더를 사용하든(예: 중첩 페이지) 내비게이션당 한 번만 데이터를 로드합니다.

가장 단순한 데이터 로더는 한 줄로 정의할 수 있으며 타입도 자동으로 추론됩니다:

ts
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
interface Book {
  title: string
  isbn: string
  description: string
}
function fetchBookCollection(): Promise<Book[]> {
  return {} as any
}
// ---cut---
export const useBookCollection = defineLoader(fetchBookCollection)
const { data } = useBookCollection()

이 RFC에서는 이 문법을 의도적으로 자주 사용하지 않습니다. 대신 이해하기 쉽도록 조금 더 긴 예제를 주로 사용합니다.

동기

현재 vue-router로 데이터 가져오기를 처리하는 방법은 너무 많고, 모두 각자의 문제가 있습니다:

  • 내비게이션 가드를 사용하는 경우:
    • onBeforeRouteUpdate() 사용: 이후 내비게이션에서만 동작합니다. beforeRouteEnter()와 제대로 결합할 수 없습니다.
    • beforeRouteEnter() 사용: next()를 사용하는 비타입·비인체공학적 API이며, 데이터 저장소(pinia, vuex, apollo 등)가 필요하고 script setup에는 존재하지 않습니다
    • meta 사용: 단순한 경우에도 설정이 복잡하고, 이렇게 흔한 사용 사례치고는 너무 저수준입니다
  • route.params...를 watcher로 감시하는 경우: 데이터 없이 컴포넌트가 렌더링됩니다(SSR과 함께 동작하지 않음)
  • Suspense를 사용하고 페이지 컴포넌트 안에서 데이터를 await 하는 경우
    • 계단식(느린) 비동기 상태
    • 한 번만 로드됨(마운트 시)
    • 내비게이션을 기다리지 않음(또는 이중 마운트 필요: pending + current view)
    • UI 로딩 상태를 직접 처리해야 함
    • 그 외에도 더 있음

결국 사람들은 데이터 가져오기를 직접 처리하기 위해 저수준 API(내비게이션 가드)를 떠안게 됩니다. 이는 Router 개념에 대한 폭넓은 이해를 요구하기 때문에 대개 어려운 문제이며, 실제로 이를 제대로 아는 사람은 많지 않습니다. 그 결과 모든 엣지 케이스를 처리하지 못하고 좋은 사용자 경험도 제공하지 못하는 불완전한 구현으로 이어집니다.

따라서 이 제안의 목표는 이해하고 사용하기 쉬우면서도 확장 가능한 방식으로 애플리케이션의 데이터 로딩을 정의할 수 있게 하는 것입니다. 또한 SSR과 호환되어야 하며 단순한 fetch 호출 에 국한되지 않고 어떤 비동기 상태든 다룰 수 있어야 합니다. Nuxt.js 같은 프레임워크에서도 채택하여 Vue.js 개념 및 Navigation API 같은 미래의 Web API와 잘 통합되는 향상된 데이터 가져오기 계층을 제공할 수 있어야 합니다.

상세 설계

데이터 로더의 설계는 두 부분으로 나뉩니다

TIP

데이터 로더를 직접 써 보는 데만 관심이 있을 수도 있습니다. 그런 경우 사용 방법은 구현체 섹션을 확인하세요. 그래도 데이터 로더에 무엇을 기대할 수 있는지 이해하려면 RFC 나머지 부분도 읽는 것이 좋습니다.

데이터 로더 설정

DataLoaderPlugin은 데이터 로더를 처리하는 내비게이션 가드를 추가합니다. 이 플러그인은 내비게이션 가드를 연결하기 위해 라우터 인스턴스에 접근해야 하며, 몇 가지 다른 옵션도 필요합니다:

  • router: Vue Router 인스턴스
  • selectNavigationResult(선택 사항): 로더가 반환한 NavigationResult 배열과 함께 호출됩니다. 로더에 의해 수정된 내비게이션의 운명 을 결정할 수 있게 해줍니다. NavigationResult 참고
ts
import { createApp } from 'vue'
import { createRouter } from 'vue-router'
import { DataLoaderPlugin } from 'vue-router/experimental'

const router = createRouter({
  // ...
})

const app = createApp(App)
app.use(DataLoaderPlugin, { router })
// DataLoaderPlugin 뒤에 router를 추가합니다
app.use(router)

라우터가 첫 번째 내비게이션을 시작하기 전에 내비게이션 가드가 연결되도록 DataLoaderPlugin을 라우터보다 먼저 추가하는 것이 중요합니다.

핵심 데이터 로더 기능

다음은 모든 데이터 로더가 구현해야 하는 데이터 로더 API의 핵심 기능입니다. RFC 전반에서는 실제로 존재하지 않는, 일반적인 defineLoader()를 사용합니다. 이는 실제 함수 이름의 자리표시자일 뿐이며, 예를 들어 defineBasicLoader(), defineColadaLoader() 등이 해당합니다. 실제로는 unplugin-auto-import를 사용해 이 함수를 전역적으로 defineLoader라는 이름의 별칭으로 지정할 수 있습니다.

데이터 로더는 오직 URL만을 기준으로 데이터를 불러올 수 있어야 합니다. 그래야 페이지를 공유할 수 있고 서버와 클라이언트 간 렌더링도 일관되게 유지됩니다.

defineLoader() 시그니처

데이터 로더는 라우트 타입 지정을 위한 선택적 첫 번째 매개변수를 받아야 합니다:

ts
import 'vue-router/auto-routes'
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
// ---cut---
import { getUserById } from '../api'

export const useUserData = defineLoader('/users/[id]', async route => {
  return getUserById(route.params.id)
})

나머지 매개변수는 로더 구현에 따라 달라지지만 추가 옵션을 받아야 합니다.

로더 내부에서는 현재 컴포넌트나 페이지 인스턴스에 접근할 수 없지만, app.provide()로 생성한 전역 주입에는 접근할 수 있습니다. 여기에는 Pinia로 만든 store도 포함됩니다.

반환되는 Composable

데이터 로더는 다음과 같은 속성 집합을 반환하는 composable입니다:

ts
import 'vue-router/auto-routes'
import { useUserData } from './loaders/users'
// ---cut---
const {
  // 각 속성 위에 마우스를 올리면 타입을 볼 수 있습니다
  data,
  isLoading,
  error,
  reload,
} = useUserData()
  • data는 로더가 반환한 resolve된 값을 담습니다. 특히 큰 데이터셋에서 성능을 위해 shallow ref입니다
  • isLoading은 요청이 진행 중일 때 true이고 요청이 settled되면 false가 됩니다
  • error는 로더가 throw한 오류를 담습니다. 이것도 shallow ref입니다
  • reload()는 내비게이션 바깥에서 로더를 다시 실행합니다

실제로는 data(또는 다른 값들)에 더 의미 있는 이름을 붙여 사용하는 것이 좋습니다:

ts
import 'vue-router/auto-routes'
import { useUserData } from './loaders/users'
// ---cut---
const { data: user } = useUserData()

defineLoader() 옵션

  • lazy: 기본적으로 로더는 내비게이션을 막습니다. 즉, 모든 로더가 resolve된 뒤에야 내비게이션이 계속될 수 있습니다. Lazy loader는 내비게이션을 막지 않습니다. data, error 및 다른 속성은 내비게이션이 끝난 뒤 업데이트될 수 있습니다. 이를 true로 설정하면 중요하지 않은 데이터 가져오기에 유용하며, 반환되는 data의 타입이 ShallowRef<T | undefined>로 바뀝니다:

    ts
    import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
    interface Book {
      title: string
      isbn: string
      description: string
    }
    function fetchBookCollection(): Promise<Book[]> {
      return {} as any
    }
    // ---cut---
    export const useBookCollection = defineLoader(fetchBookCollection, {
      lazy: true,
    })
    const { data: bookCollection } = useBookCollection()
    //            ^ undefined일 수 있음
  • commit: 비동기 상태가 dataerror에 언제 반영될지 제어합니다. 로더 상태를 즉시 반영할 수도 있고, 모든 로더가 resolve될 때까지 데이터 업데이트를 지연할 수도 있습니다(기본값). 후자는 일부만 최신인 데이터나 일관되지 않은 상태가 표시되는 것을 피하는 데 유용합니다.

    ts
    import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
    interface Book {
      title: string
      isbn: string
      description: string
    }
    function fetchBookCollection(): Promise<Book[]> {
      return {} as any
    }
    // ---cut---
    export const useBookCollection = defineLoader(fetchBookCollection, {
      commit: 'immediate',
    })

    Lazy loader는 commit: 'after-load'를 사용할 수 있지만, 내비게이션 중에는 await되지 않으므로 내비게이션 후에 반영될 수 있습니다.

  • server: 기본적으로 로더는 클라이언트와 서버 양쪽에서 실행됩니다. 이를 false로 설정하면 서버에서의 실행을 건너뜁니다. lazy: true와 마찬가지로 반환되는 data의 타입도 ShallowRef<T | undefined>로 바뀝니다:

    ts
    import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
    interface Book {
      title: string
      isbn: string
      description: string
    }
    function fetchBookCollection(): Promise<Book[]> {
      return {} as any
    }
    // ---cut---
    export const useBookCollection = defineLoader(fetchBookCollection, {
      server: false,
    })

각 사용자 정의 구현은 반환되는 속성에 더 많은 정보를 추가할 수 있습니다. 예를 들어 Pinia Coladarefresh(), status 및 그 기능에 특화된 다른 속성을 추가합니다.

병렬 가져오기

기본적으로 로더는 가능한 한 빨리, 병렬로 실행됩니다. 이 방식은 데이터 가져오기가 라우트 params/query params만 필요하거나 아무것도 필요하지 않은 대부분의 사용 사례에서 잘 동작합니다.

순차 가져오기

때로는 어떤 요청이 다른 가져온 데이터에 의존합니다(예: 추가 사용자 정보 가져오기). 이런 경우에는 다른 로더를 import해 다른 로더 안에서 사용하면 됩니다:

필요한 로더 안에서 해당 로더를 호출하고 await 하세요. 그러면 내비게이션 중 몇 번 호출하든 한 번만 가져옵니다:

ts
import 'vue-router/auto-routes'
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
// ---cut---
// 사용자 정보용 로더를 import합니다
import { useUserData } from './loaders/users'
import { getCommonFriends, getCurrentUser } from './api'

export const useUserCommonFriends = defineLoader(async route => {
  // 로더는 다른 로더 내부에서 await해야 합니다
  // .        ⤵
  const user = await useUserData() // 마법처럼 동작함
  const me = await getCurrentUser()

  // 다른 데이터를 가져옵니다
  const commonFriends = await getCommonFriends(me.id, user.id)
  return { ...user, commonFriends }
})

여기서 useUserData()에는 두 가지 다른 사용 방식이 있다는 점을 알 수 있습니다:

  • 하나는 필요한 모든 정보를 동기적으로 반환하는 방식입니다(여기서는 사용하지 않음). 컴포넌트에서 사용하는 composable이 이것입니다
  • 다른 하나는 데이터 Promise만 반환하는 버전입니다. 데이터 로더 안에서 사용되며 순차 가져오기를 가능하게 합니다

DANGER

useUserData()는 현재 사용자를 가져오기 위해 라우트에 id param이 있다고 가정합니다. 두 사용 방식(그리고 타입)을 더 명확히 구분하면서 타입 안전성도 보장하려면, 아마 라우트를 매개변수로 전달하도록 허용할 수도 있을 것입니다.

중첩 무효화

useUserCommonFriends() 로더가 useUserData()를 호출하므로, useUserData()가 어떤 식으로든 무효화되면 useUserCommonFriends()도 자동으로 무효화됩니다. 이는 로더 구현에 따라 달라지며 API의 필수 요구 사항은 아닙니다.

WARNING

두 로더가 서로를 사용할 수는 없습니다. 그렇게 하면 교착 상태 가 발생합니다.

같은 로더를 노출하는 여러 페이지가 있고, 다른 페이지가 그중 일부 이미 export된 로더를 또 다른 로더 안에서 사용하는 경우에는 구조가 복잡해질 수 있습니다. 하지만 문제는 아닙니다. 사용자가 별도로 다르게 처리할 필요는 없으며, 로더는 여전히 한 번만 호출됩니다:

ts
import 'vue-router/auto-routes'
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
// ---cut---
import {
  getFriends,
  getCommonFriends,
  getUserById,
  getCurrentUser,
} from './api'

export const useUserData = defineLoader('/users/[id]', async route => {
  return getUserById(route.params.id)
})

export const useCurrentUserData = defineLoader('/users/[id]', async route => {
  const me = await getCurrentUser()
  // 하나의 fetch로 묶을 수 없는 레거시 API를 상상해 보세요
  const friends = await getFriends(me.id)

  return { ...me, friends }
})

export const useUserCommonFriends = defineLoader('/users/[id]', async route => {
  const user = await useUserData()
  const me = await useCurrentUserData()

  const friends = await getCommonFriends(user.id, me.id)
  return { ...me, commonFriends: { with: user, friends } }
})

위 예제에서는 여러 로더를 export하고 있지만, 한 번만 호출되고 데이터를 공유하므로 호출 순서를 신경 쓰거나 최적화하려고 애쓸 필요가 없습니다.

DANGER

주의: 모든 중첩 로더는 부모 로더의 맨 위에서 호출하고 await해야 합니다(useUserData()useCurrentUserData() 참고). 그 사이에 다른 일반 await를 넣을 수 없습니다. 정말로 그 사이에서 로더가 아닌 무언가를 await해야 한다면, 로더 컨텍스트가 올바르게 복원되도록 Promise를 withDataContext()로 감싸세요:

ts
export const useUserCommonFriends = defineLoader(async (route) => {
  const user = await useUserData()
  await withContext(functionThatReturnsAPromise())
  const me = await useCurrentUserData()

  // ...
})

이렇게 하면 중첩 로더가 자신의 부모 로더 를 인식할 수 있습니다. 이는 아마 eslint 플러그인으로 lint할 수 있을 것입니다. 자동 withAsyncContext()가 도입되기 전의 <script setup> 문제와 비슷합니다. 동일한 기능을 도입할 수는 있겠지만(vite 플러그인을 통해), 성능 비용도 따릅니다. 앞으로는 async-context 제안(stage 2)으로 이 문제가 해결될 예정 입니다.

캐시 >=0.8.0

WARNING

이 부분은 API 핵심 기능에서 제거되었습니다. 이제는 Pinia Colada 같은 사용자 정의 구현의 일부입니다.

스마트 새로고침

이는 API의 필수 요구 사항은 아닙니다.

내비게이션 시 로더에 따라, 로더 안에서 사용된 params, query params, hash 를 기준으로 데이터가 자동으로 새로고침 됩니다.

예를 들어 Pinia Colada를 사용하고 페이지 /users/:id에 다음과 같은 로더가 있다고 가정해 봅시다:

ts
export const useUserData = defineColadaLoader(async route => {
  const user = await getUserById(route.params.id)
  return user
})

/users/1에서 /users/2로 가면 데이터가 다시 로드되지만, /users/2에서 /users/2#projects로 가는 경우에는 캐시가 만료되거나 수동으로 무효화(refresh 라고 부름)되지 않는 한 다시 로드되지 않습니다.

중복 제거

로더는 싱글톤 요청처럼 동작한다는 장점도 있습니다. 즉 로더가 몇 번 연결되었는지, 몇 개의 일반 컴포넌트가 이를 사용하는지와 관계없이 내비게이션당 한 번만 fetch됩니다. 또한 모든 ref(data, isLoading 등)가 한 번만 생성되어 모든 컴포넌트가 공유하므로 메모리 사용량도 줄어듭니다.

SSR

각 데이터 로더 구현은 서버에서 로드한 데이터를 직렬화하고 클라이언트로 전달하는 방법을 제공해야 합니다. 이는 SSR이 제대로 동작하기 위한 요구 사항입니다.

구현마다 서로 다른 형태의 key를 가질 수 있습니다. 가장 단순한 형태는 문자열입니다:

ts
export const useBookCollection = defineLoader(
  async () => {
    const books = await fetchBookCollection()
    return books
  },
  { key: 'bookCollection' }
)
클라이언트에서 이중 fetch 피하기

초기 상태를 갖는 장점 중 하나는 클라이언트에서 fetch를 피할 수 있다는 점입니다. 데이터 로더는 초기 상태가 제공되면 클라이언트에서 fetch를 건너뛰는 메커니즘을 구현할 수 있습니다(Pinia Colada가 이를 구현함). 이는 중첩 로더도 실행되지 않는다는 뜻 입니다. 데이터 로더는 데이터 가져오기 외의 부작용을 포함하지 않아야 하므로, 이는 문제가 되지 않아야 합니다.

내비게이션 가드

데이터 로더 로직의 대부분은 내비게이션 가드로 처리됩니다:

  • lazy load된 컴포넌트에서 로더를 수집하는 router.beforeEach() 하나
  • 로더를 실행하는 router.beforeResolve() 하나(다른 가드 뒤에 트리거됨)

오류 처리와 정리를 위해 router.afterEach()router.onError()도 사용됩니다.

내비게이션 가드에서 데이터 로딩을 처리하면 다음과 같은 장점이 있습니다:

  • 컴포넌트를 마운트하기 전에 데이터가 존재하도록 보장
  • lazy data loader를 사용해 중요하지 않은 데이터는 기다리지 않을 수 있는 유연성
  • 브라우저가 로딩 상태를 처리하도록 두는 UX 패턴을 가능하게 함(미래의 Navigation API와도 정렬됨)
  • 페이지 간 이동 시 스크롤이 바로 동작하게 함(데이터 로더가 내비게이션을 막는 경우)
  • 로더와 내비게이션당 단일 요청을 보장
  • 내비게이션 제어(중단, 리다이렉트 등)를 허용

내비게이션 제어

데이터 가져오기가 내비게이션 가드 안에서 발생하므로, 일반 내비게이션 가드처럼 내비게이션을 제어할 수 있습니다:

  • throw된 오류(또는 reject된 Promise)는 내비게이션을 취소하며(일반 내비게이션 가드와 동일한 동작), Vue Router의 오류 처리에서 가로챕니다
  • 리다이렉트: return new NavigationResult(targetLocation) -> 일반 내비게이션 가드의 return targetLocation과 동일
  • 내비게이션 취소: return new NavigationResult(false) -> 일반 내비게이션 가드의 return false와 동일
  • 그 외 반환값은 모두 해결된 데이터 로 간주됩니다
ts
import { NavigationResult } from 'vue-router'

export const useUserData = defineLoader(
  async (to) => {
    try {
      const user = await getUserById(to.params.id)

      return user
    } catch (error) {
      if (error.status === 404) {
        return new NavigationResult({ name: 'not-found', params: { pathMatch: '' } }
        )
      } else {
        throw error // vue router 내비게이션을 중단합니다
      }
    }
  }
)

new NavigationResult()는 내비게이션을 변경하기 위해 내비게이션 가드에서 반환할 수 있는 값은 무엇이든 유일한 인자로 받을 수 있습니다. 예를 들어 내비게이션을 수정하지 않는 trueundefined는 받을 수 없습니다.

몇 가지 대안:

Details
  • createNavigationResult(): 너무 장황함
  • NavigationResult()(new 없음): NavigationResult는 원시값이 아니므로 new를 써야 함
  • selectNavigationResult()에서 가져올 수 있는 추가 사용자 정의 컨텍스트를 위한 두 번째 인자를 받기

TIP

오류를 throw하면 selectNavigationResult() 메서드는 트리거되지 않습니다. 대신 일반 내비게이션 가드처럼 즉시 내비게이션을 취소하고 router.onError() 메서드를 트리거합니다.

여러 내비게이션 결과 처리

내비게이션 로더는 병렬로 실행될 수 있으므로 서로 다른 내비게이션 결과를 반환할 수도 있습니다. 이 경우 DataLoaderPluginselectNavigationResult() 메서드를 제공해 어떤 결과를 사용할지 결정할 수 있습니다:

ts
import 'vue-router/auto-routes'
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { DataLoaderPlugin } from 'vue-router/experimental'
const app = createApp({})
const router = createRouter({
  history: createWebHistory(),
  routes: [],
})
// ---cut---
// @moduleResolution: bundler
// @noErrors
app.use(DataLoaderPlugin, {
  router,
  selectNavigationResult(results) {
    for (const { value } of results) {
      if (
        typeof value === 'object' &&
        'name' in value &&
        value.name === 'not-found'
      ) {
        return value
      }
    }
  },
})

selectNavigationResult()는 모든 데이터 로더가 resolve된 후에, 반환된 모든 new NavigationResult(value) 배열과 함께 호출됩니다. 그중 하나라도 오류를 throw하거나 NavigationResult를 반환한 로더가 하나도 없다면 selectNavigationResult()는 호출되지 않습니다.

기본적으로 selectNavigation은 배열의 첫 번째 값을 반환합니다.

즉시 내비게이션 변경하기

로더가 내비게이션을 즉시 변경하고 싶다면 NavigationResult를 반환하는 대신 throw할 수 있습니다. 이렇게 하면 selectNavigationResult()를 건너뛰고 router.onError()를 트리거하지 않으면서 우선권을 갖게 됩니다.

ts
import { NavigationResult } from 'vue-router/experimental'

export const useUserData = defineLoader(
  async (to) => {
    try {
      const user = await getUserById(to.params.id)

      return user
    } catch (error) {
      throw new NavigationResult({
        name: 'not-found',
        params: { pathMatch: to.path.split('/') },
        query: to.query,
        hash: to.hash,
      })
    }
  }
)

INFO

vue router의 이름 있는 뷰를 사용할 때는 각 이름 있는 뷰가 자체 로더를 가질 수 있습니다. 다만 해당 라우트로의 어떤 내비게이션이든 모든 페이지 컴포넌트의 모든 로더 를 트리거한다는 점에 유의하세요. 라우터는 어떤 이름 있는 뷰가 사용될지 알 수 없기 때문입니다.

고급 오류 처리

로더 안에서 오류를 throw하면 내비게이션이 취소되므로, non lazy loader 에서는 UI에 오류를 표시하기 위한 error 속성을 가질 수 없습니다. 이를 해결하려면 로더를 정의할 때 예상된 오류를 지정할 수 있습니다:

ts
// 사용자 정의 오류 클래스
class MyError extends Error {
  // override는 TS에서만 필요합니다
  override name = 'MyError' // 로그에 'Error' 대신 표시됨
  // 생성자 정의는 선택 사항입니다
  constructor(message: string) {
    super(message)
  }
}

export const useUserData = defineLoader(
  async (to) => {
    // ...
  },
  {
    errors: [MyError],
  }
)

이들은 전역으로도 지정할 수 있습니다:

ts
class MyError extends Error {
  name = 'MyError'
  constructor(message: string) {
    super(message)
  }
}

app.use(DataLoaderPlugin, {
  router,
// `instanceof MyError`로 검사합니다
  errors: [MyError],
})

TIP

lazy loader에서는 오류를 throw할 수 있으며, 내비게이션을 막지 않기 때문에 그 오류는 항상 error 속성에 나타납니다. errors 속성을 정의해도 달라지는 것은 없습니다.

페이지 컴포넌트 밖에서 사용하기

페이지 컴포넌트가 직접 사용하지 않더라도(defineLoader()가 반환한 composable을 호출하지 않더라도) 로더를 페이지에 연결할 수 있습니다. 중첩 컴포넌트가 해당 데이터를 사용한다면 가능합니다. 반환된 composable 을 import하면 페이지 컴포넌트 범위 밖에서도, 심지어 부모 컴포넌트에서도 어떤 컴포넌트에서든 사용할 수 있습니다.

그뿐 아니라 로더는 어디에서든 정의 할 수 있고, 데이터를 사용하는 것이 타당한 곳에서 import할 수 있습니다. 이렇게 하면 별도의 src/loaders 폴더에 로더를 정의하고 여러 페이지에서 재사용할 수 있습니다:

ts
// src/loaders/user.ts
export const useUserData = defineLoader(...)
// ...

그 다음 페이지 컴포넌트에서 이를 export합니다:

vue
<!-- src/pages/users/[id].vue -->
<script>
export { useUserData } from '~/loaders/user.ts'
</script>
<script setup>
// ...
</script>

페이지 컴포넌트가 useUserData()를 직접 사용하지 않더라도, 다른 곳에서는 여전히 사용할 수 있습니다:

vue
<!-- src/components/NavBar.vue -->
<script setup>
import { useUserData } from '~/loaders/user.ts'

const { data: user } = useUserData()
</script>

WARNING

페이지에서 export되지 않은 로더를 컴포넌트에서 사용하면 내비게이션 중에 await되지 않습니다. 이는 예상치 못한 동작으로 이어질 수 있지만 개발 중 경고로 감지할 수 있습니다.

TypeScript

unplugin-vue-router가 라우트 타입을 자동 생성하며, 각 라우트 이름을 참조해 defineLoader()에 현재 타입의 가능한 값을 힌트로 줄 수 있습니다. 그뿐 아니라 defineLoader()는 반환 타입도 추론합니다:

vue
<script lang="ts">
// ---cut-start---
import 'vue-router/auto-routes'
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
// ---cut-end---
import { getUserById } from '../api'

export const useUserData = defineLoader('/users/[id]', async route => {
  //                                                ^|

  //
  const user = await getUserById(route.params.id)
  //                                          ^|
  // ...
  return user
})
</script>

<script lang="ts" setup>
const { data: user, isLoading, error } = useUserData()
//            ^?
//            👆 마우스를 올리거나 탭
</script>

이 인자들은 타입에만 사용되고 런타임에서는 무시되므로, 프로덕션 모드 컴파일 단계에서 제거할 수 있습니다.

비차단 데이터 가져오기(Lazy Loaders)

Nuxt의 lazy async data라고도 불리는 이 방식은, 로더를 lazy로 표시해 내비게이션을 막지 않도록 할 수 있습니다.

vue
<script lang="ts">
// ---cut-start---
import 'vue-router/auto-routes'
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
// ---cut-end---
import { getUserById } from '../api'

export const useUserData = defineLoader(
  '/users/[id]',
  async (route) => {
    const user = await getUserById(route.params.id)
    return user
  },
  { lazy: true } // 👈 lazy로 표시됨
)
</script>

<script setup>
// 위 예제와 달리 `user.value`는 처음에 `undefined`일 수 있고 실제로도 그렇습니다
const { data: user, isLoading, error } = useUserData()
//            ^?
//            👆 마우스를 올리거나 탭
</script>

이 패턴은 중요하지 않은 데이터 를 가져오는 동안 내비게이션을 막지 않기 위해 유용합니다. 페이지 일부가 아직 로딩 중이어도 더 일찍 페이지를 표시할 수 있고, isLoading 속성 덕분에 로더 표시기도 보여 줄 수 있습니다.

이 방식은 SSR과 클라이언트 사이드 내비게이션에서 서로 다른 동작을 갖게 하는 것도 허용합니다. 예를 들어 SSR에서는 로더를 기다리되 클라이언트 사이드 내비게이션에서는 기다리지 않게 할 수 있습니다:

ts
export const useUserData = defineLoader(
  async (route) => {
    // ...
  },
  {
    lazy: !import.env.SSR, // Vite에서
    lazy: process.client, // NuxtJS에서
  }
)

남아 있는 질문:

AbortSignal

로더는 두 번째 인자로 AbortSignal에 접근할 수 있으며, 이를 fetch 및 다른 Web API에 전달할 수 있습니다. 오류나 새 내비게이션 때문에 내비게이션이 취소되면 signal이 abort되고, 이를 사용하는 모든 요청도 함께 중단됩니다.

ts
import { defineBasicLoader as defineLoader } from 'vue-router/experimental'
interface Book {
  title: string
  isbn: string
  description: string
}
function fetchBookCollection(options: {
  signal?: AbortSignal
}): Promise<Book[]> {
  return {} as any
}
// ---cut---
export const useBookCollection = defineLoader(async (_route, { signal }) => {
  return fetchBookCollection({ signal })
})

이는 진행 중인 호출을 취소하기 위해 AbortSignal을 사용하는 미래의 Navigation API 및 다른 웹 API와도 일치합니다.

구현체

인터페이스

데이터 로더에 대한 최소한의 정보와 옵션 집합을 정의하는 것이 외부 라이브러리가 자체 데이터 로더를 구현할 수 있게 합니다. 이 인터페이스들은 각 라이브러리 고유의 기능을 추가할 수 있도록 확장될 예정입니다. 실제 예시는 Pinia Colada 구현에서 볼 수 있습니다.

DANGER

이 섹션은 아직 작업 중입니다. 대신 구현체를 참고하세요.

전역 API

데이터 로더가 fetch 중인지(내비게이션 중 또는 reload()가 호출되었을 때), 그리고 데이터 fetch 내비게이션 가드가 실행 중인지(내비게이션 시에만)를 나타내는 전역 상태에 접근할 수 있습니다.

  • isFetchingData: Ref<boolean>: 현재 데이터를 fetch 중인 로더가 있는가? 예: 로더의 reload() 메서드 호출
  • isNavigationFetching: Ref<boolean>: 로더가 내비게이션을 붙잡고 있는가? (isFetchingData.value === true를 의미). 로더의 reload() 메서드를 호출해도 이 값은 바뀌지 않습니다.

TBD: 이게 정말 가치가 있을까요? 다른 함수도 필요할까요?

제한 사항

  • 로더 안에서는 주입(inject/provide)을 사용할 수 없음 이제는 사용할 수 있습니다
  • watcher 및 다른 composable은 데이터 로더 안에서 사용하지 않는 것이 좋습니다:
    • composable(예: watch())을 호출하기 전에 await를 사용하면 scope가 보장되지 않습니다
    • 실제로는 로더 안에서 composable을 만들 필요가 없기 때문에, 문제가 되지 않아야 합니다

단점

  • 처음에는 <Suspense>와 함께 setup() 안에서 무언가를 await하는 것보다 직관성이 떨어져 보일 수 있습니다. 하지만 그 방식의 한계는 없고 기능은 훨씬 더 많습니다
  • 페이지 컴포넌트에 한해 추가 <script> 태그가 필요합니다. definePageLoader()/defineLoader() 같은 매크로는 오류를 유발하기 쉬울 수 있는데, 컴포넌트의 <script setup> 안에서 선언한 반응형 상태를 사용하고 싶은 유혹이 크지만 로더는 setup() 함수 밖에서 생성되어야 하기 때문입니다

대안

Suspense

Suspense를 사용하는 것은 아마 가장 먼저 떠오르는 대안일 것이며, 개념 증명 구현을 통해 데이터 가져오기 해법으로도 검토되었습니다. 하지만 현재 설계에 묶인 큰 단점들이 있어 데이터 가져오기를 위한 실질적인 해결책으로 보기 어렵습니다.

다음과 같이 작성할 수 있다고 상상해 볼 수 있습니다:

vue
<!-- src/pages/users.vue = /users -->
<!-- 모든 사용자 목록을 표시 -->
<script setup>
const userList = shallowRef(await fetchUserList())

// 필요할 때마다 호출할 reload 함수를 수동으로 노출합니다
function reload() {
  userList.value = await fetchUserList()
}
</script>

또는 데이터 가져오기에 params가 포함되는 경우:

vue
<!-- src/pages/users.[id].vue = /users/:id -->
<!-- 모든 사용자 목록을 표시 -->
<script setup>
const route = useRoute()
const user = shallowRef(await fetchUserData(route.params.id))

// 필요할 때마다 호출할 reload 함수를 수동으로 노출합니다
function reload() {
  user.value = await fetchUserData(route.params.id)
}

// 내비게이션을 막고 싶기 때문에 watcher 대신 내비게이션에 훅을 겁니다
onBeforeRouteUpdate(async (to) => {
  // 여기서는 `route`가 아니라 `to`를 사용해야 한다는 점에 유의하세요
  user.value = await fetchUserData(to.params.id)
})
</script>

이 구성에는 많은 한계가 있습니다:

  • 중첩 라우트는 순차 데이터 가져오기를 강제하게 됩니다. 최적의 병렬 가져오기 를 보장할 수 없습니다

  • <RouterView>key 속성을 추가하지 않는 한 수동 데이터 새로고침이 필요합니다. key를 추가하면 내비게이션 시 컴포넌트가 강제로 remount됩니다. 이는 데이터가 같더라도 매 내비게이션마다 remount되므로 이상적이지 않습니다. <transition>을 하려면 필요하지만, 필요 시 key와도 함께 동작하는 제안된 해법보다 유연성이 떨어집니다.

  • 가져오기 로직을 컴포넌트의 setup() 안에 두면 다른 문제도 발생합니다:

    • 가져오기 로직의 추상화가 없음 => 여러 컴포넌트에서 같은 데이터를 가져올 때 코드 중복 발생
    • 여러 컴포넌트가 같은 데이터를 사용할 때 요청을 중복 제거할 네이티브 방법이 없음: store와, 중복 fetch를 건너뛰는 추가 로직이 필요함
    • 내비게이션을 막지 못함
      • 다가오는 페이지 컴포넌트를 마운트함으로써 막을 수는 있지만(내비게이션은 여전히 데이터 로더 내비게이션 가드에 의해 막힌 상태), 새 페이지를 마운트하려고 시도하는 동안 여전히 기존 페이지도 렌더링해야 하므로 렌더링과 메모리 측면에서 비용이 큽니다
    • 내비게이션 결과를 수정할 수 없음(예: 리다이렉트, 취소 등). 가져오기에 실패하면 오류 상태에 머물게 됩니다
  • 아주 단순한 경우에도 데이터를 캐싱할 네이티브 방법이 없음(예: 브라우저 UI로 빠르게 앞뒤 이동할 때 refetch하지 않기)

  • 로딩 상태를 정확하게 읽거나(또는 쓰거나) 할 수 없음(vuejs/core#1347 참고)

그와 별개로, 이 RFC가 여러분의 선택을 제한하지 않는다는 점도 중요합니다. 여전히 Suspense를 데이터 가져오기나 다른 비동기 상태에 사용할 수 있고, 둘을 함께 사용할 수도 있습니다. 이 API는 완전히 tree shakable 하며, 사용하지 않으면 런타임 오버헤드도 추가하지 않습니다. 이는 Vue.js의 점진적 향상 특성과도 일치합니다.

다른 대안

  • blocking 데이터 로더가 속성 객체를 반환하도록 허용하기:

    Details
    ts
    export const useUserData = defineLoader(async route => {
      const user = await getUserById(route.params.id)
      // user를 반환하는 대신
      return { user }
    })
    // const { data: user } = useUserData() 대신
    const { user } = useUserData()

    이것이 초기 제안이었지만, lazy loader에서는 불가능하기 때문에 더 복잡하고 덜 직관적이었습니다. 하나의 단일 버전을 두는 편이 전체적으로 다루기 쉽습니다. 물론 await되지 않는 pending Promise를 객체 안에 반환하는 것은 가능합니다:

    ts
      export const useUserData = defineLoader(async (route) => {
      return {
        // await됨
        user: await getUserById(route.params.id)
        // lazy처럼 await되지 않음
        nonCriticalData: getNonCriticalData() // Promise<...>
      }
    })

    하지만 이 버전은 lazy: true와 겹칩니다. 의미적으로는 하나의 로더로 정의하는 편이 더 자연스러울 수 있지만, API를 페이지당 하나의 로더로 제한하고 페이지와 컴포넌트 간에 데이터, 로딩 상태, 오류 등을 재사용할 수 없게 만들어 확장성도 제한합니다.

  • <script setup>과 비슷한 새로운 <script loader>를 추가하기:

    Details
    vue
    <script lang="ts" loader="useUserData">
    import { getUserById } from '~/api/users'
    import { useRoute } from 'vue-router' // 자동으로 import될 수도 있음
    
    const route = useRoute()
    // 여기서 생성한 어떤 변수든 useLoader()에서 사용할 수 있습니다
    const user = await getUserById(route.params.id)
    </script>
    
    <script lang="ts" setup>
    const { user, isLoading, error } = useUserData()
    </script>

    명확한 이점 없이 너무 마법적입니다.

  • 전체 route 객체 대신 라우트 속성을 전달하기:

    Details
    ts
    import { getUserById } from '../api'
    
    export const useUserData = defineLoader(async ({ params }) => {
      const user = await getUserById(params.id)
      return { user }
    })

    이 방식은 route.name을 사용해 올바른 타입 지정 params를 결정할 수 없다는 문제가 있습니다(unplugin-vue-router 사용 시):

    ts
    import { getUserById } from '../api'
    
    export const useUserData = defineLoader(async route => {
      if (route.name === 'user-details') {
        const user = await getUserById(route.params.id)
        //                                    ^ 타입 지정됨!
        return { user }
      }
    })
  • 네이밍

    Details

    변수 이름은 다르게 정할 수 있으며 제안도 환영합니다:

    • isLoading -> isPending, pending (same as Nuxt)
    • Rename defineLoader() to defineDataFetching() (or others)
  • 중첩/순차 로더의 단점

    Details
    • await getUserById()를 허용하면 사람들이 <script setup> 안에서도 await해야 한다고 생각할 수 있고, 이는 필요하지 않을 때도 <Suspense>를 강제하게 되므로 문제가 될 수 있습니다. 저는 로더의 반환 타입을 데이터만 담은 Promise로 바꾸면 실수를 쉽게 알아차릴 수 있어 이 문제가 해결된다고 봅니다. 위에서 설명한 것처럼 타입 안전성을 보장하기 위해 to 매개변수를 강제하는 방식으로도 해결할 수 있습니다.

    • 또 다른 대안은 필요한 로더에 로더 배열을 전달하고 인자를 통해 가져오게 하는 것이지만, 체감상 상당히 덜 인체공학적입니다:

      ts
      import { useUserData } from '~/pages/users/[id].vue'
      
      export const useUserFriends = defineLoader(
        async (route, { loaders: [userData] }) => {
          const friends = await getFriends(user.value.id)
          return { ...userData.value, friends }
        },
        {
          // 명시적 의존성
          waitFor: [useUserData],
        }
      )
  • 고급 lazy

    Details

    lazy 플래그를 확장해 숫자(타임아웃)나 함수(동적 값)도 받을 수 있게 할 수 있습니다. 하지만 이는 과도하며 따라서 포함하지 않는 편이 낫다고 생각합니다. 사용자 정의 데이터 로더에서 구현하는 것은 가능하지만, 기본 API의 요구 사항이 되어서는 안 된다고 봅니다.

    lazy숫자 를 전달하면 해당 밀리초 동안 내비게이션을 막았다가 이후에는 통과시킬 수 있습니다:

    vue
    <script lang="ts">
    import { getUserById } from '../api'
    
    export const useUserData = defineLoader(
      async route => {
        const user = await getUserById(route.params.id)
        return user
      },
      // 1초 동안 내비게이션을 막은 뒤 통과시킵니다
      { lazy: 1000 }
    )
    </script>
    
    <script setup>
    const { data, isLoading, error } = useUserData()
    //      ^ Ref<User | undefined>
    </script>

    lazy loader는 자기 자신의 blocking 메커니즘만 제어할 수 있다는 점에 유의하세요. 다른 로더의 blocking은 제어할 수 없습니다. 여러 로더를 사용하고 그중 하나라도 blocking이면, 모든 blocking loader가 resolve될 때까지 내비게이션은 막혀 있습니다.

    함수는 내비게이션 시 조건부로 blocking할 수 있게 해줄 것입니다:

    ts
    export const useUserData = defineLoader(
      loader,
      // ...
      {
        lazy: route => {
          // ...
          return true // 또는 숫자
        },
      }
    )
  • 페이지 외의 어떤 컴포넌트에서도 로더 결과를 재사용할 수 있게 하는 것이 오히려 더 복잡하다고 주장할 수도 있습니다. 다른 프레임워크는 페이지 컴포넌트에서 단일 load 함수를 노출합니다(SvelteKit, Remix)

도입 전략

먼저 unplugin-vue-router의 일부로 도입해 실험하고, 이후 라우터의 일부로 편입합니다.

미해결 질문

  • Nuxt 같은 프레임워크의 서버 특화 요소와의 통합: 쿠키, 헤더, 서버 전용 로더(리다이렉트 코드를 만들 수 있음)
  • 모든 데이터 로더 전에 호출되고 await되는 beforeLoad() 훅이 있어야 할까
  • 모든 데이터 로더 뒤에 항상 호출되는 afterLoad()도 마찬가지일까
  • 로더 안에서 route 외에 또 무엇이 필요할까
  • placeholder data 옵션을 추가할까? 데이터 로더가 이를 스스로 구현해야 함
  • 사용자에게 필요한 다른 작업은 무엇일까

모두를 위한 문서 한글화