
Vue 3 + Pinia + Spring Security(JWT) 조합으로 인증을 구현할 때 핵심 정리
컴포넌트가 많아질수록 다음 문제가 터진다.
전역 상태 관리 라이브러리를 쓰면:
특히 인증(auth)은 대표적인 전역 상태다.
그래서 보통 “전역 상태 = 인증부터 Pinia에 태운다” 라고 생각하면 된다.
Vuex
Pinia
Vue 3 + Vite라면 그냥 Pinia를 기본값으로 두고 설계하는 게 자연스럽다.
npm install pinia
// main.js
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.mount('#app');
이렇게 해 두면 어디서든 useAuthStore(), useCartStore() 같은 형태로 스토어를 사용할 수 있다.
하나의 스토어 = 하나의 전역 상태 모듈.
예:
useAuthStore: 로그인 상태, 토큰, 사용자 정보useCartStore: 장바구니 목록, 합계 금액useUiStore: 모달 열림/닫힘, 테마 등각 스토어는 공통적으로 세 가지를 가진다.
Option Store
state, getters, actions를 옵션으로 나눠 작성Setup Store
ref, reactive, computed를 그대로 사용Composition API 기반 프로젝트라면 인증 스토어 같은 건 Setup Store로 두는 편이 자연스럽다.
전형적인 JWT 흐름:
Vue가 /api/v1/auth/login 으로 아이디/비밀번호 전송
Spring Security가 유저 검증
성공하면
이후 모든 보호된 API 요청에 Access Token을 Authorization: Bearer ... 로 실어서 보냄
서버가 매 요청마다 JWT 서명/유효기간을 검증해서 인증 처리
서버는 세션에 상태를 들고 있지 않고, 토큰만 보고 판단하므로 구조가 거의 stateless에 가깝다.
Access Token
Refresh Token
요약하면:
401 Unauthorized
“인증이 안 됐다”
토큰 없음, 만료, 위조 등의 상황
프런트에서는 보통:
403 Forbidden
“인증은 됐지만 권한이 없다”
일반 유저가 관리자 전용 API를 호출할 때 등
프런트에서는:
라우터 가드, UI 권한 분기 설계할 때 401/403 구분이 중요하다.
SecurityConfig에서 하는 일:
cors() 활성화 + corsConfigurationSource() 로 CORS 정책 지정
csrf().disable()
→ 토큰 기반 인증에서는 CSRF를 비활성화하고 대신 다른 방식을 쓴다
sessionManagement().sessionCreationPolicy(STATELESS)
→ 세션을 생성하지 않는 JWT 기반 구조
authorizeHttpRequests
/api/v1/users, /api/v1/auth/login, /api/v1/auth/refresh → permitAll()/api/v1/users/me → hasAuthority("USER")authenticated()addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
→ UsernamePasswordAuthenticationFilter 전에 JWT 필터를 태워서 매 요청마다 토큰 검증
exceptionHandling()
RestAuthenticationEntryPointRestAccessDeniedHandlercorsConfigurationSource() 에서:
setAllowedOrigins(List.of("http://localhost:5173"))setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"))setAllowedHeaders(List.of("Authorization", "Content-Type"))setAllowCredentials(true) → 쿠키(Refresh Token) 허용Vue 개발 서버(5173)에서 오는 요청만 허용하는 구조다.
이 경우 axios/fetch 에서 withCredentials: true 를 켜야 쿠키가 오간다.
POST /api/v1/auth/login 흐름:
authService.login(request) 로 토큰 두 개를 발급받는다.ResponseCookie로 만들어서 HttpOnly 쿠키로 내려보낸다.핵심 포인트:
POST /api/v1/auth/refresh 흐름:
@CookieValue("refreshToken") 로 쿠키에 담긴 Refresh Token을 읽는다.authService.refreshToken(refreshToken) 로 새 토큰 발급이렇게 하면 Refresh Token이 탈취되더라도 회전 전략으로 제어가 가능하다.
POST /api/v1/auth/logout 흐름:
authService.logout(username) 으로 Refresh Token을 무효화 (DB/Redis 등)대표적으로 이런 값들을 가진다.
accessTokenuser (id, email, role 등)loading (로그인/재발급 진행 중 여부)authErrorMessage (최근 인증 에러 메시지)initialized (스토어 초기 로딩이 끝났는지 여부)Refresh Token은 HttpOnly 쿠키에 숨기고, 프런트 스토어에는 올리지 않는 패턴을 가정할 수 있다.
isLoggedIn → !!accessTokenisAdmin → user?.role === 'ADMIN'displayName → 유저 이름/이메일 가공컴포넌트 쪽에서는 이런 식으로 쓴다.
const auth = useAuthStore();
if (auth.isLoggedIn) { ... }
if (auth.isAdmin) { ... }
핵심 동작:
login(credentials)
/auth/login 호출logout()
/auth/logout 호출 (선택)refreshTokens()
/auth/refresh 호출 (Refresh Token은 HttpOnly 쿠키에서 사용)loadFromStorage()
컴포넌트는 단순히 authStore.login(), authStore.logout()만 부르면 되고, 내부 디테일은 스토어에 숨겨 두는 구조가 깔끔하다.
localStorage
메모리(Pinia state만 사용)
실무에서는:
localStorage / sessionStorage
HttpOnly Cookie
실 서비스 기준으로는:
이 조합이 가장 많이 쓰이는 패턴이다.
역할:
Authorization: Bearer ... 헤더에 붙인다.이렇게 하면 개별 API 호출코드에서 헤더를 일일이 건드릴 필요가 없다.
주의할 점:
/auth/login, /auth/refresh 같은 엔드포인트에는 토큰을 안 붙이는게 자연스럽다.역할:
흐름 예시:
보호 API에서 401 발생
아직 한 번도 재발급을 시도하지 않았다면
/auth/refresh 호출재발급도 401/403이면
기억해야 할 포인트:
라우트 정의에 meta를 붙여서 페이지별 권한 요구사항을 표현할 수 있다.
예:
const routes = [
{
path: '/mypage',
component: MyPageView,
meta: { requiresAuth: true }
},
{
path: '/admin',
component: AdminView,
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: '/login',
component: LoginView,
meta: { guestOnly: true }
}
];
requiresAuth: true → 로그인 필수requiresAdmin: true → 로그인 + role=ADMINguestOnly: true → 로그인 안 한 사용자만 접근 가능전역 가드 개념:
라우트가 바뀔 때마다 실행
to.meta.requiresAuth 체크
authStore.isLoggedIn === false → /login으로 리다이렉트to.meta.requiresAdmin 체크
authStore.isAdmin === false → “권한 없음” 페이지로 리다이렉트to.meta.guestOnly 체크
/ 또는 /mypage 같은 곳으로 되돌리기이렇게 하면 “라우트 정의 = 권한 정보”, “beforeEach = 실제 체크 로직” 구조로 깔끔하게 분리된다.
Spring Security + JWT 로 백엔드 인증/인가 구조를 잡는다.
프런트(Vue 3 + Vite)에서는
authStore 를 만들어 accessToken, user, login/logout/refresh 로직을 모아 둔다.결국 구조를 한 줄로 줄이면 이렇게 된다.
Spring Security가 토큰을 발급/검증하고,
Pinia가 인증 상태를 기억하고,
Axios 인터셉터와 라우터 가드가
“언제 토큰을 붙이고, 언제 재발급하고, 어디로 보내 줄지”를 자동으로 관리한다.