- Published on
React 렌더링 폭발 - useMemo·key로 리렌더 차단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 느린 것도 아닌데 화면이 버벅이고, 입력 한 글자에 리스트 전체가 다시 그려지며, DevTools Profiler에서 커밋 시간이 급증한다면 대부분은 React 렌더링 폭발(render explosion) 패턴입니다. 특히 대규모 리스트, 필터/정렬, 그래프/차트, 복잡한 폼에서 자주 터집니다.
이 글은 “왜 리렌더가 연쇄적으로 퍼지는지”를 참조 동일성(reference equality) 관점에서 설명하고, 실무에서 가장 효과가 큰 두 축인 useMemo(및 memo) 와 key 전략으로 리렌더를 차단하는 방법을 코드로 정리합니다. 성능 측정 관점은 Chrome INP 점수 급락 원인 - Long Task 추적법 글과 함께 보면, “렌더링 최적화가 곧 INP 개선”으로 연결되는 지점까지 감이 잡힙니다.
렌더링 폭발의 전형적인 징후
1) 입력/토글 한 번에 리스트 전체가 리렌더
- 부모 컴포넌트 상태 변경 → 자식 전체 리렌더
- 자식이
React.memo여도 props가 매번 새로 만들어져 무력화
2) Profiler에서 “왜 렌더됐는지”가 props 변경으로 도배
onClick={() => ...}같은 인라인 함수{...obj}/[...arr]로 매번 새 참조 생성
3) key가 불안정해서 DOM 재사용이 깨짐
key={index}key={Math.random()}- 정렬/필터 시 아이템이 “다른 아이템으로 둔갑” → 재마운트/상태 초기화
React 리렌더의 핵심: “값”이 아니라 “참조”가 바뀐다
React는 기본적으로 부모가 렌더되면 자식도 렌더합니다. 여기서 React.memo는 “props가 같으면 스킵”인데, 이때의 “같다”는 대개 얕은 비교(shallow compare) 입니다.
즉 아래처럼 내용이 같아도 참조가 다르면 다른 것으로 봅니다.
const a = { q: 1 };
const b = { q: 1 };
console.log(a === b); // false
렌더링 폭발은 대부분 이 패턴으로 생깁니다.
- 렌더마다 새 객체/배열/함수 생성
- 그게 자식 props로 전달
memo가 매번 깨짐- 리스트가 크면 커밋 시간이 폭발
1차 방어선: React.memo + useMemo/useCallback로 “참조 동일성” 유지
문제 예시: 필터 결과 배열을 매번 새로 만들어 리스트 전체 리렌더
import React, { useState } from "react";
type Item = { id: string; name: string; price: number };
const Row = React.memo(function Row({ item }: { item: Item }) {
// Row는 memo지만 item 참조가 바뀌면 다시 렌더됨
return (
<li>
{item.name} - {item.price}
</li>
);
});
export default function Catalog({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
// ❌ 렌더마다 새로운 배열 생성 -> Row에 내려가는 item들도 대개 새 참조가 됨
const filtered = items
.filter((x) => x.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.price - b.price);
return (
<section>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{filtered.map((item) => (
<Row key={item.id} item={item} />
))}
</ul>
</section>
);
}
위 코드에서 Row는 memo지만, filtered가 매번 새로 계산되며 정렬까지 동반되면 대형 리스트에서 입력 INP가 급락합니다.
개선: useMemo로 파생 데이터 캐싱(“계산”이 아니라 “참조”를 고정)
import React, { useMemo, useState } from "react";
type Item = { id: string; name: string; price: number };
const Row = React.memo(function Row({ item }: { item: Item }) {
return (
<li>
{item.name} - {item.price}
</li>
);
});
export default function Catalog({ items }: { items: Item[] }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
// 정렬은 원본 배열을 mutate하지 않도록 복사 후 sort
return items
.filter((x) => x.name.toLowerCase().includes(q))
.slice()
.sort((a, b) => a.price - b.price);
}, [items, query]);
return (
<section>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>
{filtered.map((item) => (
<Row key={item.id} item={item} />
))}
</ul>
</section>
);
}
핵심은 filtered의 참조가 불필요하게 바뀌지 않게 만드는 것입니다. query가 바뀔 때만 바뀌고, 그 외 렌더에서는 동일 참조가 유지되므로 Row의 스킵이 잘 먹습니다.
콜백도 동일: useCallback은 “함수 참조”를 고정한다
import React, { useCallback, useMemo, useState } from "react";
type Item = { id: string; name: string };
const Row = React.memo(function Row({
item,
onSelect,
}: {
item: Item;
onSelect: (id: string) => void;
}) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
export default function List({ items }: { items: Item[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// ✅ 함수 참조 고정
const onSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
// (예시) selectedId 기반으로 파생 값 만들 때도 useMemo 고려
const selected = useMemo(
() => items.find((x) => x.id === selectedId) ?? null,
[items, selectedId]
);
return (
<>
<ul>
{items.map((item) => (
<Row key={item.id} item={item} onSelect={onSelect} />
))}
</ul>
<pre>{JSON.stringify(selected, null, 2)}</pre>
</>
);
}
주의할 점은 useCallback/useMemo가 “마법의 성능 버튼”이 아니라는 것입니다. 의존성이 자주 바뀌면 캐시도 자주 무효화되어 효과가 제한적입니다. 하지만 렌더링 폭발의 주요 원인인 “불필요한 참조 변경”을 막는 데는 가장 직접적인 도구입니다.
2차 방어선: key 전략으로 “재마운트 폭발”을 막는다
리렌더는 그래도 “기존 컴포넌트 인스턴스”를 재사용할 수 있지만, 잘못된 key는 아예 다른 컴포넌트로 인식하게 만들어 재마운트를 유발합니다. 이 경우는 더 치명적입니다.
- 내부 state 초기화
- 입력 포커스 튐
- 이미지/비동기 요청 재실행
- DOM 노드 재생성으로 레이아웃/페인트 비용 증가
최악의 예: index key
{todos.map((todo, index) => (
<TodoRow key={index} todo={todo} />
))}
정렬/필터/삽입/삭제가 발생하면 index가 바뀌어 다른 todo가 기존 컴포넌트 자리를 차지합니다. “A의 상태가 B에 붙는” 현상도 여기서 나옵니다.
최악의 예 2: 랜덤 key
{todos.map((todo) => (
<TodoRow key={Math.random()} todo={todo} />
))}
이건 매 렌더마다 key가 바뀌므로 항상 전부 재마운트입니다. 리스트가 크면 즉시 폭발합니다.
올바른 key: 안정적이고 유일한 식별자
{todos.map((todo) => (
<TodoRow key={todo.id} todo={todo} />
))}
- DB PK, UUID, 서버가 내려주는 id
- 로컬 생성이라면 생성 시점에 UUID를 부여하고 이후 유지
“key를 바꾸면 리셋된다”를 의도적으로 활용하기
가끔은 리렌더 차단이 아니라 정확히 특정 서브트리를 초기화하고 싶을 때가 있습니다. 이때만 key 변경을 사용합니다.
function SearchPage() {
const [session, setSession] = React.useState(0);
return (
<>
<button onClick={() => setSession((s) => s + 1)}>새 검색 세션</button>
{/* ✅ session이 바뀔 때만 SearchPanel을 완전 초기화 */}
<SearchPanel key={session} />
</>
);
}
의도적 리셋은 좋지만, 리스트 렌더에서 무분별한 key 변경은 재마운트 폭발의 지름길입니다.
“useMemo를 어디에 써야 하나?” 실전 기준
1) 비용이 큰 파생 계산 + 입력/스크롤 같은 잦은 이벤트에 걸려 있다
- filter/sort/groupBy
- 마크다운 파싱, 하이라이트
- 대량 데이터 포맷팅
2) memoized child에게 내려가는 props의 참조가 자주 바뀐다
options={{...}},style={{...}}columns={[...]}같은 테이블 설정
const columns = useMemo(
() => [
{ key: "name", title: "Name" },
{ key: "price", title: "Price" },
],
[]
);
return <DataTable columns={columns} rows={rows} />;
3) “리렌더는 괜찮은데 재마운트는 안 된다”면 key를 먼저 점검
- 폼 입력이 사라진다
- 컴포넌트 내부 state가 리셋된다
- 애니메이션이 매번 처음부터 시작한다
이런 증상은 useMemo보다 key가 원인일 확률이 높습니다.
흔한 함정: useMemo로 객체를 감쌌는데도 느리다
1) 의존성에 매번 새 객체가 들어간다
// ❌ parent에서 options를 매번 새로 만들어 내려주면
// child의 useMemo는 매번 무효화됨
<MyComp options={{ theme: "dark" }} />
해결은 상위에서 options도 메모하거나, props 구조를 평탄화하는 것입니다.
const options = useMemo(() => ({ theme: "dark" }), []);
return <MyComp options={options} />;
2) useMemo로 “계산”은 줄였는데 “렌더”가 비싸다
- DOM 노드가 너무 많다
- 리스트 가상화(react-window 등)가 필요
- 이미지/캔버스/차트 렌더 자체가 무거움
이때는 메모보다 구조적 처방(가상화, 분할 렌더, Offscreen, Web Worker)이 필요합니다.
디버깅 루틴: 폭발 지점을 찾는 순서
- React DevTools Profiler로 커밋이 큰 구간을 찾기
- 해당 컴포넌트에서 “왜 렌더됐는지” 확인
- props 중 객체/배열/함수가 매번 바뀌는지 확인
- 리스트라면 key가 안정적인지 먼저 확인
- 파생 데이터는
useMemo, 콜백은useCallback, 자식은React.memo - 여전히 느리면 “DOM 개수/가상화/비동기 분리”로 접근
프론트에서 발생한 렌더링 폭발은 종종 백엔드/DB 병목처럼 보이기도 합니다. 예를 들어 “스크롤할수록 느려진다”를 DB 문제로 오해하고 쿼리를 뜯기 전에, 먼저 클라이언트의 렌더/Long Task를 확인해보는 게 비용 대비 효과가 큽니다. 비슷한 ‘폭증’ 디버깅 관점은 pandas merge 후 행 수 폭증? 중복키 진단·해결 글의 사고방식(원인 추적 → 폭증 지점 격리)과도 닮아 있습니다.
체크리스트: useMemo·key로 리렌더를 “차단”하는 최소 조건
- 리스트 렌더에
key={index},key={Math.random()}가 없다 - key는 데이터의 안정적인 id를 사용한다
-
React.memo를 썼다면 props의 객체/배열/함수 참조가 안정적이다 - filter/sort/map 등 파생 데이터는
useMemo로 캐싱한다 - 이벤트 핸들러는
useCallback으로 고정하거나, 자식에서 필요 없는 prop 전달을 끊는다 - 최적화 전/후를 Profiler와 INP(롱태스크)로 확인한다 (Chrome INP 점수 급락 원인 - Long Task 추적법)
마무리
React 렌더링 폭발은 대개 “컴포넌트가 많아서”가 아니라, 참조 동일성이 깨진 props가 연쇄적으로 퍼지거나, 불안정한 key로 재마운트가 발생해서 생깁니다.
useMemo는 파생 데이터의 참조를 고정해memo가 동작하게 만들고,- 올바른
key는 리스트에서 DOM/컴포넌트 재사용을 보장해 재마운트 폭발을 막습니다.
이 두 가지를 먼저 잡아도 체감 성능이 크게 개선되지 않는다면, 그때는 “DOM 개수 자체”를 줄이는 가상화나 렌더 분할로 넘어가면 됩니다.