리액트 성능 최적화, 무작정 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>
);
}
이 예제에서 중요한 것은 useMemo나 useCallback 자체보다 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를 열고 아래 순서로 보는 습관을 들이면 불필요한 시행착오를 많이 줄일 수 있습니다.
- 느린 상호작용을 정확히 재현합니다. 예를 들어 검색창 타이핑, 탭 전환, 모달 열기처럼 사용자가 실제로 버벅임을 느끼는 행동 하나를 고릅니다.
- Profiler에서 녹화한 뒤 같은 행동을 수행합니다.
- 어떤 컴포넌트가 오래 렌더링됐는지, 어떤 컴포넌트가 생각보다 많이 다시 렌더링됐는지 확인합니다.
왜 렌더링되었는지를 보고props변경 때문인지, 부모 리렌더 전파 때문인지 구분합니다.
여기서 중요한 질문은 단순합니다.
- 이 컴포넌트는 정말 다시 그려질 필요가 있었는가
- 상태가 너무 위에 있어서 전파 범위가 커진 것은 아닌가
- 무거운 계산을 렌더마다 반복하고 있지는 않은가
- 큰 리스트를 그냥 전부 그리고 있지는 않은가
리스트가 아주 길다면 메모이제이션보다 가상화가 더 큰 효과를 냅니다. react-window 같은 라이브러리로 화면에 보이는 항목만 렌더링하는 쪽이 근본 해결일 수 있습니다.
흔한 안티패턴과 디버깅 포인트
성능 최적화에서 자주 보이는 안티패턴은 몇 가지로 압축됩니다.
items와query로 계산 가능한filteredItems를 다시state로 저장하면 동기화 포인트만 늘고 버그 가능성이 커집니다.- 모든 함수와 값을
useCallback,useMemo로 감싸면 코드 가독성만 나빠지고 실제 성능은 거의 좋아지지 않을 수 있습니다. - 정렬되거나 필터링되는 리스트에서
index를key로 쓰면 재사용이 꼬여 렌더링과 상태 유지가 비효율적일 수 있습니다. - 서버 데이터, 입력 상태, 모달 상태, 선택 상태를 하나의 상위 컴포넌트에 몰아두면 작은 변경에도 큰 트리가 다시 그려집니다.
- 의존성 배열을 비워 놓고 외부 값을 참조하면 stale closure가 생겨 성능보다 먼저 동작 버그가 발생합니다.
- 입력은 즉시 반응해야 하지만 결과 리스트 렌더링이 무겁다면 단순 메모이제이션보다
useDeferredValue나startTransition이 더 적절한 경우도 있습니다.
핵심은 짧게 정리할 수 있습니다. memo는 구조가 맞을 때만 효율적이고, 구조가 틀렸다면 오히려 문제를 가립니다.
무엇부터 적용하면 좋을까
실무에서는 아래 순서로 접근하면 대부분의 성능 문제를 무리 없이 정리할 수 있습니다.
- 느린 상호작용을 하나 정하고 Profiler로 먼저 측정합니다.
- 상태 소유권을 내려서 리렌더 전파 범위를 줄입니다.
- 계산 비용이 큰 값에만
useMemo를 적용합니다. memo된 자식에게 내려가는 함수 참조가 문제일 때만useCallback을 적용합니다.- 첫 화면이 느리면 라우트 단위 코드 스플리팅과 번들 분석을 진행합니다.
- 항목 수 자체가 많다면 리스트 가상화를 검토합니다.
결국 리액트 성능 최적화는 훅 몇 개를 추가하는 작업이 아닙니다. 어떤 상태를 누가 소유하는지, 그 상태 변화가 어디까지 전파되는지, 사용자가 실제로 느끼는 병목이 어디에 있는지부터 보는 작업에 가깝습니다. 이 기준이 잡히면 React.memo, useMemo, useCallback, 코드 스플리팅도 훨씬 정확한 위치에 적용할 수 있습니다.
원문 참고
https://www.maeil-mail.kr/question/18
댓글
댓글 쓰기