목록으로
AI Skills로 Vue 개발하기

AI Skills로 Vue 개발하기 (3) - Pinia 상태 관리와 Vue Router

2026년 01월 22일9 min read
#Vue#Claude Code#AI#Pinia#Vue Router

목차 보기
시리즈 목차 보기3 / 6

이전 편 요약

2편에서는 Generic 컴포넌트와 useTableSort composable을 만들었다. DataTable이라는 재사용 가능한 UI 블록이 생긴 셈인데, 아직 해결하지 않은 문제가 두 가지 있다. 데이터를 어디에서 관리할 것인지, 그리고 페이지 간 이동을 어떻게 처리할 것인지다.

이번 편에서는 Pinia로 전역 상태를 관리하고, Vue Router로 네비게이션과 인증 가드를 구성하는 과정을 다룬다.

Setup Store vs Option Store

Pinia는 Vue 3의 공식 상태 관리 라이브러리다. Vuex를 대체하면서 API가 훨씬 단순해졌다. Store를 정의하는 방식은 두 가지다.

Option Store는 Vuex에 익숙한 사람이라면 바로 이해할 수 있는 구조다. state, getters, actions를 객체로 선언한다.

ts
// Option Store
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    double: (state) => state.count * 2,
  },
  actions: {
    increment() {
      this.count++
    },
  },
})

Setup Store는 Composition API 스타일이다. refstate, computedgetters, 일반 함수가 actions에 대응한다.

ts
// Setup Store
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const double = computed(() => count.value * 2)

  function increment(): void {
    count.value++
  }

  return { count, double, increment }
})

Vue 스킬의 Pinia 레퍼런스는 Setup Store를 권장한다. Composition API와 동일한 멘탈 모델을 공유하기 때문에 composable과 store 사이를 오갈 때 컨텍스트 스위칭 비용이 없다. TypeScript 타입 추론도 Setup Store 쪽이 더 자연스럽다.

Store 설계 패턴

Store를 설계할 때 가장 먼저 결정해야 하는 건 경계다. 무엇이 전역 상태이고, 무엇이 로컬 상태인지를 구분해야 한다.

원칙은 단순하다. 여러 컴포넌트나 페이지에서 공유하는 데이터는 store에, 특정 컴포넌트 안에서만 쓰이는 로직은 composable에 둔다. useAuthStore, useProductStore처럼 도메인별로 store를 분리하고, useTableSort, useFormValidation 같은 UI 로직은 composable로 관리하는 식이다.

store에서 상태를 꺼내 쓸 때 주의할 점이 하나 있다. 구조분해 할당 시 반응성이 깨지는 문제다.

ts
// 잘못된 방법 - 반응성 깨짐
const { user, isLoggedIn } = useAuthStore()

// 올바른 방법
const store = useAuthStore()
const { user, isLoggedIn } = storeToRefs(store)
const { login, logout } = store  // actions는 직접 구조분해 OK

storeToRefs는 상태와 getter를 ref로 감싸서 반응성을 유지해준다. actions는 함수이므로 직접 구조분해해도 문제없다. 이 구분을 놓치면 UI가 업데이트되지 않는 버그를 만나게 되는데, 원인을 찾기가 상당히 까다롭다. 스킬이 코드를 생성할 때 storeToRefs를 자동으로 적용해주는 건 꽤 실용적이었다.

AI가 생성한 인증 Store

"JWT 인증 store를 Setup Store로 만들어줘"라고 프롬프트를 던졌다. 스킬은 pinia-guide.md를 거쳐 pinia/core-stores.md를 참조하면서 아래 코드를 생성했다.

ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
  email: string
}

interface LoginPayload {
  email: string
  password: string
}

export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(localStorage.getItem('token'))
  const user = ref<User | null>(null)

  const isLoggedIn = computed(() => !!token.value)

  async function login(credentials: LoginPayload): Promise<void> {
    const response = await api.post('/auth/login', credentials)
    token.value = response.data.token
    user.value = response.data.user
    localStorage.setItem('token', response.data.token)
  }

  function logout(): void {
    token.value = null
    user.value = null
    localStorage.removeItem('token')
  }

  return { token, user, isLoggedIn, login, logout }
})

