[React] 실무 패턴
개요
React 앱을 실제로 개발하다 보면 반복적으로 등장하는 패턴들이 있습니다. 인증 보호, 환경변수, 에러 처리, 코드 분할, 로딩 UI 등 자주 쓰이는 실무 패턴을 정리합니다.
Protected Route (인증 보호)
로그인하지 않은 사용자가 특정 페이지에 접근하면 로그인 페이지로 이동시킵니다.
// src/components/ProtectedRoute.tsx
import { Navigate } from "react-router-dom";
interface Props {
children: React.ReactNode;
}
function ProtectedRoute({ children }: Props) {
const token = localStorage.getItem("token");
if (!token) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
export default ProtectedRoute;
// App.tsx에서 사용
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<Settings />
</ProtectedRoute>
}
/>
</Routes>
환경 변수
개발/운영 환경에 따라 다른 값을 사용합니다.
# .env (공개해도 되는 변수, VITE_ 접두사 필수)
VITE_API_URL=http://localhost:8080
VITE_APP_NAME=MyApp
# .env.production
VITE_API_URL=https://api.myapp.com
// 컴포넌트에서 사용
const apiUrl = import.meta.env.VITE_API_URL;
const appName = import.meta.env.VITE_APP_NAME;
// 환경 구분
const isDev = import.meta.env.DEV; // 개발 환경
const isProd = import.meta.env.PROD; // 운영 환경
// src/config/env.ts - 한 곳에서 관리 (권장)
export const config = {
apiUrl: import.meta.env.VITE_API_URL ?? "http://localhost:8080",
appName: import.meta.env.VITE_APP_NAME ?? "App",
} as const;
.env파일은 반드시.gitignore에 추가하세요.
Error Boundary (에러 경계)
컴포넌트 렌더링 중 오류가 발생했을 때 앱 전체가 죽지 않도록 합니다.
// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Error caught:", error, info);
// 에러 모니터링 서비스(Sentry 등)에 전송 가능
}
render() {
if (this.state.hasError) {
return this.props.fallback ?? (
<div style=>
<h2>문제가 발생했습니다</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
다시 시도
</button>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
// 사용
<ErrorBoundary fallback={<p>이 섹션을 불러올 수 없습니다</p>}>
<SomePage />
</ErrorBoundary>
React.lazy + Suspense (코드 분할)
앱을 처음 로드할 때 모든 코드를 받지 않고, 페이지를 방문할 때 해당 코드만 받습니다.
// App.tsx
import { lazy, Suspense } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// 필요할 때 동적으로 로드
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
function App() {
return (
<BrowserRouter>
{/* 로딩 중 표시할 UI */}
<Suspense fallback={<div>페이지 로딩 중...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
로딩 스피너 패턴
// src/components/Spinner.tsx
function Spinner({ size = "md" }: { size?: "sm" | "md" | "lg" }) {
const sizeClass = { sm: "w-4 h-4", md: "w-8 h-8", lg: "w-12 h-12" }[size];
return (
<div
className={`${sizeClass} border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin`}
/>
);
}
// 전체 화면 로딩 오버레이
function LoadingOverlay() {
return (
<div className="fixed inset-0 flex items-center justify-center bg-white/80 z-50">
<Spinner size="lg" />
</div>
);
}
export { Spinner, LoadingOverlay };
// 사용
function App() {
const { data, isLoading } = useQuery({ ... });
if (isLoading) return <LoadingOverlay />;
return <div>{/* 내용 */}</div>;
}
페이지 타이틀 변경
// src/hooks/usePageTitle.ts
import { useEffect } from "react";
function usePageTitle(title: string) {
useEffect(() => {
const prevTitle = document.title;
document.title = `${title} | MyApp`;
return () => {
document.title = prevTitle;
};
}, [title]);
}
export default usePageTitle;
function Dashboard() {
usePageTitle("대시보드");
return <h1>대시보드</h1>;
}
Axios 공통 설정
npm install axios
// src/api/client.ts
import axios from "axios";
import { config } from "../config/env";
const client = axios.create({
baseURL: config.apiUrl,
timeout: 10000,
headers: { "Content-Type": "application/json" },
});
// 요청 인터셉터: 토큰 자동 추가
client.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// 응답 인터셉터: 401 시 로그아웃
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem("token");
window.location.href = "/login";
}
return Promise.reject(error);
}
);
export default client;