React|성능 최적화와 보안

useMemo, useCallback, React.memo: 메모이제이션은 공짜가 아니다

0
useMemo, useCallback, React.memo: 메모이제이션은 공짜가 아니다

크리스가 리액트 성능 최적화 강의를 듣고 감명을 받았다.

"모든 변수와 함수를 메모이제이션(Memoization) 하면 재계산을 안 하니까 무조건 빨라지겠지?"

그날부터 크리스는 모든 코드에 useMemouseCallback을 도배하기 시작했다.

// ❌ 최악의 예시: 의미 없는 메모이제이션
const one = useMemo(() => 1, []);
const plus = useCallback(() => console.log('더하기'), []);

결과는 어땠을까? 앱은 오히려 더 느려지고 메모리 사용량만 늘어났다.

메모이제이션은 공짜가 아니다. "메모리(RAM)를 써서 연산 시간(CPU)을 사는 것"이다. 비용이 이득보다 크다면 안 하느니만 못하다.

오늘은 리액트의 메모이제이션 3형제가 정확히 언제 필요한지, 그리고 언제 쓰면 안 되는지 명확한 기준을 세워본다.

1. useMemo: 값 기억하기

useMemo무거운 계산 결과를 캐싱한다. 의존성 배열(deps)에 있는 값이 바뀌지 않으면, 이전에 계산해둔 값을 그대로 재사용한다.

언제 써야 할까?

"무거운 계산"의 기준이 모호하다면 이렇게 생각하자.

"이 코드가 0.1ms 안에 끝나는가?"

// ✅ 좋은 예시: 배열 필터링 및 정렬 (데이터가 많을 때)
const sortedUsers = useMemo(() => {
  return users.filter(u => u.active).sort((a, b) => b.score - a.score);
}, [users]);

// ❌ 나쁜 예시: 단순 변환
// useMemo를 설정하는 비용(객체 생성, 비교)이 더 든다.
const userCount = useMemo(() => users.length, [users]);

리액트 공식 문서는 "수천 개의 아이템을 루프 돌리거나 하는 게 아니라면 굳이 쓰지 말라"고 조언한다.

2. React.memo: 컴포넌트 기억하기

React.memo는 고차 컴포넌트(HOC)로, Props가 바뀌지 않으면 리렌더링을 건너뛰게 해준다.

// Child.tsx
const Child = React.memo(({ name, onClick }: { name: string, onClick: () => void }) => {
  console.log("자식 렌더링!");
  return <div onClick={onClick}>{name}</div>;
});

하지만 여기에 함정이 있다. 부모 컴포넌트가 리렌더링 되면, 부모 안에 있는 함수도 새로 만들어진다.

  • 부모 리렌더링
  • handleClick 함수가 새로 생성됨 (참조 주소 변경).
  • Child에게 새로운 onClick Props가 전달됨.
  • React.memo는 "어? Props가 바뀌었네?"라고 판단.
  • Child 리렌더링 발생 (최적화 실패).
  • 이 문제를 해결하기 위해 등장한 것이 바로 useCallback이다.

    3. useCallback: 함수 기억하기

    useCallback은 함수를 새로 만들지 않고 기존 함수(참조)를 재사용하게 해준다.

    참조 동등성 (Referential Equality)

    자바스크립트에서 {} === {}는 false다. 객체(함수 포함)는 내용이 같아도 참조 주소가 다르면 다른 것으로 취급한다.

    useCallback은 이 참조 주소를 고정시켜주는 역할을 한다.

    // Parent.tsx
    function Parent() {
      const [count, setCount] = useState(0);
    
      // ✅ useCallback으로 감싸야 Child의 불필요한 렌더링을 막을 수 있다.
      const handleClick = useCallback(() => {
        console.log("클릭");
      }, []); // 의존성이 없으면 영원히 같은 함수 참조 유지
    
      return (
        <div>
          <button onClick={() => setCount(c => c + 1)}>카운트: {count}</button>
          {/* 이제 count가 변해서 부모가 리렌더링 되어도, Child는 조용하다. */}
          <Child name="철수" onClick={handleClick} />
        </div>
      );
    }

    핵심 규칙: useCallback은 단독으로 쓰면 의미가 없다. 자식 컴포넌트가 React.memo로 감싸져 있을 때만 효과가 있다. 그냥 일반 divbutton에 넘기는 함수에는 쓸 필요가 없다.

    4. 메모이제이션의 비용

    왜 모든 것을 메모이제이션 하면 안 될까?

  • 메모리 점유: 캐싱 된 값과 함수는 가비지 컬렉션(GC) 되지 않고 메모리에 남는다.
  • 비교 비용: 리액트는 매 렌더링마다 의존성 배열(deps)의 값들을 하나하나 비교해야 한다. 이 비교 비용이 단순 연산 비용보다 클 수 있다.
  • 코드 복잡도: 의존성 배열을 관리하는 것은 개발자의 몫이다. 하나라도 빼먹으면 버그(Stale Closure)가 생긴다.
  • 5. 실전 가이드라인

    크리스는 이제 명확한 기준을 세웠다.

    ⭕ 써야 할 때

  • 참조 동일성 유지: useEffect의 의존성 배열에 객체나 함수를 넣어야 할 때.
  • React.memo 최적화: 자식 컴포넌트(React.memo)에 함수를 Props로 넘길 때.
  • 정말 무거운 연산: 실행하는 데 1ms 이상 걸리는 복잡한 로직.
  • ❌ 쓰지 말아야 할 때

  • 단순한 계산: props.a + props.b 수준의 연산.
  • HTML 태그에 함수 전달: <button onClick={handleChange}> 같은 경우. 브라우저 DOM 이벤트 핸들러가 바뀌는 건 성능에 거의 영향이 없다.
  • 핵심 정리

  • useMemo: 값의 결과를 캐싱한다. 무거운 연산을 방지한다.
  • useCallback: 함수의 참조를 캐싱한다. React.memo와 함께 사용하여 자식 리렌더링을 막는다.
  • React.memo: 컴포넌트의 렌더링 결과를 캐싱한다. Props가 같으면 재사용한다.
  • 원칙: 최적화는 문제가 생겼을 때 측정하고 적용해도 늦지 않다. 미리 최적화(Premature Optimization)하지 말자.
  • 성능을 챙겼으니, 이제 앱을 가볍게 다이어트 시킬 차례다. 사용자가 들어오지도 않은 '관리자 페이지' 코드를 미리 다운로드할 필요가 있을까?

    코드를 조각내서 필요할 때만 불러오는 기술, Code Splitting을 알아보자.

    "번들 사이즈 다이어트: React.lazy와 Suspense를 활용한 Code Splitting 전략" 편에서 계속된다.


    🔗 참고 링크

  • React Docs - useMemo
  • React Docs - useCallback
  • When to useMemo and useCallback