Skip to content

Vue 2에서 마이그레이션하기 ​

Vue Router API의 대부분은 v3(Vue 2용)에서 v4(Vue 3용)로 다시 작성되는 과정에서도 바뀌지 않았지만, 애플리케이션을 마이그레이션할 때 마주칠 수 있는 몇 가지 브레이킹 체인지가 여전히 존재합니다. 이 가이드는 왜 이런 변경이 생겼는지 이해하고, 애플리케이션을 Vue Router 4에서 동작하도록 어떻게 조정해야 하는지 돕기 위해 작성되었습니다.

브레이킹 체인지 ​

변경 사항은 사용 빈도 순으로 정렬되어 있습니다. 따라서 이 목록을 순서대로 따라가는 것이 좋습니다.

new Router는 createRouter가 됩니다 ​

Vue Router는 더 이상 클래스가 아니라 함수 집합입니다. 이제 new Router()를 작성하는 대신 createRouter를 호출해야 합니다:

js
// 이전에는 다음과 같았습니다
// import Router from 'vue-router'
import { createRouter } from 'vue-router'

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

mode를 대체하는 새로운 history 옵션 ​

mode: 'history' 옵션은 더 유연한 history라는 옵션으로 대체되었습니다. 사용 중이던 모드에 따라 적절한 함수로 바꿔야 합니다:

  • "history": createWebHistory()
  • "hash": createWebHashHistory()
  • "abstract": createMemoryHistory()

전체 예시는 다음과 같습니다:

js
import { createRouter, createWebHistory } from 'vue-router'
// createWebHashHistory와 createMemoryHistory도 있습니다

createRouter({
  history: createWebHistory(),
  routes: [],
})

SSR에서는 적절한 history를 수동으로 전달해야 합니다:

js
// router.js
let history = isServer ? createMemoryHistory() : createWebHistory()
let router = createRouter({ routes, history })
// server-entry.js의 어딘가에서
router.push(req.url) // request url
router.isReady().then(() => {
  // 요청을 resolve합니다
})

이유: 사용하지 않는 history에 대한 tree shaking을 가능하게 하고, 네이티브 솔루션 같은 고급 사용 사례를 위한 사용자 정의 history 구현도 가능하게 하기 위함입니다.

base 옵션 이동 ​

이제 base 옵션은 createWebHistory(및 다른 history 생성 함수)의 첫 번째 인자로 전달됩니다:

js
import { createRouter, createWebHistory } from 'vue-router'
createRouter({
  history: createWebHistory('/base-directory/'),
  routes: [],
})

fallback 옵션 제거 ​

라우터를 생성할 때 더 이상 fallback 옵션을 지원하지 않습니다:

