인증과 라우팅 — Protected Route 구현과 토큰 관리
로그인한 사용자만 접근할 수 있는 페이지를 어떻게 만들까요? 토큰은 어디에 저장하고, 만료되면 어떻게 갱신할까요?
개념 정의
Protected Route(보호된 라우트)는 인증된 사용자만 접근할 수 있는 라우트 입니다. 미인증 사용자가 접근하면 로그인 페이지로 리다이렉트하고, 로그인 후 원래 가려던 페이지로 되돌려 보냅니다.
왜 필요한가
대부분의 웹 애플리케이션에는 공개 페이지(홈, 로그인)와 비공개 페이지(대시보드, 설정)가 혼재합니다. 라우팅 수준에서 인증을 체크하면 다음과 같은 이점이 있습니다.
- 미인증 사용자의 비공개 페이지 접근 차단
- 일관된 인증 처리 로직
- 로그인 후 원래 페이지로 리다이렉트 (redirect back)
Protected Route 구현
기본 구현
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>
레이아웃 라우트로 적용
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>
로그인 후 리다이렉트
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 구현
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 흐름
// 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 기반 접근 제어
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 렌더링
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를 위한 것 입니다. 보안은 서버에서 반드시 검증 해야 합니다. 프론트엔드 코드는 수정 가능하므로 클라이언트 검사만으로는 부족합니다.
로그아웃 처리
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를 위한 것이고, 보안은 서버에서 보장해야 합니다.
댓글 로딩 중...