Published on

React 렌더링 폭발 - useMemo·key로 리렌더 차단

Authors

서버가 느린 것도 아닌데 화면이 버벅이고, 입력 한 글자에 리스트 전체가 다시 그려지며, 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>
  );
}

위 코드에서 Rowmemo지만, 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)이 필요합니다.

디버깅 루틴: 폭발 지점을 찾는 순서

  1. React DevTools Profiler로 커밋이 큰 구간을 찾기
  2. 해당 컴포넌트에서 “왜 렌더됐는지” 확인
  3. props 중 객체/배열/함수가 매번 바뀌는지 확인
  4. 리스트라면 key가 안정적인지 먼저 확인
  5. 파생 데이터는 useMemo, 콜백은 useCallback, 자식은 React.memo
  6. 여전히 느리면 “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 개수 자체”를 줄이는 가상화나 렌더 분할로 넘어가면 됩니다.