frontend/javascript

🟨 2-36. React 성능 병목 진단법 — Re-render, Virtual DOM, Memoization 완전 정리

mirabo01 2025. 11. 7. 09:00

좋아, 이번 편은 지금까지의 UX 성능 최적화 이론을 실제로 진단하고 튜닝하는 핵심 파트야.
아무리 잘 설계해도, 불필요한 리렌더링이나 Virtual DOM 오버헤드가 쌓이면
결국 체감 성능이 떨어지게 된다.

이번엔 React 내부 동작 원리를 바탕으로
“어디서 느려지는지 → 왜 느려지는지 → 어떻게 고칠지”까지 완전하게 정리하자.


1. React가 느려지는 이유

React는 Virtual DOM 기반의 선언형 렌더링 덕분에 편리하지만,
모든 상태 변화마다 렌더 트리를 다시 계산하기 때문에
컴포넌트 구조가 커질수록 “리렌더링 비용”이 눈덩이처럼 커진다.

대표적인 병목 원인은 아래 세 가지다.

원인 설명 예시

불필요한 리렌더링 props/state 변경이 불필요한 자식까지 렌더링 부모의 state 하나 바꿨는데 전부 다시 그림
함수/객체 재생성 컴포넌트가 렌더링될 때마다 새로운 참조값 생성 inline arrow function, 객체 literal
무거운 계산 로직 렌더링 중 계산 비용이 큼 map/filter/sort를 매번 수행

2. Virtual DOM의 진짜 동작 원리

React는 렌더링 시마다 Virtual DOM을 새로 만들고,
이전 Virtual DOM과 비교(diffing)해 실제 DOM을 갱신한다.

<div>
  <h1>안녕</h1>
  <p>오늘은 {count}일째 React 공부</p>
</div>

count가 바뀔 때마다 Virtual DOM 트리 전체가 다시 만들어진다.
→ React는 이전 트리와 비교해 바뀐 부분(p 텍스트)만 실제 DOM에 반영한다.

✅ 장점: 전체 DOM 조작보다 효율적
❌ 단점: Virtual DOM diffing 자체에도 비용이 있음

즉, 리렌더링은 줄이되, 꼭 필요한 경우에만 발생해야 한다.


3. 불필요한 리렌더링의 대표 패턴

❌ 잘못된 예시

function Parent() {
  const [count, setCount] = useState(0);

  const handleClick = () => setCount(c => c + 1);

  return (
    <>
      <button onClick={handleClick}>+</button>
      <Child /> {/* state와 무관하지만 매번 리렌더 */}
    </>
  );
}

function Child() {
  console.log('렌더링됨');
  return <div>자식 컴포넌트</div>;
}

→ count가 바뀔 때마다 Child도 불필요하게 렌더링된다.

✅ 해결 방법

const Child = React.memo(() => {
  console.log('렌더링됨');
  return <div>자식 컴포넌트</div>;
});

React.memo()는 props가 변하지 않으면 재렌더링을 막는다.

📍 React.memo는 “순수 함수형 컴포넌트”에서만 효과적이다.
(side effect나 state를 직접 가지면 의미가 없다)


4. useCallback과 useMemo — 참조 안정화

문제 예시

function List({ onClick }) {
  return <button onClick={onClick}>추가</button>;
}

export default function App() {
  const [count, setCount] = useState(0);
  const handleAdd = () => setCount(count + 1);

  return <List onClick={handleAdd} />;
}

→ 매 렌더링마다 handleAdd가 새로 만들어짐
→ List 컴포넌트는 매번 props가 바뀐 걸로 인식 → 다시 렌더

해결

const handleAdd = useCallback(() => setCount(c => c + 1), []);

✅ useCallback은 함수 참조값을 고정시켜, props 변경을 막는다.


useMemo로 계산 비용 절감

const total = useMemo(() => {
  return items.reduce((acc, cur) => acc + cur.price, 0);
}, [items]);

→ items가 바뀌지 않는 한, 이전 계산 결과 재사용

