React 라우트 가드 3계층 설계 — ProtectedRoute, RoleBasedRoute, ScopedRouteGuard
인증·역할·URL 검증을 각각 하나의 관심사로
들어가며
React + React Router 기반 백오피스를 개발하면서, 역할(Role)에 따라 접근 가능한 페이지가 다른 구조를 설계해야 했다. 관리자(Admin)는 관리 대시보드만, 운영자(Operator)는 자신에게 할당된 워크스페이스 범위의 페이지만 접근할 수 있어야 한다. 여기에 관리자가 운영자의 워크스페이스를 읽기 전용으로 열람하는 기능까지 추가되면서, 단순한 인증 체크만으로는 부족해졌다.
이 글에서는 인증 → 역할 → URL 검증까지 3단계 라우트 가드를 설계한 과정과, 각 가드가 어떤 문제를 해결하는지 정리한다.
1. 왜 가드가 3개 필요한가
백오피스의 접근 제어 요구사항을 정리하면 세 가지 계층으로 나뉜다.
| 계층 | 질문 | 담당 가드 |
|---|---|---|
| 1단계 | 로그인했는가? | ProtectedRoute |
| 2단계 | 이 역할이 접근 가능한 페이지인가? | RoleBasedRoute |
| 3단계 | URL의 리소스 ID가 세션과 일치하는가? | ScopedRouteGuard |
각 계층은 독립적인 관심사를 처리하며, 상위 가드를 통과해야 하위 가드에 도달하는 구조다.
2. 1단계: ProtectedRoute — 인증 확인
가장 기본적인 가드다. 처음에는 세션 스토어의 isAuthenticated만 확인하는 단순한 구조로 시작했다.
초기 버전의 문제
// [X] 초기 버전: isAuthenticated만 확인
export const ProtectedRoute = ({ children, loginPath = '/login' }: ProtectedRouteProps) => {
const isAuthenticated = useSessionStore((state) => state.isAuthenticated)
const location = useLocation()
if (!isAuthenticated) {
return <Navigate to={loginPath} state={{ from: location }} replace />
}
return children
}
isAuthenticated는 내부적으로 !!accessToken일 뿐이다. 토큰이 만료되었어도 문자열이 존재하면 true가 된다. 브라우저를 닫고 수시간 뒤 재접속하면 localStorage에 만료된 토큰이 남아있어 가드를 통과하고, 보호된 페이지가 잠깐 노출된 뒤 첫 API 호출에서야 401로 리다이렉트되는 문제가 있었다.
개선: 토큰 만료 검증 추가
// [O] 개선 버전: accessToken 만료 시 refreshToken 유효 여부로 판단
export const ProtectedRoute = ({ children, loginPath = '/login' }: ProtectedRouteProps) => {
const isAuthenticated = useSessionStore((state) => state.isAuthenticated)
const accessToken = useSessionStore((state) => state.accessToken)
const refreshToken = useSessionStore((state) => state.refreshToken)
const logout = useSessionStore((state) => state.logout)
const location = useLocation()
if (!isAuthenticated || !accessToken) {
return <Navigate to={loginPath} state={{ from: location }} replace />
}
// accessToken 만료 시 refreshToken 유효 여부로 판단
if (isTokenExpired(accessToken)) {
if (!refreshToken || isTokenExpired(refreshToken)) {
logout()
return <Navigate to={loginPath} state={{ from: location }} replace />
}
// refreshToken 유효 → API 인터셉터가 accessToken 갱신 처리
}
return children
}
판단 로직을 표로 정리하면 다음과 같다.
| accessToken | refreshToken | 판단 |
|---|---|---|
| 유효 | — | 통과 |
| 만료 | 유효 | 통과 (API 인터셉터가 갱신) |
| 만료 | 만료/없음 | 세션 정리 후 로그인 리다이렉트 |
| 없음 | — | 로그인 리다이렉트 |
Note: accessToken 만료 + refreshToken 유효 조합에서는 가드를 통과시킨다. 이미 API 인터셉터에서 요청 시 accessToken을 선제적으로 갱신하는 로직이 있기 때문이다. 가드에서 이중으로 갱신 로직을 구현하면 복잡도만 올라간다.
핵심 포인트
state={{ from: location }}으로 원래 가려던 경로를 저장한다. 로그인 후 해당 경로로 복귀할 수 있다.loginPath를 props로 받아, 웹(/login)과 모바일(/m/login)에서 다른 로그인 페이지로 리다이렉트할 수 있다.- 이 가드는 역할을 전혀 모른다. 오직 "인증 상태"만 판단한다.
3. 2단계: RoleBasedRoute — 역할 기반 접근 제어
대부분의 멀티롤 백오피스에는 역할 계층이 존재한다. 예를 들어:
| 역할 | 설명 | 접근 가능 영역 |
|---|---|---|
ADMIN |
관리자 | 관리 대시보드 |
OPERATOR |
운영자 | 할당된 워크스페이스 범위 |
RoleBasedRoute는 현재 사용자의 역할이 허용 목록에 포함되는지 확인한다.
export const RoleBasedRoute = ({ children, roles, fallback }: RoleBasedRouteProps) => {
const user = useSessionStore((state) => state.user)
// 사용자가 없는 경우 (ProtectedRoute에서 처리되어야 하지만 방어 코드)
if (!user) {
return <Navigate to="/login" replace />
}
// 역할 확인
if (!roles.includes(user.role)) {
const defaultFallback = isOperator(user.role) ? '/select-workspace' : '/dashboard'
return <Navigate to={fallback ?? defaultFallback} replace />
}
return children ? children : <Outlet />
}
핵심 포인트
역할 불일치 시 역할별 기본 경로로 리다이렉트한다. 운영자가 관리자 전용 URL에 직접 접근하면 워크스페이스 선택 페이지로, 관리자가 운영자 URL에 접근하면 대시보드로 보낸다. 403 에러 페이지를 보여주는 것보다 자연스럽다.
Outlet 지원으로 라우트 그룹화가 가능하다. children이 없으면 <Outlet />을 렌더링하여 React Router의 중첩 라우트 패턴을 지원한다. 이를 통해 관리자 전용 라우트 전체를 하나의 RoleBasedRoute로 감쌀 수 있다.
Note: 실제 프로젝트에서는 이 가드에 접속 컨텍스트 초기화 로직을 추가했다. 예를 들어 관리자가 운영자의 워크스페이스를 읽기 전용으로 열람한 뒤 관리자 페이지로 돌아오면, 세션에 남아있는 워크스페이스 정보를 자동으로 정리한다. 이처럼 역할 가드에는 프로젝트 고유의 부가 로직을 추가할 수 있지만, 핵심은
roles.includes(user.role)한 줄이다.
4. 3단계: ScopedRouteGuard — URL 파라미터 검증
운영자의 라우트가 /workspace/:workspaceId/* 형태라면, URL에 포함된 워크스페이스 ID가 세션에 저장된 값과 일치하는지 검증해야 한다.
export const ScopedRouteGuard = ({ children }: ScopedRouteGuardProps) => {
const { workspaceId: workspaceIdFromUrl } = useParams<{ workspaceId?: string }>()
const workspaceFromSession = useSessionStore((state) => state.workspace)
const location = useLocation()
// 세션에 워크스페이스가 없으면 선택 페이지로
if (!workspaceFromSession) {
return <Navigate to="/select-workspace" replace />
}
// URL의 ID와 세션이 일치하면 통과
if (workspaceFromSession.id === workspaceIdFromUrl) {
return children
}
// 불일치 시 세션의 워크스페이스에 맞는 URL로 교정
const correctedPath = location.pathname.replace(
`workspace/${workspaceIdFromUrl}`,
`workspace/${workspaceFromSession.id}`
)
return <Navigate to={correctedPath + location.search} replace />
}
이 가드가 필요한 이유
운영자가 워크스페이스 A에 접속 중인 상태에서, 브라우저 주소창에 워크스페이스 B의 URL을 직접 입력하면 어떻게 될까? 가드 없이는 워크스페이스 B의 데이터에 접근하게 된다. ScopedRouteGuard는 이를 방지한다.
| 상황 | 동작 |
|---|---|
| 워크스페이스 미선택 | /select-workspace로 리다이렉트 |
| URL ID = 세션 ID | 통과 |
| URL ID ≠ 세션 ID | 세션 기준 URL로 교정 |
Note: 교정(redirect)이지 차단(block)이 아니다. URL을 강제로 바꾸는 대신, 세션에 저장된 올바른 워크스페이스로 경로를 수정한다. 사용자가 보고 있던 하위 경로(
/orders,/reports등)는 유지되므로, 자연스러운 경험을 제공한다.
5. 라우트 설정에서의 조합
세 가드가 실제 라우트 설정에서 어떻게 조합되는지 보자.
export const AppRoutes = () => {
return (
<Routes>
{/* 공개 라우트 (인증 불필요) */}
{publicRoutes.map((route) => (
<Route key={route.path} path={route.path} element={<RouteElement route={route} />} />
))}
{/* 인증 필요 라우트 — 1단계: ProtectedRoute */}
<Route
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
{/* 관리자 라우트 — 2단계: RoleBasedRoute */}
<Route element={<RoleBasedRoute roles={ADMIN_ROLES} />}>
{adminRoutes.map((route) => (
<Route key={route.path} path={route.path} element={<RouteElement route={route} />} />
))}
</Route>
{/* 운영자 라우트 — 3단계: ScopedRouteGuard */}
{operatorRoutes.map((route) => (
<Route
key={route.path}
path={route.path}
element={
<ScopedRouteGuard>
<RouteElement route={route} />
</ScopedRouteGuard>
}
/>
))}
</Route>
</Routes>
)
}
가드 실행 순서
로그인했는가?"} B -->|No| C["/login으로 리다이렉트"] B -->|Yes| D{"RoleBasedRoute
역할이 허용되는가?"} D -->|No| E["역할별 기본 경로로 리다이렉트"] D -->|Yes| F{"ScopedRouteGuard
URL과 세션이 일치하는가?"} F -->|미선택| G["/select-workspace로 리다이렉트"] F -->|불일치| H["올바른 URL로 교정"] F -->|일치| I["페이지 렌더링"]
Point: ProtectedRoute는 모든 인증 라우트를 감싸고, RoleBasedRoute는 관리자 라우트 그룹만, ScopedRouteGuard는 운영자 라우트 각각을 감싼다. 각 가드는 자신의 관심사만 처리하고, 나머지는 다음 가드에 위임한다.
6. 왜 하나의 가드로 합치지 않았는가
처음에는 AuthGuard 하나에서 인증 + 역할 + URL 검증을 모두 처리하는 방안을 고려했다.
// [X] Bad: 모든 관심사를 하나의 컴포넌트에
const AuthGuard = ({ children, roles, validateUrl }) => {
const isAuthenticated = useSessionStore((s) => s.isAuthenticated)
const user = useSessionStore((s) => s.user)
const workspace = useSessionStore((s) => s.workspace)
const { workspaceId } = useParams()
if (!isAuthenticated) return <Navigate to="/login" replace />
if (roles && !roles.includes(user.role)) return <Navigate to="/dashboard" replace />
if (validateUrl && workspace?.id !== workspaceId) { /* ... */ }
return children
}
문제는 모든 라우트에서 쓸 수 없다는 것이었다.
- 공개 라우트에는 인증 체크가 필요 없다
- 관리자 라우트에는 URL 파라미터 검증이 필요 없다
- 운영자 라우트에는 역할 가드 대신 URL 검증이 필요하다
결국 props로 동작을 조절하는 만능 컴포넌트가 되어, 어떤 라우트에 어떤 조합의 가드가 적용되는지 파악하기 어려워진다.
// [O] Good: 관심사별로 분리
<ProtectedRoute> {/* 인증만 */}
<RoleBasedRoute roles={..}> {/* 역할만 */}
<ScopedRouteGuard> {/* URL 검증만 */}
<Page />
</ScopedRouteGuard>
</RoleBasedRoute>
</ProtectedRoute>
각 가드가 하나의 관심사만 담당하므로, 라우트 설정을 읽으면 어떤 보호가 적용되어 있는지 즉시 파악할 수 있다.
7. 클라이언트 가드의 보안 한계
여기서 한 가지 짚고 넘어갈 점이 있다. 클라이언트 라우트 가드는 보안 장치가 아니라 네비게이션 제어 장치다.
예를 들어 공격자가 브라우저 개발자 도구에서 localStorage의 user.role을 ADMIN으로 변조하면 어떻게 될까? RoleBasedRoute의 roles.includes(user.role) 체크를 통과하고, 관리자 전용 페이지 컴포넌트가 렌더링된다.
하지만 데이터는 가져올 수 없다.
API 요청에는 서버가 서명한 JWT가 그대로 실린다. JWT는 클라이언트에서 변조할 수 없으므로, 서버는 토큰 안의 원래 role로 권한을 검증한다. 결국 공격자는 빈 페이지(또는 에러 상태)만 보게 된다.
Point: 클라이언트 가드 = UX, 서버 검증 = 보안. 라우트 가드는 정상 사용자가 권한 없는 페이지에 실수로 접근했을 때 자연스럽게 리다이렉트해주는 역할이다. 실제 인가(authorization)는 반드시 서버의 JWT 검증에서 이루어져야 한다.
8. 성과
| 지표 | 수치 |
|---|---|
| 가드 컴포넌트 수 | 3개 (ProtectedRoute, RoleBasedRoute, ScopedRouteGuard) |
| 보호 라우트 수 | 전체 인증 라우트 모두 (ProtectedRoute), 관리자 전용 라우트 그룹 (RoleBasedRoute), 운영자 스코프 라우트 개별 (ScopedRouteGuard) |
| 역할 수 | 2개 (ADMIN, OPERATOR) |
| 각 가드의 관심사 | 각 1개씩만 담당 (인증/역할/URL 검증) |
| 리다이렉트 전략 | 역할별 기본 경로로 자연스럽게 안내 (403 페이지 대신) |
9. 정리
| 가드 | 관심사 | 실패 시 | 적용 범위 |
|---|---|---|---|
ProtectedRoute |
인증 + 토큰 유효성 | 로그인 페이지로 리다이렉트 | 모든 인증 라우트 |
RoleBasedRoute |
역할 허용 여부 | 역할별 기본 경로로 리다이렉트 | 특정 역할 전용 라우트 그룹 |
ScopedRouteGuard |
URL ↔ 세션 일치 | 올바른 URL로 교정 | 스코프 기반 라우트 개별 |
라우트 가드를 설계할 때 가장 중요한 원칙은 “하나의 가드는 하나의 질문에만 답한다”는 것이다. 인증, 역할, URL 검증은 서로 다른 관심사이며, 각각을 독립된 컴포넌트로 분리하면 조합이 유연해지고 라우트 설정의 가독성이 높아진다.
결과적으로 새로운 라우트 그룹을 추가할 때, 필요한 가드만 골라서 조합하면 된다. 모바일 라우트는 ProtectedRoute만, 관리자 라우트는 ProtectedRoute + RoleBasedRoute, 운영자 라우트는 ProtectedRoute + ScopedRouteGuard 조합으로 각 라우트의 보안 요구사항이 코드에 그대로 드러난다.