리액트 불필요한 리렌더링 방지 기법: 메모이제이션 패턴 분석 및 성능 병목 트러블슈팅

리액트 불필요한 리렌더링 방지 기법: 메모이제이션 패턴 분석 및 성능 병목 트러블슈팅

초기 개발 단계에서는 번개처럼 빠르던 리액트 앱이, 컴포넌트 트리가 깊어지고 전역 상태(Global State)가 복잡하게 얽히기 시작하면서 사용자가 타이핑을 할 때마다 화면이 뚝뚝 끊기는 프레임 드랍 현상을 겪게 됩니다. 이처럼 리액트 환경에서 발생하는 성능 병목의 90% 이상은 컴포넌트가 불필요하게 다시 그려지는 '리렌더링(Re-rendering)' 이슈에서 기인합니다. 수백 개의 데이터 그리드가 렌더링되는 관리자 대시보드를 구축하던 당시, 저는 상위 계층의 사소한 상태 변화가 하위 컴포넌트 전체의 렌더링 폭포(Rendering Waterfall)를 유발하는 치명적인 구조적 결함을 경험했습니다. 이 거대한 연쇄 반응을 끊어내기 위해 도입한 것이 바로 리액트 불필요한 리렌더링 방지 기법의 핵심인 '메모이제이션(Memoization)'이었습니다. 이 글에서는 맹목적으로 훅(Hook)을 남용하여 오히려 메모리를 낭비하는 안티 패턴을 경계하고, React.memo, useMemo, useCallback 세 가지 무기를 언제, 어떻게 꺼내 들어야 하는지 참조 동일성(Reference Equality)의 원리와 함께 명확하게 파헤쳐 봅니다. 당장 여러분의 프로젝트에서 성능을 갉아먹는 무거운 컴포넌트를 식별하고 가벼운 가상 돔(Virtual DOM) 환경을 복구해 보시길 권장합니다.

참조 동일성(Reference Equality)과 리렌더링의 상관관계

리액트가 특정 컴포넌트를 리렌더링하는 조건은 매우 단순하고 명확합니다. 컴포넌트 내부의 상태(State)가 변경되거나, 부모로부터 전달받은 프롭스(Props)가 변경되었을 때입니다. 여기서 수많은 프론트엔드 주니어 개발자들이 함정에 빠지는 지점이 바로 프롭스의 변경 여부를 판단하는 척도인 '참조 동일성(Reference Equality)'입니다.

리액트는 프롭스가 변경되었는지 확인할 때 Object.is() 기반의 얕은 비교(Shallow Compare)를 수행합니다. 즉, 문자열이나 숫자 같은 원시 타입(Primitive Type)은 값이 같으면 동일하다고 판단하지만, 객체(Object), 배열(Array), 함수(Function) 같은 참조 타입(Reference Type)은 메모리 주소값이 달라지면 내용물이 완전히 같더라도 전혀 다른 값으로 인식하고 무조건 리렌더링을 유발시킵니다.

// 매 렌더링마다 메모리 주소가 새로 할당되어, 자식 컴포넌트의 리렌더링을 유발하는 안티 패턴
function ParentComponent() {
  const [count, setCount] = useState(0);

  // 컴포넌트가 리렌더링될 때마다 새로운 함수 객체가 생성됨
  const handleClick = () => {
    console.log("버튼 클릭");
  };

  // 컴포넌트가 리렌더링될 때마다 새로운 배열 객체가 생성됨
  const userData = { name: "User", role: "Admin" };

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>증가</button>
      <ChildComponent onClick={handleClick} data={userData} />
    </div>
  );
}

위 예시에서 count 상태가 변경되어 ParentComponent가 다시 그려질 때, handleClick 함수와 userData 객체는 물리적으로 완전히 새로운 주소값을 부여받아 새로 생성됩니다. 그 결과, 전혀 상관없는 ChildComponent까지 프롭스가 변경되었다고 착각하여 불필요하게 다시 화면을 그리는 낭비가 발생합니다. 메모이제이션 기법은 바로 이 '객체의 새로고침'을 막아 주소값을 고정시키는 기술입니다.

React.memo: 컴포넌트 수준의 렌더링 차단벽