diff
-new VueRouter({
+createRouter({
-  fallback: false,
// 다른 옵션들...
})

이유: Vue가 지원하는 모든 브라우저가 HTML5 History API를 지원하므로, location.hash를 조작하는 해킹을 피하고 직접 history.pushState()를 사용할 수 있기 때문입니다.

*(star 또는 catch all) 라우트 제거 ​

이제 catch all 라우트(*, /*)는 사용자 정의 regex를 가진 매개변수를 사용해 정의해야 합니다:

js
const routes = [
  // pathMatch는 param 이름입니다. 예를 들어 /not/found로 이동하면
  // { params: { pathMatch: ['not', 'found'] }}
  // 이는 마지막 * 덕분이며 반복 params를 의미합니다. 이름으로 not-found 라우트에
  // 직접 이동할 계획이 있다면 필요합니다
  { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound },
  // 마지막 `*`를 생략하면 resolve나 push 시 params 안의 `/` 문자가 인코딩됩니다
  { path: '/:pathMatch(.*)', name: 'bad-not-found', component: NotFound },
]
// 이름 있는 라우트를 사용할 때의 나쁜 예:
router.resolve({
  name: 'bad-not-found',
  params: { pathMatch: 'not/found' },
}).href // '/not%2Ffound'
// 좋은 예:
router.resolve({
  name: 'not-found',
  params: { pathMatch: ['not', 'found'] },
}).href // '/not/found'

TIP

이름을 사용해 not found 라우트로 직접 push할 계획이 없다면 반복 params를 위한 *를 추가할 필요는 없습니다. router.push('/not/found/url')를 호출하면 올바른 pathMatch param이 제공됩니다.

이유: Vue Router는 더 이상 path-to-regexp를 사용하지 않고, 라우트 순위 매기기와 동적 라우팅을 가능하게 하는 자체 파싱 시스템을 구현하기 때문입니다. 보통 프로젝트마다 catch-all 라우트는 하나만 추가하므로, *에 대한 특수 문법을 지원할 큰 이점이 없습니다. params 인코딩은 예외 없이 모든 라우트에서 일관되게 동작해 예측이 쉬워집니다.

currentRoute 속성은 이제 ref()입니다 ​

이전에는 라우터 인스턴스의 currentRoute 객체 속성에 직접 접근할 수 있었습니다.

vue-router v4에서는 라우터 인스턴스의 currentRoute 객체 기반 타입이 Ref<RouteLocationNormalizedLoaded>로 바뀌었습니다. 이는 Vue 3에서 도입된 새로운 반응성 기초에서 온 것입니다.

useRoute()나 this.$route로 라우트를 읽는다면 달라지는 것은 없지만, 라우터 인스턴스에서 직접 접근한다면 실제 라우트 객체에 currentRoute.value를 통해 접근해야 합니다:

ts
const { page } = router.currentRoute.query
const { page } = router.currentRoute.value.query

onReady는 isReady로 대체되었습니다 ​

기존 router.onReady() 함수는 인자를 받지 않고 Promise를 반환하는 router.isReady()로 대체되었습니다:

js
// 다음을
router.onReady(onSuccess, onError)
// 이렇게 바꿉니다
router.isReady().then(onSuccess).catch(onError)
// 또는 await를 사용합니다:
try {
  await router.isReady()
  // 성공 시
} catch (err) {
  // 오류 시
}

scrollBehavior 변경 사항 ​

이제 scrollBehavior에서 반환하는 객체는 ScrollToOptions와 유사합니다. x는 left로, y는 top으로 이름이 바뀌었습니다. 자세한 내용은 RFC를 참고하세요.

이유: 객체를 ScrollToOptions와 비슷하게 만들어 네이티브 JS API와 더 익숙하게 느껴지게 하고, 향후 새로운 옵션을 추가할 가능성도 열어 두기 위함입니다.

<router-view>, <keep-alive>, <transition> ​

이제 transition과 keep-alive는 v-slot API를 통해 RouterView 안에서 사용해야 합니다:

template
<router-view v-slot="{ Component }">
  <transition>
    <keep-alive>
      <component :is="Component" />
    </keep-alive>
  </transition>
</router-view>

이유: 이는 필요한 변경이었습니다. 자세한 내용은 관련 RFC를 참고하세요.

append prop은 <router-link>에서 제거되었습니다. 대신 기존 path에 값을 직접 이어 붙일 수 있습니다:

template
replace
<router-link to="child-route" append>to relative child</router-link>
with
<router-link :to="append($route.path, 'child-route')">
  to relative child
</router-link>

App 인스턴스에 전역 append 함수를 정의해야 합니다:

js
app.config.globalProperties.append = (path, pathToAppend) =>
  path + (path.endsWith('/') ? '' : '/') + pathToAppend

이유: append는 자주 사용되지 않았고, 사용자 코드에서 쉽게 재현할 수 있기 때문입니다.

event와 tag props는 모두 <router-link>에서 제거되었습니다. v-slot API를 사용해 <router-link>를 완전히 사용자 정의할 수 있습니다:

template
replace
<router-link to="/about" tag="span" event="dblclick">About Us</router-link>
with
<router-link to="/about" custom v-slot="{ navigate }">
  <span @click="navigate" @keypress.enter="navigate" role="link">About Us</span>
</router-link>

이유: 이 props들은 <a> 태그가 아닌 다른 것을 사용하기 위해 함께 쓰이는 경우가 많았지만, v-slot API 이전에 도입된 기능이며 현재는 모든 사용자를 위해 번들 크기를 늘릴 만큼 충분히 자주 사용되지 않기 때문입니다.

exact prop은 더 이상 해결해야 할 주의점이 존재하지 않기 때문에 제거되었습니다. 따라서 안전하게 제거할 수 있습니다. 다만 다음 두 가지는 알아두어야 합니다:

  • 이제 라우트는 생성된 route location 객체와 그 path, query, hash 속성이 아니라, 자신이 나타내는 라우트 레코드를 기준으로 활성 상태가 결정됩니다
  • 이제는 path 부분만 매치되며, query와 hash는 더 이상 고려되지 않습니다

이 동작을 사용자 정의하고 싶다면, 예를 들어 hash까지 고려하고 싶다면 v-slot API를 사용해 <router-link>를 확장해야 합니다.

이유: 자세한 내용은 활성 매칭 변경에 대한 RFC를 참고하세요.

현재 mixin 안의 내비게이션 가드는 지원되지 않습니다. 지원 상황은 vue-router#454에서 추적할 수 있습니다.

router.match 제거 및 router.resolve 변경 ​

router.match와 router.resolve는 약간 다른 시그니처의 router.resolve 하나로 합쳐졌습니다. 자세한 내용은 API를 참고하세요.

이유: 같은 목적에 사용되던 여러 메서드를 하나로 통합하기 위함입니다.

router.getMatchedComponents() 제거 ​

이제 매치된 컴포넌트는 router.currentRoute.value.matched에서 가져올 수 있으므로 router.getMatchedComponents 메서드는 제거되었습니다:

js
router.currentRoute.value.matched.flatMap(record =>
  Object.values(record.components)
)

이유: 이 메서드는 SSR 중에만 사용되었고, 사용자가 한 줄로 직접 구현할 수 있기 때문입니다.

redirect 레코드는 특수 경로를 사용할 수 없습니다 ​

이전에는 문서화되지 않은 기능으로 redirect 레코드에 /events/:id 같은 특수 경로를 설정하고 기존 id param을 재사용할 수 있었습니다. 이제는 불가능하며 두 가지 선택지가 있습니다:

  • param 없이 라우트 이름을 사용하기: redirect: { name: 'events' }. 단, :id param이 선택 사항이면 동작하지 않습니다
  • 함수를 사용해 대상 기준으로 새 location을 다시 만들기: redirect: to => ({ name: 'events', params: to.params })

이유: 이 문법은 거의 사용되지 않았고, 위 방식들보다 충분히 짧지도 않으면서 복잡성을 추가하고 라우터를 더 무겁게 만들었기 때문입니다.

모든 내비게이션은 이제 항상 비동기입니다 ​

이제 첫 번째 내비게이션을 포함한 모든 내비게이션이 비동기입니다. 즉 transition을 사용한다면 앱을 마운트하기 전에 라우터가 ready 상태가 될 때까지 기다려야 할 수 있습니다:

js
app.use(router)
// 참고: 서버 사이드에서는 초기 location을 수동으로 push해야 합니다
router.isReady().then(() => app.mount('#app'))

그렇지 않으면 라우터가 초기 location(아무것도 없음)을 표시한 뒤 첫 번째 location을 표시하기 때문에, 마치 transition에 appear prop을 준 것처럼 초기 전환이 발생합니다.

초기 내비게이션에 내비게이션 가드가 있다면, 서버 사이드 렌더링을 하는 경우가 아니라면 그것들이 resolve될 때까지 앱 렌더링을 막고 싶지 않을 수도 있습니다. 이 경우 라우터가 ready 상태가 될 때까지 기다리지 않고 앱을 마운트하면 Vue 2와 같은 결과를 얻게 됩니다.

router.app 제거 ​

router.app은 예전에는 라우터를 주입한 마지막 루트 컴포넌트(Vue 인스턴스)를 나타냈습니다. 이제 Vue Router는 여러 Vue 애플리케이션에서 동시에 안전하게 사용할 수 있습니다. 그래도 라우터를 사용할 때 직접 추가할 수는 있습니다:

js
app.use(router)
router.app = app

Router 인터페이스의 TypeScript 정의를 확장해 app 속성을 추가할 수도 있습니다.

이유: Vue 3의 애플리케이션 개념은 Vue 2에는 존재하지 않았고, 이제는 같은 Router 인스턴스를 사용하는 여러 애플리케이션을 제대로 지원하기 때문에 app 속성이 있으면 루트 인스턴스가 아니라 애플리케이션을 가리키게 되어 오히려 오해를 부를 수 있기 때문입니다.

라우트 컴포넌트의 <slot>에 콘텐츠 전달하기 ​

예전에는 <router-view> 컴포넌트 아래에 템플릿을 중첩하여 라우트 컴포넌트의 <slot>에서 렌더링되도록 직접 전달할 수 있었습니다:

template
<router-view>
  <p>In Vue Router 3, I render inside the route component</p>
</router-view>

하지만 <router-view>에 v-slot API가 도입되면서 이제는 v-slot API를 사용해 이를 <component>에 전달해야 합니다:

template
<router-view v-slot="{ Component }">
  <component :is="Component">
    <p>In Vue Router 3, I render inside the route component</p>
  </component>
</router-view>

route location에서 parent 제거 ​

정규화된 route location(this.$route 및 router.resolve가 반환하는 객체)에서 parent 속성이 제거되었습니다. 여전히 matched 배열을 통해 접근할 수 있습니다:

js
const parent = this.$route.matched[this.$route.matched.length - 2]

이유: parent와 children이 있으면 불필요한 순환 참조가 생기며, 해당 정보는 이미 matched를 통해 얻을 수 있기 때문입니다.

pathToRegexpOptions 제거 ​

라우트 레코드의 pathToRegexpOptions와 caseSensitive 속성은 createRouter()의 sensitive와 strict 옵션으로 대체되었습니다. 이제 createRouter()로 라우터를 만들 때 직접 전달할 수도 있습니다. path-to-regexp는 더 이상 경로 파싱에 사용되지 않으므로, 그 밖의 path-to-regexp 전용 옵션은 모두 제거되었습니다.

이름 없는 매개변수 제거 ​

path-to-regexp가 제거됨에 따라 이름 없는 매개변수는 더 이상 지원되지 않습니다:

  • /foo(/foo)?/suffix becomes /foo/:_(foo)?/suffix
  • /foo(foo)? becomes /foo:_(foo)?
  • /foo/(.*) becomes /foo/:_(.*)

TIP

매개변수 이름으로 _ 대신 어떤 이름이든 사용할 수 있습니다. 중요한 것은 이름을 제공하는 것입니다.

history.state 사용 ​

Vue Router는 history.state에 정보를 저장합니다. history.pushState()를 수동으로 호출하는 코드가 있다면 가능하면 피하거나, 일반적인 router.push()와 history.replaceState()를 사용하도록 리팩터링하는 것이 좋습니다:

js
// 다음을
history.pushState(myState, '', url)
// 이렇게 바꿉니다
await router.push(url)
history.replaceState({ ...history.state, ...myState }, '')

마찬가지로 현재 상태를 보존하지 않은 채 history.replaceState()를 호출하고 있었다면, 현재 history.state를 전달해야 합니다:

js
// 다음을
history.replaceState({}, '', url)
// 이렇게 바꿉니다
history.replaceState(history.state, '', url)

이유: 우리는 history state를 사용해 스크롤 위치, 이전 location 등 내비게이션 관련 정보를 저장하기 때문입니다.

options에서 routes 옵션은 필수입니다 ​

이제 options에서 routes 속성은 필수입니다.

js
createRouter({ routes: [] })

이유: 나중에 라우트를 추가할 수는 있지만, 라우터는 애초에 라우트와 함께 생성되도록 설계되었기 때문입니다. 대부분의 시나리오에서는 최소 한 개의 라우트가 필요하며, 이는 일반적으로 앱마다 한 번만 작성됩니다.

존재하지 않는 이름 있는 라우트 ​

존재하지 않는 이름 있는 라우트를 push하거나 resolve하면 오류가 발생합니다:

js
// 이름에 오타가 있습니다
router.push({ name: 'homee' }) // throws
router.resolve({ name: 'homee' }) // throws

이유: 이전에는 라우터가 /로 이동했지만(홈 페이지 대신) 아무것도 표시하지 않았습니다. 유효한 이동 URL을 만들 수 없으므로 오류를 throw하는 쪽이 더 타당합니다.

이름 있는 라우트에서 필수 params 누락 ​

필수 params 없이 이름 있는 라우트를 push하거나 resolve하면 오류가 발생합니다:

js
// 다음과 같은 라우트가 있다고 가정합니다:
const routes = [{ path: '/users/:id', name: 'user', component: UserDetails }]

// `id` param이 없으면 실패합니다
router.push({ name: 'user' })
router.resolve({ name: 'user' })

이유: 위와 같습니다.

빈 path를 가진 이름 있는 자식 라우트는 더 이상 슬래시를 덧붙이지 않습니다 ​

빈 path를 가진 중첩 이름 있는 라우트가 있다고 가정해 봅시다:

js
const routes = [
  {
    path: '/dashboard',
    name: 'dashboard-parent',
    component: DashboardParent,
    children: [
      { path: '', name: 'dashboard', component: DashboardDefault },
      {
        path: 'settings',
        name: 'dashboard-settings',
        component: DashboardSettings,
      },
    ],
  },
]

이제 이름 있는 라우트 dashboard로 이동하거나 resolve하면 끝 슬래시가 없는 URL이 생성됩니다:

js
router.resolve({ name: 'dashboard' }).href // '/dashboard'

이 변경은 다음과 같은 자식 redirect 레코드에 중요한 부작용을 일으킵니다:

js
const routes = [
  {
    path: '/parent',
    component: Parent,
    children: [
      // 이제 `/parent/home` 대신 `/home`으로 리다이렉트됩니다
      { path: '', redirect: 'home' },
      { path: 'home', component: Home },
    ],
  },
]

path가 /parent/였다면 이 코드는 동작합니다. /parent/에 대한 상대 location home은 실제로 /parent/home이지만, /parent에 대한 상대 location home은 /home이기 때문입니다.

이유: 끝 슬래시 동작을 일관되게 만들기 위해서입니다. 기본적으로 모든 라우트는 끝 슬래시를 허용합니다. strict 옵션을 사용하고 라우트에 슬래시를 수동으로 붙이거나 붙이지 않음으로써 이를 비활성화할 수 있습니다.

$route 속성 인코딩 ​

이제 params, query, hash의 디코딩된 값은 내비게이션이 어디서 시작되었든 일관됩니다(오래된 브라우저는 여전히 인코딩되지 않은 path와 fullPath를 만들 수 있습니다). 초기 내비게이션도 앱 내부 내비게이션과 같은 결과를 만들어야 합니다.

어떤 정규화된 라우트 location이든 다음이 적용됩니다:

  • 이제 path, fullPath의 값은 더 이상 디코딩되지 않습니다. 브라우저가 제공한 그대로 표시됩니다(대부분의 브라우저는 인코딩된 값을 제공합니다). 예를 들어 주소창에 https://example.com/hello world를 직접 입력하면 인코딩된 버전인 https://example.com/hello%20world가 되며, path와 fullPath는 둘 다 /hello%20world가 됩니다.
  • 이제 hash는 디코딩되므로 router.push({ hash: $route.hash })처럼 복사해 scrollBehavior의 el 옵션에서 바로 사용할 수 있습니다.
  • push, resolve, replace를 사용할 때 문자열 location이나 객체의 path 속성을 제공한다면 반드시 인코딩되어 있어야 합니다(이전 버전과 동일). 반면 params, query, hash는 인코딩되지 않은 버전으로 제공해야 합니다.
  • 슬래시 문자(/)는 이제 params 내부에서 올바르게 디코딩되면서도 URL에서는 %2F로 인코딩된 상태를 유지합니다.

이유: 이렇게 하면 router.push()와 router.resolve()를 호출할 때 location의 기존 속성을 쉽게 복사할 수 있고, 결과 라우트 location도 브라우저 간 일관되게 유지됩니다. 이제 router.push()는 idempotent하며, router.push(route.fullPath), router.push({ hash: route.hash }), router.push({ query: route.query }), router.push({ params: route.params })를 호출해도 추가 인코딩이 발생하지 않습니다.

$router.push()와 $router.replace() - onComplete 및 onAbort 콜백 ​

이전에는 $router.push()와 $router.replace()가 두 번째와 세 번째 인자로 onComplete와 onAbort 콜백을 받았습니다. 이 콜백들은 내비게이션 결과에 따라 호출되었습니다. Promise 기반 API가 도입되면서 이 콜백들은 중복이 되어 제거되었습니다. 성공 및 실패한 내비게이션을 감지하는 방법은 Navigation Failures를 참고하세요.

이유: 정립된 JS 표준(Promise)에 맞추어 라이브러리 크기를 줄이기 위함입니다.

TypeScript 변경 사항 ​

타입 정의를 더 일관되고 표현력 있게 만들기 위해 일부 타입 이름이 바뀌었습니다:

vue-router@3vue-router@4
RouteConfigRouteRecordRaw
LocationRouteLocation
RouteRouteLocationNormalized

새로운 기능 ​

Vue Router 4에서 주목할 만한 새로운 기능은 다음과 같습니다:

모두를 위한 문서 한글화