본문 바로가기

React

React 컴포넌트에서 성능 저하 원인 분석 및 제거 방법

React 앱이 느려지는 주된 원인은 불필요한 재렌더, 비싼 계산, 거대한 리스트 렌더링, Context 전파, 잘못된 이펙트 사용 등입니다. 이 글은 DevTools로 병목을 찾고, 코드로 제거하는 실무 중심 방법을 정리합니다.

1. 성능 저하 징후 빠르게 파악하기

다음과 같은 징후가 보이면 렌더링 병목을 의심합니다.

- 입력 시 딜레이가 느껴집니다.

- 스크롤이 끊깁니다.

- 특정 화면 전환이 버벅입니다.

- 컴포넌트가 자주 재렌더됩니다.

먼저 측정부터 합니다. 무작정 최적화는 낭비입니다.

2. DevTools Profiler로 병목 찾기

React DevTools Profiler로 렌더 타임을 기록합니다. Flamegraph에서 시간이 긴 컴포넌트와 재렌더가 반복되는 지점을 확인합니다. Highlight updates 옵션으로 업데이트가 과도한 컴포넌트를 시각적으로 파악합니다.

간단한 로컬 측정은 console.time을 활용합니다.

function Slow({ items }) {
  console.time('Slow render');
  const list = items.map(i => i * 2);
  console.timeEnd('Slow render');
  return <div>{list.length}</div>;
}

3. 불필요한 재렌더 원인과 제거

부모가 매 렌더마다 새로운 참조를 만들어 자식이 재렌더되는 경우가 많습니다. 특히 함수, 객체, 배열, 스타일 객체가 원인입니다.

// 안티패턴: 부모가 매번 새 객체/함수 생성
function Parent() {
  const data = { a: 1 };
  const handleClick = () => doSomething();
  return <Child data={data} onClick={handleClick} />;
}

// 개선: 참조 안정화 + 자식 메모
const Child = React.memo(function Child({ data, onClick }) {
  return <button onClick={onClick}>{data.a}</button>;
});

function Parent() {
  const data = React.useMemo(() => ({ a: 1 }), []);
  const handleClick = React.useCallback(() => doSomething(), []);
  return <Child data={data} onClick={handleClick} />;
}

주의: useMemo/useCallback은 비용이 있습니다. 재렌더 비용이 의미 있게 줄어들 때만 적용합니다.

4. 비용 큰 계산·생성물 캐싱하기

렌더 중에 비싼 계산을 하면 입력 지연이 커집니다. 계산과 정렬, 필터, 포맷, 큰 배열/객체 생성은 캐싱합니다.

function Chart({ points }) {
  const processed = React.useMemo(() => expensiveCompute(points), [points]);
  return <Canvas data={processed} />;
}

// 렌더마다 새 배열/객체를 만들지 않기
function List({ items }) {
  const visible = React.useMemo(() => items.filter(i => i.visible), [items]);
  return <ul>{visible.map(i => <li key={i.id}>{i.label}</li>)}</ul>;
}

5. 거대한 리스트는 가상화로

수천 개 항목을 그대로 렌더하면 느려집니다. 화면에 보이는 아이템만 그리는 가상화를 적용합니다.

import { FixedSizeList as List } from 'react-window';

function BigList({ items }) {
  return (
    <List height={600} itemCount={items.length} itemSize={48} width={800}>
      {({ index, style }) => (
        <Row style={style} item={items[index]} />
      )}
    </List>
  );
}

const Row = React.memo(function Row({ item, style }) {
  return <div style={style}>{item.label}</div>;
});

6. Context로 인한 전파 줄이기

Provider의 value가 매번 새로 만들어지면 하위 트리가 모두 재렌더됩니다. value 참조를 안정화하거나 Context를 분리합니다.

const UserContext = React.createContext(null);

function UserProvider({ user, children }) {
  const value = React.useMemo(() => ({ user }), [user]);
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

// 필요 시 Context를 역할별로 분리
const ThemeContext = React.createContext('light');

더 정교하게는 Context 셀렉터(use-context-selector 등)나 상태를 더 하위로 내리는 방식으로 영향 범위를 줄입니다.

7. 상태 설계와 컴포넌트 분리

상태가 상위에 뭉쳐 있으면 작은 변화에도 대규모 재렌더가 일어납니다. 상태를 사용처 가까이에서 관리하고, 화면을 작게 나눠 경계(React.memo)를 둡니다.

// 큰 컴포넌트 분해 + 메모로 경계 만들기
const Toolbar = React.memo(function Toolbar(props) { /* ... */ });
const Content = React.memo(function Content(props) { /* ... */ });

function Page() {
  const [query, setQuery] = React.useState('');
  return (
    <div>
      <Toolbar query={query} onChange={setQuery} />
      <Content query={query} />
    </div>
  );
}

입력 지연이 크다면 useDeferredValue로 작업을 늦춰 UX를 개선합니다.

function Search({ items }) {
  const [q, setQ] = React.useState('');
  const deferredQ = React.useDeferredValue(q);
  const filtered = React.useMemo(() => filter(items, deferredQ), [items, deferredQ]);
  return (
    <>
      <input value={q} onChange={e => setQ(e.target.value)} />
      <Results items={filtered} />
    </>
  );
}

8. 이펙트/이벤트 핸들러 최적화

잘못된 의존성 배열은 무한 루프나 잦은 재렌더를 유발합니다. 비동기 통신은 디바운스/취소를 적용합니다.

// 안티패턴: 의존성으로 인해 반복 setState
function Bad() {
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    setCount(count + 1); // 매번 변경되어 루프
  }, [count]);
}

// 개선: 함수형 업데이트 또는 의존성 정리
function Good() {
  const [count, setCount] = React.useState(0);
  React.useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id);
  }, []);
}

// 네트워크 요청 디바운스 + 취소
function useDebouncedFetch(query) {
  const abortRef = React.useRef();
  React.useEffect(() => {
    if (abortRef.current) abortRef.current.abort();
    const controller = new AbortController();
    abortRef.current = controller;
    const id = setTimeout(async () => {
      await fetch('/api?q=' + encodeURIComponent(query), { signal: controller.signal });
    }, 250);
    return () => { clearTimeout(id); controller.abort(); };
  }, [query]);
}

9. 키, 메모이제이션 남용 주의

키는 안정적인 식별자(id)를 사용합니다. 인덱스 키는 재배치 시 불필요한 재마운트를 유발합니다. 또한 React.memo/useMemo/useCallback을 무분별하게 쓰면 오히려 복잡도와 비용이 늘어납니다. 프로파일링으로 효과를 확인하고 최소 범위에 적용합니다.

// 나쁜 키
items.map((item, index) => <Row key={index} item={item} />)

// 좋은 키
items.map(item => <Row key={item.id} item={item} />)

10. 빠른 점검 체크리스트

- Profiler로 가장 느린 컴포넌트부터 측정합니다.

- 부모가 새 참조(함수/객체/배열/스타일)를 매 렌더 생성하지 않는지 확인합니다.

- 비싼 계산은 useMemo로 캐싱합니다.

- 대형 리스트는 react-window 등으로 가상화합니다.

- Context Provider value를 메모하고, 맥락을 분리합니다.

- 상태는 사용처 근처에 두고, 컴포넌트를 분리해 경계를 만듭니다.

- 이펙트 의존성을 점검하고 디바운스/취소를 적용합니다.

- 키 안정화와 메모 남용을 방지합니다.

측정 → 원인 → 최소 변경으로 제거라는 흐름을 지키면 작은 투자로 큰 성능 향상을 얻을 수 있습니다.