Skip to content

필사 모드: React SPA 인증 실전 가이드 — 쿠키 vs 스토리지, Axios 인터셉터, XSS/CSRF 방어 완전 정복

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

> **📚 SSO 쿠키/JWT 인증 시리즈** > [Django 편](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-django) ← 현재: React 편 → [Next.js 편](/blog/architecture/2026-03-08-sso-cookie-jwt-auth-nextjs)

개요 — SPA 인증의 특수성

전통적인 Multi-Page Application(MPA)에서는 서버가 세션을 관리하고 매 요청마다 HTML을 렌더링하면서 인증 상태를 자연스럽게 유지했다. 서버 측 템플릿 엔진이 로그인 여부를 확인하고, 인증되지 않은 사용자는 로그인 페이지로 리다이렉트하면 그만이었다.

React 같은 Single-Page Application(SPA)은 근본적으로 다르다. 브라우저에서 JavaScript가 모든 화면 전환을 처리하고, 서버와는 API 호출로만 통신한다. 이 구조에서 인증은 다음과 같은 고유한 도전을 안고 있다.

- **상태의 이중성**: 서버의 세션/토큰 유효성과 클라이언트의 UI 상태가 분리되어 동기화 문제가 발생한다

- **페이지 새로고침**: SPA에서 새로고침하면 JavaScript 메모리가 초기화되므로 인증 상태를 어딘가에 영속적으로 저장해야 한다

- **토큰 저장 위치**: 저장소 선택이 곧 보안 모델을 결정한다. localStorage, sessionStorage, Cookie, 메모리 중 어디에 저장할 것인가가 XSS/CSRF 방어 전략 전체를 좌우한다

- **CORS 제약**: 프론트엔드와 백엔드가 다른 도메인에 배포되는 경우, 쿠키와 인증 헤더의 동작이 복잡해진다

이 글에서는 React SPA에서 인증을 구현할 때 알아야 할 모든 것을 다룬다. 저장소 비교, 인증 상태 관리 패턴, Axios 인터셉터, XSS/CSRF 방어, Protected Route, Silent Refresh까지 실전 TypeScript 코드와 함께 완전 정복하자.

브라우저 저장소별 접근 가능성 표

토큰을 어디에 저장할 것인가는 SPA 인증의 가장 중요한 설계 결정이다. 각 저장소의 특성을 정확히 이해해야 한다.

| 저장소 | JS 접근 | 서버 자동 전송 | XSS 취약 | CSRF 취약 |

| ---------------------- | ------- | -------------- | -------- | --------- |

| HttpOnly Cookie | ❌ | ✅ | ❌ | ✅ |

| Non-HttpOnly Cookie | ✅ | ✅ | ✅ | ✅ |

| localStorage | ✅ | ❌ | ✅ | ❌ |

| sessionStorage | ✅ | ❌ | ✅ | ❌ |

| Memory (JS 변수/state) | ✅ | ❌ | △ | ❌ |

각 방식의 보안 트레이드오프

**HttpOnly Cookie**는 JavaScript로 접근할 수 없어 XSS 공격에 가장 강하다. 브라우저가 매 요청마다 자동으로 쿠키를 전송하므로 프론트엔드에서 토큰을 직접 다룰 필요가 없다. 단, 자동 전송 특성 때문에 CSRF 공격에 취약하므로 CSRF 토큰이나 SameSite 속성으로 방어해야 한다.

**localStorage/sessionStorage**는 JavaScript로 자유롭게 접근할 수 있어 구현이 간편하다. 서버에 자동으로 전송되지 않아 CSRF에는 안전하지만, XSS 공격으로 탈취될 수 있다. sessionStorage는 탭을 닫으면 사라지지만, XSS에 노출된 동안에는 동일하게 위험하다.

**메모리(JS 변수/state)**는 새로고침 시 사라지므로 영속성이 없지만, XSS 공격으로 직접 접근하기가 가장 어렵다(불가능하지는 않다). CSRF에도 안전하다. 새로고침 문제는 Silent Refresh로 해결할 수 있다.

