기본 콘텐츠로 건너뛰기

리액트 성능 최적화, 무작정 memo 쓰기 전에 먼저 봐야 할 기준들

리액트 성능 최적화, 무작정 memo 쓰기 전에 먼저 봐야 할 기준들

빠른 답

  • 느린 원인을 확인하기 전에는 memo부터 늘리지 말고 React DevTools Profiler로 병목을 먼저 찾습니다.
  • 상태를 너무 상위에 두면 하위 전체가 자주 리렌더링되므로, 상태 소유권을 필요한 범위로 최대한 좁힙니다.
  • useMemo와 useCallback은 비용이 큰 계산이나 참조 안정성이 실제로 필요한 경우에만 적용합니다.
  • 초기 로딩이 느리다면 라우트 단위 코드 스플리팅부터 검토하는 편이 체감 성능에 더 직접적입니다.

리액트 앱은 왜 느려질까

리액트 앱이 느려질 때 많은 분이 먼저 memo, useMemo, useCallback부터 떠올립니다. 하지만 실제 병목은 대개 훅이 부족해서가 아니라, 상태가 너무 위에 있거나, 작은 변화가 너무 넓은 화면에 전파되거나, 리스트 렌더링과 정렬·필터링 같은 계산이 반복되기 때문에 생깁니다.

여기서 먼저 구분할 것이 있습니다. 리액트에서 리렌더링은 정상 동작입니다. 상태나 props가 바뀌면 다시 렌더링하면서 다음 UI를 계산하는 것이 리액트의 기본 모델입니다. 문제는 그 렌더링 범위가 불필요하게 넓거나, 계산 자체가 무겁거나, 실제 DOM 반영 비용까지 커질 때입니다.

즉, 리렌더링이 있다성능 문제가 있다는 같은 말이 아닙니다. 성능 최적화의 목표는 리렌더링을 무조건 없애는 것이 아니라, 사용자 행동에 비해 과도한 계산과 전파를 줄이는 것입니다.

성능 최적화의 첫 단계: 상태 소유권 좁히기

프론트엔드에서 가장 먼저 봐야 할 것은 데이터 흐름입니다. 리액트는 부모에서 자식으로 내려가는 단방향 흐름을 가지므로, 어떤 상태를 어디서 소유하느냐가 렌더링 범위를 거의 결정합니다.

예를 들어 검색어는 ProductList에서만 필요한데, 페이지 최상단에서 상태를 들고 있으면 입력할 때마다 페이지 전체가 다시 평가될 수 있습니다.

import { useState } from "react";

function CatalogPage({ products }) {
  const [query, setQuery] = useState("");

  return (
    <>
      <Header />
      <SearchBox value={query} onChange={setQuery} />
      <ProductList products={products} query={query} />
      <Sidebar />
    </>
  );
}

이 구조에서는 query를 쓰지 않는 Header, Sidebar도 부모가 다시 렌더링되면서 함께 평가됩니다. 컴포넌트 수가 많아질수록 타이핑 한 번이 넓은 트리를 흔들게 됩니다.

더 좋은 출발점은 검색 상태를 실제로 쓰는 영역 가까이 내리는 것입니다.

import { memo, useCallback, useMemo, useState } from "react";

const ProductRow = memo(function ProductRow({ product, onSelect }) {
  return (
    <li>
      <button onClick={() => onSelect(product.id)}>{product.name}</button>
    </li>
  );
});

function CatalogPage({ products }) {
  return (
    <>
      <Header />
      <SearchableProductList products={products} />
      <Sidebar />
    </>
  );
}

function SearchableProductList({ products }) {
  const [query, setQuery] = useState("");
  const [selectedId, setSelectedId] = useState(null);

  const visibleProducts = useMemo(() => {
    const keyword = query.trim().toLowerCase();
    return products.filter((product) =>
      product.name.toLowerCase().includes(keyword)
    );
  }, [products, query]);

  const handleSelect = useCallback((id) => {
    setSelectedId(id);
  }, []);

  return (
    <section>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="상품 검색"
      />
      <p>selected: {selectedId ?? "없음"}</p>
      <ul>
        {visibleProducts.map((product) => (
          <ProductRow
            key={product.id}
            product={product}
            onSelect={handleSelect}
          />
        ))}
      </ul>
    </section>
  );
}