React.memo는 고차 컴포넌트(HOC, Higher Order Component)로, 컴포넌트가 전달받는 프롭스(Props)가 이전과 완벽히 동일하다면 렌더링 과정을 건너뛰고 마지막으로 렌더링된 결과를 재사용하도록 만드는 가장 강력한 방어선입니다.

React.memo는 부모 컴포넌트가 리렌더링되더라도 자신에게 내려오는 프롭스의 참조값이 변하지 않았다면 하위 트리 전체의 가상 돔(Virtual DOM) 연산을 단번에 잘라냅니다. 따라서 무거운 연산이 들어있거나, 매핑(Mapping)을 통해 수십 개씩 렌더링되는 리스트 아이템 컴포넌트 등에 적용하면 체감 성능이 극적으로 상승합니다.

// React.memo를 적용하여 프롭스 변경 시에만 리렌더링되도록 격리
const HeavyListItem = React.memo(({ title, content }) => {
  console.log("HeavyListItem 렌더링 연산 수행...");
  // 무거운 DOM 구조 또는 복잡한 UI 로직
  return (
    <div className="heavy-box">
      <h4>{title}</h4>
      <p>{content}</p>
    </div>
  );
});

하지만 모든 컴포넌트에 React.memo를 도배하는 것은 치명적인 실수입니다. 얕은 비교 연산 자체도 CPU 비용을 소모하기 때문입니다. 컴포넌트가 워낙 가벼워 비교 연산 시간이나 새로 그리는 시간이나 엇비슷하거나, 프롭스가 너무 자주(매 렌더링마다) 바뀌는 성질의 컴포넌트라면 오히려 memo 처리가 성능을 갉아먹게 됩니다. 순수하게 UI 요소만을 렌더링하는 거대한 'Presentational Component'에 선택적으로 씌우는 것이 핵심입니다.

useMemo와 useCallback: 함수와 객체의 참조 주소 고정

자식 컴포넌트에 React.memo를 씌워 튼튼한 방어벽을 구축했더라도, 부모가 프롭스로 넘기는 함수나 객체의 참조 주소가 매번 바뀐다면 그 방어벽은 무용지물이 됩니다. 이때 부모 측에서 화력 지원을 해주는 도구가 바로 useMemouseCallback입니다.

useMemo연산의 결과값(객체나 배열 포함)을 캐싱하여, 의존성 배열(Dependency Array)에 등록된 변수가 바뀌지 않는 한 이전 렌더링에서 계산해 둔 값을 그대로 반환합니다. 이를 통해 복잡한 수학 연산을 생략할 뿐만 아니라, 하위 컴포넌트로 내려보내는 객체 프롭스의 참조 동일성을 완벽하게 보장합니다.

useCallback은 결과값이 아닌 함수 선언 그 자체를 캐싱합니다. 자식 컴포넌트에게 이벤트 핸들러 함수를 프롭스로 넘길 때 필수적으로 동반되어야 하는 훅입니다.

import { useState, useMemo, useCallback } from 'react';

function ParentComponent({ rawData }) {
  const [query, setQuery] = useState("");

  // 1. useMemo: 복잡한 필터링 연산 결과와 배열 객체의 주소값을 고정
  const filteredData = useMemo(() => {
    return rawData.filter(item => item.name.includes(query));
  }, [rawData, query]);

  // 2. useCallback: 자식에게 넘길 이벤트 핸들러 함수의 주소값을 고정
  const handleItemClick = useCallback((id) => {
    console.log(`${id}번 아이템 클릭 처리 로직`);
  }, []); // 의존성이 없으므로 마운트 시 한 번만 생성됨

  return (
    <div>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {/* 
        ChildList가 React.memo로 감싸져 있다면, 
        query가 변하더라도 handleItemClick의 주소가 같으므로 
        관련 없는 아이템들의 불필요한 리렌더링이 차단됨 
      */}
      <ChildList data={filteredData} onClick={handleItemClick} />
    </div>
  );
}

이 세 가지(memo, useMemo, useCallback)는 마치 톱니바퀴처럼 완벽히 맞물려 돌아가야만 진짜 성능 최적화가 이루어집니다. 부모가 useCallback으로 주소를 고정해 주지 않으면 자식의 React.memo가 뚫리고, 자식이 React.memo 처리가 안 되어 있다면 부모가 아무리 useCallback을 써봤자 무조건 리렌더링됩니다.

React DevTools 프로파일링을 통한 진짜 병목 탐지

눈대중과 감에 의존한 메모이제이션은 코드를 복잡하게 만들고 가독성을 떨어뜨리는 최악의 기술 부채입니다. 진짜 프로페셔널은 성능 최적화를 시작하기 전과 후에 반드시 측정 도구를 활용해 데이터를 기반으로 움직입니다.

크롬 익스텐션으로 제공되는 React Developer Tools의 'Profiler' 탭은 프론트엔드 최적화의 엑스레이(X-ray)와 같습니다. 'Record' 버튼을 누르고 화면에서 버벅거리는 액션을 수행한 뒤 정지하면, 어떤 컴포넌트가 렌더링되었고 얼마나 시간이 걸렸는지 시각적인 불꽃 그래프(Flamegraph)로 보여줍니다.

특히 설정 옵션 중 Record why each component rendered while profiling. 옵션을 체크하면 매우 강력한 힌트를 얻을 수 있습니다. 프로파일링 결과에서 특정 컴포넌트를 클릭했을 때 우측 패널에 Hook 2 changed 또는 Props onClick changed 와 같이 리렌더링이 발생한 구체적인 원인을 영어로 정확히 짚어주기 때문입니다. 이 범인을 색출한 뒤에 해당 부분에만 외과 수술처럼 useCallback을 투입하는 것이 가장 완벽한 트러블슈팅 방법론입니다. 나아가 최근 생태계 변화와 최적화 관점이 궁금하다면 Next.js 서버 컴포넌트 렌더링 최적화 글을 참고해 보는 것도 좋습니다.

자주 묻는 질문 (FAQ)

Q. 모든 컴포넌트와 함수에 무조건 memo와 useCallback을 씌우면 안 되나요?

절대 권장하지 않습니다. 메모이제이션을 수행하기 위해서는 이전에 저장된 값을 메모리 공간에 들고 있어야 하며, 매 렌더링마다 얕은 비교(Object.is) 연산을 거쳐야 합니다. 렌더링 비용이 이 비교 연산 비용보다 적은 단순한 버튼이나 텍스트 컴포넌트라면, 무지성 최적화는 오히려 초기 로딩 속도와 런타임 메모리를 악화시킵니다.

Q. 의존성 배열(Dependency Array)에 값을 빼먹으면 어떻게 되나요?

최악의 버그인 'Stale Closure(오래된 클로저)' 현상이 발생합니다. 함수 내부에서 과거의 낡은 상태값을 참조하여 동작하므로 업데이트가 화면에 반영되지 않습니다. 반드시 ESLint의 exhaustive-deps 규칙을 켜두고 경고가 뜨는 변수들을 빠짐없이 배열에 채워 넣어야 합니다.

Q. 상태 관리 라이브러리(Zustand, Redux)를 쓰면 최적화가 필요 없나요?

상태 관리 도구는 전역 상태를 깔끔하게 분리해주지만, 컴포넌트가 해당 스토어(Store)를 구독하는 방식을 잘못 작성하면 여전히 불필요한 리렌더링이 발생합니다. 예를 들어 Zustand에서 객체 통째로 상태를 꺼내오면 객체 내부의 다른 값이 바뀔 때마다 렌더링되므로, 필요한 단일 상태값(Primitive)만 콕 집어서(Selector) 가져오는 습관이 매우 중요합니다.

리액트의 선언적 렌더링 패러다임은 개발 속도를 비약적으로 끌어올렸지만, 화면 통제권을 프레임워크에 넘겨준 만큼 개발자가 프레임워크의 눈(참조 동일성)을 정확히 이해하고 있어야 합니다. 이 가이드에서 다룬 세 가지 무기를 들고, React DevTools 프로파일러를 통해 프로젝트의 숨겨진 병목을 정확히 타격하여 60fps의 부드러운 유저 경험을 달성해 보시길 바랍니다.