React 성능 최적화 정리

Q1. Virtual DOM은 어떻게 동작하나요?

답변

Virtual DOM실제 DOM의 가벼운 복사본으로, React가 UI 업데이트를 효율적으로 처리하기 위한 메커니즘입니다.

동작 원리

1. 렌더링 과정:

// 1. 초기 렌더링
function App() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

// Virtual DOM (JavaScript 객체)
{
  type: 'div',
  props: {},
  children: [
    {
      type: 'h1',
      props: {},
      children: ['Count: 0']
    },
    {
      type: 'button',
      props: { onClick: [Function] },
      children: ['+1']
    }
  ]
}

2. 업데이트 과정:

State 변경 (count: 0 → 1)
  ↓
새로운 Virtual DOM 생성
  ↓
이전 Virtual DOM과 비교 (Diffing)
  ↓
변경된 부분만 찾기
  ↓
실제 DOM에 변경사항 적용 (Reconciliation)

3. Diffing Algorithm:

// Before
<div>
  <h1>Count: 0</h1>
  <button>+1</button>
</div>

// After (count: 0 → 1)
<div>
  <h1>Count: 1</h1>   변경됨
  <button>+1</button>
</div>

// Diffing 결과:
// - h1의 텍스트만 변경됨
// - button은 변경 없음

// 실제 DOM 업데이트 (최소한의 변경)
document.querySelector('h1').textContent = 'Count: 1';
// → button은 건드리지 않음!

Virtual DOM vs 실제 DOM 성능 비교

실제 DOM 직접 조작 (느림):

// ❌ 실제 DOM 10번 업데이트
for (let i = 0; i < 10; i++) {
  document.getElementById('list').innerHTML += `<li>${i}</li>`;
  // → 매번 Reflow/Repaint 발생 (10번)
}

Virtual DOM 사용 (빠름):

// ✅ Virtual DOM으로 10개 업데이트 → 1번에 적용
function List() {
  const [items, setItems] = useState([]);

  const addItems = () => {
    const newItems = [];
    for (let i = 0; i < 10; i++) {
      newItems.push(i);
    }
    setItems(newItems);
    // → Virtual DOM에서 Diffing
    // → 실제 DOM은 1번만 업데이트
  };

  return (
    <ul>
      {items.map(item => <li key={item}>{item}</li>)}
    </ul>
  );
}

성능 측정:

방식100개 업데이트1000개 업데이트
실제 DOM 직접150ms1500ms
Virtual DOM30ms200ms
차이5배 빠름7.5배 빠름

꼬리 질문 1: Reconciliation 알고리즘은?

React의 Reconciliation은 **O(n³) → O(n)**으로 최적화:

일반적인 Diffing: O(n³)

트리 A와 트리 B의 최소 편집 거리
→ 모든 노드 쌍 비교
→ O(n³) 복잡도

React의 Diffing: O(n)

1. 다른 타입의 엘리먼트 → 전체 교체
2. 같은 타입의 엘리먼트 → props만 비교
3. 자식 리스트 → key로 식별

예시:

// ❌ key 없이 리스트 업데이트 (느림)
<ul>
  <li>A</li>
  <li>B</li>
</ul>

// 맨 앞에 추가
<ul>
  <li>C</li>   추가
  <li>A</li>
  <li>B</li>
</ul>

// React가 보는 것:
// li[0]: 없음 → C (생성)
// li[1]: A → A (변경 없음)
// li[2]: B → B (변경 없음)
// → 실제로는 C만 추가하면 되는데, 전체 재생성! ⚠️

// ✅ key로 리스트 업데이트 (빠름)
<ul>
  <li key="a">A</li>
  <li key="b">B</li>
</ul>

// 맨 앞에 추가
<ul>
  <li key="c">C</li>   추가
  <li key="a">A</li>
  <li key="b">B</li>
</ul>

// React가 보는 것:
// key="c": 새로운 요소 (추가)
// key="a": 이미 존재 (재사용)
// key="b": 이미 존재 (재사용)
// → C만 추가! ✅

꼬리 질문 2: Virtual DOM이 항상 빠른가요?

아니요. 간단한 업데이트는 실제 DOM이 더 빠를 수 있습니다.

// ❌ Virtual DOM 오버헤드 (간단한 업데이트)
function Counter() {
  const [count, setCount] = useState(0);

  return <div>{count}</div>;
  // 1. Virtual DOM 생성
  // 2. Diffing
  // 3. 실제 DOM 업데이트
  // → 3단계 (오버헤드)
}