**권장 조합**: HttpOnly Cookie(Access Token 또는 Refresh Token) + CSRF 토큰 + SameSite=Lax/Strict

인증 상태 관리 패턴

React Context + useReducer

가장 기본적이면서도 효과적인 방법이다. 외부 라이브러리 없이 React 내장 기능만으로 구현한다.

// types/auth.ts

export interface User {

id: string

email: string

name: string

roles: string[]

}

export interface AuthState {

user: User | null

isAuthenticated: boolean

isLoading: boolean

}

export type AuthAction =

| { type: 'AUTH_START' }

| { type: 'AUTH_SUCCESS'; payload: User }

| { type: 'AUTH_FAILURE' }

| { type: 'LOGOUT' }

// context/AuthContext.tsx

const initialState: AuthState = {

user: null,

isAuthenticated: false,

isLoading: true, // 초기 로딩 상태

};

function authReducer(state: AuthState, action: AuthAction): AuthState {

switch (action.type) {

case 'AUTH_START':

return { ...state, isLoading: true };

case 'AUTH_SUCCESS':

return { user: action.payload, isAuthenticated: true, isLoading: false };

case 'AUTH_FAILURE':

return { user: null, isAuthenticated: false, isLoading: false };

case 'LOGOUT':

return { user: null, isAuthenticated: false, isLoading: false };

default:

return state;

}

}

interface AuthContextType extends AuthState {

login: (email: string, password: string) => Promise<void>;

logout: () => Promise<void>;

refreshUser: () => Promise<void>;

}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {

const [state, dispatch] = useReducer(authReducer, initialState);

// 앱 초기 로딩 시 인증 상태 확인

const refreshUser = useCallback(async () => {

dispatch({ type: 'AUTH_START' });

try {

const { data } = await apiClient.get<User>('/api/auth/me');

dispatch({ type: 'AUTH_SUCCESS', payload: data });

} catch {

dispatch({ type: 'AUTH_FAILURE' });

}

}, []);

useEffect(() => {

refreshUser();

}, [refreshUser]);

const login = async (email: string, password: string) => {

dispatch({ type: 'AUTH_START' });

try {

await apiClient.post('/api/auth/login', { email, password });

// 로그인 성공 후 사용자 정보 조회

const { data } = await apiClient.get<User>('/api/auth/me');

dispatch({ type: 'AUTH_SUCCESS', payload: data });

} catch (error) {

dispatch({ type: 'AUTH_FAILURE' });

throw error; // 컴포넌트에서 에러 핸들링

}

};

const logout = async () => {

try {

await apiClient.post('/api/auth/logout');

} finally {

dispatch({ type: 'LOGOUT' });

}

};

return (

{children}

);

}

export function useAuth(): AuthContextType {

const context = useContext(AuthContext);

if (!context) {

throw new Error('useAuth must be used within an AuthProvider');

}

return context;

}

Zustand 패턴 (간결한 대안)

Context + useReducer 대신 Zustand를 사용하면 boilerplate를 크게 줄일 수 있다.

// store/authStore.ts

interface AuthStore {

user: User | null

isAuthenticated: boolean

isLoading: boolean

login: (email: string, password: string) => Promise<void>

logout: () => Promise<void>

refreshUser: () => Promise<void>

}

export const useAuthStore = create<AuthStore>((set) => ({

user: null,

isAuthenticated: false,

isLoading: true,

login: async (email, password) => {

set({ isLoading: true })

await apiClient.post('/api/auth/login', { email, password })

const { data } = await apiClient.get<User>('/api/auth/me')

set({ user: data, isAuthenticated: true, isLoading: false })

},

logout: async () => {

await apiClient.post('/api/auth/logout')

set({ user: null, isAuthenticated: false, isLoading: false })

},

refreshUser: async () => {

try {

const { data } = await apiClient.get<User>('/api/auth/me')

set({ user: data, isAuthenticated: true, isLoading: false })

} catch {

set({ user: null, isAuthenticated: false, isLoading: false })

}

},

}))

토큰 저장 전략별 구현

방법 1: HttpOnly Cookie (권장)

