테마 전환

Vue 3 + TypeScript 모범 사례: 2025년 엔터프라이즈 프로젝트 아키텍처 가이드

Easton editorial illustration: cache waterfall instrument

지난달 팀에서 기술 스택 선정 회의를 열었는데, 프론트엔드 개발자들이 상태 관리, 디렉터리 구조, TS 설정을 두고 한참 논쟁했지만 결론을 내리지 못했습니다. 저도 처음 Vue 3 + TypeScript를 설정할 때 tsconfig.json을 20번 넘게 고쳤고, 다른 컴퓨터에서는 또 오류가 났습니다.

이 글은 우리 팀이 수많은 시행착오를 거쳐 정리한 구성입니다. 최적의 해답이라고 할 수는 없지만, 적어도 중대형 프로젝트 세 곳에서 운영하는 동안 큰 문제는 없었습니다. 새 프로젝트를 시작하거나 기존 프로젝트의 아키텍처를 개선하려는 분들이 시행착오를 줄이는 데 도움이 되길 바랍니다.


2025년 Vue 3 기술 스택 선정 가이드

먼저 기술 스택 선정부터 이야기해 보겠습니다. Vue 3가 출시된 지도 몇 년이 지났고, 2025년에 무엇을 사용해야 하는지는 이제 상당히 명확해졌습니다.

현재 우리 팀의 표준 조합은 Vite + Vue 3 + TypeScript + Pinia + Vue Router 4입니다. 이미 많은 분이 이 조합을 사용하고 있겠지만, 왜 이 도구들을 선택했는지 설명해 보겠습니다.

Vite는 길게 설명할 필요가 없습니다. 개발 경험이 Webpack보다 훨씬 좋고, 핫 업데이트도 거의 즉시 적용됩니다. Vue 3.6은 아직 alpha 단계지만 Evan You가 Vue.js Nation 2025 발표에서 공개한 수치는 상당히 놀랍습니다. Vapor Mode는 100밀리초 안에 컴포넌트 10만 개를 마운트할 수 있습니다. 아직 프로덕션에서 사용할 단계는 아니지만 방향은 옳다고 봅니다.

특히 Pinia를 이야기하고 싶습니다. 솔직히 Pinia의 API 설계를 처음 봤을 때 든 생각은 “상태 관리는 원래 이렇게 생겨야 하는구나”였습니다. 비교해 보겠습니다.

// Vuex 방식(복잡함)
const store = createStore({
  state: () => ({ count: 0 }),
  mutations: {
    increment(state) { state.count++ }
  },
  actions: {
    incrementAsync({ commit }) {
      setTimeout(() => commit('increment'), 1000)
    }
  }
})
// Pinia 방식(깔끔함)
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const increment = () => count.value++
  const incrementAsync = () => setTimeout(increment, 1000)
  return { count, increment, incrementAsync }
})
1.5KB
Pinia 크기
Vuex보다 간결하며, Vuex 5 개발이 사실상 정체된 상황에서 새 프로젝트에는 Pinia를 권장

Pinia는 크기가 약 1.5KB에 불과하고 Vuex 5 개발은 사실상 정체되어 있습니다. 프로젝트가 이미 Vuex에 깊이 의존하고 있고 마이그레이션 계획도 없는 경우가 아니라면, 새 프로젝트에서 굳이 Vuex를 사용할 필요는 없습니다.


프로젝트 디렉터리 구조 설계

모든 컴포넌트를 무작정 components 아래에 쌓아 두었다가 세 달 뒤에는 자신조차 파일을 찾지 못하는 프로젝트를 너무 많이 봤습니다.

작은 프로젝트라면 디렉터리 구조를 어떻게 짜도 큰 문제가 없지만, 프로젝트가 커지면 규칙이 없는 구조는 재앙이 됩니다. 우리 팀도 여러 방식을 시도한 끝에 다음 구조로 정착했습니다.

