- 전역 Auth Store (Zustand 권장)
user, status, etag(캐시 비교 API용도), permissions(혹은 권한 관련 data) 위주로 저장 - Rotue Guard
App 진입 혹은 Routing시에 ensurePermissions()
접근조건 확인이기 때문에 하위 Component에서는 호출금지. - 재검증 트리거
새로고침(F5)/탭 포커스/online 이벤트 → 지정 TTL 이상시 ensurePermissions()
서버에서 권한 변경(추가/삭제 등 수정 시): WebSocket/SSE Event → ensurePermissions({ force:true }) - Server
권한 API res에 ETag Header
req if-None-Match가 같을 시 304 Not Modifed
권한 수정 및 변경 시 ETang/버전 업
1. Zustand Store( ETag 재검증 + TTL) = Revalidation
// auth.store.ts
import { create } from 'zustand'
type Permission = { roles: string[]; grants: Record<string, boolean> }
type Status = 'idle' | 'loading' | 'ready' | 'error'
type AuthState = {
user?: { id: string; name: string }
permissions?: Permission
etag?: string
fetchedAt?: number
status: Status
error?: string
ensurePermissions: (opts?: { force?: boolean }) => Promise<Permission>
setUser: (u?: AuthState['user']) => void
clear: () => void
}
let inflight: Promise<Permission> | null = null
export const useAuthStore = create<AuthState>((set, get) => ({
status: 'idle',
setUser(u) { set({ user: u }) },
clear() { set({ user: undefined, permissions: undefined, etag: undefined, fetchedAt: undefined, status: 'idle', error: undefined }) },
async ensurePermissions({ force } = {}) {
const { permissions, etag, fetchedAt } = get()
const TTL = 5 * 60 * 1000
const fresh = fetchedAt && Date.now() - fetchedAt < TTL
if (!force && permissions && fresh) return permissions
if (inflight) return inflight
set({ status: 'loading', error: undefined })
inflight = (async () => {
const resp = await fetch('/api/auth/permissions', {
headers: etag ? { 'If-None-Match': etag } : {},
credentials: 'include',
})
if (resp.status === 304 && permissions) {
set({ status: 'ready', fetchedAt: Date.now() })
return permissions
}
if (!resp.ok) {
const msg = `HTTP ${resp.status}`
set({ status: 'error', error: msg }); throw new Error(msg)
}
const data: Permission = await resp.json()
const newEtag = resp.headers.get('ETag') ?? undefined
set({ permissions: data, etag: newEtag, fetchedAt: Date.now(), status: 'ready' })
return data
})()
try { return await inflight } finally { inflight = null }
},
}))
2. Route Guard (하위단 컴포터는에서는 호출 금지) = 최상에서 권한을 로드함
// AppRoutes.tsx
import { Outlet, Navigate } from 'react-router-dom'
import { useEffect } from 'react'
import { useAuthStore } from './auth.store'
function RequireAuth() {
const status = useAuthStore(s => s.status)
const ensure = useAuthStore(s => s.ensurePermissions)
useEffect(() => { ensure().catch(() => {}) }, [ensure])
if (status === 'idle' || status === 'loading') return <div>Loading...</div>
if (status === 'error') return <div>권한 정보 로딩 실패</div>
return <Outlet />
}
function RoleGuard({ need }: { need: string }) {
const grants = useAuthStore(s => s.permissions?.grants)
if (!grants?.[need]) return <Navigate to="/403" replace />
return <Outlet />
}
// 라우팅 예시
// <Route element={<RequireAuth/>}>
// <Route element={<RoleGuard need="ADMIN"/>}>
// <Route path="/admin" element={<AdminPage/>}/>
// </Route>
// </Route>
3. Revalidation Trigger(Optional)
// bootstrap.ts (앱 시작 시 1번만 세팅)
import { useAuthStore } from './auth.store'
window.addEventListener('focus', () => {
const { ensurePermissions } = useAuthStore.getState()
ensurePermissions().catch(() => {})
})
window.addEventListener('online', () => {
const { ensurePermissions } = useAuthStore.getState()
ensurePermissions().catch(() => {})
})
// 멀티탭 동기화
const ch = new BroadcastChannel('auth')
ch.onmessage = (e) => {
if (e.data === 'perm:changed') {
useAuthStore.getState().ensurePermissions({ force: true }).catch(() => {})
}
}
// 권한 변경을 유발한 탭에서는 ch.postMessage('perm:changed')
- 초기 깜박임 최소화
받아온 or 이전 권한 JSON을 sessionStorage에 저장하고 첫 혹은 초기 mount에 담은 후 Rendering → Back에서 esurePermissions()으로 확인 가능 [304/200] - Redux + Zustand
Auth는 Zustand로 사용 → 전역상태 및 비교가 얕기때문
나머지는 Redux 및 RTK 등등
Redis를 사용하면 캐시를 저장하고 비교하는 방법은 있다고 하는데,
많이 사용해보지 않아서 자세하는 모르겠다..