Skip to main content
frontend2025년 12월 21일6분 소요

React 앱을 망치는 5가지 성능 실수

대부분의 React 앱은 필요 이상으로 느립니다. 여기 프로덕션 코드베이스에서 가장 흔히 볼 수 있는 5가지 성능 실수와 각각을 해결하는 방법이 있습니다.

reactperformancenextjs
React 앱을 망치는 5가지 성능 실수

수년간 수백 개의 React 코드베이스를 검토했습니다. 클라이언트 프로젝트, 오픈소스 저장소, 내부 도구, 스타트업 MVP 등에서 동일한 성능 문제가 반복적으로 나타납니다. 이는 특이한 엣지 케이스가 아니라, 앱이 느려지고 사용자들이 떠나기 시작할 때까지 누적되는 기본적이고 예방 가능한 실수들입니다.

여기 제가 가장 자주 보는 다섯 가지 문제와 오늘 바로 적용할 수 있는 실제 해결책이 있습니다.

1. 메모이제이션 누락으로 인한 불필요한 리렌더링

이것은 React 앱에서 가장 흔한 성능 문제입니다. 부모 컴포넌트가 리렌더링되면, 자식 컴포넌트의 props가 변경되지 않았더라도 모든 자식 컴포넌트가 함께 리렌더링됩니다. 목록에 수십 개의 항목이 있는 깊은 컴포넌트 트리 전체에 이 문제가 발생하면, 키 입력마다 앱이 버벅거리는 현상이 나타납니다.

문제점:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = calculateStats(user.transactions);

  return (
    <div>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <StatsPanel stats={stats} />
      <TransactionList transactions={user.transactions} />
    </div>
  );
}

searchQuery가 변경될 때마다 user.transactions가 변경되지 않았음에도 불구하고 calculateStats가 다시 실행됩니다. StatsPanelTransactionList도 아무 이유 없이 리렌더링됩니다.

해결책:

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = useMemo(
    () => calculateStats(user.transactions),
    [user.transactions]
  );

  const handleSearchChange = useCallback(
    (value: string) => setSearchQuery(value),
    []
  );

  return (
    <div>
      <SearchBar value={searchQuery} onChange={handleSearchChange} />
      <MemoizedStatsPanel stats={stats} />
      <MemoizedTransactionList transactions={user.transactions} />
    </div>
  );
}

const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);

여기서 세 가지 변화가 있었습니다:

  1. **useMemo**는 calculateStats가 모든 렌더링에서 실행되는 것을 방지합니다. user.transactions가 실제로 변경될 때만 다시 계산합니다.
  2. **useCallback**은 변경 핸들러에 대한 참조를 안정화하여 SearchBar가 모든 렌더링에서 "새로운" 함수를 보지 않도록 합니다.
  3. **memo**는 자식 컴포넌트들을 감싸서 props가 얕게 동일할 때 리렌더링을 건너뛰도록 합니다.

주의할 점: 모든 컴포넌트를 맹목적으로 memo로 감싸지 마세요. React DevTools Profiler를 사용하여 먼저 프로파일링하세요. 메모이제이션 자체에도 비용(얕은 비교)이 발생합니다. 중요한 곳에 적용하세요: 비용이 많이 드는 계산, 큰 목록, 그리고 props가 변경되지 않았음에도 자주 리렌더링되는 컴포넌트들입니다.

2. 코드 스플리팅 부재로 인한 번들 비대화

저는 정기적으로 모든 것을 단일 JavaScript 번들로 제공하는 React 앱을 봅니다. 관리자 대시보드, 설정 페이지, 거의 방문하지 않는 고객 지원 센터 등 모든 것이 첫 페이지 방문 시 미리 로드됩니다. 사용자는 랜딩 페이지를 보려고 했는데, 브라우저는 아직 필요하지 않은 2MB의 JavaScript를 다운로드한 것입니다.

문제점:

import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";

function App() {
  return (
    <Routes>
      <Route path="/" element={<Landing />} />
      <Route path="/admin" element={<AdminDashboard />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/help" element={<HelpCenter />} />
    </Routes>
  );
}

정적 임포트는 번들러가 모든 것을 하나의 청크에 포함시킨다는 것을 의미합니다. 사용자가 어떤 경로를 방문하든, 모든 것을 다운로드하게 됩니다.

해결책:

import { lazy, Suspense } from "react";

const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Landing />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/help" element={<HelpCenter />} />
      </Routes>
    </Suspense>
  );
}