서버가 `Set-Cookie` 헤더로 토큰을 설정하고, 프론트엔드는 토큰에 직접 접근하지 않는다. 가장 안전한 방법이다.

// 서버 응답 헤더 (Django/Express 등)

// Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900

// 프론트엔드에서는 withCredentials만 설정하면 된다

const apiClient = axios.create({

baseURL: process.env.REACT_APP_API_URL,

withCredentials: true, // 쿠키를 요청에 포함

})

// 로그인

const login = async (email: string, password: string) => {

// 서버가 Set-Cookie로 토큰을 설정해 준다

await apiClient.post('/api/auth/login', { email, password })

// 토큰을 직접 다루지 않는다! 서버에서 사용자 정보만 조회

const { data } = await apiClient.get('/api/auth/me')

return data

}

이 방식에서는 프론트엔드가 토큰 값을 절대 모른다. 사용자 정보가 필요하면 `/api/auth/me` 같은 엔드포인트로 서버에 질의한다.

방법 2: In-Memory (보안 우선)

Access Token을 JavaScript 변수에만 보관한다. XSS로 저장소를 뒤져도 토큰이 없다.

// lib/tokenManager.ts

let accessToken: string | null = null

export const tokenManager = {

getToken: (): string | null => accessToken,

setToken: (token: string): void => {

accessToken = token

},

clearToken: (): void => {

accessToken = null

},

}

// 새로고침 시 Refresh Token(HttpOnly Cookie)으로 Access Token 재발급

const silentRefresh = async (): Promise<string | null> => {

try {

const { data } = await axios.post(

'/api/auth/refresh',

{},

{ withCredentials: true } // Refresh Token은 HttpOnly Cookie

)

tokenManager.setToken(data.accessToken)

return data.accessToken

} catch {

tokenManager.clearToken()

return null

}

}

이 패턴에서는 Access Token은 메모리에, Refresh Token은 HttpOnly Cookie에 저장한다. 두 가지 장점을 결합하여 Access Token은 XSS에 안전하고, Refresh Token은 CSRF+SameSite로 보호된다.

방법 3: localStorage (간단하지만 주의)

가장 구현이 쉽지만, XSS에 취약하다는 것을 반드시 인지해야 한다.

// lib/tokenStorage.ts

const ACCESS_TOKEN_KEY = 'access_token'

export const tokenStorage = {

getToken: (): string | null => localStorage.getItem(ACCESS_TOKEN_KEY),

setToken: (token: string): void => {

localStorage.setItem(ACCESS_TOKEN_KEY, token)

},

clearToken: (): void => {

localStorage.removeItem(ACCESS_TOKEN_KEY)

},

}

**언제 localStorage가 괜찮은가?**

- 내부 어드민 도구 등 신뢰할 수 있는 환경

- 토큰 수명이 매우 짧고(5분 이하), Refresh Token은 HttpOnly Cookie에 저장하는 경우

- CSP가 엄격하게 설정되어 외부 스크립트 삽입이 사실상 불가능한 경우

**언제 localStorage를 피해야 하는가?**

- 사용자 입력을 HTML로 렌더링하는 서비스(게시판, 댓글, 위키 등)

- 서드파티 스크립트가 많이 삽입되는 페이지

- 높은 보안이 요구되는 금융/의료 서비스

Axios 인터셉터 설정

인증 요청의 핵심인 Axios 인터셉터를 설정한다. Request에서 토큰을 자동 삽입하고, Response에서 401을 감지하여 토큰을 갱신한다.

// lib/apiClient.ts

const apiClient = axios.create({

baseURL: process.env.REACT_APP_API_URL,

timeout: 10000,

withCredentials: true, // Cookie 방식일 때 필수

})

// ─── Request Interceptor ──────────────────────────────────

apiClient.interceptors.request.use(

(config: InternalAxiosRequestConfig) => {

// In-Memory 또는 localStorage 방식일 때 Authorization 헤더 추가

const token = tokenManager.getToken()

if (token && config.headers) {

config.headers.Authorization = `Bearer ${token}`

}

return config

},

(error) => Promise.reject(error)

)

