Protected Route 패턴
SPA에서는 페이지 전환이 서버를 거치지 않는다. 전통적인 서버 렌더링 앱에서는 서버가 세션을 확인하고 비인증 사용자를 로그인 페이지로 리다이렉트했는데, SPA에서는 이 역할을 클라이언트 라우터가 대신해야 한다. 문제는 React Router 같은 라우팅 라이브러리가 "인증"이라는 개념을 기본으로 제공하지 않는다는 것이다. 라우트 정의에 authenticated: true 같은 옵션은 없다.
그래서 등장한 것이 Protected Route 패턴이다. 인증이 필요한 라우트를 감싸는 래퍼 컴포넌트를 만들어서, 인증 상태에 따라 자식을 렌더링하거나 로그인 페이지로 리다이렉트하는 방식이다.
핵심 아이디어
Protected Route의 본질은 간단하다. 조건부 렌더링 + 리다이렉트다.
사용자가 /dashboard 접근
→ 인증됨? → Dashboard 렌더링
→ 인증 안 됨? → /login으로 리다이렉트
이걸 컴포넌트로 표현하면 이렇게 된다:
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" />;
}
return children;
}
React Router v6의 <Navigate> 컴포넌트가 렌더링되면 즉시 해당 경로로 이동한다. 이전 버전의 <Redirect>를 대체한 컴포넌트다.
Navigate 컴포넌트
<Navigate>는 React Router v6에서 선언적 리다이렉트를 수행하는 컴포넌트다. 렌더링되는 순간 지정된 경로로 네비게이션이 발생한다.
// 기본 사용
<Navigate to="/login" />
// 히스토리 교체 (뒤로가기 방지)
<Navigate to="/login" replace />
// state 전달
<Navigate to="/login" state={{ from: location }} replace />
replace 옵션
replace를 쓰지 않으면 브라우저 히스토리에 리다이렉트 경로가 쌓인다. 사용자가 /dashboard에 접근했다가 /login으로 리다이렉트되면, 히스토리가 [/dashboard, /login]이 된다. 이 상태에서 뒤로가기를 누르면 다시 /dashboard로 갔다가 또 /login으로 리다이렉트되는 무한 루프에 빠진다.
replace를 사용하면 히스토리에서 현재 항목을 교체하기 때문에 이 문제가 발생하지 않는다. Protected Route에서 리다이렉트할 때는 거의 항상 replace를 사용해야 한다.
원래 위치로 되돌려 보내기
가장 기본적인 Protected Route는 로그인 후 항상 홈으로 보낸다. 하지만 사용자 경험 측면에서 이건 좋지 않다. 사용자가 /settings/profile에 접근하려다 로그인 페이지로 갔다면, 로그인 후에는 /settings/profile로 돌아가야 자연스럽다.
이걸 구현하려면 리다이렉트할 때 원래 가려던 경로를 state로 전달해야 한다.
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
useLocation()으로 현재 위치 정보를 가져와서 state.from에 담아 전달한다. location 객체에는 pathname, search, hash 등이 포함되어 있어서 쿼리 파라미터까지 보존된다.
로그인 페이지에서는 이 state를 꺼내서 로그인 성공 후 해당 경로로 이동한다:
function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
const handleLogin = async (credentials: LoginCredentials) => {
await login(credentials);
navigate(from, { replace: true });
};
return <LoginForm onSubmit={handleLogin} />;
}
location.state?.from?.pathname에서 옵셔널 체이닝을 사용하는 이유는, 사용자가 직접 /login을 입력해서 접근한 경우에는 state가 없기 때문이다. 이때는 기본값 "/"로 홈으로 보낸다.
라우트 구성 방식
Protected Route를 라우트에 적용하는 방식은 크게 두 가지가 있다.
방식 1: 개별 래핑
각 라우트를 개별적으로 감싸는 방식이다.
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
</Routes>
라우트가 적을 때는 괜찮지만, 보호할 라우트가 많아지면 반복이 심해진다.
방식 2: Layout Route (Outlet 패턴)
React Router v6의 Layout Route를 활용하면 훨씬 깔끔하다. <Outlet>을 사용해서 중첩 라우트의 부모 역할을 하는 레이아웃 컴포넌트를 만든다.
function ProtectedLayout() {
const { isAuthenticated } = useAuth();
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
<Routes>
<Route path="/login" element={<LoginPage />} />
{/* 이 아래 모든 라우트는 인증 필요 */}
<Route element={<ProtectedLayout />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
</Routes>
<Route element={<ProtectedLayout />}>에 path가 없다는 것에 주목하자. 이것이 Layout Route다. URL 매칭에는 관여하지 않고, 자식 라우트가 렌더링되기 전에 ProtectedLayout이 먼저 실행된다. 인증 확인을 통과하면 <Outlet />이 자식 라우트의 element를 렌더링한다.
이 방식의 장점은 보호할 라우트를 추가할 때 그냥 <Route> 안에 넣기만 하면 된다는 것이다. ProtectedRoute로 일일이 감쌀 필요가 없다.
인증 상태 관리: useAuth 훅
Protected Route가 인증 상태를 확인하려면 어딘가에서 인증 상태를 관리해야 한다. 가장 일반적인 방법은 Context API로 인증 상태를 전역에서 공유하는 것이다.
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const login = async (credentials: LoginCredentials) => {
const response = await api.login(credentials);
setUser(response.user);
localStorage.setItem("token", response.token);
};
const logout = () => {
setUser(null);
localStorage.removeItem("token");
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
AuthProvider는 라우터보다 상위에 위치해야 한다. 라우터 안의 모든 컴포넌트가 인증 상태에 접근할 수 있어야 하기 때문이다.
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>{/* ... */}</Routes>
</BrowserRouter>
</AuthProvider>
);
}
로딩 상태 처리
실제 앱에서는 페이지 로드 시 토큰으로 사용자 정보를 확인하는 비동기 작업이 필요하다. 이 확인이 완료되기 전에 Protected Route가 실행되면, 인증된 사용자임에도 로그인 페이지로 리다이렉트될 수 있다.
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("token");
if (token) {
api.getMe(token)
.then((user) => setUser(user))
.catch(() => localStorage.removeItem("token"))
.finally(() => setIsLoading(false));
} else {
setIsLoading(false);
}
}, []);
if (isLoading) {
return <LoadingSpinner />;
}
return (
<AuthContext.Provider value={{ user, isAuthenticated: !!user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
isLoading 상태를 관리해서 인증 확인이 끝나기 전에는 전체 앱을 로딩 상태로 보여준다. 이렇게 하면 깜빡임 없이 정확한 라우팅이 보장된다.
대안으로 isLoading을 Protected Route 레벨에서 처리할 수도 있다:
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return <LoadingSpinner />;
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
어떤 방식을 선택하든 핵심은 같다. 인증 확인이 완료되기 전에 리다이렉트하면 안 된다.
역방향 보호: 로그인 페이지 가드
이미 로그인한 사용자가 /login에 접근하면 대시보드로 리다이렉트해야 한다. 로그인 상태에서 로그인 페이지를 보여주는 것은 어색하다.
function PublicOnlyRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
if (isAuthenticated) {
return <Navigate to="/dashboard" replace />;
}
return children;
}
<Routes>
<Route
path="/login"
element={
<PublicOnlyRoute>
<LoginPage />
</PublicOnlyRoute>
}
/>
{/* ... */}
</Routes>
이 패턴을 "역방향 보호" 또는 "게스트 전용 라우트"라고 부른다. Protected Route의 거울 이미지다.
역할 기반 접근 제어 (RBAC)
단순한 인증/비인증 구분을 넘어서, 사용자의 역할(role)에 따라 접근을 제한해야 하는 경우도 있다. 관리자 페이지는 admin만, 매니저 페이지는 manager 이상만 접근 가능한 식이다.
interface RoleGuardProps {
children: React.ReactNode;
allowedRoles: string[];
fallback?: string;
}
function RoleGuard({ children, allowedRoles, fallback = "/unauthorized" }: RoleGuardProps) {
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={fallback} replace />;
}
return children;
}
<Routes>
<Route element={<ProtectedLayout />}>
<Route path="/dashboard" element={<DashboardPage />} />
{/* admin만 접근 가능 */}
<Route
path="/admin"
element={
<RoleGuard allowedRoles={["admin"]}>
<AdminPage />
</RoleGuard>
}
/>
{/* admin, manager 접근 가능 */}
<Route
path="/reports"
element={
<RoleGuard allowedRoles={["admin", "manager"]}>
<ReportsPage />
</RoleGuard>
}
/>
</Route>
</Routes>
인증 실패와 권한 실패를 다른 페이지로 보내는 것이 중요하다. 인증 실패는 /login으로, 권한 실패는 /unauthorized(403 페이지)로 보내야 사용자가 상황을 이해할 수 있다.
주의할 점
클라이언트 보호는 UX일 뿐이다
Protected Route는 서버 보안이 아니다. 클라이언트 코드는 누구나 조작할 수 있다. 브라우저 개발자 도구에서 상태를 변경하면 Protected Route를 우회할 수 있다. 실제 보안은 반드시 서버에서 해야 한다. API 엔드포인트에서 토큰을 검증하고, 권한을 확인하는 것이 진짜 보안이다.
Protected Route의 역할은 비인증 사용자가 의미 없는 빈 페이지를 보는 것을 방지하는 UX 보호다.
토큰 만료 처리
JWT 토큰이 만료되면 API 호출이 401을 반환한다. 이때 Axios 인터셉터 같은 곳에서 토큰을 삭제하고 로그인 페이지로 리다이렉트하는 처리가 필요하다. Protected Route만으로는 토큰 만료를 감지할 수 없다. 토큰이 localStorage에 존재하면 인증된 것으로 판단하기 때문이다.
// Axios 인터셉터에서 처리
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
깜빡임 방지
페이지 새로고침 시 인증 상태 확인이 비동기이므로, 확인이 끝나기 전에 보호된 페이지가 잠깐 보이거나 로그인 페이지가 잠깐 보이는 깜빡임이 발생할 수 있다. 앞서 설명한 isLoading 패턴으로 이를 방지하되, 로딩 UI가 너무 오래 보이지 않도록 토큰 검증 API의 응답 속도도 신경 써야 한다.
전체 예시
지금까지의 패턴을 조합한 전체 라우팅 구조다:
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
{/* 공개 라우트 */}
<Route path="/" element={<HomePage />} />
{/* 게스트 전용 (로그인한 사용자는 접근 불가) */}
<Route
path="/login"
element={
<PublicOnlyRoute>
<LoginPage />
</PublicOnlyRoute>
}
/>
{/* 인증 필요 라우트 */}
<Route element={<ProtectedLayout />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/settings" element={<SettingsPage />} />
{/* 역할 기반 접근 제어 */}
<Route
path="/admin/*"
element={
<RoleGuard allowedRoles={["admin"]}>
<AdminRoutes />
</RoleGuard>
}
/>
</Route>
{/* 에러 페이지 */}
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
공개 → 게스트 전용 → 인증 필요 → 역할 기반 순서로 라우트를 구성하면 가독성이 좋다. 에러 페이지는 가장 아래에 배치한다.
정리
- Protected Route는 조건부 렌더링 +
<Navigate replace />로 구현하며,state.from에 원래 경로를 담아 로그인 후 되돌려 보내는 것이 핵심이다. - Layout Route(Outlet 패턴)를 쓰면 보호할 라우트를 중첩시키기만 하면 되어 반복 래핑이 사라진다.
- 클라이언트 보호는 UX일 뿐이므로 실제 보안은 반드시 서버 API에서 토큰/권한을 검증해야 하고,
isLoading상태로 인증 확인 전 깜빡임을 방지해야 한다.
관련 문서
- React Context API - Provider 패턴, 전역 상태 관리
- JWT 디코딩 - 토큰 파싱과 만료 확인
- Axios 인터셉터 - 401 응답 처리와 토큰 갱신