이 예제에서 중요한 것은 useMemouseCallback 자체보다 query의 소유 범위를 줄였다는 점입니다. 즉, 먼저 전파 범위를 줄이고, 그다음 정말 필요한 곳에서만 메모이제이션을 얹는 흐름이 맞습니다.

다만 예시를 그대로 공식처럼 외우면 안 됩니다. products가 작고 필터 계산이 가볍다면 useMemo를 빼는 편이 더 단순할 수 있습니다. 최적화는 패턴 복제가 아니라 실제 비용을 기준으로 해야 합니다.

React.memo, useMemo, useCallback은 무엇이 다를까

이 세 도구는 이름이 비슷하지만 막아주는 대상이 다릅니다.

  • React.memo는 컴포넌트 자체의 리렌더링을 줄입니다. 부모가 다시 렌더링돼도 전달받은 props가 같으면 자식 렌더를 건너뛸 수 있습니다.
  • useMemo는 계산 결과 값을 재사용합니다. 정렬, 필터링, 그룹핑처럼 비용이 있는 연산에 적합합니다.
  • useCallback은 함수 참조를 재사용합니다. 함수가 빨라지는 것이 아니라, 함수 참조가 바뀌지 않게 해서 memo된 자식의 최적화를 깨지 않도록 돕습니다.

실무에서는 아래 기준으로 판단하면 크게 틀리지 않습니다.

  • 자식 컴포넌트가 무겁고 같은 props로 자주 다시 그려진다면 React.memo를 검토합니다.
  • 렌더링 중 계산 비용이 반복적으로 크다면 useMemo를 검토합니다.
  • 자식이 React.memo를 쓰고 있고, 부모가 매번 새 함수를 내려서 최적화가 깨진다면 useCallback을 검토합니다.
  • 세 가지 모두 비교 비용과 의존성 관리 비용이 있으므로, 가벼운 컴포넌트나 싼 계산에는 오히려 손해일 수 있습니다.

또 하나 중요한 점은 React.memo가 기본적으로 얕은 비교를 한다는 것입니다. 부모가 매 렌더마다 새 객체, 새 배열, 새 함수를 만들면 값이 같아 보여도 참조가 달라져서 memo 효과가 줄어듭니다. 그래서 메모이제이션은 개별 훅의 문제가 아니라 props 구조와 데이터 전달 방식의 문제이기도 합니다.

초기 로딩이 느리다면 코드 스플리팅부터 보기

입력 반응이 느린 문제와 첫 화면 로딩이 느린 문제는 원인이 다릅니다. 전자는 리렌더링 범위나 계산 비용 문제일 가능성이 높고, 후자는 번들 크기와 네트워크 비용 문제일 가능성이 높습니다.

초기 로딩이 느릴 때는 라우트 단위 코드 스플리팅이 가장 먼저 검토할 대상입니다. 특히 관리자 화면, 차트 페이지, 에디터 페이지처럼 일부 사용자만 들어가는 무거운 화면은 처음부터 한 번에 내려받을 이유가 없습니다.

import { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";

const HomePage = lazy(() => import("./pages/HomePage"));
const AnalyticsPage = lazy(() => import("./pages/AnalyticsPage"));
const AdminPage = lazy(() => import("./pages/AdminPage"));

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/analytics" element={<AnalyticsPage />} />
          <Route path="/admin" element={<AdminPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

이 방식은 첫 진입 시 필요한 코드만 내려받게 해 주므로 체감 속도 개선에 직접적입니다. 다만 fallback이 너무 거칠면 화면이 깜빡거리거나 전환 경험이 나빠질 수 있으니, 스켈레톤이나 페이지 단위 로딩 UI를 함께 설계하는 편이 좋습니다.

번들 크기 분석도 같이 해야 합니다. 감으로 manualChunks를 나누기보다 실제로 어떤 라이브러리가 큰지 먼저 보는 편이 안전합니다.

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      filename: "dist/stats.html",
      open: true,
      gzipSize: true,
    }),
  ],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ["react", "react-dom", "react-router-dom"],
        },
      },
    },
  },
});

설정을 추가했다면 빌드 결과를 꼭 확인해야 합니다.

npm i -D rollup-plugin-visualizer
npm run build
npm run preview

