프론트엔드

[Vue.js 3.0] (2) Navigation Guard, Pinia 활용기

CyberI 2024. 1. 22. 08:23

 

안녕하세요.

이번 주제는 앞서 포스팅했던 Vue 소개 및 프로젝트 생성 편에 이어 실제 프로젝트에서 Vue를 사용하는데 많이 도움되었던 Navigation Guard, Pinia의 활용 경험과 장점을 공유해드리고자 합니다.

 

목차

 

※ 시작하기에 앞서 해당 글에서 제공하는 코드는 실제 코드가 아니며 예제를 위해 유사하게 만든 샘플 코드이니 참고바랍니다.

 

1. 네비게이션 가드 (Navigation Guard)

(1) 네비게이션 가드란?

 

네비게이션 가드는 Vue Router 라이브러리에서 제공하는 라우터로 페이지를 이동하는 동안 특정 조건에 따라 화면 이동을 제어하거나 변경하는 기능입니다. 다시 말해 특정 URL로 접근할 때 조건에 따라 접근을 막을 수 있습니다. 이번 글에선 라우터 가드에 대해 설명드리겠습니다.

 

네비게이션 가드 종류는 아래 세 가지 입니다.

  • 애플리케이션 전역에서 동작하는 전역 가드
  • 특정 URL에서만 동작하는 라우터 가드
  • 라우터 컴포넌트 안에 정의하는 컴포넌트 가드

 

(2) 라우터 가드 알아보기

 

라우터 가드는 지정한 경로로 요청 시에만 트리거되며 params, query, hash 변경에는 트리거되지 않습니다. beforeEnter에 선언된 가드함수가 false를 리턴하지 않는다면 to에서 지정한 url로 이동합니다.

라우터 가드를 포함한 모든 가드 함수는 RouteLocationNormalized 타입의 아래 두 개 인자를 사용할 수 있습니다.

  • to : 탐색될 경로
  • from : 현재 경로

 

RouteLocationNormalized 타입의 객체는 route에 대한 다양한 정보를 갖고 있습니다. 필요한 경우 이 객체의 값으로 가드 함수를 수행할 수 있습니다.

Vue Router 3.x 버전까지는 세 번째 인자로 next 함수를 사용했는데 to에서 지정한 url로 이동하기 위해 필수로 호출해야 하는 함수였습니다. (현재도 호환성을 위해서 optional하게 선언은 가능합니다.) 하지만 4.x 버전에서는 next 함수를 사용하지 않고 false가 return 될 때 to에서 지정한 url로 이동하지 않도록 변경되었습니다. 그래서 4.x 버전의 Vue Router는 명시적으로 false를 return 하지 않는다면 가드 함수는 정상적으로 수행된 것으로 간주하고 url로 이동하게 됩니다.

아래는 기본적인 라우터 가드 적용 예시입니다.

const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    beforeEnter: (to, from) => {
      // 경로 진입 거부
      return false
    },
  },
]

 

beforeEnter를 정의할 때 함수의 배열로도 정의할 수 있습니다. 또한 meta 필드를 이용해서 임의 정보를 추가하면 가드 함수에서 접근할 수 있는데 아래 예시로 설명드리겠습니다.

 

(2-1) 라우터 가드를 활용한 화면이동 로깅

 

먼저 소개해드릴 예제에서는 step1 ~ step3 까지 순서대로 처리해야 하는 화면이 있다고 가정하고 샘플 코드를 작성했습니다. 이때 step2는 step1, step3에서 접근이 가능해야하고 step3는 step2화면에서만 접근이 가능해야 합니다.

우선 화면 이동 시 로그를 남기는 함수를 추가해보겠습니다. 샘플을 위해 라우터 가드를 이용하여 로그를 남겼지만 모든 화면을 대상으로 이동 로그가 필요하다면 전역 가드 사용도 고려할 수 있습니다.

import { createRouter, createWebHistory } from 'vue-router'
import Step1 from '@/views/guard/Step1.vue'
import Step2 from '@/views/guard/Step2.vue'
import Step3 from '@/views/guard/Step3.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/step1',
      name: 'step1',
      component: Step1,
      beforeEnter: [perRouteGuardForLogging],
    },
    {
      path: '/step2',
      name: 'step2',
      component: Step2,
      beforeEnter: [perRouteGuardForLogging],
    },
    {
      path: '/step3',
      name: 'step3',
      component: Step3,
      beforeEnter: [perRouteGuardForLogging],
    }
  ]
})

