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

onReadyisReady로 대체되었습니다

기존 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와 유사합니다. xleft로, ytop으로 이름이 바뀌었습니다. 자세한 내용은 RFC를 참고하세요.

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

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

이제 transitionkeep-alivev-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는 자주 사용되지 않았고, 사용자 코드에서 쉽게 재현할 수 있기 때문입니다.

eventtag 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 부분만 매치되며, queryhash는 더 이상 고려되지 않습니다

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

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

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

router.match 제거 및 router.resolve 변경

router.matchrouter.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을 표시하기 때문에, 마치 transitionappear 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.$routerouter.resolve가 반환하는 객체)에서 parent 속성이 제거되었습니다. 여전히 matched 배열을 통해 접근할 수 있습니다:

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

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

pathToRegexpOptions 제거

라우트 레코드의 pathToRegexpOptionscaseSensitive 속성은 createRouter()sensitivestrict 옵션으로 대체되었습니다. 이제 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의 디코딩된 값은 내비게이션이 어디서 시작되었든 일관됩니다(오래된 브라우저는 여전히 인코딩되지 않은 pathfullPath를 만들 수 있습니다). 초기 내비게이션도 앱 내부 내비게이션과 같은 결과를 만들어야 합니다.

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

  • 이제 path, fullPath의 값은 더 이상 디코딩되지 않습니다. 브라우저가 제공한 그대로 표시됩니다(대부분의 브라우저는 인코딩된 값을 제공합니다). 예를 들어 주소창에 https://example.com/hello world를 직접 입력하면 인코딩된 버전인 https://example.com/hello%20world가 되며, pathfullPath는 둘 다 /hello%20world가 됩니다.
  • 이제 hash는 디코딩되므로 router.push({ hash: $route.hash })처럼 복사해 scrollBehaviorel 옵션에서 바로 사용할 수 있습니다.
  • 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() - onCompleteonAbort 콜백

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

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

TypeScript 변경 사항

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

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

새로운 기능

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

모두를 위한 문서 한글화