src/
├── api/                  # API 인터페이스 계층
│   ├── modules/          # 비즈니스 모듈별 분리
│   │   ├── user.ts
│   │   └── order.ts
│   └── index.ts
├── assets/               # 정적 리소스
│   ├── images/
│   └── styles/
├── components/           # 전역 공용 컴포넌트
│   ├── base/             # 기본 컴포넌트(Button, Input 등)
│   └── business/         # 공용 비즈니스 컴포넌트
├── composables/          # 조합 함수
│   ├── useAuth.ts
│   └── useRequest.ts
├── layouts/              # 페이지 레이아웃 컴포넌트
├── router/               # 라우트 설정
│   ├── modules/          # 라우트 모듈
│   └── index.ts
├── stores/               # Pinia 상태 관리
│   ├── modules/
│   └── index.ts
├── types/                # 전역 타입 정의
│   ├── api.d.ts
│   └── global.d.ts
├── utils/                # 유틸리티 함수
├── views/                # 페이지 컴포넌트
│   ├── user/
│   └── order/
├── App.vue
└── main.ts

여기에는 몇 가지 핵심 포인트가 있습니다.

composables와 utils의 차이: composables에는 useAuth, useRequest처럼 반응형 로직을 포함한 조합 함수를 둡니다. utils에는 formatDate, debounce 같은 순수 유틸리티 함수를 둡니다. 이 둘을 섞는 경우가 많은데, 나중에 유지보수가 상당히 힘들어집니다.

파일 유형이 아닌 기능 도메인별 분리: api, stores, views 내부도 비즈니스 모듈별로 한 단계 더 나눕니다. 그러면 사용자 관련 항목은 모두 user 폴더에 있어 파일을 논리적으로 찾기 쉽습니다.

types 별도 분리: 전역 타입 정의는 types 디렉터리에 두고, 컴포넌트 내부 타입은 컴포넌트 파일 안에 작성할 수 있습니다. 모든 타입을 하나의 types 파일에 몰아넣으면 오히려 더 복잡해집니다.


TypeScript 타입 정의 모범 사례

솔직히 TS의 복잡한 타입 활용은 진입 장벽이 꽤 높지만, 다음 몇 가지 상황만 익혀도 대부분의 요구 사항을 처리할 수 있습니다.

먼저 tsconfig.json에서 다음 설정은 반드시 활성화해야 합니다.

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true,
    "lib": ["ESNext", "DOM"],
    "skipLibCheck": true,
    "noEmit": true,
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

strict: true는 필수입니다. 처음에는 오류가 많이 나더라도 장기적으로는 확실히 가치가 있습니다. moduleResolution: "bundler"는 비교적 새로운 옵션이며 Vite와 함께 사용할 때 경험이 더 좋습니다.

다음은 Vue 컴포넌트의 타입 정의입니다. 처음 defineProps<Props>() 문법을 봤을 때는 제네릭이 어떻게 전달되는지 몰라 몇 초 동안 멈칫했습니다. 사실 이것은 Vue 컴파일러가 처리해 주는 기능입니다.

<script setup lang="ts">
// Props 타입 정의
interface Props {
  title: string
  count?: number
  items: string[]
}
const props = defineProps<Props>()
// 기본값이 있는 Props
const propsWithDefaults = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => []
})
// Emits 타입 정의
interface Emits {
  (e: 'update', value: string): void
  (e: 'delete', id: number): void
}
const emit = defineEmits<Emits>()
// 또는 더 간결한 방식 사용(Vue 3.3+)
const emit2 = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()
</script>

또 하나 자주 문제가 되는 부분은 .vue 파일의 타입 인식입니다. IDE에서 모듈을 찾을 수 없다는 오류가 난다면 src 디렉터리에 env.d.ts 또는 shims-vue.d.ts를 만들어야 합니다.

/// <reference types="vite/client" />
declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

Pinia 상태 관리 실전

Vuex의 mutations에 익숙하다면 Pinia에서 state를 처음 직접 수정할 때 뭔가 규칙을 어기는 듯한 느낌이 듭니다. 나중에 생각해 보니 mutations 규칙은 사실 Flux 아키텍처에서 남은 부담이었고, Pinia는 이를 과감하게 없앤 것입니다.

우리 팀은 Composition API 방식으로 Store를 정의하는 것을 권장합니다.

// stores/modules/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getUserInfo, login } from '@/api/modules/user'
export const useUserStore = defineStore('user', () => {
  // state
  const token = ref<string>('')
  const userInfo = ref<UserInfo | null>(null)
  // getters
  const isLoggedIn = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name ?? '게스트')
  // actions
  const setToken = (newToken: string) => {
    token.value = newToken
    localStorage.setItem('token', newToken)
  }
  const fetchUserInfo = async () => {
    try {
      const res = await getUserInfo()
      userInfo.value = res.data
    } catch (error) {
      console.error('사용자 정보 조회 실패', error)
    }
  }
  const logout = () => {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
  }
  return {
    token,
    userInfo,
    isLoggedIn,
    userName,
    setToken,
    fetchUserInfo,
    logout
  }
})

사용할 때 주의할 점이 하나 있습니다. 구조 분해하면 반응성을 잃게 되므로 storeToRefs()를 사용해야 합니다.

import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// 잘못된 방식: 구조 분해 후 반응성을 잃음
const { userName, isLoggedIn } = userStore
// 올바른 방식
const { userName, isLoggedIn } = storeToRefs(userStore)
// actions는 일반 함수이므로 직접 구조 분해 가능
const { logout, fetchUserInfo } = userStore

영속 저장에는 pinia-plugin-persistedstate 플러그인을 권장합니다. 설정도 간단합니다.

// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// store에서 활성화
export const useUserStore = defineStore('user', () => {
  // ...
}, {
  persist: true  // 또는 세부 옵션 설정
})

Vue Router 4 타입 안전 라우팅 설정

라우트 가드를 많이 작성하다 보면 모든 로직을 beforeEach에 넣고 싶어집니다. 예전에 한 프로젝트에서는 beforeEach 코드가 거의 200줄에 달했고, 권한 검사, 분석 추적, 제목 설정을 모두 몰아넣었습니다. 나중에 여러 가드로 분리하고 나서야 유지보수가 조금 수월해졌습니다.

먼저 라우트 설정의 타입 정의를 살펴보겠습니다.

// router/index.ts
import type { RouteRecordRaw } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
// 라우트 meta 타입 확장
declare module 'vue-router' {
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
    roles?: string[]
  }
}
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/home/index.vue'),
    meta: {
      title: '홈',
      requiresAuth: false
    }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/dashboard/index.vue'),
    meta: {
      title: '대시보드',
      requiresAuth: true,
      roles: ['admin', 'editor']
    }
  }
]
const router = createRouter({
  history: createWebHistory(),
  routes
})
export default router

권한 검사 가드는 다음과 같이 작성할 수 있습니다.

// router/guards/auth.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
export function setupAuthGuard(router: Router) {
  router.beforeEach((to, from, next) => {
    const userStore = useUserStore()
    // 로그인이 필요 없는 페이지는 바로 통과
    if (!to.meta.requiresAuth) {
      next()
      return
    }
    // 로그인하지 않았다면 로그인 페이지로 이동
    if (!userStore.isLoggedIn) {
      next({ path: '/login', query: { redirect: to.fullPath } })
      return
    }
    // 권한 검사
    const { roles } = to.meta
    if (roles && roles.length > 0) {
      const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
      if (!hasRole) {
        next({ path: '/403' })
        return
      }
    }
    next()
  })
}

라우트 모듈화는 비즈니스 도메인별로 여러 파일로 나눈 뒤 index.ts에서 병합하는 방식을 권장합니다.

// router/modules/user.ts
export const userRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layouts/BasicLayout.vue'),
    children: [
      {
        path: 'profile',
        name: 'UserProfile',
        component: () => import('@/views/user/profile.vue')
      }
    ]
  }
]

코드 규칙과 엔지니어링 설정

ESLint 9가 flat config로 바뀐 뒤 기존 설정을 마이그레이션하는 데 꼬박 오후 한나절이 걸렸습니다. 이전 .eslintrc 설정은 더 이상 사용할 수 없으며, 새 설정은 다음과 같습니다.