⚠️ 주의: 모든 계산에 useMemo를 남발하면 오히려 오버헤드가 커진다.
“매번 계산하면 느려지는 로직에만 사용”


5. 렌더링 병목 진단 도구

5-1. React DevTools Profiler

Chrome DevTools에서
→ Components 탭 옆 “Profiler” → Record → UI 조작 → Stop

결과로 확인할 수 있는 것:

  • 어떤 컴포넌트가 얼마나 자주 렌더링되는지
  • 각 렌더링에 걸린 시간
  • React.memo, useCallback이 실제로 효과가 있었는지

💡 Tip: “왜 렌더링되었는가?”는 React 18에서 StrictMode로 테스트해보면 좋다.
(개발 모드에서 일부러 두 번 렌더링해 문제를 조기 감지)


5-2. why-did-you-render 라이브러리

npm install @welldone-software/why-did-you-render
import React from 'react';
if (process.env.NODE_ENV === 'development') {
  const whyDidYouRender = require('@welldone-software/why-did-you-render');
  whyDidYouRender(React);
}

→ 불필요하게 리렌더링된 컴포넌트를 콘솔에 자동 표시
→ React.memo 효과 검증에도 탁월


6. Context API, Redux, Zustand 등에서의 렌더링 분리

전역 상태를 잘못 구성하면 “모든 구독자 컴포넌트가 다 리렌더링되는” 문제가 생긴다.

❌ Context 전역 상태의 함정

const UserContext = createContext();
function App() {
  const [user, setUser] = useState({ name: "철수" });
  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Nav />
      <Profile />
      <Footer />
    </UserContext.Provider>
  );
}

→ user가 바뀌면 Nav, Profile, Footer 전부 다시 렌더링

✅ 개선: Selector 기반 전역 상태

Zustand나 Jotai, Redux Toolkit을 쓰면 아래처럼 분리 가능

const useUser = create(set => ({
  name: "철수",
  setName: (name) => set({ name }),
}));

function Nav() {
  const name = useUser(state => state.name); // 필요한 부분만 구독
}

→ 해당 selector에 연결된 부분만 렌더링


7. 리스트 성능 최적화

긴 리스트(예: 1000개 이상)는 브라우저 렌더링 자체가 병목이 된다.
이때 사용하는 전략은 Virtualized Rendering.

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

function Row({ index, style }) {
  return <div style={style}>Item #{index}</div>;
}

export default function Example() {
  return (
    <List height={400} itemCount={1000} itemSize={35} width={300}>
      {Row}
    </List>
  );
}

→ 실제로는 20~30개의 아이템만 렌더링
→ 스크롤할 때만 새 요소가 교체
→ DOM 부하 급감


8. 실무용 최적화 패턴 요약

항목 해결책

불필요한 렌더링 React.memo
함수 재생성 useCallback
계산 중복 useMemo
전역 상태 리렌더링 Selector 기반 구독
긴 리스트 react-window / react-virtualized
복잡한 컴포넌트 Code Splitting + Lazy Loading
느린 초기 로딩 Suspense + Streaming SSR

9. 예시: 실전 병목 개선 전후

항목 개선 전 개선 후

렌더링 횟수 120회 35회
평균 렌더 시간 18ms 4ms
Core Web Vitals LCP 3.2초 1.6초

✅ React.memo와 useCallback만 적용해도 절반 이상 개선 가능
✅ 추가로 Virtualized List 적용 시 대규모 데이터에서도 부드럽게 동작


10. 마무리

React의 성능 병목은 “컴퓨터가 느려서”가 아니라,
“렌더링이 너무 자주 일어나서” 생긴다.

Virtual DOM이 효율적이라 해도,
불필요한 렌더링이 반복되면 결국 실제 DOM보다 느려진다.

따라서 진짜 성능 최적화란

  • 어떤 컴포넌트가
  • 왜 다시 렌더링되었는지
  • 그걸 줄일 수 있는가

이 세 가지를 꾸준히 추적하는 일이다.