// ─── Response Interceptor: 401 감지 → 토큰 리프레시 → 재시도 ───

let isRefreshing = false

let failedQueue: Array<{

resolve: (token: string | null) => void

reject: (error: unknown) => void

}> = []

const processQueue = (error: unknown, token: string | null = null) => {

failedQueue.forEach(({ resolve, reject }) => {

if (error) {

reject(error)

} else {

resolve(token)

}

})

failedQueue = []

}

apiClient.interceptors.response.use(

(response) => response,

async (error: AxiosError) => {

const originalRequest = error.config as InternalAxiosRequestConfig & {

_retry?: boolean

}

// 401이 아니거나, 이미 재시도한 요청이면 에러 전파

if (error.response?.status !== 401 || originalRequest._retry) {

return Promise.reject(error)

}

// 이미 리프레시 중이면 큐에 추가

if (isRefreshing) {

return new Promise((resolve, reject) => {

failedQueue.push({

resolve: (token) => {

if (token && originalRequest.headers) {

originalRequest.headers.Authorization = `Bearer ${token}`

}

resolve(apiClient(originalRequest))

},

reject,

})

})

}

originalRequest._retry = true

isRefreshing = true

try {

const { data } = await axios.post(

`${process.env.REACT_APP_API_URL}/api/auth/refresh`,

{},

{ withCredentials: true }

)

const newToken = data.accessToken

tokenManager.setToken(newToken)

processQueue(null, newToken)

if (originalRequest.headers) {

originalRequest.headers.Authorization = `Bearer ${newToken}`

}

return apiClient(originalRequest)

} catch (refreshError) {

processQueue(refreshError, null)

tokenManager.clearToken()

// 로그인 페이지로 리다이렉트

window.location.href = '/login'

return Promise.reject(refreshError)

} finally {

isRefreshing = false

}

}

)

export { apiClient }

fetch API 사용 시

Axios 대신 fetch를 사용하는 경우 `credentials: 'include'`를 설정한다.

const response = await fetch(`${API_URL}/api/auth/me`, {

method: 'GET',

credentials: 'include', // 쿠키 포함

headers: {

'Content-Type': 'application/json',

// In-Memory/localStorage 방식일 때

...(token ? { Authorization: `Bearer ${token}` } : {}),

},

})

로그인/로그아웃 플로우

로그인 컴포넌트

// components/LoginForm.tsx

export function LoginForm() {

const [email, setEmail] = useState('')

const [password, setPassword] = useState('')

const [error, setError] = useState<string | null>(null)

const [isSubmitting, setIsSubmitting] = useState(false)

const { login } = useAuth()

const navigate = useNavigate()

const location = useLocation()

// 로그인 전에 접근하려던 페이지가 있으면 그곳으로 리다이렉트

const from = (location.state as { from?: string })?.from || '/dashboard'

const handleSubmit = async (e: FormEvent) => {

e.preventDefault()

setError(null)

setIsSubmitting(true)

try {

await login(email, password)

navigate(from, { replace: true })

} catch (err) {

setError('이메일 또는 비밀번호가 올바르지 않습니다.')

} finally {

setIsSubmitting(false)

}

}

return (

id="email"

type="email"

value={email}

onChange={(e) => setEmail(e.target.value)}

required

autoComplete="email"

/>

id="password"

type="password"

value={password}

onChange={(e) => setPassword(e.target.value)}

required

autoComplete="current-password"

/>

{error && (

{error}

)}

{isSubmitting ? '로그인 중...' : '로그인'}

)

}

로그아웃 처리

// components/LogoutButton.tsx

export function LogoutButton() {

const { logout } = useAuth()

const navigate = useNavigate()

const handleLogout = async () => {

await logout()

// 로그아웃 후 서버에서 HttpOnly Cookie 삭제를 처리

// 클라이언트 상태는 AuthContext에서 초기화됨

navigate('/login', { replace: true })

}

return <button onClick={handleLogout}>로그아웃</button>

}

JWT 클레임 파싱 (프론트엔드)

Non-HttpOnly 토큰이나 서버에서 별도로 전달받은 토큰의 클레임을 읽어 UI에 표시할 수 있다. 단, **프론트에서 파싱한 클레임은 표시 용도일 뿐 인증/인가 판단에 사용해서는 안 된다**.

// lib/jwtUtils.ts

interface JwtPayload {

sub: string

email: string

name: string

roles: string[]

exp: number

iat: number

}

// jwt-decode 라이브러리 사용

export function parseToken(token: string): JwtPayload | null {

try {

return jwtDecode<JwtPayload>(token)

} catch {

return null

}

}

// 라이브러리 없이 직접 파싱

export function parseTokenManual(token: string): JwtPayload | null {

try {

const base64Url = token.split('.')[1]

const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')

const jsonPayload = decodeURIComponent(

atob(base64)

.split('')

.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))

.join('')

)

return JSON.parse(jsonPayload)

} catch {

return null

}

}

// 토큰 만료 여부 확인

export function isTokenExpired(token: string, bufferSeconds = 30): boolean {

const payload = parseToken(token)

if (!payload) return true

// 만료 bufferSeconds 전부터 만료로 간주

return payload.exp * 1000 < Date.now() + bufferSeconds * 1000

}

// 사용자 표시용 정보 추출

export function extractDisplayInfo(token: string) {

const payload = parseToken(token)

if (!payload) return null

return {

name: payload.name,

email: payload.email,

roles: payload.roles,

}

}

**프론트엔드 클레임 처리 원칙**:

1. **표시용(Display)**: 이름, 이메일, 프로필 이미지 URL 등은 UI에 표시하기 위해 파싱해도 된다

2. **인가용(Authorization)**: 역할, 권한 등은 **서버에서 검증**해야 한다. 프론트에서 `roles.includes('admin')`으로 UI를 분기하는 것은 UX 향상 목적이지 보안이 아니다

3. **만료 확인**: 프론트에서 만료 시간을 확인하여 미리 갱신을 트리거하는 것은 좋은 패턴이다

Protected Route 구현

인증이 필요한 페이지를 보호하는 라우트 컴포넌트를 구현한다.

// components/RequireAuth.tsx

interface RequireAuthProps {

children: React.ReactNode

requiredRoles?: string[]

}

export function RequireAuth({ children, requiredRoles }: RequireAuthProps) {

const { isAuthenticated, isLoading, user } = useAuth()

const location = useLocation()

// 초기 로딩 중에는 로딩 UI 표시

if (isLoading) {

return (

)

}

// 미인증 → 로그인 페이지로, 현재 경로 기억

if (!isAuthenticated) {

return <Navigate to="/login" state={{ from: location.pathname }} replace />

}

// 역할 기반 접근 제어

if (requiredRoles && user) {

const hasRequiredRole = requiredRoles.some((role) => user.roles.includes(role))

if (!hasRequiredRole) {

return <Navigate to="/unauthorized" replace />

}

}

return <>{children}</>

}

// App.tsx — 라우트 설정 예시

function App() {

return (

path="/dashboard"

element={

}

/>

path="/admin"

element={

}

/>

)

}

XSS 방어

Cross-Site Scripting(XSS) 공격은 SPA 인증의 가장 큰 위협이다. 공격자가 페이지에 악성 스크립트를 삽입하면, 토큰 탈취, 쿠키 접근, 키 입력 가로채기 등이 가능하다.

React의 기본 방어

React는 JSX에서 변수를 렌더링할 때 **자동으로 HTML escape** 처리한다.

// 안전 — React가 자동 escape

const userInput = '<script>alert("XSS")</script>'

return <div>{userInput}</div>

// 렌더링: &lt;script&gt;alert("XSS")&lt;/script&gt;

dangerouslySetInnerHTML 위험성

// 위험!! — 절대 사용자 입력을 직접 넣지 말 것

return <div dangerouslySetInnerHTML={{ __html: userInput }} />

// 반드시 사용해야 한다면 DOMPurify로 sanitize

const sanitized = DOMPurify.sanitize(userInput, {

ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],

ALLOWED_ATTR: [],

})

return <div dangerouslySetInnerHTML={{ __html: sanitized }} />

Content Security Policy (CSP)

<!-- public/index.html 또는 서버 응답 헤더 -->

http-equiv="Content-Security-Policy"

content="

default-src 'self';

script-src 'self';

style-src 'self' 'unsafe-inline';

img-src 'self' data: https:;

connect-src 'self' https://api.example.com;

"

/>

CSP를 설정하면 외부 도메인의 스크립트 로드, 인라인 스크립트 실행을 차단하여 XSS 공격의 영향을 크게 줄일 수 있다.

CSRF 방어

Cookie를 사용하면 브라우저가 요청에 자동으로 쿠키를 포함하므로 CSRF 공격에 취약하다.

SameSite Cookie 설정

Set-Cookie: access_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/

- `SameSite=Strict`: 외부 사이트에서의 모든 요청에 쿠키 미전송 (링크 클릭 포함)

- `SameSite=Lax`: GET 요청(링크 클릭)은 허용, POST/PUT/DELETE는 차단

- `SameSite=None; Secure`: 모든 교차 출처 요청에 쿠키 전송 (크로스 도메인 SSO 시)

Double Submit Cookie 패턴

// 서버가 CSRF 토큰을 Non-HttpOnly Cookie로 전달

// Set-Cookie: csrf_token=abc123; Path=/; SameSite=Lax (HttpOnly 아님!)

// 프론트엔드: 쿠키에서 CSRF 토큰 읽어서 헤더로 전송

function getCsrfToken(): string {

const match = document.cookie.match(/csrf_token=([^;]+)/)

return match ? match[1] : ''

}

// Axios interceptor에서 자동 추가

apiClient.interceptors.request.use((config) => {

const csrfToken = getCsrfToken()

if (csrfToken && config.headers) {

config.headers['X-CSRF-Token'] = csrfToken

}

return config

})

Django와 함께 사용할 때

Django는 `csrftoken` 쿠키를 기본 제공한다. React에서 이를 읽어 `X-CSRFToken` 헤더로 전송하면 된다.

// Django CSRF 연동

apiClient.interceptors.request.use((config) => {

const csrfToken = document.cookie

.split('; ')

.find((row) => row.startsWith('csrftoken='))

?.split('=')[1]

if (csrfToken && config.headers) {

config.headers['X-CSRFToken'] = csrfToken

}

return config

})

CORS 설정 주의사항

프론트엔드(예: `localhost:3000`)와 백엔드(예: `localhost:8000`)가 다른 도메인/포트에서 실행되면 CORS 정책이 적용된다.

credentials: 'include' 시 제약

// 프론트엔드

axios.defaults.withCredentials = true

// 또는

fetch(url, { credentials: 'include' })

이 설정을 사용하면 서버 측에서 **반드시** 다음을 준수해야 한다:

Django 예시 (django-cors-headers)

CORS_ALLOWED_ORIGINS = [

"http://localhost:3000", # 와일드카드 '*' 사용 불가!

]

CORS_ALLOW_CREDENTIALS = True

- `Access-Control-Allow-Origin: *`는 `credentials: include`와 함께 사용할 수 **없다**

- 반드시 구체적인 도메인을 지정해야 한다

- Preflight(OPTIONS) 요청도 같은 헤더를 반환해야 한다

개발 환경 프록시 설정

CORS 문제를 개발 단계에서 우회하는 가장 간단한 방법은 프록시다.

// package.json (Create React App)

{

"proxy": "http://localhost:8000"

}

// vite.config.ts (Vite)

export default defineConfig({

server: {

proxy: {

'/api': {

target: 'http://localhost:8000',

changeOrigin: true,

},

},

},

})

프록시를 사용하면 프론트엔드와 백엔드가 같은 origin으로 동작하므로 CORS와 쿠키 문제가 해결된다. 단, 프로덕션에서는 Nginx 등 리버스 프록시로 동일한 효과를 구현해야 한다.

Silent Refresh / Token Rotation

Access Token이 만료되었을 때 사용자 경험을 해치지 않고 새 토큰을 발급받는 방법이다.

Refresh Token Rotation 패턴

Refresh Token을 사용할 때마다 새로운 Refresh Token을 발급하고, 이전 토큰은 무효화한다. 토큰이 탈취되더라도 한 번만 사용 가능하므로 피해를 최소화할 수 있다.

// lib/silentRefresh.ts

let refreshTimer: ReturnType<typeof setTimeout> | null = null

export function scheduleRefresh(expiresInMs: number) {

// 만료 1분 전에 갱신

const refreshTime = expiresInMs - 60 * 1000

if (refreshTimer) {

clearTimeout(refreshTimer)

}

if (refreshTime <= 0) {

// 이미 만료 임박 → 즉시 갱신

performRefresh()

return

}

refreshTimer = setTimeout(performRefresh, refreshTime)

}

async function performRefresh() {

try {

const { data } = await apiClient.post('/api/auth/refresh')

tokenManager.setToken(data.accessToken)

// 새 토큰의 만료 시간으로 다음 갱신 스케줄

scheduleRefresh(data.expiresIn * 1000)

} catch {

tokenManager.clearToken()

window.location.href = '/login'

}

}

export function cancelRefresh() {

if (refreshTimer) {

clearTimeout(refreshTimer)

refreshTimer = null

}

}

setInterval vs 요청 시점 갱신

| 전략 | 장점 | 단점 |

| ------------------------ | ------------------------------- | -------------------------------------------- |

| setInterval / setTimeout | 만료 전 미리 갱신, UX 끊김 없음 | 비활성 탭에서도 실행, 불필요한 네트워크 요청 |

| 요청 시점 확인 | 필요할 때만 갱신, 효율적 | 갱신 중 약간의 딜레이 발생 가능 |

실전에서는 **Axios 인터셉터에서 요청 시점에 만료를 확인**하고, **타이머로 미리 갱신하는 하이브리드 전략**을 권장한다. 위의 Axios 인터셉터 코드가 요청 시점 갱신을, `scheduleRefresh`가 타이머 기반 갱신을 담당한다.

체크리스트

React SPA 인증 구현 시 반드시 확인해야 할 항목들이다.

- [ ] **토큰 저장 위치 결정**: HttpOnly Cookie(권장) 또는 In-Memory + HttpOnly Refresh Cookie

- [ ] **withCredentials / credentials 설정**: Cookie 방식 사용 시 모든 API 요청에 설정

- [ ] **Axios 인터셉터 구현**: 401 감지 → 토큰 갱신 → 재시도, Race condition 방지

- [ ] **AuthProvider 구현**: 앱 초기 로딩 시 인증 상태 확인 (`/api/auth/me`)

- [ ] **Protected Route 구현**: 미인증 → 리다이렉트, 역할 기반 접근 제어

- [ ] **CSRF 토큰 처리**: Cookie 사용 시 Double Submit Cookie 또는 SameSite 설정

- [ ] **CSP 설정**: script-src, connect-src 등 엄격한 정책 적용

- [ ] **dangerouslySetInnerHTML 감사**: 사용하는 곳마다 DOMPurify 적용 여부 확인

- [ ] **CORS 설정 확인**: credentials + 구체적 도메인, preflight 응답 확인

- [ ] **Silent Refresh 구현**: 토큰 만료 전 자동 갱신, Refresh Token Rotation

- [ ] **로그아웃 처리**: 서버 + 클라이언트 양쪽 상태 초기화, Cookie 삭제 API

- [ ] **에러 핸들링**: 네트워크 오류, 토큰 갱신 실패 시 사용자 안내

흔한 버그와 오해

1. "JWT를 localStorage에 넣으면 절대 안 된다" — 진짜?

이 주장은 과장이다. localStorage의 JWT는 XSS에 취약하지만, XSS가 발생하면 HttpOnly Cookie 방식이라도 공격자는 사용자 세션으로 API를 호출할 수 있다(토큰을 직접 탈취하지 않더라도). **핵심은 XSS를 방지하는 것**이지, 저장 위치만으로 보안이 완성되는 것이 아니다. 그렇다고 해도 HttpOnly Cookie가 방어 심층에서 한 단계 더 보호해 주는 것은 사실이므로 권장된다.

2. 토큰 만료 체크를 프론트에서만 하기

프론트에서 `exp` 클레임을 확인하여 만료된 토큰을 보내지 않는 것은 UX 최적화다. 하지만 **서버는 반드시 독립적으로 토큰 유효성을 검증**해야 한다. 프론트의 시계가 틀릴 수 있고, 토큰이 서버 측에서 revoke되었을 수 있다.

3. withCredentials를 설정했는데 쿠키가 안 간다

- CORS 응답에 `Access-Control-Allow-Credentials: true`가 없는 경우

- `Access-Control-Allow-Origin: *`를 사용한 경우 (credentials와 와일드카드는 공존 불가)

- `SameSite=Strict`인데 다른 도메인에서 요청하는 경우

- `Secure` 플래그인데 HTTP(비 HTTPS)로 요청하는 경우

- 개발 환경에서 localhost와 127.0.0.1을 혼용하는 경우

4. XSS와 CSRF 혼동

| | XSS | CSRF |

| -------------- | --------------------------------------- | ------------------------------------ |

| 공격 방법 | 악성 스크립트 삽입 | 인증된 사용자의 브라우저로 위조 요청 |

| 방어 | 입력 sanitize, CSP, HttpOnly Cookie | CSRF 토큰, SameSite Cookie |

| 토큰 저장 관련 | localStorage 취약, HttpOnly Cookie 안전 | Cookie 전체 취약, localStorage 안전 |

이 둘은 **서로 다른 공격 벡터**이며, 둘 다 동시에 방어해야 한다.

5. Refresh Token을 Access Token처럼 매 요청에 보내기

Refresh Token은 **토큰 갱신 엔드포인트에만** 전송해야 한다. 모든 API 요청에 Refresh Token을 보내면 공격 표면이 넓어진다. 쿠키의 `Path` 속성을 `/api/auth/refresh`로 제한하여 이를 강제할 수 있다.

6. 로그아웃 시 클라이언트 상태만 지우기

클라이언트에서 토큰을 삭제해도 서버에서는 여전히 유효하다(JWT는 stateless). 서버에 로그아웃 API를 호출하여 Refresh Token을 무효화(revoke)해야 완전한 로그아웃이 된다.

7. 초기 로딩 시 isLoading 처리 누락

AuthProvider가 `/api/auth/me`를 호출하여 인증 상태를 확인하는 동안, `isLoading`이 true여야 한다. 이를 누락하면 미인증 상태로 잠깐 렌더링되었다가 인증 후 깜빡이는 현상이 발생한다. Protected Route에서 isLoading 상태를 반드시 처리하자.

참고자료

- [React 공식 문서 — Context](https://react.dev/learn/passing-data-deeply-with-context)

- [OWASP — Cross-Site Scripting (XSS) Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Scripting_Prevention_Cheat_Sheet.html)

- [OWASP — Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)

- [OWASP — JSON Web Token Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)

- [RFC 7519 — JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)

- [MDN — HTTP cookies](https://developer.mozilla.org/ko/docs/Web/HTTP/Cookies)

- [MDN — SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value)

- [Axios 공식 문서 — Interceptors](https://axios-http.com/docs/interceptors)

- [jwt.io — JWT 디버거](https://jwt.io/)

- [Auth0 Blog — Token Storage in Single Page Applications](https://auth0.com/docs/secure/security-guidance/data-security/token-storage)

- [hasura.io — The Ultimate Guide to handling JWTs on frontend clients](https://hasura.io/blog/best-practices-of-using-jwt-with-graphql/)

- [DOMPurify — XSS sanitizer](https://github.com/cure53/DOMPurify)

- [React Router v6 — Authentication](https://reactrouter.com/en/main/start/concepts)

현재 단락 (1/622)

전통적인 Multi-Page Application(MPA)에서는 서버가 세션을 관리하고 매 요청마다 HTML을 렌더링하면서 인증 상태를 자연스럽게 유지했다. 서버 측 템플릿 엔진이...

작성 글자: 0원문 글자: 19,309작성 단락: 0/622