function perRouteGuardForLogging(to, from) {
  console.log(`from: ${from.path} => to: ${to.path}`)
  return true
}

export default router

 

위 샘플 코드처럼 로그를 남기는 perRouteGuardForLogging 함수를 정의하고 beforeEnter 배열에 정의한 함수를 추가해주면 router 이동 시 로그를 확인할 수 있습니다.

 

(2-2) 라우터 가드를 활용한 URL 접근 제어

 

이번에는 라우터 가드를 활용해서 해당 화면을 특정 router에서만 접근 가능하도록 수정해보겠습니다. 라우터 가드는 어떤 화면에서 접근이 가능한지, 접근이 불가할 때는 어떤 화면으로 이동해야 하는지 정보가 필요합니다. 이때 router에서 제공하는 meta 필드를 사용합니다.

각 step의 meta 필드에 진입할 수 있는 경로와 그 외 경로에서 진입하면 이동시킬 경로를 추가해 보겠습니다.

routes: [
  {
    path: '/step1',
    name: 'step1',
    component: Step1,
    beforeEnter: [perRouteGuardForLogging],
  },
  {
    path: '/step2',
    name: 'step2',
    component: Step2,
    beforeEnter: [perRouteGuardForLogging],
    meta: {
      allow: ['step1', 'step3'],
      default: 'step1',
    },
  },
  {
    path: '/step3',
    name: 'step3',
    component: Step3,
    beforeEnter: [perRouteGuardForLogging],
    meta: {
      allow: ['step2'],
      default: 'step1',
    },
  }
]

 

allow에 배열 형태로 현재 step에 진입할 수 있는 경로를 지정했고 이 외 다른 경로에서 진입하게 되면 강제로 이동시킬 default도 선언했습니다.

 

이제 이 meta정보를 이용해서 beforeEnter에 추가될 함수를 정의해보겠습니다.

function perRouteGuardForCheckStep(to, from) {
  const metaByTo = to.meta

  const isAllow = metaByTo.allow.includes(from.name)

  if (!isAllow) {
    return {
      name: metaByTo.default
    }
  }
}

 

from.name이 진입하려는 경로의 allow에 작성되어 있어야 진입이 가능한 메서드입니다. meta정보를 작성하고 beforeEnter에 해당 메서드를 추가해 주면 URL 접근 제어가 수월해집니다.

완성된 router는 아래와 같습니다.

routes: [
  {
    path: '/step1',
    name: 'step1',
    component: Step1,
    beforeEnter: [perRouteGuardForLogging],
  },
  {
    path: '/step2',
    name: 'step2',
    component: Step2,
    beforeEnter: [perRouteGuardForLogging, perRouteGuardForCheckStep],
    meta: {
      allow: ['step1', 'step3'],
      default: 'step1',
    },
  },
  {
    path: '/step3',
    name: 'step3',
    component: Step3,
    beforeEnter: [perRouteGuardForLogging, perRouteGuardForCheckStep],
    meta: {
      allow: ['step2'],
      default: 'step1',
    },
  }
]

 

지금까지 Vue 라우터 가드를 활용하여 URL 접근을 제어하는 방법에 대해 알아보았습니다.

 

저는 프로젝트를 진행하면서 라우터 가드를 통해 실제로 코드의 가독성과 유지보수성이 개선된 것을 느낄 수 있었습니다. 컴포넌트에서 접근 제어 로직을 중복 사용하지 않고 한 곳에서 효과적으로 관리할 수 있기 때문입니다. 또 여러 개의 가드를 등록하고 우선순위를 설정하며 URL 접근 제어를 세밀하게 조정할 수 있어 편리했던 점도 있었습니다.

 


2. 피니아 (Pinia)

(1) Pinia란?

 

Pinia는 Vue에서 사용할 수 있는 상태 관리 라이브러리로 기존 상태 관리 라이브러리인 Vuex를 보완한 라이브러리입니다. 참고로 실제 Vuex의 새로운 버전을 구상할 때 Pinia가 이미 개선된 기능을 구현하고 있었다고 합니다.

 

먼저 Pinia의 기본 구조를 살펴보겠습니다.

Vue 3 프로젝트를 생성할 때 Pinia를 사용할 것인지에 대해 체크할 수 있는데 ‘사용’을 체크하면 dependency가 추가되고 stores폴더에 counter.js가 생성되면서 Pinia 기본 구조를 확인해 볼 수 있습니다. 만약 사용하지 않는다고 체크했다면 직접 dependency를 추가하고 사용할 store를 생성하면 됩니다.

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

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})

 

