인증 토큰 관리
14.4.1. JWT 저장 전략
- Access Token은 메모리(Pinia Store) 에 저장합니다.
localStorage는 XSS (Cross-Site Scripting) 공격에 취약하므로 사용하지 않습니다. - Refresh Token은 HttpOnly 쿠키로 관리합니다. 클라이언트 JavaScript에서 접근할 수 없어야 합니다.
| 토큰 유형 | 저장 위치 | 근거 |
|---|---|---|
| Access Token | Pinia Store (메모리) | XSS 공격 시 탈취 방지 |
| Refresh Token | HttpOnly 쿠키 | JavaScript 접근 차단 |
- Auth Store(
src/stores/useAuthStore.ts)에accessToken상태와setAccessToken,clearAccessToken액션을 정의합니다. 페이지 새로고침 시 메모리의 토큰이 유실되므로, 앱 초기화 시점에 Refresh Token을 이용하여 재발급받아야 합니다.
14.4.2. 토큰 갱신
- 401 응답 수신 시 Refresh Token을 이용하여 Access Token 갱신을 자동으로 시도합니다.
- 갱신 요청 중 발생하는 추가 요청은 큐에 저장하고, 갱신 완료 후 순차적으로 재시도합니다.
- 갱신 요청 자체가 실패하면 큐에 저장된 모든 요청을 거부하고 로그아웃 처리합니다.
typescript
// src/api/interceptors/tokenRefresh.ts
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import apiClient from '@/api/client'
import { useAuthStore } from '@/stores/useAuthStore'
let isRefreshing = false
let pendingRequests: Array<{ resolve: Function; reject: Function }> = []
export async function handleTokenRefresh(error: AxiosError): Promise<unknown> {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
if (error.response?.status !== 401 || originalRequest._retry) {
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingRequests.push({ resolve, reject })
})
}
originalRequest._retry = true
isRefreshing = true
try {
const { data } = await apiClient.post<{ accessToken: string }>('/auth/refresh')
useAuthStore().setAccessToken(data.accessToken)
pendingRequests.forEach(({ resolve }) => resolve(apiClient(originalRequest)))
pendingRequests = []
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
return apiClient(originalRequest)
} catch (refreshError) {
pendingRequests.forEach(({ reject }) => reject(refreshError))
pendingRequests = []
useAuthStore().clearAccessToken()
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}14.4.3. 401 자동 리다이렉트
- 토큰 갱신이 실패하면 사용자를 로그인 페이지로 리다이렉트합니다.
- 리다이렉트 시 현재 페이지 경로를
returnUrl쿼리 파라미터로 보존합니다. returnUrl은 반드시/로 시작하는 경로인지 검증합니다. 외부 URL로의 리다이렉트를 방지해야 합니다.
typescript
// src/api/utils/redirect.ts
import router from '@/router'
export function redirectToLogin(): void {
const currentPath = router.currentRoute.value.fullPath
if (currentPath === '/login') return
router.push({ path: '/login', query: { returnUrl: currentPath } })
}- 로그인 성공 후
returnUrl쿼리를 읽어 해당 경로로 복귀합니다.returnUrl이/로 시작하는지 반드시 검증합니다.
14.4.4. 로그아웃 처리
- 로그아웃 시 다음 항목을 순차적으로 처리합니다.
| 순서 | 처리 항목 | 실패 시 동작 |
|---|---|---|
| 1 | 서버 로그아웃 API 호출 | 무시하고 다음 단계 진행 |
| 2 | Access Token 삭제 | 필수 (항상 실행) |
| 3 | 사용자 Store 초기화 | 필수 (항상 실행) |
| 4 | 로그인 페이지 리다이렉트 | 필수 (항상 실행) |
typescript
// src/composables/useLogout.ts
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/useAuthStore'
import { useUserStore } from '@/stores/useUserStore'
import apiClient from '@/api/client'
export function useLogout() {
const router = useRouter()
async function logout(): Promise<void> {
try { await apiClient.post('/auth/logout') } catch { /* 실패 무시 */ }
useAuthStore().clearAccessToken()
useUserStore().$reset()
await router.push('/login')
}
return { logout }
}- 서버 로그아웃 API 실패 시에도 클라이언트 측 정리 작업은 반드시 완료해야 합니다.