// eslint.config.js
import js from '@eslint/js'
import vue from 'eslint-plugin-vue'
import typescript from '@typescript-eslint/eslint-plugin'
import tsParser from '@typescript-eslint/parser'
import vueParser from 'vue-eslint-parser'
export default [
  js.configs.recommended,
  ...vue.configs['flat/recommended'],
  {
    files: ['**/*.{ts,tsx,vue}'],
    languageOptions: {
      parser: vueParser,
      parserOptions: {
        parser: tsParser,
        sourceType: 'module'
      }
    },
    plugins: {
      '@typescript-eslint': typescript
    },
    rules: {
      'vue/multi-word-component-names': 'off',
      '@typescript-eslint/no-unused-vars': 'warn',
      '@typescript-eslint/no-explicit-any': 'warn'
    }
  }
]

Prettier와 ESLint가 충돌하는 문제는 eslint-config-prettier로 해결할 수 있습니다. 이 패키지는 ESLint에서 Prettier와 충돌하는 규칙을 비활성화합니다.

unplugin-auto-import는 Vue, Vue Router, Pinia의 API를 자동으로 임포트하여 수많은 import 문을 생략하게 해 주는 유용한 도구입니다.

// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig({
  plugins: [
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/auto-imports.d.ts',
      eslintrc: {
        enabled: true
      }
    })
  ]
})

설정하고 나면 코드에서 ref(), computed()를 직접 작성할 수 있고 수동 import가 필요 없습니다.

Git 커밋 규칙에는 husky + lint-staged를 사용하여 커밋 전에 lint 검사를 자동으로 실행합니다.

// package.json
{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint . --fix",
    "lint-staged": "lint-staged"
  },
  "lint-staged": {
    "*.{js,ts,vue}": ["eslint --fix", "prettier --write"]
  }
}

마무리하며

여기까지 쓰고 보니 처음 Vue 3를 접했을 때 어찌할 바를 몰랐던 기억이 떠오릅니다. 당시에는 Composition API가 막 나왔고, 커뮤니티에서는 이를 사용할지 Options API를 고수할지를 두고 논쟁이 이어졌습니다. 튜토리얼 품질도 제각각이라 시행착오가 일상이었습니다.

이 구성이 모든 프로젝트에 적합한 것은 아닙니다. 작은 프로젝트라면 상태 관리에 provide/inject만 사용해도 충분하고 디렉터리 구조도 이렇게 세분화할 필요가 없습니다. 하지만 중대형 엔터프라이즈 프로젝트에서 세 명 이상이 협업한다면 이 아키텍처가 상당한 수고를 덜어 줄 것입니다.

기술 스택 선정에 만능 해결책은 없습니다. 가장 중요한 것은 팀이 합의하고 규칙을 만들며 꾸준히 지키는 것입니다. 이 글이 참고가 되어 시행착오를 줄이는 데 도움이 되길 바랍니다.

여러분의 팀은 Vue 3 프로젝트를 어떻게 구성하고 있나요? 좋은 실전 경험이 있다면 댓글로 공유해 주세요.

Vue 3 + TypeScript 엔터프라이즈 프로젝트 아키텍처 구축 전체 과정

프로젝트 초기화부터 코드 규칙까지 Vite, Pinia, Vue Router, ESLint 등의 모범 사례를 포함한 전체 설정 절차

Estimated time: PT4H

  1. 1

    Step 1: 프로젝트 초기화와 기술 스택 선정

    Vite 프로젝트 생성:
  2. 2

    Step 2: 프로젝트 디렉터리 구조 설계

    디렉터리 구조를 기능 도메인별로 분리:
  3. 3

    Step 3: TypeScript 타입 정의 설정

    tsconfig.json 핵심 설정:
  4. 4

    Step 4: Pinia 상태 관리 설정

    Composition API 방식으로 Store 정의 권장:
  5. 5

    Step 5: const userInfo = ref<UserInfo

    null>(null);
  6. 6

    Step 6: Vue Router 4 타입 안전 라우팅 설정

    라우트 meta 타입 확장:
  7. 7

    Step 7: ESLint 9와 코드 규칙 설정

    ESLint 9에서는 flat config 사용:

FAQ

Pinia와 Vuex의 차이점은 무엇이며, 왜 Pinia를 권장하나요?
Pinia는 크기가 1.5KB에 불과하고 Vuex 5 개발은 사실상 정체되어 있습니다.

API 설계 비교:

Vuex는 state, mutations, actions의 3단 구조가 필요합니다.
const store = createStore({
state: () => ({ count: 0 }),
mutations: { increment(state) { state.count++ } },
actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }
})

Pinia는 Composition API 방식이라 더 간결합니다.
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const increment = () => count.value++;
const incrementAsync = () => setTimeout(increment, 1000);
return { count, increment, incrementAsync };
})

Pinia의 장점:
• mutations가 필요 없음(state를 직접 수정)
• 더 나은 TypeScript 지원
• 더 작은 크기
• 더 직관적인 API

프로젝트가 이미 Vuex에 깊이 의존하고 있고 마이그레이션 계획도 없는 경우가 아니라면, 새 프로젝트에서 굳이 Vuex를 사용할 필요는 없습니다.
Vue 3 프로젝트 디렉터리 구조는 어떻게 설계해야 하나요? composables와 utils의 차이는 무엇인가요?
디렉터리 구조는 파일 유형이 아니라 기능 도메인별로 나눕니다.
• src/api/modules/(user.ts, order.ts처럼 비즈니스 모듈별 분리)
• src/components/base/와 business/(기본 컴포넌트와 공용 비즈니스 컴포넌트)
• src/composables/(useAuth, useRequest 같은 반응형 로직을 포함한 조합 함수)
• src/utils/(formatDate, debounce 같은 순수 유틸리티 함수)
• src/stores/modules/(비즈니스 모듈별 Pinia 상태 관리)
• src/views/(user/, order/처럼 비즈니스 모듈별 페이지 컴포넌트)
• src/types/(전역 타입 정의)

composables와 utils의 차이:
• composables에는 useAuth, useRequest 같은 반응형 로직을 포함한 조합 함수를 둡니다.
• utils에는 formatDate, debounce 같은 순수 유틸리티 함수를 둡니다.
• 이 둘을 섞으면 나중에 유지보수가 상당히 힘들어집니다.

기능 도메인별 분리의 장점:
• api, stores, views 내부도 비즈니스 모듈별로 한 단계 더 나눕니다.
• 그러면 사용자 관련 항목은 모두 user 폴더에 있어 파일을 논리적으로 찾기 쉽습니다.
Vue 3에서 TypeScript는 어떻게 설정하며, 핵심 포인트는 무엇인가요?
tsconfig.json 핵심 설정:
• strict: true 활성화(필수입니다. 처음에는 오류가 많이 나더라도 장기적으로는 확실히 가치가 있습니다.)
• moduleResolution: 'bundler'(Vite와 함께 사용할 때 경험이 더 좋으며 비교적 새로운 옵션입니다.)
• paths에 @/* 별칭 설정

Vue 컴포넌트 타입 정의:
• defineProps<Props>() 제네릭 문법 사용:
interface Props { title: string; count?: number; items: string[] }
const props = defineProps<Props>()
• 기본값은 withDefaults(defineProps<Props>(), { count: 0, items: () => [] }) 사용
• Emits 타입은 defineEmits<Emits>() 또는 더 간결한 방식 사용:
const emit2 = defineEmits<{ update: [value: string]; delete: [id: number] }>()

.vue 파일 타입 인식:
src 디렉터리에 env.d.ts 또는 shims-vue.d.ts를 만듭니다.
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

IDE에서 모듈을 찾을 수 없다는 오류가 나면 대개 이 파일이 제대로 설정되지 않은 경우입니다.
Pinia 상태 관리는 어떻게 사용하며, 주의할 점은 무엇인가요?
Composition API 방식으로 Store를 정의하는 것을 권장합니다.
defineStore('user', () => {
const token = ref<string>('');
const userInfo = ref<UserInfo | null>(null);
const isLoggedIn = computed(() => !!token.value);
const fetchUserInfo = async () => {
try {
const res = await getUserInfo();
userInfo.value = res.data;
} catch (error) {
console.error('사용자 정보 조회 실패', error);
}
};
return { token, userInfo, isLoggedIn, fetchUserInfo };
})

사용 시 주의 사항:
• 반응성을 유지하며 구조 분해하려면 storeToRefs()를 사용합니다: const { userName, isLoggedIn } = storeToRefs(userStore)
• actions는 일반 함수이므로 직접 구조 분해할 수 있습니다: const { logout, fetchUserInfo } = userStore

영속 저장:
• pinia-plugin-persistedstate 설치
• main.ts에서 pinia.use(piniaPluginPersistedstate) 호출
• store에서 persist: true를 활성화하거나 세부 옵션 설정

Vuex의 mutations에 익숙하다면 Pinia에서 state를 처음 직접 수정할 때 뭔가 잘못하는 듯한 느낌이 듭니다. 하지만 mutations 규칙은 사실 Flux 아키텍처에서 남은 부담이며, Pinia는 이를 과감하게 없앴습니다.
ESLint 9의 flat config는 어떻게 설정하며 이전 버전과 무엇이 다른가요?
ESLint 9가 flat config로 바뀌면서 기존 .eslintrc 설정은 더 이상 사용할 수 없습니다.

새 설정 방식:
• eslint.config.js 생성
• js.configs.recommended와 vue.configs['flat/recommended'] 사용
• files: ['**/*.{ts,tsx,vue}'] 설정
• languageOptions.parser를 vue-eslint-parser로 설정
• parserOptions.parser를 @typescript-eslint/parser로 설정
• plugins에 @typescript-eslint 설정
• rules에 세부 규칙 설정('vue/multi-word-component-names': 'off', '@typescript-eslint/no-unused-vars': 'warn')