생성된 store는 vue component에서 데이터가 필요할 때 유연하게 꺼내올 수 있게합니다. 이때 구조 분해 할당하여 개별 변수로 선언하면 해당 변수는 반응형이 아닌 자바스크립트 변수로 취급되기 때문에 아래와 같이 storeToRefs를 사용해야 반응형을 잃지 않습니다.

<script setup>
  import { useCounterStore } from '@/stores/counter'
  import { storeToRefs } from 'pinia'

  const countStore = useCounterStore()
  const { count, doubleCount } = storeToRefs(countStore)

  count.value++
  countStore.increment()
</script>

 

(2) Pinia 활용하기

 

(2-1) Pinia를 활용한 전역 가드 추가

 

앞서 라우터 가드 적용 예시를 알아보았는데 이번엔 Pinia를 활용하여 전역 가드를 추가해보겠습니다. Vue Router 공식문서에서 제공하는 전역가드 예시는 아래와 같습니다.

router.beforeEach(async (to, from) => {
  if (
    // 유저 로그인 인증여부 확인
    !isAuthenticated &&
    // ❗ 무한 리디렉션 방지
    to.name !== 'Login'
  ) {
    // 유저를 로그인 페이지로 리디렉션
    return { name: 'Login' }
  }
})

 

생성한 router 인스턴스의 beforeEach 메서드에 콜백함수를 전달하여 전역 가드를 등록합니다. 위 예시대로 진행하면 isAuthenticated가 정의되지 않아서 오류가 나는데 router를 정의하는 곳에서 현재 User의 로그인 인증여부 값 확인이 필요합니다. Pinia를 활용해서 User의 로그인 인증여부를 확인해보겠습니다.

 

우선 User의 로그인 여부를 저장하고 있는 store를 생성해보겠습니다.

import { ref } from 'vue'
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const isAuthenticated = ref(true)
  
  function login() {
    isAuthenticated.value = true
  }

  function logout() {
    isAuthenticated.value = false
  }

  return { isAuthenticated, login, logout }
})

 

로그인 여부 변수를 선언하고 login, logout에 대해 구현했습니다.

이제 전역 가드를 등록할 때 userStore의 isAuthenticated를 사용해보겠습니다.

router.beforeEach(async (to, from) => {
  const userStore = useUserStore()
  const { isAuthenticated } = storeToRefs(userStore)

  if (
    !isAuthenticated.value &&
    to.name !== 'Login'
  ) {
    return { name: 'Login' }
  }
})

 

위 예시대로 작성하면 userStore의 isAuthenticated 값을 보고 로그인 화면으로 redirect 시킬지 결정합니다. 그런데 전역 가드는 vue의 모든 URL을 변경할 때 동작하게 되는데 로그인 없이 진입 가능한 화면이 존재할 수 있으므로 한번 더 수정이 필요합니다. Router 선언 시 meta에 로그인 필요 여부를 작성하고 전역 가드에서 사용해 처리해보겠습니다.

routes: [
  {
    path: '/step1',
    name: 'step1',
    component: Step1,
    beforeEnter: [perRouteGuardForLogging],
    meta: {
      requiresAuth: false,
    },
  },
  {
    path: '/step2',
    name: 'step2',
    component: Step2,
    beforeEnter: [perRouteGuardForLogging, perRouteGuardForCheckStep],
    meta: {
      allow: ['step1', 'step3'],
      default: 'step1',
      requiresAuth: true,
    },
  },
  {
    path: '/step3',
    name: 'step3',
    component: Step3,
    beforeEnter: [perRouteGuardForLogging, perRouteGuardForCheckStep],
    meta: {
      allow: ['step2'],
      default: 'step1',
      requiresAuth: true,
    },
  }
]

 

 

router.beforeEach(async (to, from) => {
  const metaByTo = to.meta
  
  if (!metaByTo.requiresAuth) {
    return true
  }

  const userStore = useUserStore()
  const { isAuthenticated } = storeToRefs(userStore)

  if (
    !isAuthenticated.value &&
    to.name !== 'Login'
  ) {
    return { name: 'Login' }
  }
})

 

Meta에 requiresAuth (로그인 필요여부)를 작성하고 전역 가드 함수에서 이 값을 사용하여 로그인이 필요없다면 로그인 여부와 상관없이 요청 URL에 진입이 가능하게 수정했습니다.

 

(2-2) Pinia reset

 

Pinia를 사용하면 여러 컴포넌트 간 효율적인 데이터 전달이 가능하지만 초기화 (reset)를 주의해야합니다. Store는 프로세스 시작 또는 종료 시에 보안, 일관성, 오류방지 등 여러 이유로 초기화가 필요한 시점이 있습니다. 이때 초기화하지 않으면 이전 데이터가 새로운 프로세스에 영향을 줄 수 있으므로 적절한 시점에 초기화하는 것이 중요합니다.

Pinia에서는 store.$reset() 메서드를 통해 초기화 기능을 제공하는데 이는 옵션(Option) Stores 방식으로 작성된 store에서만 동작합니다. 예시로 알아본 store는 컴포지션(Composition) API와 동일한 구조로 정의한 Setup Stores입니다. 그래서 $reset() 메서드를 정의하고 main.js에서 Pinia 인스턴스를 생성할 때 등록해서 사용해야 합니다. 아래는 main.js에 적용한 예시입니다.

import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

import { cloneDeep } from "lodash";

function resetStore({ store }) {
  const initialState = cloneDeep(store.$state);
  store.$reset = () => store.$patch(cloneDeep(initialState));
}

const app = createApp(App)
const pinia = createPinia()
pinia.use(resetStore)

app.use(pinia)
app.use(router)

app.mount('#app')

 

resetStore 메서드를 정의하고 Pinia 인스턴스에 등록했습니다.

resetStore는 store의 기본값을 deepClone하고 $reset 메서드에서 $patch를 통해 초기 상태로 변경하였습니다. $patch에서 deepClone한 store의 기본값 자체에 대한 참조를 제거하려면 다시 deepClone 하는 것이 중요합니다. 이렇게 등록한 $reset 메서드는 아래와 같이 사용할 수 있습니다.

<script setup>
import { useCounterStore } from '@/stores/counter'
const countStore = useCounterStore()
countStore.$reset()
</script>

 

지금까지 테스트 예제를 통해 전역 가드를 등록하고 가드 함수 내에서 Pinia로 로그인 정보를 관리해보았습니다. 위 예제처럼 로그인 상태를 전역적으로 관리하면 상태 변경 시 애플리케이션의 다른 부분이 변경된 사항을 즉시 인식하고 적절하게 반응할 수 있게되어 간편하게 상태 관리를 할 수 있습니다.

 

실제로 로그인 상태 외에도 다양한 상태를 관리해야 할 때 Pinia를 활용하면 효율적으로 할 수 있습니다. 다만 위에서 설명드렸듯이 reset함수를 적절한 시점(로그아웃 또는 화면진입)에 호출하여 store를 초기 상태로 되돌리는 것이 중요합니다.

 

지금까지 Navigation Guard와 Pinia에 대해 알아보았습니다. 저는 프로젝트의 규모가 커질수록 두 가지 기능을 적극적으로 활용하면서 많은 장점을 느낄 수 있었습니다. 위 예제처럼 인증이나 권한 부여, URL 접근 제어와 같은 로직을 처리할 때 각 component에서 처리해야 하는 내용들을 분리하여 한 곳에서 처리할 수 있었고 이에 프로젝트를 더 안정적이고 효율적으로 관리할 수 있었습니다. 이 글을 보시는 개발자분들도 해당 내용을 도움삼아 Vue 프로젝트를 편리하게 진행할 수 있으면 좋겠습니다.

 

Vue 시리즈는 이번 글로 마무리하도록 하겠습니다.

1편을 확인하고 싶으신 분들은 아래 링크를 클릭해주세요.

2024.01.15 - [프론트엔드] - [Vue.js 3.0] (1) Vue 소개 및 프로젝트 생성

 

[Vue.js 3.0] (1) Vue 소개 및 프로젝트 생성

안녕하세요. 이번 글에서는 웹 개발에서 중요한 역할을 하는 프론트엔드 프레임워크 Vue.js를 설명드리겠습니다. 우선 Vue.js에 대해 알아보고 프로젝트를 생성하며 간단한 예제를 만들어보고, 다

cyberx.tistory.com

 

Vue 활용 현대차증권 ‘모바일 리빌딩’ 프로젝트 보기

프로젝트 케이스 스터디 보러가기