몇 가지 눈여겨볼 점이 있다. token의 초기값을 localStorage에서 읽어오기 때문에 새로고침 후에도 로그인 상태가 유지된다. isLoggedIncomputed로 token의 존재 여부에서 파생된다. logout에서는 상태 초기화와 localStorage 정리를 한 번에 처리한다. 인증 관련 로직이 하나의 store에 응집되어 있어서 관리 포인트가 명확하다.

Vue Router 가드

인증 store가 있으면 Navigation Guard에서 자연스럽게 활용할 수 있다. 다만 한 가지 주의할 점이 있다. 가드 함수 안에서 useAuthStore()를 호출하려면 Pinia 인스턴스가 먼저 설치되어 있어야 한다. router/index.ts에서 store를 import만 해두고 가드 내부에서 호출하면, app.use(pinia)보다 먼저 실행될 경우 에러가 발생한다.

이 문제를 피하려면 가드를 router.beforeEach 콜백 안에서 store를 호출하되, router 파일이 아닌 app 진입점에서 가드를 등록하는 방법이 있다. 혹은 가장 간단하게, beforeEach 콜백이 실행되는 시점에는 이미 Pinia가 설치되어 있으므로 콜백 내부에서 호출하면 된다.

ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/login',
      component: () => import('@/pages/Login.vue'),
    },
    {
      path: '/dashboard',
      component: () => import('@/pages/Dashboard.vue'),
      meta: { requiresAuth: true },
    },
  ],
})

router.beforeEach((to) => {
  const auth = useAuthStore()

  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
})

export default router

meta.requiresAuthtrue인 라우트에 접근할 때 로그인 상태가 아니면 /login으로 리다이렉트한다. 이때 원래 가려던 경로를 query.redirect에 담아두면, 로그인 성공 후 해당 페이지로 되돌려 보낼 수 있다. 이 패턴은 거의 모든 SPA 인증 흐름에서 쓰인다.

2편의 DataTable과 연결

Store와 composable, 컴포넌트 사이의 데이터 흐름을 정리하면 이렇다. Store에서 API를 호출해 데이터를 가져오고, 페이지 컴포넌트에서 store의 상태를 꺼내 composable에 전달하고, composable이 가공한 결과를 DataTable에 바인딩한다.

2편에서 만든 useTableSort와 연결하면 아래와 같은 구조가 된다.

vue
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useProductStore } from '@/stores/product'
import { useTableSort } from '@/composables/useTableSort'
import DataTable from '@/components/DataTable.vue'

const productStore = useProductStore()
const { products } = storeToRefs(productStore)

const { sorted, toggleSort, sortKey, sortDirection } = useTableSort({
  items: products,
  defaultKey: 'name',
})
</script>

<template>
  <DataTable
    :items="sorted"
    :columns="columns"
    @sort="toggleSort"
  />
</template>

Store가 데이터의 원천이 되고, composable이 정렬 로직을 처리하고, DataTable이 렌더링만 담당한다. 각 레이어의 책임이 분리되어 있으므로 정렬 로직을 바꿔도 store를 건드릴 필요가 없고, 데이터 소스를 바꿔도 DataTable을 수정할 필요가 없다.

정리

이번 편에서는 Pinia의 Setup Store로 전역 상태를 관리하고, Vue Router의 Navigation Guard로 인증 흐름을 구성하고, 2편의 DataTable과 연결하는 데이터 흐름까지 살펴봤다. storeToRefs로 반응성을 유지하는 것, 가드 내부에서 store를 호출하는 타이밍, Store-Composable-Component의 레이어 분리 원칙이 핵심이었다.

다음 편에서는 Vite 설정과 Vitest를 활용한 테스트 자동화를 다룬다.

검색...

검색...