Vue Router 핵심 정리
# 설치 및 구성
다음과 같이 vue-router를 설치합니다.
1npm i vue-router
다음의 폴더 및 파일 구조로 시작합니다.
12345678├─src/ │ ├─routes/ │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ └─HomePage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
/src/routes/pages 폴더에 사용할 페이지 컴포넌트를 추가합니다.
우선 간단하게 Home과 About 페이지를 추가하겠습니다.
123<template> <h1>Home page!</h1> </template>
123<template> <h1>About page!</h1> </template>
보여줄 페이지를 추가했다면 이제 /src/routes/index.ts 파일에서 프로젝트의 라우터를 구성합니다.
createRouter 함수를 사용해 라우터를 생성합니다.
이때 history 옵션에는 라우팅 모드를 지정하고, routes 옵션에는 라우트 객체(정보)를 배열로 전달합니다.
각 라우트 객체는 path 속성에 접근 경로를, component 속성에 경로가 일치할 때 표시할 컴포넌트를 지정합니다.
즉, 다음 예제에서 사용자는 /, /about 주소로 접근할 수 있습니다.
그리고 createRouter 함수로 생성한 라우터 객체(router)를 외부에서 사용할 수 있도록 내보냅니다.
12345678910111213141516171819import { createRouter, createWebHistory } from 'vue-router' import HomePage from './pages/HomePage.vue' import AboutPage from './pages/AboutPage.vue' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: HomePage }, { path: '/about', component: AboutPage } ] }) export default router
이제 /src/main.ts로 이동해 생성된 라우터 객체를 가져와 프로젝트의 플러그인으로 등록합니다.
1234567import { createApp } from 'vue' import App from './App.vue' import router from './routes' createApp(App) .use(router) .mount('#app')
마지막으로 프로젝트의 최상위 컴포넌트(/src/App.vue)에서 사용자의 접근 경로에 맞게 각 페이지가 출력될 위치를 <RouterView /> 컴포넌트로 지정합니다.
1234567<script setup lang="ts"> import { RouterView } from 'vue-router' </script> <template> <RouterView /> </template>
# 레이아웃
1234567891011121314├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ └─HomePage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
헤더를 만들어 각 페이지로 이동할 수 있는 내비게이션 버튼을 제공하고 필요한 모든 페이지에서 표시할 수 있도록 해봅시다.
먼저 다음과 같이 <TheHeader> 컴포넌트를 만듭니다.
12345678<template> <header> <nav> <a href="/">Home</a> <a href="/about">About</a> </nav> </header> </template>
헤더나 푸터 같이 대부분의 페이지에서 공통적으로 사용되는 구조를 각 페이지 컴포넌트에서 직접 추가하면, 페이지 전환 시마다 불필요한 리렌더링이 발생하게 됩니다.
그래서 공통 구조를 다시 렌더링하지 않도록 별도의 레이아웃 구조를 제공해야 합니다.
다음과 같이 기본 레이아웃(DefaultLayout.vue)을 추가해서 앞서 만든 헤더를 표시합니다.
그리고 접근 경로에 맞게 페이지가 출력될 위치를 <slot> 컴포넌트로 지정합니다.
12345678<script setup lang="ts"> import TheHeader from '@/components/TheHeader.vue' </script> <template> <TheHeader /> <slot /> </template>
헤더나 푸터 등의 공통 레이아웃이 없는 페이지도 있을 수 있으니, 다음과 같이 빈 레이아웃(EmptyLayout.vue)도 추가하겠습니다.
123<template> <slot /> </template>
페이지에서 어떤 레이아웃을 사용할 것인지는 다음과 같이 라우트 객체의 meta.layout 속성에서 지정합니다.
다만 이 과정에서는 기본 레이아웃만 사용할 것이므로 따로 명시하지 않습니다.
12345678910111213141516171819202122232425import { createRouter, createWebHistory } from 'vue-router' import HomePage from './pages/HomePage.vue' import AboutPage from './pages/AboutPage.vue' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: HomePage, meta: { layout: 'Default' } }, { path: '/about', component: AboutPage, meta: { layout: 'Empty' } } ] }) export default router
이제 각 페이지가 레이아웃 안에서 출력되도록 레이아웃 제공자(LayoutProvider.vue)를 추가하겠습니다.
프로젝트에서 사용할 레이아웃을 목록(layouts)으로 정의하고, meta.layout 속성 값에 맞게 레이아웃을 출력하도록 동적 컴포넌트(<Component>)를 사용합니다.
현재 라우트 정보는 컴포넌트에서 useRoute() 훅을 호출해 얻을 수 있고, meta.layout 속성이 없으면 기본 레이아웃(DefaultLayout.vue)을 사용하도록 작성합니다.
그리고 페이지가 출력될 위치(<RouterView />)를 동적 컴포넌트의 자식으로 전달하면, 앞서 각 레이아웃에 작성해 둔 <slot> 위치에 페이지가 출력됩니다.
혹시 meta.layout 속성에서 정의된 레이아웃 이름(Default나 Empty)이 아닌 잘못된 이름이 사용되지 않도록 라우터 인터페이스(RouteMeta)를 확장하면 안전한 타입으로 관리할 수 있습니다.
12345678910111213141516171819202122232425262728<script setup lang="ts"> import { RouterView, useRoute } from 'vue-router' import Default from './DefaultLayout.vue' import Empty from './EmptyLayout.vue' // 라우터 인터페이스 확장 declare module 'vue-router' { interface RouteMeta { layout?: keyof typeof layouts } } // 사용할 레이아웃 목록 지정 const layouts = { Default, Empty } as const // 현재 라우트 객체(정보) 가져오기 const route = useRoute() </script> <template> <!-- 동적 레이아웃 출력 및 기본 레이아웃 지정 --> <Component :is="layouts[route.meta.layout || 'Default']"> <RouterView /> </Component> </template>
마지막으로, 최상위 컴포넌트의 <RouterView />를 레이아웃 제공자 컴포넌트(<LayoutProvider>)로 교체합니다.
페이지 출력 위치는 레이아웃 제공자 안으로 옮겨졌으므로, 이제 모든 페이지가 레이아웃을 거쳐 출력됩니다.
1234567<script setup lang="ts"> import LayoutProvider from '@/routes/layouts/LayoutProvider.vue' </script> <template> <LayoutProvider /> </template>
여러 페이지에 같은 레이아웃을 적용할 때는 라우트마다 지정하지 않고 한 번에 지정할 수 있습니다.route.meta는 현재 경로가 거쳐 온 모든 라우트 객체의 meta를 병합한 결과이기 때문입니다.
따라서 component 속성 없이 children 속성(중첩 경로)만 가지는 라우트로 페이지들을 묶고, 그 부모에 meta.layout을 한 번만 작성하면 됩니다.
자식 라우트에서 다시 작성하면 그 값이 우선합니다.
123456789101112131415161718192021routes: [ { path: '/admin', meta: { layout: 'Empty' }, children: [ { path: '', component: AdminHomePage }, { path: 'users', component: AdminUsersPage, meta: { layout: 'Default' } } ] } ]
component 속성이 없는 부모 라우트는 화면에 아무것도 추가하지 않고 경로와 meta만 묶습니다.
따라서 자식 페이지는 지금처럼 레이아웃 제공자의 <RouterView /> 위치에 그대로 출력됩니다.
# 스크롤 복원
사용자가 뒤로 혹은 앞으로 가기를 할 때 페이지의 스크롤 위치를 복원하거나, 새로운 페이지의 스크롤 위치를 최상단으로 이동시켜 사용자에게 더 나은 페이지 탐색 경험을 제공할 수 있습니다.createRouter 함수의 scrollBehavior 옵션에서 스크롤 위치를 처리할 수 있습니다.savedPosition은 뒤로/앞으로 가기로 이동할 때만 저장된 위치를 가지며, 그 외에는 null입니다.
1234567891011121314// ... const router = createRouter({ history: createWebHistory(), scrollBehavior(_to, _from, savedPosition) { if (savedPosition) return savedPosition return { top: 0, left: 0 } }, routes: [ // ... ] }) export default router
첫 두 매개변수의 이름 앞에 밑줄(_)을 붙인 이유는, Vite가 생성한 타입스크립트 구성에 noUnusedParameters 옵션이 켜져 있기 때문입니다.
사용하지 않는 매개변수를 그대로 두면 개발 서버는 통과하지만 npm run build 명령에서 오류가 발생합니다.
# 탐색
# 컴포넌트 방식
앞서 작성한 헤더의 <a> 요소 탐색은 항상 페이지 전체를 로드합니다.
대신에 <RouterLink> 컴포넌트를 사용하면 탐색 시 필요한 부분만 업데이트하여 더 나은 사용자 경험을 제공할 수 있습니다.
<RouterLink> 컴포넌트는 href 대신 to 속성에 이동할 경로를 지정합니다.
그리고 to 속성에 지정된 경로와 현재 경로의 일치 여부에 따라서 활성 클래스(router-link-active, router-link-exact-active)가 자동으로 추가됩니다.
활성 클래스에 맞게 내비게이션의 스타일을 추가하면, 사용자가 현재 페이지를 더 명확하게 파악할 수 있습니다.
다음과 같이 <TheHeader> 컴포넌트를 수정합니다.
1234567891011121314151617181920212223<script setup lang="ts"> import { RouterLink } from 'vue-router' </script> <template> <header> <nav> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> </nav> </header> </template> <style scoped> nav { display: flex; gap: 10px; } .router-link-exact-active { font-weight: bold; color: red; } </style>
router-link-active 클래스는 지정된 경로가 현재 경로의 일부인 경우에 추가되며, router-link-exact-active 클래스는 지정된 경로와 현재 경로가 완전히 일치하는 경우에만 추가됩니다.
지정 경로(to) |
현재 경로(URL) | router-link-active |
router-link-exact-active |
|---|---|---|---|
/ |
/ |
✅ | ✅ |
/ |
/about |
❌ | ❌ |
/about |
/about |
✅ | ✅ |
/movies |
/movies |
✅ | ✅ |
/movies |
/movies/abc123 |
✅ | ❌ |
/movies/abc123 |
/movies/abc123 |
✅ | ✅ |
활성 여부는 경로 문자열의 앞부분이 같은지가 아니라, 현재 경로가 거쳐 온 라우트 객체 목록에 지정 경로의 라우트 객체가 들어 있는지로 판단합니다.
따라서 표의 /movies와 /movies/abc123 조합은 두 라우트가 부모와 자식(중첩 경로) 관계일 때를 기준으로 합니다.
두 라우트를 나란히 최상위에 두면 /movies/abc123에서 /movies 링크에 활성 클래스가 추가되지 않습니다.
to 속성에는 기본적으로 경로 전체를 문자로 작성합니다.
또는 필수적인 기본 경로(path)와 함께 쿼리스트링(query), 해시 프래그먼트(hash)를 선택적으로 포함하는 객체로 전달할 수 있습니다.
또는 필수적인 라우트 이름(name)과 함께 동적 파라미터(params), 쿼리스트링, 해시를 선택적으로 포함하는 객체로 전달할 수도 있습니다.
123456789101112131415161718192021222324<template> <RouterLink :to="`/movies/${movieId}?plot=full#title`"> 페이지 이동 </RouterLink> <RouterLink :to="{ path: `/movies/${movieId}`, query: { plot: 'full' }, hash: '#title' }"> 페이지 이동 </RouterLink> <RouterLink :to="{ name: 'MovieDetails', params: { movieId }, query: { plot: 'full' }, hash: '#title' }"> 페이지 이동 </RouterLink> </template>
라우트 이름은 라우트 객체의 name 속성에 선택적으로 작성하며, 고유해야 합니다.
라우트를 이름으로 관리하면 경로가 변경되더라도 코드 수정이 최소화되고, 잘못된 경로를 실수로 작성하는 것을 방지할 수 있습니다.
1234567891011121314151617181920212223242526272829303132333435363738394041// ... const router = createRouter({ // ... routes: [ { name: 'Home', path: '/', component: HomePage }, { name: 'About', path: '/about', component: AboutPage }, { name: 'SignIn', path: '/signin', component: SignInPage }, { name: 'Movies', path: '/movies', component: MoviesPage, children: [ { name: 'MovieDetails', path: ':movieId', component: MovieDetailsPage } ] }, { name: 'NotFound', path: '/:pathMatch(.*)*', component: NotFoundPage } ] }) export default router
# 프로그래밍 방식
<RouterLink> 컴포넌트를 사용하는 대신, 프로그래밍 방식으로도 탐색을 구현할 수 있습니다.useRouter() 훅을 호출하면, 페이지 이동을 처리하는 여러 메서드를 가진 라우터 인스턴스를 얻을 수 있습니다.
push: 새로운 페이지로 이동replace: 현재 페이지를 대체하고 새로운 페이지로 이동back: 뒤로가기forward: 앞으로가기go: 지정된 숫자만큼 뒤로 혹은 앞으로 이동
1234567891011121314<script setup lang="ts"> import { useRouter } from 'vue-router' const router = useRouter() </script> <template> <button @click="router.push('/')">페이지 이동</button> <button @click="router.replace('/about')">페이지 이동(뒤로가기 불가)</button> <button @click="router.back()">뒤로가기</button> <button @click="router.forward()">앞으로가기</button> <button @click="router.go(-1)">뒤로가기</button> <button @click="router.go(1)">앞으로가기</button> </template>
앞서 살펴본 <RouterLink> 컴포넌트의 to 속성과 마찬가지로, push와 replace 메서드에서도 경로를 문자 혹은 객체로 전달할 수 있습니다.
1234567891011121314151617181920function onlyString(movieId: string) { router.push(`/movies/${movieId}?plot=full#title`) } function pathObject(movieId: string) { router.push({ path: `/movies/${movieId}`, query: { plot: 'full' }, hash: '#title' }) } function nameObject(movieId: string) { router.push({ name: 'MovieDetails', params: { movieId }, query: { plot: 'full' }, hash: '#title' }) }
# 동적 경로 일치
동적 경로(Dynamic Routes)는 URL의 일부를 변수처럼 활용할 수 있는 편리한 기능입니다.
이를 통해 단일 라우트 컴포넌트로 다양한 페이지를 동적으로 관리할 수 있어 재사용성과 유연성이 크게 증가합니다.
예를 들어, 영화의 상세 정보를 표시하는 페이지를 모두 개별적으로 만들지 않고 하나의 컴포넌트로 모든 영화의 상세 정보를 효과적으로 표시할 수 있습니다.
아래와 같이 동적 경로를 적용해 봅시다.
123456789101112131415├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ └─MovieDetailsPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
먼저 영화 상세 정보를 표시할 페이지(MovieDetailsPage.vue)를 작성합니다.useRoute() 훅을 호출해 얻은 라우트 정보의 params 속성에서 movieId라는 이름의 동적 경로 정보를 얻습니다.
그리고 이 movieId를 사용해 API 요청으로 영화 정보를 가져오고 출력합니다.
123456789101112131415161718192021222324252627282930313233<script setup lang="ts"> import { ref, watch } from 'vue' import { useRoute } from 'vue-router' interface Movie { imdbID: string Title: string Poster: string } const route = useRoute() const movie = ref<Movie | null>(null) async function fetchMovieDetails(movieId: string) { const res = await fetch(`https://omdbapi.com/?apikey=7035c60c&i=${movieId}`) movie.value = await res.json() } watch( () => route.params.movieId as string, movieId => fetchMovieDetails(movieId), { immediate: true } // 첫 진입에서도 한 번 호출합니다. ) </script> <template> <template v-if="movie"> <h1>{{ movie.Title }}</h1> <img :src="movie.Poster" :alt="movie.Title" /> </template> </template>
영화 상세 정보 페이지를 작성했으니 접근 경로를 지정합니다.
라우트 객체의 path 속성에 '/movies/:movieId'와 같이 : 기호를 이용해서 동적으로 경로를 일치시키는 변수(movieId)를 지정합니다.
12345678910111213141516// ... import MovieDetailsPage from './pages/MovieDetailsPage.vue' const router = createRouter({ // ... routes: [ // ... { name: 'MovieDetails', path: '/movies/:movieId', component: MovieDetailsPage } ] }) export default router
준비된 페이지로 이동할 수 있도록 헤더에 내비게이션 버튼을 추가합니다.
다음 예시에 작성된 이동 경로의 tt4154796는 'Avengers: Endgame(2019)' 영화의 ID입니다.
12345678910111213<script setup lang="ts"> import { RouterLink } from 'vue-router' </script> <template> <header> <nav> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> <RouterLink to="/movies/tt4154796">Movie(Avengers: Endgame)</RouterLink> </nav> </header> </template>
# 쿼리스트링
경로 뒤에 붙는 ?plot=full&page=2 같은 쿼리스트링은 라우트 정보의 query 속성에서 얻습니다.
동적 경로와 달리 라우트 객체에 미리 정의할 필요가 없고, 어느 경로에서든 읽을 수 있습니다.
123456789<script setup lang="ts"> import { useRoute } from 'vue-router' const route = useRoute() // /movies/tt4154796?plot=full&page=2 로 접근한 경우 console.log(route.query.plot) // 'full' console.log(route.query.page) // '2' </script>
값은 주소 문자의 일부이므로, 숫자가 필요하면 직접 변환해야 합니다.
그리고 값이 비어 있거나 같은 이름이 반복되는 경우도 있어서, 타입은 string | null 혹은 그 배열입니다.
| 주소 | route.query.plot |
|---|---|
?plot=full |
'full' |
?plot= |
'' |
?plot |
null |
?plot=full&plot=short |
['full', 'short'] |
쿼리스트링만 바뀌는 이동도 동적 경로와 마찬가지로 같은 컴포넌트를 재사용합니다.
값이 바뀔 때마다 처리할 내용이 있다면 watch로 감시합니다.
123456watch( () => route.query.plot, plot => { console.log(plot) } )
반대로 쿼리스트링과 함께 이동하는 방법은 탐색에서 살펴본 것처럼, <RouterLink> 컴포넌트의 to 속성이나 router.push 메서드에 query 속성을 포함한 객체를 전달하면 됩니다.
만약 검색어나 필터처럼 값이 자주 바뀌는 쿼리스트링을 다루는 경우, 히스토리 내역을 남기지 않으려면 <RouterLink> 컴포넌트에 replace 속성을 추가하거나 replace 메서드를 사용하면 됩니다.
123456789101112131415161718<script setup lang="ts"> import { useRoute, useRouter } from 'vue-router' const route = useRoute() const router = useRouter() function updatePlot(plot: string) { router.replace({ query: { ...route.query, plot } }) } </script> <template> <RouterLink :to="{ query: { plot: 'short' } }" replace> 줄거리 짧게 </RouterLink> </template>
경로를 생략하고 query만 전달하면 현재 경로를 유지한 채 쿼리스트링만 변경됩니다.
이때 기존 쿼리스트링은 통째로 대체되므로, 유지할 값은 route.query를 펼쳐서 함께 전달해야 합니다.
123456router.replace({ query: { ...route.query, plot: 'short' } })
# 중첩 경로
하나의 라우트 컴포넌트 안에서 다른 라우트 컴포넌트를 포함할 수 있습니다.
아래 예제에서는 영화를 검색할 수 있는 페이지를 만들어 검색 결과를 선택해 영화 상세 정보 출력하도록 합니다.
다만 영화 상세 정보를 별도의 페이지가 아닌 모달 형태로 표시합니다.
12345678910111213141516├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ ├─MovieDetailsPage.vue │ │ │ └─MoviesPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
먼저 영화 검색 페이지(MoviesPage.vue)를 작성합니다.
검색된 영화 목록에서 원하는 영화를 선택하면 영화 상세 정보 페이지로 이동합니다.
그리고 <RouterView /> 컴포넌트를 사용해, 영화 상세 정보 페이지가 검색 페이지의 일부분으로 표시되도록 중첩 경로를 적용합니다.
123456789101112131415161718192021222324252627282930313233343536373839404142<script setup lang="ts"> import { ref } from 'vue' import { RouterLink } from 'vue-router' interface Movie { imdbID: string Title: string } const searchTitle = ref('') const movies = ref<Movie[]>([]) async function fetchMovies() { const res = await fetch( `https://omdbapi.com/?apikey=7035c60c&s=${searchTitle.value}` ) const { Search } = await res.json() movies.value = Search } </script> <template> <div> <h1>Movies page!</h1> <div> <input v-model="searchTitle" @keydown.enter="fetchMovies" /> <button @click="fetchMovies">검색</button> </div> <ul> <li v-for="movie in movies" :key="movie.Title"> <RouterLink :to="`/movies/${movie.imdbID}`"> {{ movie.Title }} </RouterLink> </li> </ul> <RouterView /> </div> </template>
작성한 영화 검색 페이지를 라우트 객체로 등록합니다.
이때 앞서 만든 영화 상세 정보 페이지의 라우트 객체를 검색 페이지의 children 속성으로 이동시켜 중첩 경로를 적용하고, path 속성의 경로를 /movies/:movieId에서 :movieId로 수정합니다.
부모 라우트(Movies)의 경로가 이미 /movies이므로, 자식 라우트(MovieDetails)에서는 /movies를 생략하고 :movieId만 작성하면 됩니다.
이제 위에서 설명한 대로, 영화 상세 정보 페이지는 영화 검색 페이지의 <RouterView /> 컴포넌트 위치에 출력됩니다.
123456789101112131415161718192021222324// ... import MoviesPage from './pages/MoviesPage.vue' import MovieDetailsPage from './pages/MovieDetailsPage.vue' const router = createRouter({ history: createWebHistory(), routes: [ // ... { name: 'Movies', path: '/movies', component: MoviesPage, children: [ { name: 'MovieDetails', path: ':movieId', component: MovieDetailsPage } ] } ] }) export default router
이제 영화 상세 정보 페이지를 모달 형태로 표시해 봅시다.
모달로 표시될 요소 구조와 함께 스타일을 추가합니다.
그리고 오버레이를 클릭하면 부모 경로(/movies)로 이동해 모달이 닫히도록 closeModal 함수를 추가합니다.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859<script setup lang="ts"> // ... import { useRouter } from 'vue-router' // ... const router = useRouter() function closeModal() { // 자식 라우트에서 벗어나면 모달이 닫힙니다. router.push('/movies') } </script> <template> <div class="modal"> <div class="overlay" @click="closeModal" /> <div class="content"> <template v-if="movie"> <h1>{{ movie.Title }}</h1> <img :src="movie.Poster" :alt="movie.Title" /> </template> </div> </div> </template> <style scoped> .modal { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; display: flex; justify-content: center; align-items: center; } .overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); cursor: pointer; } .content { position: relative; max-width: 500px; padding: 20px 30px; border-radius: 10px; box-shadow: 0 10px 10px rgba(0, 0, 0, 0.1); background-color: white; } </style>
준비가 끝났으니, 영화 검색 페이지로 이동할 수 있는 내비게이션 버튼을 추가하고 테스트해 봅시다.
1234567891011121314<!-- ... --> <template> <header> <nav> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> <RouterLink to="/movies">Movies</RouterLink> <RouterLink to="/movies/tt4154796">Movie(Avengers: Endgame)</RouterLink> </nav> </header> </template> <!-- ... -->
# 찾을 수 없는 페이지
1234567891011121314151617├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ ├─MovieDetailsPage.vue │ │ │ ├─MoviesPage.vue │ │ │ └─NotFoundPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
사용자가 정의하지 않은 경로로 접근했을 때 표시할 페이지를 만들어 봅시다.
다음과 같이 NotFoundPage.vue를 작성합니다.
123<template> <h1>404 Not Found page!</h1> </template>
그리고 모든 경로에 일치하는 라우트 객체를 추가합니다.pathMatch 라는 동적 경로 이름으로 모든 하위 경로를 일치시키기 위해 정규식도 같이 작성합니다.
Vue Router는 배열에 작성한 순서가 아니라 경로가 얼마나 구체적인지를 점수로 계산해서 일치 여부를 확인합니다.
이 라우트 객체의 경로는 가장 덜 구체적이므로, 배열의 어느 위치에 두어도 다른 라우트 객체를 모두 확인한 뒤에 일치합니다.
다만 읽는 순서와 실제 동작을 맞추기 위해 배열의 가장 마지막에 작성하는 것이 일반적입니다.
12345678910111213141516// ... import NotFoundPage from './pages/NotFoundPage.vue' const router = createRouter({ // ... routes: [ // ... { name: 'NotFound', path: '/:pathMatch(.*)*', component: NotFoundPage } ] }) export default router
# 내비게이션 가드
내비게이션 가드는 페이지 이동 전에 특정 조건 여부에 맞게 이동을 취소하거나 다른 경로로 리다이렉트할 수 있는 기능을 말합니다.
주로 로그인 등의 사용자 인증 여부를 확인하는 용도로 사용합니다.
12345678910111213141516171819202122├─src/ │ ├─components/ │ │ └─TheHeader.vue │ ├─routes/ │ │ ├─guards/ │ │ │ ├─guestOnly.ts │ │ │ ├─requiresAuth.ts │ │ │ └─index.ts │ │ ├─layouts/ │ │ │ ├─DefaultLayout.vue │ │ │ ├─EmptyLayout.vue │ │ │ └─LayoutProvider.vue │ │ ├─pages/ │ │ │ ├─AboutPage.vue │ │ │ ├─HomePage.vue │ │ │ ├─MovieDetailsPage.vue │ │ │ ├─MoviesPage.vue │ │ │ ├─NotFoundPage.vue │ │ │ └─SignInPage.vue │ │ └─index.ts | ├─App.vue │ └─main.ts
먼저 로그인 페이지(SignInPage.vue)를 작성합니다.
테스트를 위해, 사용자가 아이디(이메일)와 비밀번호를 모두 입력하면 로그인 성공으로 처리하고 접근 토큰(accessToken)을 로컬 스토리지에 저장합니다.
1234567891011121314151617181920212223242526272829303132333435<script setup lang="ts"> import { useRoute, useRouter } from 'vue-router' const route = useRoute() const router = useRouter() function handleSubmit(event: Event) { // `<form>`의 데이터를 가져와 사용하기 쉽게 객체로 변환합니다. const formData = new FormData(event.target as HTMLFormElement) const { email, password } = Object.fromEntries(formData) as Record<string, string> // 로그인 정보가 모두 있으면, 임시로 로그인 처리합니다. if (email && password) { localStorage.setItem('accessToken', 'abcd1234') const redirectTo = (route.query.redirectTo as string) || '/' router.push(redirectTo) } } </script> <template> <div> <h1>Sign In page!</h1> <form @submit.prevent="handleSubmit"> <input name="email" type="email" placeholder="Email" /> <input name="password" type="password" placeholder="Password" /> <button type="submit">로그인</button> </form> </div> </template>
로그인 페이지를 라우트 객체로 등록합니다.
이때 라우트 객체의 meta.guestOnly 속성을 지정해서 로그인 페이지에는 로그인하지 않은 사용자만 접근할 수 있도록 합니다.
반대로 영화 검색 및 상세 정보 페이지는 로그인한 사용자만 접근할 수 있도록 meta.auth 속성을 지정합니다.
1234567891011121314151617181920212223242526272829// ... const router = createRouter({ // ... routes: [ { name: 'SignIn', path: '/signin', component: SignInPage, meta: { guestOnly: true } }, { name: 'Movies', path: '/movies', component: MoviesPage, meta: { auth: true }, children: [ // ... ] } // ... ] }) export default router
앞서 지정한 meta 속성의 값에 따라 동작할 내비게이션 가드를 작성합니다.
requiresAuth는 로그인한 사용자만 접근할 수 있도록 하는 가드이고, guestOnly는 로그인하지 않은 사용자만 접근할 수 있도록 하는 가드입니다.
접근이 거부되면 이동할 경로를 redirect 함수에서 반환합니다.
이때 원래 가려던 경로(to.fullPath)를 redirectTo 쿼리스트링으로 함께 넘기면, 로그인 후 그 경로로 돌려보낼 수 있습니다.
123456789101112131415import type { RouteGuard } from '.' export const requiresAuth: RouteGuard = { guard(to) { if (to.meta.auth) { const token = localStorage.getItem('accessToken') // 토큰이 유효한지 확인! if (!token) { return false } } return true }, redirect: to => ({ path: '/signin', query: { redirectTo: to.fullPath } }) }
123456789101112131415import type { RouteGuard } from '.' export const guestOnly: RouteGuard = { guard(to) { if (to.meta.guestOnly) { const token = localStorage.getItem('accessToken') // 유효 토큰이 있는지 확인! if (token) { return false } } return true }, redirect: () => '/' }
이제 작성한 내비게이션 가드를 적용해 봅시다.router.beforeEach 메서드의 콜백은 모든 페이지의 접근 직전에 호출되며, 매개변수 to는 이동할 페이지의 라우트 객체입니다.
이를 통해 각 가드에서 라우트 객체의 meta 속성의 값을 확인할 수 있습니다.
앞서 레이아웃에서 meta.layout 속성의 타입을 확장했던 것처럼, 가드가 사용하는 meta.auth와 meta.guestOnly 속성도 라우터 인터페이스에서 함께 확장합니다.
확장하지 않으면 두 속성의 타입이 unknown으로 남습니다.
1234567891011121314151617181920212223import type { RouteLocationNormalizedGeneric, RouteLocationRaw } from 'vue-router' import router from '@/routes' import { requiresAuth } from './requiresAuth' import { guestOnly } from './guestOnly' // 라우터 인터페이스 확장 declare module 'vue-router' { interface RouteMeta { auth?: boolean guestOnly?: boolean } } router.beforeEach(to => { if (!requiresAuth.guard(to)) return requiresAuth.redirect(to) if (!guestOnly.guard(to)) return guestOnly.redirect(to) return true }) export interface RouteGuard { guard(to: RouteLocationNormalizedGeneric): boolean redirect(to: RouteLocationNormalizedGeneric): RouteLocationRaw }
마지막으로 작성한 가드가 실제로 등록되도록, /src/main.ts에서 가드 파일을 가져옵니다.
이 과정을 빠뜨리면 router.beforeEach가 호출되지 않아 가드가 전혀 동작하지 않습니다.
12345678import { createApp } from 'vue' import App from './App.vue' import router from './routes' import './routes/guards' createApp(App) .use(router) .mount('#app')
# 페이지 전환 애니메이션
Vue <Transition> 컴포넌트를 사용하면, 페이지가 바뀔 때마다의 전환 애니메이션을 쉽게 추가할 수 있습니다.
0.3초에 걸쳐 기존 페이지가 사라지고 새 페이지가 나타나는 애니메이션을 추가합니다.
12345678910111213141516171819202122232425262728293031323334353637383940414243<script setup lang="ts"> import { RouterView, useRoute } from 'vue-router' import Default from './DefaultLayout.vue' import Empty from './EmptyLayout.vue' declare module 'vue-router' { interface RouteMeta { layout?: keyof typeof layouts } } const layouts = { Default, Empty } as const const route = useRoute() </script> <template> <Component :is="layouts[route.meta.layout || 'Default']"> <Transition name="fade" mode="out-in"> <RouterView /> </Transition> </Component> </template> <style scoped> .fade-enter-active, .fade-leave-active { transition: opacity 0.3s; } .fade-enter-from, .fade-leave-to { opacity: 0; } .fade-enter-to, .fade-leave-from { opacity: 1; } </style>
# 페이지 지연 로딩
지금까지는 모든 페이지 컴포넌트를 파일 위쪽에서 정적으로 가져왔습니다.
이러면 사용자가 방문하지 않을 페이지까지 초기 번들에 포함되어, 첫 화면이 나타나기까지의 시간이 길어집니다.
component 속성에 컴포넌트 대신 import 함수를 호출하는 함수를 작성하면, 그 페이지는 별도 파일로 분리되어 접근하는 시점에 내려받습니다.
다만 사용자가 가장 먼저 보게 되는 페이지는 어차피 필요하므로, 지연 로딩하지 않고 그대로 두는 것이 좋습니다.
1234567891011121314151617181920212223// ... import HomePage from './pages/HomePage.vue' const router = createRouter({ // ... routes: [ { path: '/', component: HomePage }, { path: '/about', component: () => import('./pages/AboutPage.vue') }, { name: 'Movies', path: '/movies', component: () => import('./pages/MoviesPage.vue'), // ... } // ... ] })
Vue Router는 이동을 확정하기 전에 페이지를 먼저 내려받습니다.
내려받는 동안에는 이전 페이지가 그대로 표시되고 화면이 비지 않으므로, 로딩 처리를 위한 별도의 경계 컴포넌트를 만들 필요가 없습니다.
대신 네트워크가 느리면 클릭하고 화면이 바뀌기까지 시간이 걸리므로, 진행 중임을 알리는 표시를 추가하는 것이 좋습니다.
router.beforeEach에서 켜고 router.afterEach에서 끄면 됩니다.
다만 내려받기에 실패하면 afterEach 대신 router.onError가 호출되므로, 여기서도 함께 꺼야 표시가 남지 않습니다.
12345678910111213141516171819202122import { ref } from 'vue' // ... const router = createRouter({ // ... }) // 페이지를 내려받는 동안 참이 되는 상태 export const isPageLoading = ref(false) router.beforeEach(() => { isPageLoading.value = true }) router.afterEach(() => { isPageLoading.value = false }) // 내려받기에 실패하면 afterEach가 호출되지 않습니다. router.onError(() => { isPageLoading.value = false }) export default router
이제 레이아웃 제공자에서 이 상태를 사용해 로딩 표시를 출력합니다.
123456789101112131415161718<script setup lang="ts"> // ... import { isPageLoading } from '@/routes' // ... </script> <template> <Component :is="layouts[route.meta.layout || 'Default']"> <div v-if="isPageLoading">로딩 중...</div> <Transition name="fade" mode="out-in"> <RouterView /> </Transition> </Component> </template> <!-- ... -->
# 파일 기반 라우팅
지금까지는 routes 배열에 라우트 객체를 직접 작성해서 경로를 관리했습니다.
Vue Router 5부터는 페이지 컴포넌트의 파일 경로에서 라우트를 자동으로 만들어 주는 파일 기반 라우팅(File-based Routing)을 함께 제공합니다.
별도 패키지였던 unplugin-vue-router가 Vue Router에 통합된 것이라, 추가로 설치할 패키지는 없습니다.
먼저 번들러에 플러그인을 등록합니다.
Vue 플러그인이 페이지 컴포넌트를 처리하기 전에 라우트를 먼저 수집해야 하므로, VueRouter()를 vue()보다 앞에 작성해야 합니다.
12345678910import { defineConfig } from 'vite' import VueRouter from 'vue-router/vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [ VueRouter(), vue() ] })
플러그인을 등록하고 개발 서버를 한 번 실행하면 프로젝트 루트에 typed-router.d.ts 파일이 생성됩니다.
각 라우트의 이름과 경로, 동적 경로 파라미터의 타입이 이 파일에 자동으로 기록되므로, 타입스크립트가 인식할 수 있도록 include 옵션에 추가합니다.
1234567891011{ "compilerOptions": { // ... }, "include": [ "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "./typed-router.d.ts" ] }
이제 /src/pages 폴더에 페이지 컴포넌트를 추가하면 파일 경로가 그대로 접근 경로가 됩니다.
파일 이름을 대괄호([])로 감싸면 동적 경로가 되고, 폴더와 이름이 같은 파일을 나란히 두면 중첩 경로가 됩니다.
1234567├─src/ │ └─pages/ │ ├─movies/ │ │ └─[movieId].vue │ ├─about.vue │ ├─index.vue │ └─movies.vue
| 파일 | 생성되는 경로 |
|---|---|
pages/index.vue |
/ |
pages/about.vue |
/about |
pages/movies.vue |
/movies |
pages/movies/[movieId].vue |
/movies/:movieId |
앞서 중첩 경로에서 직접 작성했던 children 속성도 폴더 구조가 대신합니다.movies/[movieId].vue는 movies.vue의 자식 라우트가 되어, movies.vue에 작성한 <RouterView /> 위치에 출력됩니다.
생성된 라우트는 vue-router/auto-routes에서 가져와 createRouter 함수에 그대로 전달합니다.
123456789import { createRouter, createWebHistory } from 'vue-router' import { routes } from 'vue-router/auto-routes' const router = createRouter({ history: createWebHistory(), routes }) export default router
페이지 지연 로딩은 따로 작성하지 않아도 모든 페이지에 이미 적용되어 있습니다.
반대로 첫 화면처럼 즉시 필요한 페이지는 플러그인의 importMode 옵션에서 'sync'를 반환해 초기 번들에 포함시킵니다.
12345678export default defineConfig({ plugins: [ VueRouter({ importMode: filepath => (filepath.endsWith('src/pages/index.vue') ? 'sync' : 'async') }), vue() ] })
meta처럼 라우트 객체에 작성하던 정보는 페이지 컴포넌트에서 definePage 매크로로 지정합니다.
컴파일 시점에 처리되는 매크로이므로 별도로 가져오지 않아도 됩니다.
1234567<script setup lang="ts"> definePage({ meta: { auth: true } }) </script>
자동으로 생성된 타입 덕분에, useRoute() 훅에 라우트 이름을 전달하면 그 페이지의 동적 경로 파라미터 타입까지 추론됩니다.
아래 예제의 movieId는 별도의 타입 단언 없이 string으로 추론됩니다.
123456<script setup lang="ts"> import { useRoute } from 'vue-router' const route = useRoute('/movies/[movieId]') const movieId = route.params.movieId </script>
라우트 이름도 파일 경로에서 자동으로 만들어집니다.pages/movies/[movieId].vue의 이름은 'MovieDetails'가 아니라 '/movies/[movieId]'이므로, 앞서 직접 붙인 이름으로 이동하던 코드는 그대로 동작하지 않습니다.
쓰던 이름을 유지하려면 definePage의 name 속성에 작성하고, 라우트별 가드(beforeEnter)도 같은 방식으로 지정합니다.
123456789101112<script setup lang="ts"> // ... definePage({ name: 'MovieDetails', beforeEnter: to => { console.log(to.fullPath) } }) // ... </script>
이름을 바꿨으니, 위에서 작성한 useRoute('/movies/[movieId]')도 useRoute('MovieDetails')로 수정합니다.
찾을 수 없는 페이지에서 작성한 /:pathMatch(.*)* 라우트는 대괄호 안에 마침표 세 개를 앞세운 pages/[...pathMatch].vue 파일이 대신합니다.
이 두 가지 외에는 앞에서 살펴본 내용을 그대로 사용할 수 있습니다.
파일 기반 라우팅은 routes 배열을 대체할 뿐이므로, 레이아웃과 스크롤 복원, router.beforeEach 가드, 전환 애니메이션은 손댈 필요가 없습니다.
마지막으로 ESLint를 사용한다면 규칙 하나를 조정해야 합니다.
모든 HTML 요소의 이름은 한 단어이므로, vue/multi-word-component-names는 지금이나 앞으로 추가될 요소와 이름이 겹치지 않도록 컴포넌트 이름을 두 단어 이상으로 요구합니다.
그런데 페이지 파일의 이름은 곧 접근 경로라서 about.vue처럼 한 단어일 수밖에 없습니다.
프로젝트 전체에서 끄지 말고, 페이지 폴더에서만 끕니다.
플랫 구성은 뒤에 오는 객체가 앞을 덮어쓰므로, 규칙을 켜는 eslint-plugin-vue 프리셋보다 뒤에 작성해야 합니다.
123456789export default defineConfigWithVueTs( // ... { files: ['src/pages/**/*.vue'], rules: { 'vue/multi-word-component-names': 'off' } } )
타입스크립트가 아닌 자바스크립트 구성이라면, definePage가 no-undef 규칙에 걸리므로 전역으로도 선언합니다.
12345{ languageOptions: { globals: { definePage: 'readonly' } } }
# 배포
HTML5 Mode(createWebHistory)를 사용하는 Vue Router 프로젝트를 배포할 때는, 모든 요청을 index.html로 재작성(Rewrite)하는 서버 설정이 필요합니다.
주소를 다른 곳으로 돌리는 리다이렉트와 달리, 재작성은 사용자가 입력한 주소를 그대로 둔 채 서버가 응답할 파일만 바꿉니다.
이를 통해 사용자가 직접 URL을 입력해 특정 페이지에 접근하거나 페이지를 새로고침해도 애플리케이션이 정상적으로 작동하도록 만들어야 합니다.
간단하게 배포할 수 있는 주요 호스팅 서비스별로 필요한 설정을 살펴봅시다.
# Vercel
Vercel 서비스로 배포하는 경우, 프로젝트의 루트 경로에 vercel.json 파일을 생성하고 다음 구성을 추가합니다.
123{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
# Netlify
Netlify 서비스로 배포하는 경우, 프로젝트의 공개 폴더(/public) 경로에 _redirects 파일을 생성하고 다음 구성을 추가합니다.
1/* /index.html 200
또는 프로젝트의 루트 경로에 netlify.toml 파일을 생성하고 다음 구성을 추가합니다.
1234[[redirects]] from = "/*" to = "/index.html" status = 200
# Firebase
Firebase 서비스로 배포하는 경우, 프로젝트의 루트 경로에 firebase.json 파일을 생성하고 다음 구성을 추가합니다.
1234567891011{ "hosting": { "public": "dist", "rewrites": [ { "source": "**", "destination": "/index.html" } ] } }
끝까지 읽어주셔서 감사합니다.
좋아요와 응원 댓글은 블로그 운영에 큰 힘이 됩니다!