다크 모드
Chapter 11. 라우터 네비게이션 가드를 활용한 인가(Authorization) AOP
"4.5장에서 맛본 라우터에 관문(Gateway)을 세우자. 로그인하지 않은 사용자의 글쓰기 화면 진입을
<ProtectedRoute>래퍼 한 곳에서 가로채고, 로그인 후에는 원래 목적지(returnUrl)로 안전하게 돌려보내자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다. Routes, Route, Link, useNavigate, useLocation 같은 라우터 기초는 4.5장에서 다뤘습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| AOP(횡단 관심사)와 라우트 래퍼 패턴 | 여러 화면에 공통으로 필요한 인증·권한 검사를 한 컴포넌트로 모으기 | 11.1 |
인증 스토어 useAuthStore | JWT 토큰·사용자명·권한(Role)을 관리하는 Zustand 스토어 | 11.2 [코드 11-1] |
<ProtectedRoute> & <Outlet /> | 조건 충족 시 자식 라우트를 그리고, 미인증 시 <Navigate>로 튕겨내는 관문 | 11.3 🆕 박스 |
returnUrl 보존과 replace 이동 | 원래 가려던 경로를 쿼리/상태로 넘기고 히스토리 오염 없이 복귀 | 11.3~11.4 |
| 오픈 리다이렉트(Open Redirect) 방어 | ?returnUrl=//evil.com 같은 외부 피싱 URL 탈취 공격 차단 | 11.4 [코드 11-4] |
지연 로딩 React.lazy & <Suspense> | 화면 JS 청크를 진입 시점에 분리 다운로드하여 초기 로딩 속도 개선 | 11.3 [코드 11-3] |
TIP
🏁 이 장의 시작 파일frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch10.tsx와 같은 상태에서 시작합니다(막혔다면 그 파일을 복사해 오십시오). 이 장은 BoardApp.tsx를 고치지 않습니다. 이 장에서 손대는 파일: frontend/src/stores/useAuthStore.ts, frontend/src/router/ProtectedRoute.tsx, frontend/src/router/index.tsx, frontend/src/views/LoginPage.tsx (및 호환용 LoginView.tsx)
11.1 왜 라우터 관문 한 곳에서 막아야 하는가?
실제 사내 포털은 URL 경로마다 화면과 보호 등급을 나눕니다:
/: 게시판 목록 (BoardApp.tsx— 전체 공개)/write: 새 글 작성 (로그인 필수,USER또는ADMIN)/admin: 사내 게시판 관리자 설정 (관리자 전용,ADMIN만 허용)/login: 사내 통합 로그인 화면 (LoginPage.tsx)
もし 각 화면 컴포넌트마다 useEffect 안에서 if (!isLoggedIn) navigate('/login')을 복붙한다면 두 가지 심각한 문제가 생깁니다:
- 보호할 페이지가 30개로 늘어나면 30군데에 똑같은 조건문을 복붙해야 합니다.
useEffect는 첫 렌더링이 화면에 그려진 직후에 실행되므로, 비로그인 사용자에게도 보호된 화면이 0.1초 동안 깜빡이며 노출된 뒤에야 로그인 페이지로 튕겨 나갑니다!
따라서 렌더링 단계에서 아예 진입을 가로채는 선언적 관문 컴포넌트인 **<ProtectedRoute>**를 둡니다. 백엔드 Spring의 HandlerInterceptor나 SecurityFilterChain과 똑같은 역할입니다.
IMPORTANT
프론트엔드 라우터 가드는 UX 장치이고, 진짜 인가는 백엔드의 몫입니다.
브라우저의 자바스크립트는 개발자 도구로 누구나 우회할 수 있습니다. 가드의 목적은 "권한 없는 사용자를 친절하게 로그인 화면으로 안내하는 것"이며, POST /api/posts 같은 실제 API 요청의 인증·인가 검증은 반드시 Spring Security / JWT 필터에서 강제해야 합니다.
🏬 비유로 이해하는 라우터 가드 vs 백엔드 보안
- 프론트엔드 라우터 가드 (
<ProtectedRoute>): 백화점 VIP 라운지 입구의 친절한 안내원입니다. "고객님, 죄송하지만 VIP 카드가 없으시면 일반 매장으로 안내해 드릴게요~" 하고 상냥하게 방향을 틀어줍니다(UX 개선). - 백엔드 보안 필터 (Spring Security): 은행 금고 앞을 지키는 무장 경비원입니다. 만약 어떤 해커가 안내원을 밀치거나 창문으로 침입(자바스크립트 변조)하더라도, 금고 문 앞에서 신분증(JWT)이 없으면 그 자리에서
401 Unauthorized/403 Forbidden으로 차단해 서버를 지킵니다.
11.2 [실습 1] 인증 스토어(useAuthStore.ts) 구축
먼저 로그인 상태(token, username)와 권한(role: 'USER' | 'ADMIN')을 관리하는 Zustand 스토어를 작성합니다.
[코드 11-1] 인증 스토어 (frontend/src/stores/useAuthStore.ts)
typescript
// frontend/src/stores/useAuthStore.ts
import { create } from 'zustand';
export type UserRole = 'USER' | 'ADMIN';
export interface AuthState {
token: string | null;
username: string | null;
role: UserRole;
isLoggedIn: boolean;
login: (inputUser: string, password?: string) => Promise<void>;
logout: () => void;
}
function getSafeStorageItem(key: string): string | null {
if (typeof localStorage === 'undefined') return null;
return localStorage.getItem(key);
}
export const useAuthStore = create<AuthState>()((set) => {
const initialToken = getSafeStorageItem('ACCESS_TOKEN');
const initialUser = getSafeStorageItem('USERNAME');
const initialRole = (getSafeStorageItem('USER_ROLE') as UserRole) || 'USER';
return {
token: initialToken,
username: initialUser,
role: initialRole,
isLoggedIn: Boolean(initialToken),
login: async (inputUser: string, _password?: string) => {
// 실습용 모의 JWT 발급 (12장에서 실제 HTTP 인터셉터와 연동)
const trimmedUser = inputUser.trim();
const mockJwt = `mock-jwt-token-for-${trimmedUser}`;
const assignedRole: UserRole = trimmedUser === 'admin' ? 'ADMIN' : 'USER';
if (typeof localStorage !== 'undefined') {
localStorage.setItem('ACCESS_TOKEN', mockJwt);
localStorage.setItem('USERNAME', trimmedUser);
localStorage.setItem('USER_ROLE', assignedRole);
}
set({
token: mockJwt,
username: trimmedUser,
role: assignedRole,
isLoggedIn: true
});
},
logout: () => {
// 🚨 중요: localStorage.clear()를 부르면 10장의 임시저장 초안(BOARD_APP_DRAFT_STORAGE)까지 날아갑니다!
// 반드시 인증 관련 키만 선택적으로 제거합니다.
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('ACCESS_TOKEN');
localStorage.removeItem('REFRESH_TOKEN');
localStorage.removeItem('USERNAME');
localStorage.removeItem('USER_ROLE');
}
set({
token: null,
username: null,
role: 'USER',
isLoggedIn: false
});
}
};
});11.3 [실습 2] React Router v7 <ProtectedRoute> 래퍼 컴포넌트와 라우트 설정
React Router v6.4+ 및 v7에서 인증/인가 가드를 구현하는 가장 깔끔한 표준은 <ProtectedRoute> 컴포넌트입니다. 자식 컴포넌트를 children으로 감싸는 방식과 중첩 라우트 <Outlet /> 방식 모두를 지원하도록 작성합니다.
🆕 처음 보는 문법: <Navigate replace />와 <Outlet />
<Navigate to={...} replace />: 렌더링되는 즉시 지정된 주소로 리다이렉트하는 컴포넌트입니다.useEffect안의navigate()와 달리 자식 화면을 단 1프레임도 그리지 않고 즉시 이동시킵니다.replace속성을 주면 브라우저 히스토리 스택에서 현재 진입 시도 기록을 덮어써서, 뒤로 가기를 눌렀을 때 무한 루프에 빠지지 않게 합니다.<Outlet />: 부모 라우트 안에 매칭된 자식<Route>가 그려질 자리표시자입니다(Vue Router의<RouterView />와 동일).encodeURIComponent(returnUrl): 원래 가려던 경로가/write?category=DEV&draft=1처럼 쿼리스트링을 포함하고 있을 때,?나&가 바깥 URL과 섞이지 않도록 안전하게 인코딩해?returnUrl=%2Fwrite%3F...형태로 전달합니다.
[코드 11-2] 인가 관문 컴포넌트 (frontend/src/router/ProtectedRoute.tsx)
tsx
// frontend/src/router/ProtectedRoute.tsx
import React from 'react';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuthStore, type UserRole } from '@/stores/useAuthStore';
export interface ProtectedRouteProps {
// 허용할 권한 목록 (지정하지 않으면 로그인한 모든 사용자 허용)
allowedRoles?: UserRole[];
// <ProtectedRoute><MyPage /></ProtectedRoute> 처럼 직접 감쌀 때 사용
children?: React.ReactNode;
}
export function ProtectedRoute({ allowedRoles, children }: ProtectedRouteProps) {
const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
const role = useAuthStore((s) => s.role);
const location = useLocation();
// 1. 인증(Authentication) 검사: 미로그인 시 원래 목적지(pathname + search)를 returnUrl로 담아 로그인 화면으로 이동
if (!isLoggedIn) {
const returnUrl = `${location.pathname}${location.search}`;
console.warn(`🚨 [AOP Guard] 미인증 사용자의 보호 구역 진입 차단: ${returnUrl}`);
return (
<Navigate
to={`/login?returnUrl=${encodeURIComponent(returnUrl)}`}
state={{ from: returnUrl }}
replace
/>
);
}
// 2. 인가(Authorization) 검사: 역할(Role) 권한 부족 시 차단
if (allowedRoles && !allowedRoles.includes(role)) {
alert('해당 메뉴에 접근할 권한이 없습니다.');
return <Navigate to="/" replace />;
}
// 3. 통과! children이 있으면 children을, 중첩 라우트면 <Outlet />을 렌더링
return children ? <>{children}</> : <Outlet />;
}[코드 11-3] 라우터 구성 및 지연 로딩 (frontend/src/router/index.tsx)
tsx
// frontend/src/router/index.tsx
import React, { Suspense, lazy } from 'react';
import { Routes, Route } from 'react-router-dom';
import BoardApp from '@/BoardApp';
import { ProtectedRoute } from './ProtectedRoute';
// 🌟 지연 로딩(Lazy Loading): /login에 처음 진입할 때만 로그인 화면 JS 청크를 다운로드
const LoginPage = lazy(() => import('@/views/LoginPage'));
export function AppRouter() {
return (
<Suspense fallback={<div className="page-loading">화면을 불러오는 중입니다...</div>}>
<Routes>
{/* 1. 누구나 접근 가능한 공개 라우트 */}
<Route path="/" element={<BoardApp />} />
<Route path="/login" element={<LoginPage />} />
{/* 2. 로그인 필수 보호 구역 (USER, ADMIN 모두 허용) */}
<Route
path="/write"
element={
<ProtectedRoute allowedRoles={['USER', 'ADMIN']}>
<BoardApp />
</ProtectedRoute>
}
/>
{/* 3. 관리자 전용 보호 구역 예시 (ADMIN만 허용) */}
<Route element={<ProtectedRoute allowedRoles={['ADMIN']} />}>
<Route path="/admin" element={<div className="admin-panel">🔒 사내 관리자 전용 설정 화면</div>} />
</Route>
</Routes>
</Suspense>
);
}11.4 [실습 3] 로그인 화면(LoginPage.tsx)과 안전한 returnUrl 복귀 (Open Redirect 방어)
가드가 /login?returnUrl=%2Fwrite로 사용자를 보내면, 로그인 화면은 인증 성공 직후 returnUrl을 읽어 원래 가려던 /write로 돌려보내야 합니다.
이때 해커가 피싱 메일에 https://intranet.company.com/login?returnUrl=https://evil-phishing.com이나 ?returnUrl=//evil.com 같은 링크를 심어 두면, 사우가 로그인하자마자 악성 사이트로 이동해 버리는 오픈 리다이렉트(Open Redirect) 보안 취약점이 발생합니다! 따라서 반드시 내부 상대 경로인지 검증하는 getSafeReturnUrl 함수를 거쳐야 합니다.
[코드 11-4] 로그인 페이지 (frontend/src/views/LoginPage.tsx & LoginView.tsx)
tsx
// frontend/src/views/LoginPage.tsx
import React, { useState } from 'react';
import { useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/useAuthStore';
/**
* 🌟 오픈 리다이렉트(Open Redirect) 방어 함수
* 외부 도메인(http://, https://, //evil.com, /\evil.com)이나 /login 자체로의 무한 루프를 차단하고
* 안전한 앱 내부 상대 경로만 허용합니다.
*/
export function getSafeReturnUrl(rawUrl: unknown): string {
if (
typeof rawUrl === 'string' &&
rawUrl.startsWith('/') &&
!rawUrl.startsWith('//') &&
!rawUrl.startsWith('/\\') &&
!rawUrl.startsWith('/login')
) {
return rawUrl;
}
return '/';
}
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [searchParams] = useSearchParams();
const location = useLocation();
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const isExpired = searchParams.get('expired') === 'true';
async function handleLoginSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!username.trim()) return;
await login(username, password);
// 1순위: URL 쿼리 파라미터(?returnUrl=...) / 2순위: location.state.from
const queryReturnUrl = searchParams.get('returnUrl') ?? searchParams.get('redirect');
const stateReturnUrl = (location.state as { from?: string } | null)?.from;
const targetPath = getSafeReturnUrl(queryReturnUrl ?? stateReturnUrl);
// 🌟 replace: true로 이동해 뒤로 가기 시 다시 로그인 화면이 나오지 않게 처리!
navigate(targetPath, { replace: true });
}
return (
<div className="login-card">
<h2>🔐 사내 통합 로그인</h2>
{isExpired && (
<p className="expired-notice" role="alert">
세션이 만료되었습니다. 다시 로그인해 주세요.
</p>
)}
<form onSubmit={handleLoginSubmit} className="login-form">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="아이디 (admin 입력 시 ADMIN 권한 부여)"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호 (실습용: 아무 값이나 입력)"
required
/>
<button type="submit" className="btn-primary">
로그인
</button>
</form>
</div>
);
}tsx
// frontend/src/views/LoginView.tsx (기존 import 호환용 별칭 파일)
export { default, getSafeReturnUrl } from './LoginPage';💡 [확인하기 - 가드 차단과
returnUrl복귀 테스트]:
- 개발자 도구 → Application → Local Storage에서
ACCESS_TOKEN이 없다면(있으면 삭제) 주소창에http://localhost:3000/write를 입력합니다.- 화면 깜빡임 없이 즉시
/login?returnUrl=%2Fwrite로 리다이렉트되며 콘솔에🚨 [AOP Guard]경고가 찍힙니다.- 아이디에
kim, 비밀번호에 아무 값이나 입력하고 로그인하면 원래 가려던/write로 자동 복귀합니다! 이때 브라우저[뒤로 가기]를 눌러도 로그인 화면으로 돌아가지 않습니다(replace: true효과).- 주소창에
http://localhost:3000/login?returnUrl=//evil.com을 직접 입력하고 로그인해 보십시오. 외부 사이트로 튕기지 않고 안전하게 홈(/)으로 이동합니다.
11.5 [직접 해보기] 실습 미션 💪
🎯 미션: getSafeReturnUrl 보안 검증 함수를 단위 테스트로 증명하기
악성 해커가 시도할 수 있는 4가지 공격 패턴(https://evil.com, //evil.com, /\evil.com, /login?returnUrl=/)은 모두 '/'로 차단하고, 정상 내부 경로('/write?category=DEV')는 그대로 통과시키는지 검증하는 Vitest 테스트 코드를 작성해 보십시오.
💡 미션 정답 코드 확인하기
typescript
// frontend/tests/unit/getSafeReturnUrl.test.ts
import { describe, it, expect } from 'vitest';
import { getSafeReturnUrl } from '@/views/LoginPage';
describe('getSafeReturnUrl 오픈 리다이렉트 방어 검증', () => {
it('안전한 내부 상대 경로(쿼리 포함)는 그대로 반환한다', () => {
expect(getSafeReturnUrl('/write?category=DEV')).toBe('/write?category=DEV');
});
it('외부 절대 URL이나 프로토콜 상대 URL(//), 역슬래시(/\\), /login 경로는 모두 "/"로 차단한다', () => {
expect(getSafeReturnUrl('https://evil.com')).toBe('/');
expect(getSafeReturnUrl('//evil.com/phishing')).toBe('/');
expect(getSafeReturnUrl('/\\evil.com')).toBe('/');
expect(getSafeReturnUrl('/login?returnUrl=/write')).toBe('/');
expect(getSafeReturnUrl(null)).toBe('/');
});
});11.6 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄
- 인증/권한 검사는 각 페이지의
useEffect에서 복붙하지 않고, 렌더링 단계에서 즉시<Navigate replace />를 반환하는<ProtectedRoute>래퍼 한 곳에서 처리합니다. - 미인증 차단 시 원래 목적지를
?returnUrl=...로 보존하고, 로그인 성공 후에는getSafeReturnUrl로 오픈 리다이렉트 공격을 방어한 뒤navigate(targetPath, { replace: true })로 복귀시킵니다. - 로그아웃 시
localStorage.clear()를 호출하면 10장의 임시저장 초안까지 삭제되므로, 반드시 인증 관련 키만removeItem으로 지웁니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/stores/useAuthStore.ts | 토큰·사용자명·권한(USER/ADMIN) 상태 및 안전한 login/logout 구현 |
src/router/ProtectedRoute.tsx | 미인증 리다이렉트(returnUrl 보존) 및 권한(allowedRoles) 검증 래퍼 |
src/router/index.tsx | <ProtectedRoute> 적용 및 LoginPage 지연 로딩(React.lazy) 구성 |
src/views/LoginPage.tsx | 로그인 폼, getSafeReturnUrl 검증, replace: true 자동 복귀 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
| 보호 페이지 진입 시 화면이 0.1초 잠깐 보였다가 로그인으로 튕김 | <Navigate> 대신 useEffect 안에서 navigate('/login')을 호출함 | 렌더링 본문에서 조건 분기로 <Navigate to="..." replace /> 반환 |
로그인 후 [뒤로 가기]를 누르면 다시 로그인 페이지가 나옴 | navigate(targetPath)에 { replace: true } 옵션을 빠뜨림 | navigate(targetPath, { replace: true })로 로그인 히스토리 덮어쓰기 |
| 로그아웃했더니 작성 중이던 게시글 초안까지 다 날아감 | logout()에서 localStorage.clear()를 호출함 | ACCESS_TOKEN, USERNAME, USER_ROLE만 removeItem으로 제거 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
getSafeReturnUrl('//evil.com')과getSafeReturnUrl('/write?draft=1')은 각각 무엇을 반환하며, 왜'//evil.com'을 막아야 하나요?- 일반 사우(
USER권한)로 로그인한 상태에서/admin경로로 접속하면<ProtectedRoute>의 어느 분기가 실행되나요? - 프론트엔드에
<ProtectedRoute>를 완벽히 적용했는데도 백엔드 Spring Boot에서 JWT 검증 필터를 생략하면 안 되는 이유는 무엇인가요?
셀프 체크 정답
'//evil.com'은'/'를,'/write?draft=1'은'/write?draft=1'을 반환합니다.//로 시작하는 주소는 브라우저가 현재 프로토콜(https:)을 붙여https://evil.com이라는 외부 사이트로 이동시키는 프로토콜 상대 URL이기 때문입니다.isLoggedIn은 통과하지만 두 번째 조건인!allowedRoles.includes(role)(!['ADMIN'].includes('USER'))에 걸려 권한 부족 알림(alert) 후 홈('/')으로 리다이렉트됩니다.- 프론트엔드 코드는 사용자의 브라우저에서 실행되므로 개발자 도구나 Postman/curl로
<ProtectedRoute>를 아예 거치지 않고 백엔드 API(POST /api/posts)를 직접 호출할 수 있기 때문입니다.
다음 챕터로 이어지는 질문
이제 화면 라우팅과 인증/인가 관문까지 갖추었습니다.
하지만 소스 코드의 네트워크 통신 부분을 돌아보십시오. 여전히 컴포넌트와 스토어 곳곳에 axios.get('/api/posts'), axios.post(...)가 직접 흩어져 있습니다.
만약 백엔드에서 Access Token이 만료되어 동시에 3개의 API에서 401 Unauthorized가 떨어졌을 때 중복 없이 단 한 번만 토큰을 갱신(Single-Flight)해야 하거나, 하위 컴포넌트의 렌더링 에러로 화면 전체가 하얗게 죽는(White-out) 현상을 막으려면 어떻게 해야 할까요?
이어지는 Chapter 12에서는 httpClient Single-Flight 토큰 재발급 큐, postRepository 패턴, 그리고 **React <ErrorBoundary>**를 구축해 보겠습니다.