동적 import()와 함께 React.lazy를 사용하면 번들러에게 이 페이지들을 별도의 청크로 분할하도록 지시합니다. 이들은 사용자가 해당 경로로 이동할 때 필요에 따라 로드됩니다. 초기 번들은 작게 유지되고, Time to Interactive는 크게 줄어듭니다.

Next.js를 사용한다면, App Router를 통해 경로 기반 코드 스플리팅을 무료로 얻을 수 있습니다. 하지만 무거운 서드파티 라이브러리의 경우 컴포넌트 수준 스플리팅에 대해 의도적으로 접근해야 합니다:

import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false,
});

이는 리치 텍스트 편집기, 차트 라이브러리 또는 PDF 렌더러와 같이 번들에 200-500KB를 쉽게 추가할 수 있는 라이브러리에 매우 중요합니다.

3. 컴포넌트 트리의 너무 높은 곳에 위치한 상태

React의 렌더링 모델은 하향식입니다. 컴포넌트의 상태가 변경되면 해당 컴포넌트와 전체 서브트리가 리렌더링됩니다. "편의를 위해" 상태를 트리의 맨 위로 끌어올리면, React에게 모든 상태 변경 시 그 아래의 모든 것을 리렌더링하라고 지시하는 셈입니다.

문제점:

function App() {
  const [selectedFilter, setSelectedFilter] = useState("all");
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <Layout>
      <Header />
      <Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
      <MainContent>
        <SearchBar query={searchQuery} onChange={setSearchQuery} />
        <ProductList filter={selectedFilter} />
        <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
      </MainContent>
      <Footer />
    </Layout>
  );
}

검색창에 입력하면 App에서 상태 업데이트가 발생합니다. 이로 인해 Header, Sidebar, Footer, ProductList, Modal이 리렌더링되는데, 이들 중 어느 것도 검색 쿼리에 관심이 없습니다.

해결책:

상태가 실제로 사용되는 곳으로 내리세요:

function App() {
  return (
    <Layout>
      <Header />
      <SidebarWithFilter />
      <MainContent>
        <SearchSection />
        <ProductList />
      </MainContent>
      <Footer />
    </Layout>
  );
}

function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  );
}

이제 검색창에 입력하면 SearchSection과 그 자식들만 리렌더링됩니다. 다른 모든 것은 영향을 받지 않습니다.

원칙은 간단합니다: 상태는 그것을 읽는 컴포넌트와 가능한 한 가깝게 존재해야 합니다. 두 개의 형제 컴포넌트가 동일한 상태를 필요로 한다면, 가장 가까운 공통 부모로 끌어올리세요 — 하지만 그 이상은 안 됩니다. 진정으로 전역적인 상태(인증, 테마, 로케일)의 경우, Zustand 또는 Jotai와 같이 선택적 구독을 지원하는 상태 관리 라이브러리를 사용하여 특정 상태 조각을 사용하는 컴포넌트만 해당 조각이 변경될 때 리렌더링되도록 하세요.

4. Next.js에서 최적화되지 않은 이미지

이미지는 일반적으로 웹 페이지에서 가장 무거운 자산입니다. 저는 개발자들이 거대한 압축되지 않은 PNG를 일반 <img> 태그와 함께 사용하는 Next.js 앱을 본 적이 있습니다. 그 결과: 8MB의 이미지를 로드하고, 지연 로딩이 전혀 없으며, 반응형 크기 조정도 없고, Lighthouse를 울게 만드는 Largest Contentful Paint 시간을 가진 페이지가 됩니다.

문제점:

function ProjectCard({ project }) {
  return (
    <div className="card">
      <img src={`/images/${project.cover}`} alt={project.title} />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

이것은 사용자의 뷰포트나 장치에 관계없이 원본 크기의 전체 해상도 이미지를 제공합니다. 형식 변환, 지연 로딩, 반응형 srcset이 없습니다.

해결책:

import Image from "next/image";

function ProjectCard({ project }) {
  return (
    <div className="card">
      <Image
        src={`/images/${project.cover}`}
        alt={project.title}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder="blur"
        blurDataURL={project.blurHash}
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

Next.js Image 컴포넌트는 자동으로 여러 가지를 제공합니다:

  • 브라우저 지원에 따른 WebP 또는 AVIF로의 자동 형식 변환
  • sizes 속성을 통한 반응형 크기 조정 — 브라우저는 필요한 크기만 다운로드합니다.
  • 기본적으로 지연 로딩 — 스크롤 아래에 있는 이미지는 사용자가 근처로 스크롤할 때까지 가져오지 않습니다.
  • 블러 플레이스홀더 — 전체 이미지가 로드되는 동안 가벼운 미리보기를 표시하여 레이아웃 이동을 제거합니다.

스크롤 없이 볼 수 있는 부분(히어로 배너, 주요 콘텐츠)의 이미지의 경우, 지연 로딩을 비활성화하고 미리 로드하기 위해 priority를 추가하세요:

<Image src={heroImage} alt="Hero" priority sizes="100vw" />

이 한 가지 변경만으로도 페이지 용량을 60-80% 줄이고 Core Web Vitals 점수를 크게 향상시킬 수 있습니다.

5. 무거운 계산으로 메인 스레드 차단

브라우저의 메인 스레드는 렌더링, 사용자 입력, 애니메이션, JavaScript 실행을 모두 단일 스레드에서 처리합니다. 무거운 계산을 동기적으로 실행하면 다른 모든 것이 멈춥니다. 사용자가 버튼을 클릭해도 500ms 동안 아무 일도 일어나지 않습니다. 애니메이션이 끊기고, 스크롤이 응답하지 않습니다.

문제점:

function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);

  useEffect(() => {
    // This blocks the main thread for 200-800ms
    const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
    setProcessedData(result);
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

processLargeDataset는 메인 스레드에서 동기적으로 실행됩니다. 400ms가 걸린다면, 전체 UI가 400ms 동안 멈춥니다. 사용자는 스크롤하거나, 입력하거나, 어떤 것과도 상호작용할 수 없습니다.

해결책 — Web Worker로 이동:

// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
  const rawData = event.data;
  const result = processLargeDataset(rawData);
  self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/dataProcessor.ts", import.meta.url)
    );

    workerRef.current.onmessage = (event: MessageEvent) => {
      setProcessedData(event.data);
    };

    workerRef.current.postMessage(rawData);

    return () => {
      workerRef.current?.terminate();
    };
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

이제 무거운 계산은 별도의 스레드에서 실행됩니다. 메인 스레드는 사용자 상호작용, 애니메이션, 렌더링을 처리하는 데 자유롭게 유지됩니다. UI는 항상 반응성을 유지합니다.

계산이 50-150ms 정도 걸리는 덜 극단적인 경우에는, 완전한 Worker를 사용하는 대신 requestIdleCallback 또는 scheduler.yield()를 사용하여 작업을 더 작은 청크로 나눌 수 있습니다. 하지만 대규모 데이터셋을 처리하거나, 보고서를 생성하거나, 복잡한 알고리즘을 실행하는 모든 작업에는 Web Worker는 선택 사항이 아니라 필수적입니다.

복합적인 효과

이러한 실수 중 어느 하나도 단독으로 앱을 충돌시키지는 않습니다. 그것이 이들을 위험하게 만드는 이유입니다. 각각은 100-300ms의 지연을 추가하며, 사용자들은 그 총합을 경험합니다. 불필요하게 리렌더링되는 컴포넌트, 비대해진 번들, 최적화되지 않은 이미지, 그리고 메인 스레드 계산이 합쳐지면 앱이 1초 대신 4초 만에 상호작용 가능해진다는 의미입니다.

최적화하기 전에 프로파일링하세요. React DevTools Profiler를 사용하여 리렌더링 핫스팟을 찾으세요. Lighthouse와 Web Vitals를 사용하여 실제 사용자 영향을 측정하세요. 브라우저의 네트워크 탭을 사용하여 과도하게 큰 번들과 이미지를 찾아내세요. 성능 탭을 사용하여 메인 스레드를 차단하는 긴 작업을 식별하세요.

이것들은 제가 레스토랑 플랫폼부터 의료 SaaS 시스템에 이르기까지 구축하는 모든 프로젝트에 적용하는 패턴입니다. 성능은 나중에 추가하는 기능이 아닙니다. 첫 커밋부터 유지해야 하는 품질입니다.

DU

Danil Ulmashev

Full Stack Developer

함께 일하는 데 관심이 있으신가요?