Vue 3에서 Pinia와 JWT를 활용한 전역 권한 관리

dev.hyjang·2025년 8월 19일

Vue 프로젝트에서 로그인 상태나 사용자 권한(role) 같은 데이터를 관리할 때, 모든 컴포넌트에서 매번 localStorage를 확인하고 토큰을 decode하는 방식은 번거롭고 오류가 생기기 쉽습니다.

이번 글에서는 Pinia를 사용해 전역 상태로 관리하고, JWT 토큰에서 role 추출 → 메뉴 권한 제어까지 구현하는 방법을 정리해보겠습니다.


1. 문제 상황

예를 들어, 관리자만 접근할 수 있는 메뉴가 있다고 가정해봅시다.

<li class="nav-item mb-2" v-if="isAdmin">
  <a class="nav-link" href="/manageBoard">관리 페이지</a>
</li>

매 페이지마다 localStorage에서 토큰을 읽고 decode해서 role을 확인하려면 코드가 반복되고 관리가 어렵습니다.

토큰이 refresh로 갱신될 때 화면 반영도 자동으로 되지 않습니다.


2. Pinia란?

Pinia는 Vue 3 공식 상태 관리 라이브러리입니다.

Vue 컴포넌트는 보통 자기 자신 내부의 상태만 관리하는데,
로그인 정보(토큰, 사용자 역할, 이름 등)처럼 앱 전역에서 필요한 데이터는 각 컴포넌트마다 props로 주고받기엔 불편하죠.

쉽게 말해, localStorage는 단순 저장소지만, Pinia store는 Vue가 반응형으로 바라보는 전역 상태 저장소(store) 라고 생각하면 됩니다.

왜 Pinia를 쓰냐?

  1. 중앙 집중 관리
    로그인 유저 정보(role, userId, isAdmin) 같은 걸 한 곳에서 관리 → 여러 컴포넌트에서 동일한 데이터 보장

  2. 반응성 유지
    store 값이 바뀌면(store.state.role 같은 거) → 그 값을 쓰는 모든 컴포넌트가 자동 업데이트됨

  3. 토큰 갱신 자동 반영
    지금 만든 apiRequest에서 새 토큰을 발급받으면 → store 안의 role도 자동 업데이트
    그럼 메뉴 같은 UI가 즉시 바뀌어요 (새로고침 필요 없음)

  4. Vue 3와 궁합 최고
    Vuex의 차세대 버전인데 훨씬 가볍고, TypeScript 지원도 잘됨

예시 상황

지금 관리자 페이지 메뉴를 관리자만 보이게 하고 싶은데?

  • Pinia store 없으면
    → 매 페이지마다 localStorage.getItem("accessToken") → jwtDecode() → role 뽑기 해야 해요(localStorage = 그냥 로컬에 저장된 값 (변경돼도 Vue는 모름))

  • Pinia store 쓰면
    → 로그인 시 한 번만 role을 저장해두고, userStore.role이나 userStore.isAdmin만 체크하면 끝!(Pinia store = Vue 반응형 데이터 창고 (바뀌면 화면도 자동 반영))

Pinia 없이(localStorage 직접) 구현

-> 모든 컴포넌트에서 role을 확인하려면 매번 decode 해야 함

<script setup>
import jwtDecode from "jwt-decode";

const token = localStorage.getItem("accessToken");
let role = null;

if (token) {
  try {
    const decoded = jwtDecode(token);
    role = decoded.role;
  } catch (e) {
    console.error("토큰 decode 실패", e);
  }
}
</script>

<template>
  <div>
    <a v-if="role === 'ADMIN'" href="/manageBoard">관리 페이지</a>
  </div>
</template>

문제점:

  • 모든 컴포넌트에서 중복 코드 (localStorage → jwtDecode → role)
  • 토큰이 refresh로 바뀌면 화면 자동 반영 X (새로고침 해야 반영됨)
  • 상태 동기화 어려움 (A 컴포넌트에서 로그인 했는데, B 컴포넌트는 아직 로그아웃 상태처럼 보이는 문제)

3. pnia Store 구성

stores/user.js로 유저 정보를 관리합니다.

import { defineStore } from "pinia";
import { jwtDecode } from "jwt-decode";

export const useUserStore = defineStore("user", {
  state: () => ({
    role: null,
    userId: null,
  }),
  getters: {
    isAdmin: (state) => state.role === "ADMIN",
    isLoggedIn: (state) => !!state.userId,
  },
  actions: {
    setToken(token) {
      localStorage.setItem("accessToken", token);
      const decoded = jwtDecode(token);
      this.role = decoded.role;
      this.userId = decoded.sub;
    },
    logout() {
      localStorage.clear();
      this.role = null;
      this.userId = null;
    }
  }
});
  • setToken()으로 JWT 토큰을 decode하여 role과 userId를 store에 저장
  • logout()으로 모든 상태와 localStorage 초기화

4. Pinia 앱 등록

main.js에서 Pinia를 Vue 앱에 등록합니다.

import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { createPinia } from "pinia";

const app = createApp(App);
const pinia = createPinia();

app.use(router);
app.use(pinia);  // 반드시 필요
app.mount('#app');

5. API 요청과 토큰 갱신 연동

apiRequest.js에서 AccessToken 만료 시 RefreshToken으로 갱신하고, 갱신된 토큰으로 store를 업데이트합니다.

import { useUserStore } from "@/stores/user";

export async function apiRequest(url, options = {}) {
  const userStore = useUserStore();
  let accessToken = localStorage.getItem("accessToken");
  const refreshToken = localStorage.getItem("refreshToken");

  options.headers = {
    ...options.headers,
    "Content-Type": "application/json",
    "Authorization": accessToken ? `Bearer ${accessToken}` : undefined
  };

  let response = await fetch(url, options);

  if (response.status === 401 || response.status === 403) {
    if (!refreshToken) {
      alert("로그인이 필요합니다.");
      userStore.logout();
      window.location.href = "/";
      return;
    }

    const refreshResponse = await fetch("/refresh", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refreshToken })
    });

    if (!refreshResponse.ok) {
      alert("세션 만료. 다시 로그인 해주세요.");
      userStore.logout();
      window.location.href = "/";
      return;
    }

    const data = await refreshResponse.json();
    localStorage.setItem("accessToken", data.accessToken);
    accessToken = data.accessToken;

    // Pinia store 업데이트
    userStore.setToken(accessToken);

    options.headers["Authorization"] = `Bearer ${accessToken}`;
    response = await fetch(url, options);
  }

  return response;
}
  • 토큰 갱신 시 store가 자동 업데이트 되므로, 화면은 새로고침 없이도 변경 사항이 반영됩니다.

6. 컴포넌트에서 role 확인

<script setup>
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
</script>

<template>
  <li class="nav-item mb-2" v-if="userStore.isAdmin">
    <a class="nav-link" href="/manageBoard">관리 페이지</a>
  </li>
</template>

이렇게 하면

  • 로그인 후 토큰 저장 → userStore에 role 자동 반영
  • 토큰이 만료돼서 갱신되더라도 → apiRequest가 새 토큰 decode 해서 userStore 갱신
  • 로그아웃(세션 만료) → userStore.clear()

즉, 모든 페이지에서 userStore.isAdmin만 보면 끝입니다.


7. 결론

  • localStorage만 사용: 코드 중복, 토큰 갱신 시 UI 반영 안됨
  • Pinia + store: 전역 상태 관리, 반응형 UI, 코드 재사용성 높음
  • 프로젝트가 클수록 Pinia를 사용하는 것이 훨씬 편하고 유지보수가 쉽습니다.
  • 로그인/권한 관리 같은 전역 상태는 store로 관리하는 것이 효율적입니다.
profile
낭만감자

0개의 댓글