3 분 소요

개요

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;


관련 링크