- Published on
React 리렌더 폭증 원인 - useMemo 의존성 함정
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 DB가 아니라 프론트에서 CPU가 타는 케이스를 보면, 의외로 useMemo가 등장합니다. 최적화하려고 넣었는데 렌더가 더 많아지고, DevTools에서 컴포넌트가 초당 수십 번씩 그려지며, 심지어 스크롤·입력도 버벅거리는 상황이 생깁니다.
핵심은 간단합니다. useMemo는 “값을 캐시”하지만, 캐시를 무효화하는 기준은 의존성 배열의 참조 동일성입니다. 의존성에 매 렌더마다 새로 만들어지는 객체/배열/함수/옵션을 넣으면, useMemo는 매번 다시 계산되고 그 결과가 다시 하위 컴포넌트에 전달되면서 리렌더가 연쇄적으로 폭증합니다.
이 글은 useMemo 의존성 함정의 전형적인 패턴을 코드로 재현하고, 어떤 기준으로 고쳐야 하는지(참조 안정화, 파생 상태 제거, 경계 재설계)를 정리합니다.
관련해서, 렌더 폭증이 DOM 측정/레이아웃과 얽히면 브라우저가 강제 레이아웃을 반복하며 더 악화됩니다. 이 경우는 Chrome Forced reflow 경고 원인·해결 7단계도 함께 참고하면 진단 속도가 빨라집니다.
useMemo가 막아주는 것과 못 막는 것
useMemo(factory, deps)는 다음을 보장합니다.
deps가 모두 이전과Object.is로 동일하면, 이전에 계산한 값을 재사용- 그렇지 않으면
factory를 다시 실행하여 새 값을 반환
즉, useMemo는 “계산량”을 줄이는 도구이지 “리렌더” 자체를 막는 도구가 아닙니다.
- 부모가 리렌더되면 자식도 기본적으로 리렌더됩니다.
- 다만 자식이
React.memo로 감싸져 있고, props가 얕은 비교에서 동일하면 자식 리렌더를 피할 수 있습니다. - 그런데 부모가
useMemo로 만든 값을 props로 넘기더라도, 그useMemo가 매번 무효화되면 props 참조가 바뀌고 자식은 계속 리렌더됩니다.
정리하면, 리렌더 폭증은 보통 다음 조합에서 발생합니다.
- 부모가 자주 리렌더(입력, 애니메이션, 전역 상태, 컨텍스트)
useMemodeps에 불안정한 참조가 포함되어 매번 재계산- 재계산 결과(새 객체/배열)를 자식에 전달
- 자식이
React.memo여도 props가 매번 바뀌어 무력화
함정 1: 의존성에 “매 렌더 새 객체”를 넣는 패턴
가장 흔한 실수는 옵션 객체, 필터 객체, 쿼리 파라미터 객체를 렌더 바디에서 즉석 생성하고 deps에 넣는 것입니다.
import { useMemo, useState } from "react";
type Item = { id: number; name: string; active: boolean };
export function ListPage({ items }: { items: Item[] }) {
const [keyword, setKeyword] = useState("");
// 매 렌더마다 새 객체
const filter = { keyword, onlyActive: true };
const filtered = useMemo(() => {
const k = filter.keyword.trim().toLowerCase();
return items
.filter((it) => (filter.onlyActive ? it.active : true))
.filter((it) => it.name.toLowerCase().includes(k));
}, [items, filter]); // filter 참조가 매번 바뀜
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<ul>
{filtered.map((it) => (
<li key={it.id}>{it.name}</li>
))}
</ul>
</div>
);
}
겉으로는 useMemo가 있으니 필터링이 캐시될 것 같지만, filter는 렌더마다 새로 만들어져 deps 비교에서 항상 변경으로 판단됩니다. 결과적으로 useMemo는 매번 재계산합니다.
해결 1) deps를 원시 값으로 분해하기
filter 객체를 deps로 넣지 말고, 실제로 계산에 필요한 원시 값만 deps로 넣습니다.
const filtered = useMemo(() => {
const k = keyword.trim().toLowerCase();
const onlyActive = true;
return items
.filter((it) => (onlyActive ? it.active : true))
.filter((it) => it.name.toLowerCase().includes(k));
}, [items, keyword]);
이 방식은 가장 단순하고 강력합니다. “객체로 묶어서 보기 좋게” 만드는 순간 참조 동일성 이슈가 생길 수 있다는 점을 기억하세요.
해결 2) 객체 자체를 useMemo로 안정화하기
객체를 꼭 유지해야 한다면, 그 객체를 만드는 것부터 useMemo로 감싸 참조를 안정화합니다.
const filter = useMemo(
() => ({ keyword, onlyActive: true }),
[keyword]
);
const filtered = useMemo(() => {
const k = filter.keyword.trim().toLowerCase();
return items
.filter((it) => (filter.onlyActive ? it.active : true))
.filter((it) => it.name.toLowerCase().includes(k));
}, [items, filter]);
다만 이 패턴은 “메모를 위한 메모”가 연쇄적으로 늘 수 있어, 팀 규칙 없이 남발하면 오히려 코드 복잡도가 올라갑니다. 가능하면 deps 분해가 더 낫습니다.
함정 2: deps에 함수(콜백)를 넣는데 그 함수가 매 렌더 새로 생김
정렬 함수, 비교 함수, 매핑 함수 등을 렌더 바디에서 선언하고 deps에 넣으면 같은 문제가 발생합니다.
const sortFn = (a: Item, b: Item) => a.name.localeCompare(b.name);
const sorted = useMemo(() => {
return [...items].sort(sortFn);
}, [items, sortFn]);
sortFn은 매 렌더마다 새 함수입니다. 따라서 sorted는 매번 다시 계산됩니다.
해결: useCallback으로 함수 참조를 안정화하거나 deps에서 제거
import { useCallback, useMemo } from "react";
const sortFn = useCallback(
(a: Item, b: Item) => a.name.localeCompare(b.name),
[]
);
const sorted = useMemo(() => {
return [...items].sort(sortFn);
}, [items, sortFn]);
또는 정렬 기준이 고정이라면, 굳이 deps에 함수를 넣지 않고 useMemo 내부에서 직접 정의해도 됩니다.
const sorted = useMemo(() => {
return [...items].sort((a, b) => a.name.localeCompare(b.name));
}, [items]);
함정 3: useMemo 결과를 다시 state로 저장(파생 상태)해서 루프 만들기
리렌더 폭증에서 특히 위험한 패턴은 “useMemo로 만든 값을 useEffect로 state에 넣는” 파생 상태입니다.
const computed = useMemo(() => heavyCompute(items, keyword), [items, keyword]);
useEffect(() => {
setViewModel(computed);
}, [computed]);
이 자체가 항상 루프를 만들지는 않지만, 다음 조건이 겹치면 폭발합니다.
computed가 매번 새 참조(배열/객체)setViewModel이 부모/컨텍스트 업데이트를 유발- 업데이트가 다시 렌더를 유발하고,
computed가 다시 생성
해결: 파생 상태를 제거하고 “계산된 값은 계산된 값으로만” 사용
const viewModel = useMemo(
() => heavyCompute(items, keyword),
[items, keyword]
);
return <List viewModel={viewModel} />;
정말로 state가 필요한 경우는 “사용자 입력으로 변경되는 값”처럼 원천 데이터일 때가 대부분입니다. 계산 결과는 가능한 한 state로 복제하지 마세요.
함정 4: Context value를 객체로 만들어 뿌리기
컨텍스트는 리렌더 폭증의 주범이 되기 쉽습니다. Provider의 value가 매 렌더마다 새 객체면, 구독하는 모든 컴포넌트가 매번 리렌더됩니다.
const AppContext = createContext<{ theme: string; setTheme: (t: string) => void } | null>(null);
function AppProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState("dark");
// 매 렌더마다 새 객체
const value = { theme, setTheme };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
해결: Provider value를 useMemo로 안정화
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
추가로, 컨텍스트에 “자주 바뀌는 값”과 “거의 안 바뀌는 값”을 같이 넣으면 변경 빈도가 높은 값 때문에 전체가 흔들립니다. 컨텍스트를 분리하거나, 상태 관리 라이브러리의 selector를 고려하세요.
함정 5: deps에 배열을 넣는데 그 배열이 매번 새로 생성됨
예를 들어 컬럼 정의, 메뉴 정의, 탭 정의를 렌더 바디에서 만들면 매번 새 배열입니다.
const columns = [
{ key: "name", title: "Name" },
{ key: "status", title: "Status" },
];
const tableModel = useMemo(() => buildTableModel(items, columns), [items, columns]);
해결: 상수는 컴포넌트 밖으로 빼거나 useMemo로 고정
const COLUMNS = [
{ key: "name", title: "Name" },
{ key: "status", title: "Status" },
];
function Page({ items }: { items: Item[] }) {
const tableModel = useMemo(() => buildTableModel(items, COLUMNS), [items]);
return <Table model={tableModel} />;
}
“useMemo deps를 줄이면 되지 않나”의 함정
리렌더가 많다고 deps를 일부러 빼는 경우가 있습니다. 예를 들어 keyword를 deps에서 제거하면 계산이 덜 일어나겠지만, 결과가 최신 상태를 반영하지 못하는 버그가 생깁니다.
// 안티패턴: 최신 keyword를 반영하지 못함
const filtered = useMemo(() => heavyCompute(items, keyword), [items]);
이런 코드는 QA에서 “검색어가 가끔 씹힌다”, “한 박자 늦게 반영된다” 같은 형태로 나타납니다. 성능 최적화는 정합성을 해치지 않는 범위에서 해야 합니다.
리렌더 폭증을 잡는 진단 체크리스트
아래 순서로 보면 원인을 빠르게 좁힐 수 있습니다.
- React DevTools Profiler로 어떤 컴포넌트가 자주 렌더되는지 확인
- 자주 렌더되는 컴포넌트의 props 중 객체/배열/함수가 있는지 확인
- 그 값이 렌더 바디에서 즉석 생성되는지 확인
useMemodeps에 “묶음 객체”가 들어가 있는지 확인- Context Provider의
value가 새 객체인지 확인 - 파생 상태(
useMemo결과를 state로 복제)가 있는지 확인 - DOM 측정(
getBoundingClientRect등)과 함께 발생하면 forced reflow 여부 확인
프론트 성능 문제는 “원인 파악이 늦어져 비용이 커지는” 성격이 강합니다. 로그 기반으로 근본 원인을 좁히는 접근은 인프라 장애 분석과도 유사합니다. 이런 식의 트러블슈팅 감각은 K8s CrashLoopBackOff 원인 10분내 찾는 법 같은 글에서 다루는 “증상에서 원인으로 수렴하는 방법”과 결이 같습니다.
실전 패턴: “안정적인 props”를 만들어 React.memo를 살리기
리렌더를 실질적으로 줄이려면, 다음 조합이 자주 쓰입니다.
- 부모:
useMemo/useCallback로 자식에게 전달하는 props의 참조를 안정화 - 자식:
React.memo로 props가 동일하면 렌더 스킵
예시입니다.
import React, { useCallback, useMemo, useState } from "react";
type Item = { id: number; name: string; active: boolean };
const List = React.memo(function List({
items,
onToggle,
}: {
items: Item[];
onToggle: (id: number) => void;
}) {
return (
<ul>
{items.map((it) => (
<li key={it.id}>
<button onClick={() => onToggle(it.id)}>
{it.active ? "on" : "off"}
</button>
{it.name}
</li>
))}
</ul>
);
});
export function Page({ initial }: { initial: Item[] }) {
const [items, setItems] = useState(initial);
const [keyword, setKeyword] = useState("");
const filtered = useMemo(() => {
const k = keyword.trim().toLowerCase();
return items.filter((it) => it.name.toLowerCase().includes(k));
}, [items, keyword]);
const onToggle = useCallback((id: number) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, active: !it.active } : it))
);
}, []);
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<List items={filtered} onToggle={onToggle} />
</div>
);
}
여기서 포인트는 다음입니다.
filtered는items/keyword가 바뀔 때만 새 배열onToggle은 참조가 고정List는React.memo로 감싸져 있고, 불필요한 리렌더 가능성이 크게 줄어듭니다
결론: useMemo 의존성은 “값”이 아니라 “참조 안정성”으로 설계
useMemo는 만능 최적화가 아니라, “참조 동일성”이라는 규칙을 정확히 이해하고 써야 효과가 납니다.
- deps에는 가능한 한 원시 값(문자열/숫자/불리언)과 안정적인 참조만 넣기
- 렌더 바디에서 객체/배열/함수를 즉석 생성하면, 그것을 deps로 쓰는 순간 캐시가 무력화될 수 있음
- 파생 상태를 만들지 말고 계산 결과는 계산 결과로 사용하기
- Context Provider의
value는 반드시 안정화 고려
렌더 폭증이 UI 스레드 점유로 이어지면 forced reflow 같은 브라우저 경고와 함께 체감 성능이 급격히 나빠질 수 있습니다. 이 경우 Chrome Forced reflow 경고 원인·해결 7단계에서 소개하는 측정/레이아웃 분리 전략까지 같이 적용하면 효과가 큽니다.