Skip to content

Chapter 11. 라우터 네비게이션 가드를 활용한 인가(Authorization) AOP ​

"4.5장에서 맛본 라우터에 관문(Gateway)을 세우자. 로그인하지 않은 사용자의 글쓰기 화면 진입을 <ProtectedRoute> 래퍼 한 곳에서 가로채고, 로그인 후에는 원래 목적지(returnUrl)로 안전하게 돌려보내자."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다. Routes, Route, Link, useNavigate, useLocation 같은 라우터 기초는 4.5장에서 다뤘습니다.

개념한 줄 요약설명 위치
AOP(횡단 관심사)와 라우트 래퍼 패턴여러 화면에 공통으로 필요한 인증·권한 검사를 한 컴포넌트로 모으기11.1
인증 스토어 useAuthStoreJWT 토큰·사용자명·권한(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')을 복붙한다면 두 가지 심각한 문제가 생깁니다:

  1. 보호할 페이지가 30개로 늘어나면 30군데에 똑같은 조건문을 복붙해야 합니다.
  2. 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 복귀 테스트]:

  1. 개발자 도구 → Application → Local Storage에서 ACCESS_TOKEN이 없다면(있으면 삭제) 주소창에 http://localhost:3000/write를 입력합니다.
  2. 화면 깜빡임 없이 즉시 /login?returnUrl=%2Fwrite로 리다이렉트되며 콘솔에 🚨 [AOP Guard] 경고가 찍힙니다.
  3. 아이디에 kim, 비밀번호에 아무 값이나 입력하고 로그인하면 원래 가려던 /write로 자동 복귀합니다! 이때 브라우저 [뒤로 가기]를 눌러도 로그인 화면으로 돌아가지 않습니다(replace: true 효과).
  4. 주소창에 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줄

  1. 인증/권한 검사는 각 페이지의 useEffect에서 복붙하지 않고, 렌더링 단계에서 즉시 <Navigate replace />를 반환하는 <ProtectedRoute> 래퍼 한 곳에서 처리합니다.
  2. 미인증 차단 시 원래 목적지를 ?returnUrl=...로 보존하고, 로그인 성공 후에는 getSafeReturnUrl로 오픈 리다이렉트 공격을 방어한 뒤 navigate(targetPath, { replace: true })로 복귀시킵니다.
  3. 로그아웃 시 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으로 제거

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. getSafeReturnUrl('//evil.com')과 getSafeReturnUrl('/write?draft=1')은 각각 무엇을 반환하며, 왜 '//evil.com'을 막아야 하나요?
  2. 일반 사우(USER 권한)로 로그인한 상태에서 /admin 경로로 접속하면 <ProtectedRoute>의 어느 분기가 실행되나요?
  3. 프론트엔드에 <ProtectedRoute>를 완벽히 적용했는데도 백엔드 Spring Boot에서 JWT 검증 필터를 생략하면 안 되는 이유는 무엇인가요?
셀프 체크 정답
  1. '//evil.com'은 '/'를, '/write?draft=1'은 '/write?draft=1'을 반환합니다. //로 시작하는 주소는 브라우저가 현재 프로토콜(https:)을 붙여 https://evil.com이라는 외부 사이트로 이동시키는 프로토콜 상대 URL이기 때문입니다.
  2. isLoggedIn은 통과하지만 두 번째 조건인 !allowedRoles.includes(role)(!['ADMIN'].includes('USER'))에 걸려 권한 부족 알림(alert) 후 홈('/')으로 리다이렉트됩니다.
  3. 프론트엔드 코드는 사용자의 브라우저에서 실행되므로 개발자 도구나 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>**를 구축해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub