Vue 2 + Router 3 + Vuex를 활용한 SPA가 궁금하다면?
2025.11.20 - [Web/Vue] - Vue Router와 Axios 활용해 라우팅 + API 연동으로 SPA 만들기
Vue Router와 Axios 활용해 라우팅 + API 연동으로 SPA 만들기
Vue Router가 무엇인지, 어떻게 활용하는지 궁금하다면?2025.11.19 - [Web/Vue] - Vue Router 기초 (Vue 2 + Vue Router 3) Vue Router 기초 (Vue 2 + Vue Router 3)1. SPA에서 라우터가 필요한 이유Vue로 간단한 컴포넌트까지만
sproutinghye.tistory.com
1. 서론
오늘은 Vue 3, Vue Router 4, Pinia, <script setup>를 한 프로젝트 안에서 동시에 써보는 단계입니다.
오늘은 Vue 3 + Router 4 + Pinia가 붙어 있는 기본 구조를 만들고,
로그인 + 게시글 목록 + 상세 정도의 아주 간단한 SPA를 구성해 보고,
폴더 구조와 데이터 흐름을 눈으로 확인하는 것이 목표입니다.
2. 프로젝트 뼈대 준비: 라우터 + Pinia 연결
Vite로 Vue 3 프로젝트 생성
# 프로젝트 생성
npm create vue@latest
# 프로젝트 폴더로 이동
cd 프로젝트명
# 설치
npm install
# 시작
npm run dev
Router 4, Pinia 설치
npm install vue-router@4 pinia
# 또는
yarn add vue-router@4 pinia
- Vue 3에서는 vue-router@4를 사용합니다.
- Pinia는 Vue 팀이 권장하는 공식 상태관리 라이브러리입니다.
main.js에서 한 번에 묶기
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
app.use(router)
app.use(pinia)
app.mount('#app')
여기까지 하면 Router 4를 통해 페이지 전환, Pinia를 통해 전역 상태 관리를 할 준비가 끝난 상태입니다.
3. 라우터 설계
이번 프로젝트에서는 라우트를 아래와 같이 잡아보겠습니다.
- /login - 단순한 로그인 폼 (실제 인증 X, 토큰 흉내만)
- /posts - 게시글 목록
- /posts/:id - 게시글 상세
router/index.js
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import LoginView from '@/views/LoginView.vue'
import PostListView from '@/views/PostListView.vue'
import PostDetailView from '@/views/PostDetailView.vue'
const routes = [
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/posts',
name: 'posts',
component: PostListView
},
{
path: '/posts/:id',
name: 'post-detail',
component: PostDetailView
},
{
path: '/',
redirect: '/posts'
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
- createRouter + createWebHistory() 조합은 Router 4의 기본 패턴입니다.
- 동적 라우트 /posts/:id에서 :id 부분은 route.params.id로 접근할 수 있습니다.
4. Pinia store 설계
상태 관리는 두 개의 store로 나눕니다.
- useAuthStore - 로그인 여부, 사용자 이름
- usePostStore - 게시글 목록/상세, 로딩/에러
auth store
// src/stores/auth.js
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null // { name: '...' } 또는 null
}),
getters: {
isLoggedIn(state) {
return !!state.user
}
},
actions: {
login(name) {
this.user = { name }
},
logout() {
this.user = null
}
}
})
- Vuex와 달리 mutations 없이 actions에서 바로 상태를 수정합니다.
- Pinia는 defineStore를 통해 store를 정의하는 것이 기본 패턴입니다.
posts store
// src/stores/posts.js
import { defineStore } from 'pinia'
import axios form 'axios'
export const usePostsStore = defineStore('posts', {
state: () => ({
items: [],
selected: null,
loading: false,
error: null
}),
actions: {
async fetchPosts() {
this.loading = true
this.error = null
try {
const res = await axios.get(
'https://jsonplaceholder.typicode.com/posts?_limit=10'
)
this.items = res.data
} catch (e) {
this.error = '게시글을 불러오지 못했습니다'
} finally {
this.loading = false
}
},
async fetchPostById(id) {
this.loading = true
this.error = null
this.selected = null
try {
const res = await axios.get(
`https://jsonplaceholder.typicode.com/posts/${id}`
)
this.selected = res.data
} catch (e) {
this.error = '게시글 상세를 불러오지 못했습니다.'
} finally {
this.loading = false
}
}
}
})
- items: 목록
- selected: 현재 상세
- loading, error: API 상세
이 패턴은 로딩/에러/데이터를 묶어서 store로 관리하는 실무에서 자주 쓰이는 스타일입니다.
5. 화면 구현: <script setup>으로 각 View 만들기
App.vue
<!-- src/App.vue -->
<template>
<div id="app">
<header class="app-header">
<h1>Vue 3 + Router 4 + Pinia 미니 프로젝트</h1>
<nav>
<RouterLink to="/posts">Posts</RouterLink>
<RouterLink to="/login">Login</RouterLink>
</nav>
<div v-if="auth.isLoggedIn">
<span>{{ auth.user.name }}님 로그인 중</span>
<button @click="auth.logout">로그아웃</button>
</div>
</header>
<main class="app-main">
<RouterView />
</main>
</div>
</template>
<script>
import { RouterLink, RouterView } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>
- <RouterView> 위치에서 라우트에 맞는 View 컴포넌트가 바뀝니다.
- Pinia store는 useAuthStore()로 불러와 전역 상태를 사용합니다.
LoginView.vue
<!-- src/views/LoginView.vue -->
<template>
<section>
<h2>Login</h2>
<form @submit.prevent="onSubmit">
<label>
이름:
<input v-model="name" placeholder="이름을 입력하세요" />
</label>
<button type="submit">로그인</button>
</form>
</section>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const name = ref('')
const auth = useAuthStore()
const router = useRouter()
const onSubmit = () => {
if (!name.value.trim()) return
auth.login(name.value.trim())
router.push({ name: 'posts' })
}
</script>
- useRouter()로 로그인 성공 후 /posts로 이동합니다.
PostListView.vue
<!-- src/views/PostListLoginView.vue -->
<template>
<section>
<h2>Posts</h2>
<button @click="fetch" :disabled="postStore.loading">
{{ postStore.loading ? '로딩 중...' : '다시 불러오기' }}
</button>
<p v-if="postStore.error">{{ postStore.error }}</p>
<ul v-else>
<li v-for="post in postStore.items" :key="post.id">
<RouterLink :to="{ name: 'post-detail', params: { id: post.id } }">
{{ post.title }}
</RouterLink>
</li>
</ul>
</section>
</template>
<script setup>
import { onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostStore } from '@/stores/posts'
const fetch = () => {
postStore.fetchPosts()
}
onMounted(() => {
if (!postStore.items.length) {
fetch()
}
})
</script>
- Router 4에서 이름 기반 내비게이션(name: 'post-detail')은 공식 문서에서도 권장하는 패턴입니다.
- 목록은 Pinia store의 items를 그대로 사용합니다.
PostDetailView.vue
<!-- src/views/PostDetailView.vue -->
<template>
<section>
<button @click="goBack">목록으로</button>
<div v-if="postStore.loading">
로딩 중...
</div>
<p v-else-if="postStore.error">
{{ postStore.error }}
</p>
<article v-else-if="postStore.selected">
<h2>{{ postStore.selected.title }}</h2>
<p>{{ postStore.selected.body }}</p>
</article>
<p v-else>
게시글 정보가 없습니다.
</p>
</section>
</template>
<script setup>
import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePostStore } from '@/stores/posts'
const route = useRoute()
const router = useRouter()
const postStore = usePostStore()
const load = () => {
const id = route.params.id
if (!id) return
postStore.fetchPostById(id)
}
const goBack = () => {
router.push({ name: 'posts' })
}
onMounted(false)
// 라우트 파라미터가 바뀌면 다시 로딩
watch(
() => route.params.id,
() => load()
)
</script>
- useRoute()로 현재 라우트 정보를 가져오고, route.params.id로 파라미터에 접근합니다.
- 라우트 파라미터가 바뀌었을 때 watch로 다시 불러오는 패턴 역시 공식 문서/예제에서 자주 등장합니다.
6. 구조 정리
- 라우팅 (Router 4)
- /login, /posts, /posts/:id
- <RouterView> 위치에서 컴포넌트 교체
- 상태관리 (Pinia)
- auth store: 로그인 상태
- posts store: 게시글 목록/상세, 로딩/에러
- 컴포넌트 작성 (Vue 3 + <script setup>)
- 각 View 컴포넌트는 <script setup>으로 깔끔하게 작성
- store는 useXXXStore(), 라우터는 useRoute/useRouter로 사용
7. 확장 아이디어
- 로그인 상태가 없으면 /posts 진입 시 /login으로 보내는 네비게이션 가드 추가
- Post 목록/상세에 검색/필터 상태를 추가해서 Pinia store 확장
- API 대신 실제 서버(혹은 mock 서버)를 붙여보고, 에러 케이스를 일부러 발생시켜 보기
이런 걸 하나씩 붙여보면, 프로젝트 구조를 이해하고 마이그레이션 전략을 세울 때 훨씬 도움이 됩니다.
출처
'Framework > Vue' 카테고리의 다른 글
| Vue 2에서 Vue 3로: 공식 마이그레이션 가이드 구조 한 눈에 정리 (0) | 2025.11.27 |
|---|---|
| Vue 3 생태계 한 눈에 정리 (0) | 2025.11.27 |
| Vue3 <script setup> 정리: defineProps, defineEmits, defineExpose (0) | 2025.11.26 |
| Pinia 입문: Vuex 대신 선택하는 Vue 3 공식 상태 관리 라이브러리 (0) | 2025.11.26 |
| Vue Router 3 vs 4 비교: Vue2에서 Vue3로 라우터 마이그레이션 (0) | 2025.11.25 |