React useMemo와 useCallback, 언제 쓰면 이득인가 | 렌더링 최적화 실전
이 글의 핵심
useMemo와 useCallback을 모든 곳에 두르면 오히려 메모리와 비교 비용만 늘고 코드는 읽기 어려워집니다. 리렌더링이 왜 일어나는지부터 짚고, 비싼 계산인지 참조 안정성이 필요한지로 판단하는 기준을 세운 뒤, 실제로 빨라졌는지 React DevTools Profiler로 확인하는 순서를 제시합니다.
들어가며
React 18/19에서 함수 컴포넌트는 props·state·부모 리렌더에 따라 자주 다시 실행됩니다. useMemo와 useCallback은 이전 결과를 재사용해 불필요한 연산·참조 변경을 줄이는 도구입니다. React useMemo·useCallback을 언제 쓸지는 “비싼 계산인가, 참조 안정이 필요한가”로 먼저 갈라집니다. 다만 둘 자체도 비용이 있어 남발하면 오히려 느려질 수 있습니다.
이 글은 언제 메모이제이션이 이득인지를 판별하는 기준과, 프로파일러로 검증하는 순서를 담았습니다. 비동기 흐름은 async 가이드, 디버깅은 비동기 디버깅 사례와 연결됩니다.
왜 useMemo·useCallback이 존재하나요?
함수 컴포넌트는 매 렌더마다 새로 실행되므로, 안에서 만든 객체·함수 참조도 기본적으로 새로 생깁니다. 그 참조가 React.memo 자식, useEffect 의존성, 외부 훅의 안정성에 영향을 주기 때문에, 필요할 때만 이전 참조를 재사용하려는 도구가 useMemo와 useCallback입니다.
프로덕션에서 주의할 점
- 측정 없이 전 레이어에 적용하면 메모리·비교 비용만 늘고 체감 개선이 없을 수 있습니다. React DevTools Profiler로 먼저 병목을 확인하는 것을 권장합니다.
- 의존성 배열을 잘못 맞추면 “메모했는데도 갱신이 이상하다” 또는 “안 바뀌어야 하는데 바뀐다”가 납니다. ESLint
exhaustive-deps와 팀 규칙을 함께 씁니다. - 서버 컴포넌트로 데이터를 끌어올 수 있는 부분은 클라이언트 메모이제이션 부담 자체가 줄어듭니다. 경계 설계를 먼저 검토합니다.
개념 설명
리렌더링 기본 원리
- 리렌더: 부모가 렌더되면 기본적으로 자식도 다시 렌더됩니다(조건 없다면).
- 참조 동일성: 객체·함수·배열 리터럴은 렌더마다 새 참조가 됩니다.
React.memo로 감싼 자식이나useEffect의 의존성 배열에서 불필요한 변화로 이어질 수 있습니다.
useMemo와 useCallback
- useMemo: 값(객체·배열·계산 결과)을 의존성이 바뀔 때만 다시 계산합니다.
- useCallback: 함수 참조를 의존성이 바뀔 때만 유지합니다. 사실상
useMemo(() => fn, deps)의 문법 설탕입니다.
언제 사용하나?
useMemo 사용 시기:
- 비용이 큰 계산: 배열 필터링, 정렬, 복잡한 연산
- 참조 안정화: 객체나 배열을 props로 전달할 때
- 의존성 배열:
useEffect의 의존성으로 사용될 때useCallback사용 시기: React.memo자식: 메모이제이션된 자식에게 콜백 전달- 의존성 배열:
useEffect의 의존성으로 사용될 때 - 외부 훅: 참조 안정성이 필요한 외부 라이브러리
언제 사용하지 말아야 하나?
- 가벼운 연산: 단순 계산은 메모이제이션 비용이 더 클 수 있음
- 측정 없이: Profiler로 병목을 확인하기 전
- 모든 함수: 과도한 메모이제이션은 코드 복잡도만 증가
실전 구현 (단계별 코드)
비용 큰 리스트 필터링 — useMemo
문제: 매 렌더마다 필터링 재실행
import { useState } from "react";
type Item = { id: string; label: string; score: number };
export function LeaderboardBad({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const visible = !q
? items
: items.filter((it) => it.label.toLowerCase().includes(q));
return (
<section>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{visible.map((it) => (
<li key={it.id}>
{it.label} — {it.score}
</li>
))}
</ul>
</section>
);
}
문제점:
- 부모가 리렌더될 때마다 필터링 재실행
items가 수천 건이면 성능 저하 해결:useMemo로 캐싱
import { useMemo, useState } from "react";
type Item = { id: string; label: string; score: number };
export function Leaderboard({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const visible = useMemo(() => {
console.log("필터링 실행");
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter((it) => it.label.toLowerCase().includes(q));
}, [items, query]);
return (
<section>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{visible.map((it) => (
<li key={it.id}>
{it.label} — {it.score}
</li>
))}
</ul>
</section>
);
}
효과:
items와query가 변경될 때만 필터링 실행- 부모 리렌더 시 캐시된 결과 재사용
자식 콜백 안정화 — useCallback + memo
문제: 매 렌더마다 새 함수 생성
import { memo, useState } from "react";
const Row = memo(function Row({
id,
onSelect,
}: {
id: string;
onSelect: (id: string) => void;
}) {
console.log(`Row ${id} 렌더`);
return <button onClick={() => onSelect(id)}>{id}</button>;
});
export function TableBad({ ids }: { ids: string[] }) {
const [active, setActive] = useState<string | null>(null);
const handleSelect = (id: string) => {
setActive(id);
};
return (
<div>
<p>active: {active}</p>
{ids.map((id) => (
<Row key={id} id={id} onSelect={handleSelect} />
))}
</div>
);
}
문제점:
handleSelect가 매 렌더마다 새로 생성Row의memo가 무력화됨- 모든
Row가 매번 리렌더 해결:useCallback으로 안정화
import { memo, useCallback, useState } from "react";
const Row = memo(function Row({
id,
onSelect,
}: {
id: string;
onSelect: (id: string) => void;
}) {
console.log(`Row ${id} 렌더`);
return <button onClick={() => onSelect(id)}>{id}</button>;
});
export function Table({ ids }: { ids: string[] }) {
const [active, setActive] = useState<string | null>(null);
const handleSelect = useCallback((id: string) => {
setActive(id);
}, []);
return (
<div>
<p>active: {active}</p>
{ids.map((id) => (
<Row key={id} id={id} onSelect={handleSelect} />
))}
</div>
);
}
효과:
handleSelect참조가 안정적Row의memo가 제대로 작동- 클릭해도
Row들은 props(id,onSelect)가 그대로라 리렌더되지 않고,active를 표시하는<p>만 갱신
의존성 배열이 []인데도 이 콜백이 항상 올바르게 동작하는 이유는 setActive가 React가 보장하는 안정된 참조이고, 콜백이 현재 state를 읽지 않기 때문입니다. 만약 콜백 안에서 active를 읽어 if (active === id) ...처럼 분기한다면, 의존성에 active를 넣어야 하고 그러면 active가 바뀔 때마다 콜백이 새로 만들어져 모든 Row가 다시 렌더됩니다. 이럴 때는 setActive(prev => prev === id ? null : id)처럼 함수형 업데이트로 바꾸면 state를 읽지 않아도 되므로 의존성을 비운 채 유지할 수 있습니다. 메모이제이션을 오래 유지하는 요령의 대부분이 이런 식으로 “콜백이 렌더 시점의 값을 읽지 않게” 만드는 것입니다.
한 가지 더 알아 둘 점은 useCallback만으로는 아무 효과가 없다는 것입니다. 자식이 memo로 감싸져 있지 않으면 부모가 렌더될 때 자식도 무조건 다시 렌더되므로, 참조를 아무리 안정화해도 결과는 같습니다. 반대로 memo 자식에 넘기는 props 가운데 하나라도 매번 새 객체나 함수라면 memo가 무력화됩니다. 두 도구는 세트로 쓸 때만 의미가 있습니다.
객체 props 안정화 — useMemo
문제: 매 렌더마다 새 객체 생성
import { memo, useState } from "react";
const Chart = memo(function Chart({ config }: { config: { theme: string; width: number } }) {
console.log("Chart 렌더");
return <div style={{ width: config.width }}>차트 ({config.theme})</div>;
});
export function DashboardBad() {
const [count, setCount] = useState(0);
const config = { theme: "dark", width: 600 };
return (
<div>
<button onClick={() => setCount(count + 1)}>카운트: {count}</button>
<Chart config={config} />
</div>
);
}
문제점:
config객체가 매 렌더마다 새로 생성Chart의memo가 무력화됨 해결:useMemo로 안정화
import { memo, useMemo, useState } from "react";
const Chart = memo(function Chart({ config }: { config: { theme: string; width: number } }) {
console.log("Chart 렌더");
return <div style={{ width: config.width }}>차트 ({config.theme})</div>;
});
export function Dashboard() {
const [count, setCount] = useState(0);
const config = useMemo(() => ({ theme: "dark", width: 600 }), []);
return (
<div>
<button onClick={() => setCount(count + 1)}>카운트: {count}</button>
<Chart config={config} />
</div>
);
}
효과:
config참조가 안정적Chart는count변경 시 리렌더되지 않음
이 예제처럼 의존성이 빈 배열이고 값이 렌더와 무관한 상수라면, useMemo보다 컴포넌트 바깥에 상수로 끌어올리는 것이 더 간단하고 비용도 없습니다. const CHART_CONFIG = { theme: "dark", width: 600 };을 모듈 최상단에 두면 참조가 영원히 같습니다. useMemo는 props나 state에 따라 값이 달라지는 경우에만 필요합니다.
Context 값 분리 — Provider 안에서 객체 쪼개기
문제: Context 값이 매 렌더마다 새로 생성
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext<{ theme: string; setTheme: (t: string) => void } | null>(null);
export function AppBad() {
const [theme, setTheme] = useState("light");
const value = { theme, setTheme };
return (
<ThemeContext.Provider value={value}>
<Header />
<Content />
</ThemeContext.Provider>
);
}
문제점:
value객체가 매 렌더마다 새로 생성- 모든 구독자가 리렌더
해결:
useMemo로 안정화
import { createContext, useContext, useMemo, useState } from "react";
const ThemeContext = createContext<{ theme: string; setTheme: (t: string) => void } | null>(null);
export function App() {
const [theme, setTheme] = useState("light");
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return (
<ThemeContext.Provider value={value}>
<Header />
<Content />
</ThemeContext.Provider>
);
}
효과:
theme이 변경될 때만 새value생성- 불필요한 리렌더 방지
단, 이 방식이 막아 주는 것은 App이 다른 이유로 리렌더될 때 구독자가 덩달아 리렌더되는 경우뿐입니다. theme이 실제로 바뀌면 useContext(ThemeContext)를 쓰는 모든 컴포넌트가 여전히 다시 렌더되고, Context에는 “일부 필드만 구독”하는 기능이 없습니다. setTheme만 필요한 버튼까지 테마 변경 때 리렌더되는 게 문제라면, 제목처럼 값과 setter를 서로 다른 Context로 쪼개는 것이 근본 해결입니다. 상태가 자주 바뀌고 구독자가 많다면 Context 대신 선택적 구독을 지원하는 상태 관리 라이브러리(Zustand의 selector 등)를 검토하는 편이 낫습니다.
useEffect 의존성 안정화
문제: 함수가 의존성에 포함되어 무한 루프
import { useEffect, useState } from "react";
export function DataFetcherBad() {
const [data, setData] = useState(null);
const fetchData = async () => {
const res = await fetch("/api/data");
const json = await res.json();
setData(json);
};
useEffect(() => {
fetchData();
}, [fetchData]);
return <div>{JSON.stringify(data)}</div>;
}
문제점:
fetchData가 매 렌더마다 새로 생성useEffect가 매번 실행 (무한 루프 가능) 해결:useCallback으로 안정화
import { useCallback, useEffect, useState } from "react";
export function DataFetcher() {
const [data, setData] = useState(null);
const fetchData = useCallback(async () => {
const res = await fetch("/api/data");
const json = await res.json();
setData(json);
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return <div>{JSON.stringify(data)}</div>;
}
효과:
fetchData참조가 안정적useEffect가 마운트 시 한 번만 실행
사실 이 경우에는 useCallback보다 함수를 effect 안으로 옮기는 것이 더 좋은 해법입니다. useEffect(() => { const fetchData = async () => {...}; fetchData(); }, [])처럼 쓰면 의존성으로 관리할 함수 자체가 사라집니다. useCallback은 같은 함수를 effect 바깥(버튼 클릭 핸들러 등)에서도 써야 할 때 선택합니다. 또 개발 모드의 StrictMode는 effect를 일부러 두 번 실행하므로 “마운트 시 한 번”이라는 기대와 달리 요청이 두 번 나가는 것이 정상이며, 이는 정리(cleanup) 함수가 없는 effect를 찾아내기 위한 동작입니다. 요청 경쟁을 막으려면 AbortController로 이전 요청을 취소하는 cleanup을 두는 것이 좋습니다.
고급 활용: 자식 메모·커스텀 비교
memo의 커스텀 비교 함수
기본 memo (얕은 비교)
import { memo } from "react";
const User = memo(function User({ user }: { user: { id: string; name: string } }) {
console.log("User 렌더");
return <div>{user.name}</div>;
});
커스텀 비교 함수
import { memo } from "react";
const User = memo(
function User({ user }: { user: { id: string; name: string; updatedAt: number } }) {
console.log("User 렌더");
return <div>{user.name}</div>;
},
(prevProps, nextProps) => {
return prevProps.user.id === nextProps.user.id;
}
);
주의사항:
- 커스텀 비교는 복잡도 증가
- 디버깅이 어려워질 수 있음
- 측정 후 필요할 때만 사용
위 비교 함수는 id만 비교하므로, 같은 사용자의 name이 바뀌어도 화면이 갱신되지 않는 버그를 만듭니다. 커스텀 비교 함수가 위험한 이유가 바로 이것입니다. props가 늘어날 때마다 비교 함수도 함께 고쳐야 하는데, 잊어버리면 “데이터는 바뀌었는데 화면만 옛날 값”인 증상이 나고 원인을 찾기 어렵습니다. 비교 기준이 필요하다면 updatedAt처럼 변경을 대표하는 필드를 함께 비교하고, 가능하면 부모에서 불변 업데이트로 객체 참조 자체를 바꿔 기본 얕은 비교에 맡기는 편이 안전합니다.
React Compiler
자동 메모이제이션
// React Compiler가 활성화되면 자동으로 최적화
export function AutoOptimized({ items }: { items: Item[] }) {
const filtered = items.filter((it) => it.score > 50);
return (
<ul>
{filtered.map((it) => (
<li key={it.id}>{it.label}</li>
))}
</ul>
);
}
현재 상태:
React Compiler는 빌드 단계(Babel 플러그인 등)에서 컴포넌트를 분석해, 위처럼 평범하게 쓴 코드에 필요한 메모이제이션을 자동으로 넣어 줍니다. 2025년에 1.0 안정 버전이 나와 React 17 이상에서 쓸 수 있고, Next.js·Vite 같은 도구에서도 옵션으로 켤 수 있습니다. 컴파일러를 도입한 코드베이스에서는 새 코드에 useMemo·useCallback을 손으로 넣을 일이 크게 줄어듭니다.
다만 컴파일러는 컴포넌트가 React 규칙(렌더 중 부수효과 없음, props·state 직접 변경 금지 등)을 지킨다고 가정합니다. 규칙을 어기는 컴포넌트는 최적화 대상에서 건너뛰므로, 도입 전에 eslint-plugin-react-hooks의 컴파일러 관련 규칙으로 위반을 먼저 정리하는 것이 순서입니다. 이미 있는 수동 메모이제이션은 굳이 지우지 않아도 되지만, 이 글의 판단 기준(비싼 계산인가, 참조 안정이 필요한가)은 컴파일러가 없는 프로젝트나 effect 의존성을 정확히 제어해야 하는 곳에서 여전히 필요합니다.
Server Components와의 조합
서버 컴포넌트에서 데이터 가져오기
// app/page.tsx (Server Component)
async function getData() {
const res = await fetch("https://api.example.com/data");
return res.json();
}
export default async function Page() {
const data = await getData();
return <ClientComponent data={data} />;
}
클라이언트 컴포넌트에서 메모이제이션
// components/ClientComponent.tsx
"use client";
import { useMemo } from "react";
export function ClientComponent({ data }: { data: Item[] }) {
const sorted = useMemo(() => {
// sort()는 원본 배열을 직접 바꾸므로 props를 변형하지 않도록 복사본을 정렬
return [...data].sort((a, b) => b.score - a.score);
}, [data]);
return (
<ul>
{sorted.map((it) => (
<li key={it.id}>{it.label}</li>
))}
</ul>
);
}
효과:
- 서버에서 데이터 가져오기 (클라이언트 부담 감소)
- 클라이언트에서 필요한 부분만 메모이제이션
성능·비교: 이득이 나는 조건
비교표
| 상황 | useMemo | useCallback |
|---|---|---|
| 무거운 순수 계산 | 후보 | 해당 없음 |
| 안정 참조가 필요한 객체/배열 props | 후보 | 객체를 만들 때 함수도 함께 고정하려면 둘 다 |
memo 자식에 넘기는 핸들러 | 보통 직접 이득 적음 | 자식 메모와 세트일 때 의미 |
| 이미 가벼운 연산 | 오버헤드만 증가 가능 | 동일 |
직접 측정하기
useMemo 없이
const visible = items.filter((it) => it.label.includes(query));
- 매 렌더마다 필터링 실행
useMemo사용
const visible = useMemo(
() => items.filter((it) => it.label.includes(query)),
[items, query]
);
items·query가 바뀔 때만 필터링 실행, 다른 이유의 리렌더에서는 캐시 재사용
주의할 점은 query가 바뀌는 렌더에서는 두 코드의 비용이 같다는 것입니다. 입력창에 글자를 칠 때마다 query가 바뀌므로, 타이핑이 느린 문제는 useMemo로 해결되지 않습니다. useMemo가 도움이 되는 것은 부모의 다른 state 변경처럼 필터 조건과 무관한 리렌더가 잦을 때입니다. 타이핑 자체가 버벅인다면 useDeferredValue로 필터링 결과의 갱신을 뒤로 미루거나, 목록을 가상화해 렌더되는 DOM 수를 줄이는 쪽이 효과적입니다. 계산 하나가 얼마나 걸리는지 확인하려면 console.time으로 감싸 보는 것이 가장 빠르며, 공식 문서도 대략 1ms 이상 걸리는 계산을 메모이제이션 후보로 봅니다. 측정은 개발 모드가 아니라 프로덕션 빌드에서, 가능하면 CPU 쓰로틀링을 건 상태에서 하는 편이 실제 사용자 환경에 가깝습니다.
측정 방법: React DevTools Profiler
- Profiler 탭 열기
- 녹화 시작
- 상호작용 (입력, 클릭 등)
- 녹화 중지
- 커밋 시간 비교
메모이제이션 비용
useMemo 오버헤드:
- 의존성 배열 비교: 렌더마다 각 의존성을
Object.is로 비교 (개수가 적으면 매우 작음) - 메모리 사용: 이전 결과와 의존성 배열을 컴포넌트가 살아 있는 동안 보관
- 가독성: 의존성 배열이라는 관리 대상이 하나 더 생김 언제 손해인가?:
- 가벼운 연산 (< 1ms)
- 의존성이 매번 변경
- 메모리 제약이 큰 환경
실무 사례
사례 1: 차트 라이브러리 최적화
문제: 차트 데이터 가공이 무거움
import { useMemo } from "react";
import { LineChart } from "recharts";
export function SalesChart({ sales }: { sales: Sale[] }) {
const chartData = useMemo(() => {
return sales.map((sale) => ({
date: sale.date,
revenue: sale.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));
}, [sales]);
return <LineChart data={chartData} />;
}
효과:
sales가 변경될 때만 데이터 가공- 차트 리렌더 성능 개선
사례 2: 가상 스크롤 최적화
문제: 스크롤 시 모든 아이템 리렌더
import { memo, useCallback, useMemo, useState } from "react";
const VirtualRow = memo(function VirtualRow({
index,
item,
onClick,
}: {
index: number;
item: Item;
onClick: (id: string) => void;
}) {
return (
<div onClick={() => onClick(item.id)}>
{index}: {item.label}
</div>
);
});
export function VirtualList({ items }: { items: Item[] }) {
const [selected, setSelected] = useState<string | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const visibleItems = useMemo(() => {
const startIndex = Math.floor(scrollTop / 50);
return items.slice(startIndex, startIndex + 20);
}, [items, scrollTop]);
const handleClick = useCallback((id: string) => {
setSelected(id);
}, []);
return (
<div onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
{visibleItems.map((item, index) => (
<VirtualRow key={item.id} index={index} item={item} onClick={handleClick} />
))}
</div>
);
}
효과:
- 보이는 아이템만 렌더링
- 스크롤 중에도
handleClick참조가 유지되어, 새로 보이게 된 행만 렌더
이 코드는 개념을 보여 주기 위한 축약판입니다. 실제로 동작하려면 스크롤 컨테이너에 고정 높이와 overflow: auto, 전체 높이만큼의 안쪽 여백이 있어야 하고, index에는 잘라낸 배열의 위치가 아니라 startIndex + index를 넘겨야 합니다. 지금처럼 슬라이스 기준 인덱스를 넘기면 스크롤할 때마다 모든 행의 index prop이 바뀌어 memo가 무력화됩니다. 직접 구현하기보다는 TanStack Virtual이나 react-window 같은 검증된 라이브러리를 쓰는 편이 안전합니다.
사례 3: 폼 검증 최적화
문제: 입력마다 전체 폼 검증 실행
import { useMemo, useState } from "react";
export function SignupForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const errors = useMemo(() => {
const errs: string[] = [];
if (email && !email.includes("@")) {
errs.push("유효한 이메일을 입력하세요");
}
if (password && password.length < 8) {
errs.push("비밀번호는 8자 이상이어야 합니다");
}
return errs;
}, [email, password]);
return (
<form>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{errors.map((err, i) => (
<p key={i}>{err}</p>
))}
</form>
);
}
효과:
email이나password가 변경될 때만 검증
솔직히 말하면 이 사례는 메모이제이션이 필요 없는 쪽에 가깝습니다. 이 폼에서 리렌더를 일으키는 것은 두 입력값뿐이라 거의 모든 렌더에서 의존성이 바뀌고, 검증 자체도 문자열 검사 두 번이라 비용이 미미합니다. 검증 로직이 정규식 수십 개나 비동기 중복 확인처럼 무거워지거나, 같은 컴포넌트에 다른 state가 많아 무관한 리렌더가 잦을 때 비로소 의미가 생깁니다. 처음 최적화를 배우면 이런 곳에도 습관처럼 useMemo를 두르게 되는데, 리뷰에서 “이게 없으면 무엇이 느려지나”를 물었을 때 답할 수 없다면 빼는 것이 맞습니다.
트러블슈팅
흔한 실수와 해결
| 실수 | 결과 | 해결 |
|---|---|---|
Profiler 없이 useCallback만 증설 | 의존성 비교 비용만 증가 | 병목 구간만 좁혀 적용 |
| Context에 매 렌더 새 객체 | 구독 컴포넌트 전부 리렌더 | 값 분리·useMemo로 value 안정화 |
useMemo 안에서 부수효과 | Concurrent 등에서 예측 어려움 | 부수효과는 useEffect로 |
memo만 쓰고 props 참조는 불안정 | 메모 무력화 | 콜백·객체를 의도적으로 안정화 |
증상별 해결 방법
증상: 메모했는데도 자식이 매번 렌더된다
// 문제
const Parent = () => {
const [count, setCount] = useState(0);
const config = { theme: "dark" };
return <Child config={config} />;
};
// 해결
const Parent = () => {
const [count, setCount] = useState(0);
const config = useMemo(() => ({ theme: "dark" }), []);
return <Child config={config} />;
};
증상: useMemo가 갱신이 안 된다
// 문제: 의존성 배열 누락
const filtered = useMemo(() => items.filter((it) => it.score > threshold), [items]);
// 해결: threshold 추가
const filtered = useMemo(() => items.filter((it) => it.score > threshold), [items, threshold]);
증상: 코드만 복잡해지고 체감 없음
// 문제: 가벼운 연산에 메모이제이션
const doubled = useMemo(() => count * 2, [count]);
// 해결: 메모이제이션 제거
const doubled = count * 2;
증상: Concurrent 렌더에서 이상하다
// 문제: useMemo 안에서 부수효과
const data = useMemo(() => {
logAnalytics("data computed");
return items.filter((it) => it.active);
}, [items]);
// 해결: useEffect로 분리
const data = useMemo(() => items.filter((it) => it.active), [items]);
useEffect(() => {
logAnalytics("data computed");
}, [data]);
ESLint 규칙 설정
exhaustive-deps 활성화
{
"rules": {
"react-hooks/exhaustive-deps": "error"
}
}
exhaustive-deps가 제안하는 의존성 수정은 동작을 바꿀 수 있어서 eslint --fix로 자동 적용되지 않고, 에디터의 빠른 수정(suggestion)으로만 제공됩니다. 경고를 없애려고 의존성을 기계적으로 추가하면 effect가 예상보다 자주 실행될 수 있으므로, 추가하기 전에 “이 값이 바뀌면 정말 다시 실행돼야 하는가”를 확인하고, 아니라면 값을 effect 안으로 옮기거나 함수형 업데이트로 의존성 자체를 없애는 쪽을 먼저 검토합니다. // eslint-disable-next-line으로 경고를 끄는 것은 스테일 클로저 버그를 숨기는 가장 흔한 경로입니다.
마무리
useMemo와 useCallback은 “렌더 비용 줄이기”와 “참조 안정화” 두 축으로 이해하면 선택이 쉬워집니다.
핵심 원칙:
- Profiler로 병목 확인 후 적용
- 가벼운 연산은 메모이제이션하지 않기
- 의존성 배열 정확히 관리 (ESLint 활용)
React.memo와 세트로 사용 (콜백 안정화)- 과도한 메모이제이션 경계 (코드 복잡도 증가) 패턴 전반은 JavaScript 패턴과 함께 보면 컴포넌트 설계까지 연결하기 좋습니다.
자주 묻는 질문 (FAQ)
Q. 모든 함수와 계산을 useMemo·useCallback으로 감싸 두면 안전하지 않나요?
A. 오히려 손해일 수 있습니다. 메모이제이션도 매 렌더마다 의존성 배열을 비교하고 이전 결과를 메모리에 저장하는 비용이 들기 때문에, 가벼운 연산이거나 의존성이 매번 바뀌는 경우에는 이득 없이 복잡도만 늘어납니다. React.memo로 감싼 자식에게 넘기는 콜백, useEffect 의존성, 비용 큰 필터링처럼 이유가 분명한 곳에만 쓰고 Profiler로 병목을 확인한 뒤 적용하는 것이 좋습니다.