manualChunks는 만능 해법이 아닙니다. 너무 잘게 쪼개면 오히려 요청 수가 늘고 캐시 전략이 꼬일 수 있습니다. 항상 측정 결과를 보고 조정해야 합니다.

Profiler로 먼저 확인할 것

최적화는 측정 전과 후를 비교할 수 있어야 의미가 있습니다. React DevTools Profiler를 열고 아래 순서로 보는 습관을 들이면 불필요한 시행착오를 많이 줄일 수 있습니다.

  1. 느린 상호작용을 정확히 재현합니다. 예를 들어 검색창 타이핑, 탭 전환, 모달 열기처럼 사용자가 실제로 버벅임을 느끼는 행동 하나를 고릅니다.
  2. Profiler에서 녹화한 뒤 같은 행동을 수행합니다.
  3. 어떤 컴포넌트가 오래 렌더링됐는지, 어떤 컴포넌트가 생각보다 많이 다시 렌더링됐는지 확인합니다.
  4. 왜 렌더링되었는지를 보고 props 변경 때문인지, 부모 리렌더 전파 때문인지 구분합니다.

여기서 중요한 질문은 단순합니다.

  • 이 컴포넌트는 정말 다시 그려질 필요가 있었는가
  • 상태가 너무 위에 있어서 전파 범위가 커진 것은 아닌가
  • 무거운 계산을 렌더마다 반복하고 있지는 않은가
  • 큰 리스트를 그냥 전부 그리고 있지는 않은가

리스트가 아주 길다면 메모이제이션보다 가상화가 더 큰 효과를 냅니다. react-window 같은 라이브러리로 화면에 보이는 항목만 렌더링하는 쪽이 근본 해결일 수 있습니다.

흔한 안티패턴과 디버깅 포인트

성능 최적화에서 자주 보이는 안티패턴은 몇 가지로 압축됩니다.

  • itemsquery로 계산 가능한 filteredItems를 다시 state로 저장하면 동기화 포인트만 늘고 버그 가능성이 커집니다.
  • 모든 함수와 값을 useCallback, useMemo로 감싸면 코드 가독성만 나빠지고 실제 성능은 거의 좋아지지 않을 수 있습니다.
  • 정렬되거나 필터링되는 리스트에서 indexkey로 쓰면 재사용이 꼬여 렌더링과 상태 유지가 비효율적일 수 있습니다.
  • 서버 데이터, 입력 상태, 모달 상태, 선택 상태를 하나의 상위 컴포넌트에 몰아두면 작은 변경에도 큰 트리가 다시 그려집니다.
  • 의존성 배열을 비워 놓고 외부 값을 참조하면 stale closure가 생겨 성능보다 먼저 동작 버그가 발생합니다.
  • 입력은 즉시 반응해야 하지만 결과 리스트 렌더링이 무겁다면 단순 메모이제이션보다 useDeferredValuestartTransition이 더 적절한 경우도 있습니다.

핵심은 짧게 정리할 수 있습니다. memo는 구조가 맞을 때만 효율적이고, 구조가 틀렸다면 오히려 문제를 가립니다.

무엇부터 적용하면 좋을까

실무에서는 아래 순서로 접근하면 대부분의 성능 문제를 무리 없이 정리할 수 있습니다.

  1. 느린 상호작용을 하나 정하고 Profiler로 먼저 측정합니다.
  2. 상태 소유권을 내려서 리렌더 전파 범위를 줄입니다.
  3. 계산 비용이 큰 값에만 useMemo를 적용합니다.
  4. memo된 자식에게 내려가는 함수 참조가 문제일 때만 useCallback을 적용합니다.
  5. 첫 화면이 느리면 라우트 단위 코드 스플리팅과 번들 분석을 진행합니다.
  6. 항목 수 자체가 많다면 리스트 가상화를 검토합니다.

결국 리액트 성능 최적화는 훅 몇 개를 추가하는 작업이 아닙니다. 어떤 상태를 누가 소유하는지, 그 상태 변화가 어디까지 전파되는지, 사용자가 실제로 느끼는 병목이 어디에 있는지부터 보는 작업에 가깝습니다. 이 기준이 잡히면 React.memo, useMemo, useCallback, 코드 스플리팅도 훨씬 정확한 위치에 적용할 수 있습니다.

원문 참고

https://www.maeil-mail.kr/question/18

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...