14.5 Vue Router 4 — 선언적/프로그래매틱 네비게이션, 가드, 동적 라우트
Vue Router 4는 Vue 3의 공식 라우팅 라이브러리입니다. SPA(Single Page Application)에서 URL과 컴포넌트를 매핑하고, 페이지 전환 로직을 중앙에서 관리합니다. Composition API와 완전히 통합되어 useRouter(), useRoute() 훅으로 손쉽게 사용할 수 있습니다.
1. 설치 및 기본 설정
npm install vue-router@4
라우터 인스턴스 생성
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// 지연 로딩(Lazy Loading)으로 초기 번들 크기 감소
const routes = [
{
path: '/',
name: 'Home',
component: () => import('@/views/HomeView.vue'),
},
{
path: '/about',
name: 'About',
component: () => import('@/views/AboutView.vue'),
},
{
path: '/users/:id',
name: 'UserDetail',
component: () => import('@/views/UserDetailView.vue'),
},
{
// 404 처리
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFoundView.vue'),
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
// 스크롤 동작 제어
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition // 뒤로가기 시 이전 위치로
}
if (to.hash) {
return { el: to.hash, behavior: 'smooth' }
}
return { top: 0 } // 항상 최상단으로
},
})
export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
App.vue — RouterView와 RouterLink
<!-- App.vue -->
<template>
<nav>
<RouterLink to="/">홈</RouterLink>
<RouterLink to="/about">소개</RouterLink>
<RouterLink :to="{ name: 'UserDetail', params: { id: 1 } }">
사용자 1
</RouterLink>
</nav>
<!-- 현재 라우트에 매칭된 컴포넌트가 렌더링되는 위치 -->
<RouterView />
</template>
2. 선언적 네비게이션 — RouterLink
<RouterLink>는 HTML <a> 태그를 대체하며, 현재 경로와 일치하면 자동으로 활성 CSS 클래스를 부여합니다.
<template>
<!-- 기본 사용 -->
<RouterLink to="/dashboard">대시보드</RouterLink>
<!-- 이름 기반 네비게이션 (경로 변경에 강건함) -->
<RouterLink :to="{ name: 'UserDetail', params: { id: userId } }">
내 프로필
</RouterLink>
<!-- 쿼리 파라미터와 해시 -->
<RouterLink :to="{ path: '/search', query: { q: 'vue', page: 1 }, hash: '#results' }">
검색
</RouterLink>
<!-- 활성 클래스 커스텀 -->
<RouterLink
to="/admin"
active-class="nav-active"
exact-active-class="nav-exact"
>
관리자
</RouterLink>
<!-- 히스토리 대신 현재 엔트리 교체 -->
<RouterLink to="/login" replace>로그인</RouterLink>
</template>
RouterLink 커스텀 스타일
<!-- NavBar.vue -->
<script setup>
import { RouterLink, useLink } from 'vue-router'
// useLink: RouterLink의 내부 로직을 직접 사용
const { href, isActive, isExactActive, navigate } = useLink({
to: '/dashboard',
})
</script>
<template>
<nav class="sidebar">
<RouterLink
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-item"
:class="{ 'nav-item--active': isActive }"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</RouterLink>
</nav>
</template>
3. 프로그래매틱 네비게이션
useRouter()로 라우터 인스턴스를 가져와 코드에서 직접 페이지를 전환합니다.
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 현재 라우트 정보 읽기
console.log(route.path) // '/users/42'
console.log(route.params.id) // '42'
console.log(route.query.tab) // 'profile'
console.log(route.name) // 'UserDetail'
console.log(route.meta) // { requiresAuth: true, ... }
// 기본 이동
function goToHome() {
router.push('/')
}
// 이름 기반 이동
function goToUser(id) {
router.push({ name: 'UserDetail', params: { id } })
}
// 쿼리 파라미터와 함께 이동
function search(query) {
router.push({ path: '/search', query: { q: query, page: 1 } })
}
// 히스토리 교체 (뒤로가기로 돌아올 수 없음)
function replaceLogin() {
router.replace({ name: 'Login' })
}
// 히스토리 앞/뒤 이동
function goBack() {
router.back() // router.go(-1) 과 동일
}
function goForward() {
router.forward() // router.go(1) 과 동일
}
</script>
push()의 Promise 활용
// 네비게이션 완료 후 작업 수행
async function submitForm(data) {
try {
await api.createUser(data)
await router.push({ name: 'UserDetail', params: { id: data.id } })
console.log('이동 완료')
} catch (err) {
console.error('네비게이션 실패:', err)
}
}
4. 동적 라우트
파라미터 라우트
// routes 정의
{
path: '/users/:id',
name: 'UserDetail',
component: UserDetail,
},
{
// 선택적 파라미터
path: '/posts/:year(\\d{4})/:month(\\d{2})?',
name: 'PostArchive',
component: PostArchive,
},
{
// 반복 파라미터 (모든 경로 캡처)
path: '/files/:path+',
name: 'FileViewer',
component: FileViewer,
},
<!-- UserDetail.vue -->
<script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 같은 컴포넌트에서 파라미터가 변경될 때 감지
watch(
() => route.params.id,
async (newId) => {
if (newId) {
await fetchUser(newId)
}
},
{ immediate: true }
)
</script>
Props로 파라미터 전달 (컴포넌트-라우터 분리)
// 라우트 정의에서 props: true 설정
{
path: '/users/:id',
component: UserDetail,
props: true, // params를 컴포넌트 props로 전달
}
// 함수형 props: 쿼리도 포함
{
path: '/search',
component: SearchView,
props: (route) => ({ query: route.query.q, page: Number(route.query.page) || 1 }),
}
<!-- UserDetail.vue — 라우터와 분리된 컴포넌트 -->
<script setup>
defineProps({
id: String, // route.params.id가 props로 주입됨
})
</script>
중첩 라우트 (Nested Routes)
{
path: '/dashboard',
component: DashboardLayout,
children: [
{
path: '', // /dashboard
name: 'DashboardHome',
component: DashboardHome,
},
{
path: 'analytics', // /dashboard/analytics
name: 'Analytics',
component: AnalyticsView,
},
{
path: 'users', // /dashboard/users
name: 'UserList',
component: UserList,
children: [
{
path: ':id', // /dashboard/users/:id
name: 'UserProfile',
component: UserProfile,
},
],
},
],
}
<!-- DashboardLayout.vue -->
<template>
<div class="dashboard">
<Sidebar />
<main>
<!-- 중첩 라우트가 렌더링되는 위치 -->
<RouterView />
</main>
</div>
</template>
5. 네비게이션 가드 (Navigation Guards)
네비게이션 가드는 라우트 전환을 가로채어 인증, 권한 확인, 데이터 프리패치 등을 처리합니다.
전역 가드 — beforeEach
// src/router/index.js
import { useAuthStore } from '@/stores/auth'
router.beforeEach(async (to, from) => {
const auth = useAuthStore()
// 인증이 필요한 라우트인지 확인
if (to.meta.requiresAuth && !auth.isLoggedIn) {
// 로그인 후 원래 페이지로 돌아오기 위해 redirect 파라미터 저장
return { name: 'Login', query: { redirect: to.fullPath } }
}
// 권한 확인
if (to.meta.roles && !to.meta.roles.includes(auth.userRole)) {
return { name: 'Forbidden' }
}
// 아무것도 반환하지 않으면(또는 true) 네비게이션 진행
})
// 전역 afterEach — 페이지 제목 변경, 분석 이벤트
router.afterEach((to) => {
document.title = to.meta.title ? `${to.meta.title} | MyApp` : 'MyApp'
})
라우트별 가드 — beforeEnter
{
path: '/admin',
component: AdminView,
meta: { requiresAuth: true, roles: ['admin'] },
beforeEnter: [checkPermission, loadAdminData],
}
function checkPermission(to, from) {
if (!isAdmin()) return { name: 'Home' }
}
async function loadAdminData(to, from) {
to.meta.adminData = await fetchAdminData()
}
컴포넌트 내 가드
<script setup>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
const isDirty = ref(false)
// 라우트를 떠나기 전 — 저장되지 않은 변경사항 경고
onBeforeRouteLeave((to, from) => {
if (isDirty.value) {
const confirmed = window.confirm('저장되지 않은 변경사항이 있습니다. 떠나시겠습니까?')
if (!confirmed) return false // 네비게이션 취소
}
})
// 같은 컴포넌트에서 라우트 파라미터 변경 감지
onBeforeRouteUpdate(async (to, from) => {
if (to.params.id !== from.params.id) {
await fetchUser(to.params.id)
}
})
</script>
메타 필드 타입 정의 (TypeScript)
// src/types/router.d.ts
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
requiresAuth?: boolean
roles?: string[]
title?: string
layout?: 'default' | 'admin' | 'blank'
}
}
6. 실전 예제 — 인증 플로우 완전 구현
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes = [
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
children: [
{ path: '', name: 'Home', component: () => import('@/views/HomeView.vue') },
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: 'admin',
name: 'Admin',
component: () => import('@/views/AdminView.vue'),
meta: { requiresAuth: true, roles: ['admin'] },
},
],
},
{
path: '/auth',
component: () => import('@/layouts/AuthLayout.vue'),
children: [
{ path: 'login', name: 'Login', component: () => import('@/views/LoginView.vue') },
{ path: 'register', name: 'Register', component: () => import('@/views/RegisterView.vue') },
],
},
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFoundView.vue') },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// 인증 가드
router.beforeEach(async (to) => {
const auth = useAuthStore()
// 토큰 있으면 사용자 정보 로드 (새로고침 시)
if (auth.token && !auth.user) {
try {
await auth.fetchUser()
} catch {
auth.logout()
return { name: 'Login' }
}
}
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'Login', query: { redirect: to.fullPath } }
}
if (to.meta.roles && !to.meta.roles.some((r) => auth.user?.roles.includes(r))) {
return { name: 'Home' }
}
// 이미 로그인한 사용자가 로그인 페이지 접근 시 홈으로
if (to.name === 'Login' && auth.isLoggedIn) {
return { name: 'Dashboard' }
}
})
export default router
<!-- LoginView.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const email = ref('')
const password = ref('')
const error = ref('')
const isLoading = ref(false)
async function handleLogin() {
error.value = ''
isLoading.value = true
try {
await auth.login({ email: email.value, password: password.value })
// 로그인 전 방문하려던 페이지로 리다이렉트
const redirect = route.query.redirect as string
await router.push(redirect || { name: 'Dashboard' })
} catch (err) {
error.value = err.message || '로그인에 실패했습니다.'
} finally {
isLoading.value = false
}
}
</script>
<template>
<form @submit.prevent="handleLogin" class="login-form">
<h1>로그인</h1>
<div v-if="error" class="error-message">{{ error }}</div>
<label>
이메일
<input v-model="email" type="email" required autocomplete="email" />
</label>
<label>
비밀번호
<input v-model="password" type="password" required autocomplete="current-password" />
</label>
<button type="submit" :disabled="isLoading">
{{ isLoading ? '로그인 중...' : '로그인' }}
</button>
<RouterLink :to="{ name: 'Register' }">계정이 없으신가요? 가입하기</RouterLink>
</form>
</template>
7. 동적 라우트 추가 — 권한별 메뉴
런타임에 라우트를 동적으로 추가하는 패턴(어드민 시스템에서 자주 사용)입니다.
// src/router/dynamicRoutes.js
const adminRoutes = [
{
path: '/admin/users',
name: 'AdminUsers',
component: () => import('@/views/admin/UsersView.vue'),
},
{
path: '/admin/settings',
name: 'AdminSettings',
component: () => import('@/views/admin/SettingsView.vue'),
},
]
export function addDynamicRoutes(router, userRoles) {
if (userRoles.includes('admin')) {
adminRoutes.forEach((route) => {
if (!router.hasRoute(route.name)) {
router.addRoute(route)
}
})
}
}
// 사용처 — 로그인 후 호출
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (auth.isLoggedIn && !auth.routesAdded) {
addDynamicRoutes(router, auth.user.roles)
auth.routesAdded = true
// 라우터 재적용을 위해 현재 라우트로 다시 이동
return to.fullPath
}
})
8. 라우트 트랜지션 애니메이션
<!-- App.vue -->
<template>
<RouterView v-slot="{ Component, route }">
<Transition :name="route.meta.transition || 'fade'" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.3s ease;
}
.slide-right-enter-from {
transform: translateX(100%);
}
.slide-right-leave-to {
transform: translateX(-100%);
}
</style>
// 라우트별 트랜지션 설정
{
path: '/detail/:id',
component: DetailView,
meta: { transition: 'slide-right' },
}
9. 고수 팁
useRoute()는 반응형 — 구조분해 주의
<script setup>
import { useRoute } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
// 안전: route 객체 자체를 참조
const userId = computed(() => route.params.id)
// 위험: 초기값만 읽힘, 이후 변경 미감지
// const { params } = route ← 이렇게 하면 반응성 깨짐
</script>
라우터 타입 안전성 강화 (unplugin-vue-router)
npm install -D unplugin-vue-router
파일 기반 라우팅으로 자동 타입 생성 — 라우트 이름 오타 컴파일 에러로 잡기.
// vite.config.js
import VueRouter from 'unplugin-vue-router/vite'
export default {
plugins: [VueRouter()],
}
데이터 프리패치 패턴
<script setup>
import { ref } from 'vue'
import { useRoute, onBeforeRouteUpdate } from 'vue-router'
const route = useRoute()
const user = ref(null)
const isLoading = ref(false)
async function loadUser(id) {
isLoading.value = true
try {
user.value = await fetchUser(id)
} finally {
isLoading.value = false
}
}
// 컴포넌트 최초 로드
await loadUser(route.params.id)
// 같은 컴포넌트 내 파라미터 변경
onBeforeRouteUpdate(async (to) => {
await loadUser(to.params.id)
})
</script>
라우트 히스토리 모드 선택
| 모드 | API | URL 예시 | 특징 |
|---|---|---|---|
| HTML5 History | createWebHistory() | /users/1 | SEO 친화적, 서버 설정 필요 |
| Hash | createWebHashHistory() | /#/users/1 | 서버 설정 불필요, SEO 불리 |
| Memory | createMemoryHistory() | — | SSR/테스트용 |