// ✅ 실제 DOM 직접 조작 (더 빠를 수 있음)
const div = document.createElement('div');
div.textContent = count;
// → 1단계 (직접 업데이트)

Virtual DOM의 장점:

  • 복잡한 UI 업데이트 시 최적화
  • 선언적 프로그래밍 (가독성)
  • 크로스 플랫폼 (React Native)

Q2. React.memo는 어떻게 사용하나요?

답변

React.memo컴포넌트를 메모이제이션하여 불필요한 리렌더링을 방지합니다.

기본 사용법

문제 상황:

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <Child name={name} />  {/* count 변경 시에도 리렌더링! ⚠️ */}
    </div>
  );
}

function Child({ name }) {
  console.log('Child 렌더링');  // count 변경 시마다 실행됨!
  return <div>Name: {name}</div>;
}

// 동작:
// count 변경 → Parent 리렌더링 → Child도 리렌더링 (name은 안 변했는데!)

해결: React.memo 사용:

// ✅ React.memo로 최적화
const Child = React.memo(function Child({ name }) {
  console.log('Child 렌더링');
  return <div>Name: {name}</div>;
});

// 동작:
// count 변경 → Parent 리렌더링 → Child는 리렌더링 안 함 (name 변경 없음)
// name 변경 → Parent 리렌더링 → Child도 리렌더링 (name 변경됨)

Custom Comparison Function

얕은 비교의 한계:

function Parent() {
  const [count, setCount] = useState(0);
  const user = { name: 'John', age: 30 };  // 매번 새 객체!

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <Child user={user} />  {/* user는 매번 새 객체라 리렌더링! ⚠️ */}
    </div>
  );
}

const Child = React.memo(function Child({ user }) {
  console.log('Child 렌더링');
  return <div>{user.name}</div>;
});

// React.memo는 기본적으로 얕은 비교 (shallow comparison)
// user !== user (참조가 다름) → 리렌더링 발생!

해결 1: useMemo 사용:

function Parent() {
  const [count, setCount] = useState(0);
  const user = useMemo(() => ({ name: 'John', age: 30 }), []);
  // → user 객체 메모이제이션 (재생성 안 함)

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <Child user={user} />  {/* user 참조 동일 → 리렌더링 안 함 ✅ */}
    </div>
  );
}

해결 2: Custom Comparison:

const Child = React.memo(
  function Child({ user }) {
    console.log('Child 렌더링');
    return <div>{user.name}</div>;
  },
  (prevProps, nextProps) => {
    // true 반환 → 리렌더링 스킵
    // false 반환 → 리렌더링
    return prevProps.user.name === nextProps.user.name;
  }
);

React.memo 사용 시 주의사항

1. 무분별한 사용 금지:

// ❌ 모든 컴포넌트에 memo 적용 (오히려 성능 저하)
const TinyComponent = React.memo(function TinyComponent() {
  return <div>Hello</div>;
});
// → memo 비교 비용 > 리렌더링 비용

// ✅ 무거운 컴포넌트에만 적용
const HeavyComponent = React.memo(function HeavyComponent({ data }) {
  // 복잡한 계산이나 많은 자식 컴포넌트
  return <ExpensiveChart data={data} />;
});

2. Props 변경이 잦으면 무의미:

// ❌ props가 매번 변경되면 memo 무의미
function Parent() {
  const [time, setTime] = useState(Date.now());

  useEffect(() => {
    const timer = setInterval(() => setTime(Date.now()), 1000);
    return () => clearInterval(timer);
  }, []);

  return <Child time={time} />;  // time은 1초마다 변경
}

const Child = React.memo(function Child({ time }) {
  return <div>{time}</div>;
  // → 1초마다 리렌더링되므로 memo 효과 없음!
});

꼬리 질문: memo vs PureComponent 차이는?

React.memo: 함수형 컴포넌트용 PureComponent: 클래스형 컴포넌트용

// React.memo (함수형)
const MyComponent = React.memo(function MyComponent({ value }) {
  return <div>{value}</div>;
});

// PureComponent (클래스형)
class MyComponent extends React.PureComponent {
  render() {
    return <div>{this.props.value}</div>;
  }
}

// 둘 다 얕은 비교 (shallow comparison) 수행

Q3. useCallback과 useMemo의 차이는?

답변

useCallback: 함수를 메모이제이션 useMemo: 값을 메모이제이션

useCallback

문제 상황:

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');

  // ❌ 매번 새로운 함수 생성
  const handleClick = () => {
    console.log(name);
  };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <Child onClick={handleClick} />
      {/* count 변경 → handleClick 재생성 → Child 리렌더링! ⚠️ */}
    </div>
  );
}

const Child = React.memo(function Child({ onClick }) {
  console.log('Child 렌더링');
  return <button onClick={onClick}>Click me</button>;
});

해결: useCallback:

function Parent() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('John');

  // ✅ 함수 메모이제이션
  const handleClick = useCallback(() => {
    console.log(name);
  }, [name]);  // name이 변경될 때만 재생성

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <Child onClick={handleClick} />
      {/* count 변경 → handleClick 재사용 → Child 리렌더링 안 함 ✅ */}
    </div>
  );
}

useMemo

문제 상황:

function ExpensiveComponent({ items }) {
  // ❌ 매번 재계산 (부모가 리렌더링될 때마다)
  const total = items.reduce((sum, item) => sum + item.price, 0);

  return <div>Total: ${total}</div>;
}

function Parent() {
  const [count, setCount] = useState(0);
  const items = [
    { price: 100 },
    { price: 200 },
    { price: 300 }
  ];

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveComponent items={items} />
      {/* count 변경 → total 재계산 (items는 안 변했는데!) ⚠️ */}
    </div>
  );
}

해결: useMemo:

function ExpensiveComponent({ items }) {
  // ✅ 값 메모이제이션
  const total = useMemo(() => {
    console.log('계산 중...');
    return items.reduce((sum, item) => sum + item.price, 0);
  }, [items]);  // items가 변경될 때만 재계산

  return <div>Total: ${total}</div>;
}

useCallback vs useMemo

동일한 동작:

// useCallback
const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

// useMemo로 동일하게 구현
const memoizedCallback = useMemo(() => {
  return () => {
    doSomething(a, b);
  };
}, [a, b]);

// useCallback = useMemo의 함수 특화 버전

비교표:

특징useCallbackuseMemo
반환함수
용도함수를 자식에게 전달무거운 계산 결과 캐싱
예시이벤트 핸들러필터링, 정렬, 계산

실무 사용 예시

useCallback 사용:

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  // ✅ useCallback으로 검색 함수 메모이제이션
  const handleSearch = useCallback(
    debounce(async (searchQuery) => {
      const data = await fetch(`/api/search?q=${searchQuery}`);
      setResults(data);
    }, 300),
    []
  );

  useEffect(() => {
    if (query) {
      handleSearch(query);
    }
  }, [query, handleSearch]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <ResultList results={results} />
    </div>
  );
}

useMemo 사용:

function ProductList({ products, filters }) {
  // ✅ useMemo로 필터링/정렬 결과 캐싱
  const filteredProducts = useMemo(() => {
    console.log('필터링 중...');
    return products
      .filter(p => p.category === filters.category)
      .filter(p => p.price >= filters.minPrice)
      .sort((a, b) => a.price - b.price);
  }, [products, filters]);

  return (
    <ul>
      {filteredProducts.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

꼬리 질문: 언제 useCallback/useMemo를 사용해야 하나요?

사용해야 할 때:

// ✅ 1. React.memo와 함께 사용
const Child = React.memo(function Child({ onClick }) {
  return <button onClick={onClick}>Click</button>;
});

function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return <Child onClick={handleClick} />;
}

// ✅ 2. 무거운 계산
const expensiveValue = useMemo(() => {
  let result = 0;
  for (let i = 0; i < 1000000; i++) {
    result += Math.sqrt(i);
  }
  return result;
}, [dependency]);

// ✅ 3. useEffect 의존성 배열
useEffect(() => {
  fetchData(memoizedValue);
}, [memoizedValue]);  // 메모이제이션 안 하면 무한 루프!

사용하지 말아야 할 때:

// ❌ 1. 가벼운 계산
const sum = useMemo(() => a + b, [a, b]);
// → 그냥 계산이 더 빠름

// ❌ 2. 매번 변하는 값
const value = useMemo(() => Math.random(), []);
// → 의미 없음

// ❌ 3. 컴포넌트 최상위
const Component = useMemo(() => <div>Hello</div>, []);
// → 잘못된 사용법

Q4. 리스트 렌더링 최적화 방법은?

답변

대량의 리스트를 효율적으로 렌더링하는 방법입니다.

1. key 올바르게 사용하기

❌ 잘못된 key 사용:

// Anti-pattern 1: index를 key로 사용
{items.map((item, index) => (
  <li key={index}>{item.name}</li>
))}

// 문제: 순서가 바뀌면 리렌더링 발생
// Before: [A, B, C]
// After:  [C, A, B]  (C를 맨 앞으로 이동)
// → React는 모든 항목이 변경된 것으로 판단! ⚠️

// Anti-pattern 2: random을 key로 사용
{items.map((item) => (
  <li key={Math.random()}>{item.name}</li>
))}
// → 매번 새 key 생성 → 전체 리렌더링! ⚠️

✅ 올바른 key 사용:

// ✅ 고유 ID를 key로 사용
{items.map((item) => (
  <li key={item.id}>{item.name}</li>
))}

// 순서가 바뀌어도 React가 정확히 추적 ✅
// Before: [A(id:1), B(id:2), C(id:3)]
// After:  [C(id:3), A(id:1), B(id:2)]
// → C만 이동, A와 B는 재사용

2. Virtualization (가상 스크롤)

문제: 10,000개 항목 렌더링 → 느림

// ❌ 10,000개 DOM 노드 생성 (느림)
function List({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}
// → 초기 렌더링: 3초
// → 스크롤: 버벅임

해결: react-window 사용:

// ✅ 화면에 보이는 항목만 렌더링 (빠름)
import { FixedSizeList } from 'react-window';

function VirtualList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={600}        // 리스트 높이
      itemCount={items.length}  // 전체 항목 : 10,000개
      itemSize={50}       // 항목 높이: 50px
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

// 동작:
// 화면에 보이는 항목만 렌더링 (약 12개)
// 스크롤 시 동적으로 렌더링/제거
// → 초기 렌더링: 0.1초 (30배 빠름!)

성능 비교:

항목 수일반 렌더링Virtualization
100개50ms20ms
1,000개500ms30ms
10,000개3000ms40ms

3. Lazy Loading (지연 로딩)

문제: 모든 이미지를 한 번에 로드 → 느림

// ❌ 100개 이미지를 한 번에 로드
{products.map(product => (
  <div key={product.id}>
    <img src={product.imageUrl} alt={product.name} />
  </div>
))}
// → 네트워크 요청 100개
// → 초기 로딩: 10초

해결: Intersection Observer:

// ✅ 화면에 보일 때만 이미지 로드
function LazyImage({ src, alt }) {
  const [imageSrc, setImageSrc] = useState(null);
  const imgRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setImageSrc(src);
          observer.disconnect();
        }
      },
      { rootMargin: '50px' }  // 50px 전에 미리 로드
    );

    observer.observe(imgRef.current);

    return () => observer.disconnect();
  }, [src]);

  return (
    <img
      ref={imgRef}
      src={imageSrc || 'placeholder.png'}
      alt={alt}
    />
  );
}

// 동작:
// 1. 처음에는 placeholder 표시
// 2. 화면에 50px 가까워지면 실제 이미지 로드
// 3. 로드 완료 후 이미지 표시

4. Pagination (페이지네이션)

function ProductList() {
  const [page, setPage] = useState(1);
  const [products, setProducts] = useState([]);
  const itemsPerPage = 20;

  useEffect(() => {
    fetchProducts(page, itemsPerPage).then(setProducts);
  }, [page]);

  return (
    <div>
      <ul>
        {products.map(product => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>

      <button onClick={() => setPage(page - 1)} disabled={page === 1}>
        Previous
      </button>
      <button onClick={() => setPage(page + 1)}>
        Next
      </button>
    </div>
  );
}

// → 한 번에 20개만 렌더링
// → 메모리 효율적

꼬리 질문: Infinite Scroll vs Pagination?

Infinite Scroll:

function InfiniteList() {
  const [items, setItems] = useState([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const loaderRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting && hasMore) {
          setPage(prev => prev + 1);
        }
      }
    );

    observer.observe(loaderRef.current);
    return () => observer.disconnect();
  }, [hasMore]);

  useEffect(() => {
    fetchItems(page).then(newItems => {
      setItems(prev => [...prev, ...newItems]);
      setHasMore(newItems.length > 0);
    });
  }, [page]);

  return (
    <div>
      {items.map(item => <div key={item.id}>{item.name}</div>)}
      <div ref={loaderRef}>Loading...</div>
    </div>
  );
}

비교:

특징Infinite ScrollPagination
UX부드러운 스크롤명확한 경계
성능메모리 증가일정한 메모리
SEO어려움쉬움
적합SNS, 피드검색 결과, 카탈로그

Q5. 실무에서 React 성능 최적화 경험은?

답변

프로젝트: 대용량 상품 목록 최적화

문제 상황

// Before: 10,000개 상품 렌더링
function ProductList() {
  const [products, setProducts] = useState([]);
  const [filters, setFilters] = useState({});

  // ❌ 문제점들
  // 1. 10,000개 DOM 노드 생성 (3초 소요)
  // 2. 필터 변경 시 전체 리렌더링
  // 3. 모든 이미지 한 번에 로드

  return (
    <div>
      <FilterPanel onFilterChange={setFilters} />
      <ul>
        {products.map(product => (
          <ProductItem
            key={product.id}
            product={product}
            onAddToCart={handleAddToCart}  // 매번  함수!
          />
        ))}
      </ul>
    </div>
  );
}

성능 지표 (Before):

  • 초기 렌더링: 3초
  • 필터 변경: 1.5초
  • 메모리 사용: 500MB

최적화 적용

1. Virtualization (react-window):

import { FixedSizeList } from 'react-window';

function ProductList() {
  const [products, setProducts] = useState([]);
  const filteredProducts = useFilteredProducts(products, filters);

  const Row = ({ index, style }) => (
    <div style={style}>
      <ProductItem product={filteredProducts[index]} />
    </div>
  );

  return (
    <FixedSizeList
      height={800}
      itemCount={filteredProducts.length}
      itemSize={120}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

// 결과: 10,000개 → 화면에 보이는 7개만 렌더링

2. useMemo로 필터링 최적화:

function useFilteredProducts(products, filters) {
  return useMemo(() => {
    console.log('필터링 중...');
    return products
      .filter(p => !filters.category || p.category === filters.category)
      .filter(p => p.price >= (filters.minPrice || 0))
      .filter(p => p.price <= (filters.maxPrice || Infinity))
      .sort((a, b) => {
        if (filters.sortBy === 'price') return a.price - b.price;
        if (filters.sortBy === 'name') return a.name.localeCompare(b.name);
        return 0;
      });
  }, [products, filters]);
  // → filters 변경 시에만 재계산
}

3. React.memo + useCallback:

// ProductItem 메모이제이션
const ProductItem = React.memo(function ProductItem({ product, onAddToCart }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
});

// 부모 컴포넌트
function ProductList() {
  const handleAddToCart = useCallback((productId) => {
    // 장바구니에 추가
    addToCart(productId);
  }, []);

  return (
    <FixedSizeList>
      {({ index, style }) => (
        <div style={style}>
          <ProductItem
            product={products[index]}
            onAddToCart={handleAddToCart}  // 재사용됨!
          />
        </div>
      )}
    </FixedSizeList>
  );
}

4. 이미지 Lazy Loading:

function ProductImage({ src, alt }) {
  const [loaded, setLoaded] = useState(false);
  const imgRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setLoaded(true);
          observer.disconnect();
        }
      },
      { rootMargin: '100px' }
    );

    observer.observe(imgRef.current);
    return () => observer.disconnect();
  }, []);

  return (
    <img
      ref={imgRef}
      src={loaded ? src : 'data:image/svg+xml,...'}  // Placeholder
      alt={alt}
      loading="lazy"
    />
  );
}

최적화 결과

항목BeforeAfter개선율
초기 렌더링3초0.2초93% 감소
필터 변경1.5초0.1초93% 감소
메모리 사용500MB50MB90% 감소
렌더링 DOM 노드10,000개7개99% 감소

요약 체크리스트

Virtual DOM

  • 동작 원리: 실제 DOM의 가벼운 복사본으로 Diffing 후 변경사항만 적용
  • Reconciliation: O(n³) → O(n) 최적화
  • key: 리스트 렌더링 시 고유한 key 사용 필수

React.memo

  • 용도: 컴포넌트 메모이제이션으로 불필요한 리렌더링 방지
  • 얕은 비교: 기본적으로 props를 얕게 비교
  • Custom Comparison: 깊은 비교가 필요하면 비교 함수 제공

useCallback vs useMemo

  • useCallback: 함수 메모이제이션 (이벤트 핸들러)
  • useMemo: 값 메모이제이션 (무거운 계산 결과)
  • 사용 시점: React.memo와 함께, useEffect 의존성, 무거운 계산

리스트 최적화

  • key: 고유 ID 사용 (index, random 금지)
  • Virtualization: react-window로 대량 리스트 최적화
  • Lazy Loading: Intersection Observer로 지연 로딩
  • Pagination: 페이지 단위로 데이터 로드