Prettier 통합:
• eslint-config-prettier로 ESLint와 Prettier 충돌 해결
• 이 패키지는 ESLint에서 Prettier와 충돌하는 규칙을 비활성화합니다.

자동 임포트:
• unplugin-auto-import 설치
• vite.config.ts에서 AutoImport({ imports: ['vue', 'vue-router', 'pinia'], dts: 'src/auto-imports.d.ts' }) 설정
• 설정 후 코드에서 ref(), computed()를 직접 작성해도 수동 import가 필요 없습니다.

Git 커밋 규칙:
• husky + lint-staged 설정
• package.json에 lint-staged 설정("*.{js,ts,vue}": ["eslint --fix", "prettier --write"])
• 커밋 전에 lint 검사 자동 실행
Vue Router 4에서 타입 안전 라우팅과 권한 가드는 어떻게 설정하나요?
라우트 meta 타입 확장:
declare module 'vue-router' {
interface RouteMeta {
title?: string;
requiresAuth?: boolean;
roles?: string[];
}
}

라우트 설정:
• RouteRecordRaw[] 타입 사용
• meta에 title, requiresAuth, roles 등 정의

권한 검사 가드:
• router/guards/auth.ts에 setupAuthGuard 함수 생성
• router.beforeEach에서 to.meta.requiresAuth 확인(로그인이 필요 없는 페이지는 바로 통과)
• 로그인하지 않았다면 로그인 페이지로 이동: next({ path: '/login', query: { redirect: to.fullPath } })
• roles 권한 검사: const hasRole = roles.some(role => userStore.userInfo?.roles?.includes(role))
• 권한이 부족하면 403으로 이동

라우트 모듈화:
• 비즈니스 도메인별로 여러 파일로 분리(router/modules/user.ts)
• index.ts에서 병합

라우트 가드를 많이 작성하다 보면 모든 로직을 beforeEach에 넣고 싶어집니다. 예전에 한 프로젝트에서는 beforeEach 코드가 거의 200줄에 달했고, 권한 검사, 분석 추적, 제목 설정을 모두 몰아넣었습니다. 나중에 여러 가드로 분리하고 나서야 유지보수가 조금 수월해졌습니다.

4분 읽기 · 게시일: 2025년 11월 24일 · 수정일: 2026년 9월 8일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog