로그인한 사용자만 접근할 수 있는 페이지를 어떻게 만들까요? 토큰은 어디에 저장하고, 만료되면 어떻게 갱신할까요?

개념 정의

Protected Route(보호된 라우트)는 인증된 사용자만 접근할 수 있는 라우트 입니다. 미인증 사용자가 접근하면 로그인 페이지로 리다이렉트하고, 로그인 후 원래 가려던 페이지로 되돌려 보냅니다.

왜 필요한가

대부분의 웹 애플리케이션에는 공개 페이지(홈, 로그인)와 비공개 페이지(대시보드, 설정)가 혼재합니다. 라우팅 수준에서 인증을 체크하면 다음과 같은 이점이 있습니다.

  • 미인증 사용자의 비공개 페이지 접근 차단
  • 일관된 인증 처리 로직
  • 로그인 후 원래 페이지로 리다이렉트 (redirect back)

Protected Route 구현

기본 구현

JSX
import { Navigate, useLocation } from 'react-router-dom';

function ProtectedRoute({ children }) {
  const { isAuthenticated, loading } = useAuth();
  const location = useLocation();

  if (loading) {
    return <PageSpinner />;
  }

  if (!isAuthenticated) {
    // 현재 위치를 state로 전달 → 로그인 후 돌아오기 위해
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

// 사용
<Routes>
  {/* 공개 라우트 */}
  <Route path="/" element={<Home />} />
  <Route path="/login" element={<LoginPage />} />

  {/* 보호된 라우트 */}
  <Route
    path="/dashboard"
    element={
      <ProtectedRoute>
        <Dashboard />
      </ProtectedRoute>
    }
  />
  <Route
    path="/settings"
    element={
      <ProtectedRoute>
        <Settings />
      </ProtectedRoute>
    }
  />
</Routes>

레이아웃 라우트로 적용

JSX
function ProtectedLayout() {
  const { isAuthenticated, loading } = useAuth();
  const location = useLocation();

  if (loading) return <PageSpinner />;
  if (!isAuthenticated) return <Navigate to="/login" state={{ from: location }} replace />;

  return (
    <div className="app-layout">
      <Sidebar />
      <main>
        <Outlet /> {/* 보호된 자식 라우트들 */}
      </main>
    </div>
  );
}

<Routes>
  <Route path="/login" element={<LoginPage />} />

  {/* 보호된 라우트 그룹 */}
  <Route element={<ProtectedLayout />}>
    <Route path="/dashboard" element={<Dashboard />} />
    <Route path="/settings" element={<Settings />} />
    <Route path="/profile" element={<Profile />} />
  </Route>
</Routes>

로그인 후 리다이렉트

JSX
function LoginPage() {
  const navigate = useNavigate();
  const location = useLocation();
  const { login } = useAuth();

  const from = location.state?.from?.pathname || '/dashboard';

  const handleSubmit = async (credentials) => {
    await login(credentials);
    navigate(from, { replace: true }); // 원래 가려던 페이지로
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 로그인 폼 */}
    </form>
  );
}

Auth Context 구현

JSX
const AuthContext = createContext(null);

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  // 앱 시작 시 토큰 확인
  useEffect(() => {
    const token = getStoredToken();
    if (token) {
      verifyToken(token)
        .then(user => setUser(user))
        .catch(() => removeStoredToken())
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);

  const login = async (credentials) => {
    const { user, accessToken, refreshToken } = await loginAPI(credentials);
    storeTokens(accessToken, refreshToken);
    setUser(user);
  };

  const logout = () => {
    removeStoredToken();
    setUser(null);
  };

  const value = useMemo(
    () => ({
      user,
      loading,
      isAuthenticated: !!user,
      login,
      logout,
    }),
    [user, loading]
  );

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth는 AuthProvider 안에서 사용해야 합니다');
  return context;
}

토큰 관리

저장 위치별 트레이드오프

저장 위치XSS 방어CSRF 방어특성
localStorage취약안전탈취 시 영구 사용 가능
sessionStorage취약안전탭 닫으면 삭제
httpOnly 쿠키안전취약 (CSRF 토큰 필요)서버 설정 필요
메모리 (변수)안전안전새로고침 시 삭제

가장 안전한 조합은 Access Token은 메모리에, Refresh Token은 httpOnly 쿠키에 저장하는 것입니다.

Access Token + Refresh Token 흐름

JSX
// Axios 인터셉터로 토큰 자동 갱신
let accessToken = null;

const api = axios.create({ baseURL: '/api' });

// 요청 인터셉터: 토큰 주입
api.interceptors.request.use((config) => {
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// 응답 인터셉터: 401 시 토큰 갱신
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        // Refresh Token으로 새 Access Token 발급
        const { data } = await axios.post('/api/auth/refresh');
        accessToken = data.accessToken;

        // 실패했던 요청 재시도
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        return api(originalRequest);
      } catch (refreshError) {
        // Refresh Token도 만료 → 로그아웃
        accessToken = null;
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

Role 기반 접근 제어

JSX
function RoleRoute({ children, allowedRoles }) {
  const { user, isAuthenticated } = useAuth();
  const location = useLocation();

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  if (!allowedRoles.includes(user.role)) {
    return <Navigate to="/unauthorized" replace />;
  }

  return children;
}

// 사용
<Routes>
  <Route
    path="/admin"
    element={
      <RoleRoute allowedRoles={['admin']}>
        <AdminDashboard />
      </RoleRoute>
    }
  />
  <Route
    path="/editor"
    element={
      <RoleRoute allowedRoles={['admin', 'editor']}>
        <EditorPage />
      </RoleRoute>
    }
  />
</Routes>

조건부 UI 렌더링

JSX
function Navigation() {
  const { user } = useAuth();

  return (
    <nav>
      <Link to="/dashboard">대시보드</Link>
      {user?.role === 'admin' && (
        <Link to="/admin">관리자</Link>
      )}
      {['admin', 'editor'].includes(user?.role) && (
        <Link to="/editor">에디터</Link>
      )}
    </nav>
  );
}

중요: 클라이언트 측 role 검사는 UX를 위한 것 입니다. 보안은 서버에서 반드시 검증 해야 합니다. 프론트엔드 코드는 수정 가능하므로 클라이언트 검사만으로는 부족합니다.

로그아웃 처리

JSX
function useLogout() {
  const navigate = useNavigate();
  const queryClient = useQueryClient();
  const { logout } = useAuth();

  return useCallback(() => {
    logout();                                    // 인증 상태 초기화
    queryClient.clear();                         // 캐시된 데이터 삭제
    navigate('/login', { replace: true });       // 로그인 페이지로
  }, [logout, navigate, queryClient]);
}

주의할 점

클라이언트 인증만으로 보안을 보장하려는 실수

Protected Route는 UX 가드 일 뿐, 보안 수단이 아닙니다. 사용자가 DevTools로 인증 상태를 조작할 수 있으므로, 모든 보호된 API 요청에 대해 서버 측 인증 검증이 필수 입니다.

Access Token을 localStorage에 저장

localStorage는 XSS 공격에 취약합니다. Access Token은 메모리(변수) 에, Refresh Token은 httpOnly 쿠키 에 저장하는 것이 가장 안전한 조합입니다.

정리

항목설명
Protected Route인증 상태 확인 후 미인증 시 리다이렉트하는 클라이언트 가드
레이아웃 라우트보호된 라우트를 그룹으로 관리
리다이렉트 복귀location.state로 로그인 후 원래 페이지로 이동
토큰 저장메모리(Access) + httpOnly 쿠키(Refresh) 조합
Role 기반 접근클라이언트는 UX용, 서버 검증이 필수

인증은 프론트엔드만의 문제가 아니라 서버와의 협력이 핵심입니다. 클라이언트 가드는 UX를 위한 것이고, 보안은 서버에서 보장해야 합니다.

댓글 로딩 중...