React.memo 를 넘어: 성능 최적화를 위한 더 스마트한 방법들

rada·2025년 8월 14일
0

개발

목록 보기
49/53

원문: React.memo Demystified: When It Helps and When It Hurts

소개

React 성능 최적화 시 React.memo는 개발자들이 가장 먼저 찾는 도구입니다. 재렌더링 문제를 발견하면 망치를 잡는 것처럼, 갑자기 모든 것이 못처럼 보이기 때문입니다. 하지만 만약 제가 많은 경우에 React의 구성적 특성과 더 잘 맞는 더 간단하고 우아한 해결책이 존재한다고 말한다면 어떨까요?

오늘은 React가 컴포넌트를 렌더링하는 기본 개념을 탐구하고, 메모화(memoization)의 복잡성과 함정 없이 성능을 크게 개선할 수 있는 구성 패턴을 공유하고자 합니다.

이 주제에 대해 더 알고 싶다면 Nadia Makarevich의 우수한 책 《Advanced React: Deep dives, investigations, performance patterns and techniques》을 참고하세요.

재렌더링의 미스터리

일반적인 시나리오부터 시작해 보겠습니다: React 앱에 간단한 기능을 추가했습니다 - 예를 들어 버튼으로 트리거되는 모달 대화상자 - 갑자기 모든 것이 느려집니다. 대화상자가 열릴 때 UI가 일시적으로 멈춥니다. 무슨 일이 일어나고 있는 걸까요?

const App = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="layout">
      <Button onClick={() => setIsOpen(true)}>Open dialog</Button>

      {isOpen && <ModalDialog onClose={() => setIsOpen(false)} />}

      <VerySlowComponent />
      <BunchOfStuff />
      <OtherComplexComponents />
    </div>
  );
};

React의 렌더링 방식이 어떻게 작동하는지 이해하면 문제가 명확해집니다: setIsOpen이 호출되면 React는 App 컴포넌트와 그 안의 모든 요소를 재렌더링합니다 - 대화상자와 무관한 느린 컴포넌트들도 포함해서요.

메모화 반사작용

일반적인 대응 방법은 React.memo를 사용하는 것일 수 있습니다:

const VerySlowComponent = React.memo(() => {
  // Complex rendering logic
});

이 방법은 작동하지만 복잡성을 도입합니다. 의존성을 신중하게 관리해야 하며, 이벤트 핸들러에 useCallback을 추가해야 할 수도 있고, 메모이제이션을 잊어버릴 경우 발생할 수 있는 버그를 처리해야 합니다. 이는 해결책이지만 항상 가장 우아한 방법은 아닙니다.

React의 렌더링 모델 이해

더 나은 해결책으로 넘어가기 전에 기본 개념을 명확히 해보겠습니다:

  1. 컴포넌트 vs 요소: 컴포넌트는 React 요소를 반환하는 함수입니다. 요소는 화면에 표시되어야 할 내용을 설명하는 객체입니다.

  2. 재렌더링: 상태가 변경되면 React는 컴포넌트 함수를 다시 호출하고 반환된 요소를 비교하여 필요한 DOM 업데이트를 결정합니다.

  3. 큰 오해: 많은 개발자는 “프로퍼티가 변경되면 컴포넌트가 재렌더링된다”고 믿습니다. 이는 정확하지 않습니다. 컴포넌트는 부모가 재렌더링되면 프로퍼티가 변경되었는지 여부와 관계없이 재렌더링됩니다 - React.memo로 감싸이지 않은 경우를 제외하고는.

상태를 아래로 이동: 구성 패턴

모든 것을 메모이제이션하는 대신 이 우아한 패턴을 고려해 보세요:

const ButtonWithModalDialog = () => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setIsOpen(true)}>Open dialog</Button>

      {isOpen && <ModalDialog onClose={() => setIsOpen(false)} />}
    </>
  );
};

const App = () => {
  return (
    <div className="layout">
      <ButtonWithModalDialog />
      <VerySlowComponent />
      <BunchOfStuff />
      <OtherComplexComponents />
    </div>
  );
};

이 간단한 리팩토링은 상태와 그 영향을 더 작은 구성 요소로 분리합니다. 대화상자가 열릴 때 ButtonWithModalDialog만 재렌더링되며, 느린 구성 요소는 그대로 유지됩니다. 메모화 필요 없음!

이 패턴은 Uncle Bob의 “Clean Architecture” 원칙과 완벽하게 일치합니다. 특히 단일 책임 원칙(Single Responsibility Principle)과 일치합니다. 각 구성 요소는 이제 더 명확하고 집중된 책임을 갖게 됩니다.

자식 요소로서의 프로퍼티: 구성의 힘

또 다른 시나리오를 살펴보겠습니다: 스크롤 이벤트에 따라 위치를 업데이트해야 하지만 전체 내용을 재렌더링하지 않아야 하는 스크롤 가능한 컨테이너:

// Problematic implementation
const ScrollableArea = () => {
  const [scrollPosition, setScrollPosition] = useState(0);

  const handleScroll = (e) => {
    setScrollPosition(e.target.scrollTop);
  };

  return (
    <div className="scrollable" onScroll={handleScroll}>
      <FloatingNavigation position={scrollPosition} />
      <VerySlowComponent />
      <MoreComplexContent />
    </div>
  );
};

모든 스크롤 이벤트는 모든 콘텐츠의 재렌더링을 트리거합니다. React.memo를 사용하는 대신 React의 구성 모델을 사용할 수 있습니다:

const ScrollableWithFloatingNav = ({ children }) => {
  const [scrollPosition, setScrollPosition] = useState(0);

  const handleScroll = (e) => {
    setScrollPosition(e.target.scrollTop);
  };

  return (
    <div className="scrollable" onScroll={handleScroll}>
      <FloatingNavigation position={scrollPosition} />
      {children}
    </div>
  );
};

const App = () => {
  return (
    <ScrollableWithFloatingNav>
      <VerySlowComponent />
      <MoreComplexContent />
    </ScrollableWithFloatingNav>
  );
};

여기서의 핵심은 'children'이 단순히 일반적인 프로퍼티일 뿐이라는 점입니다. React는 렌더링 과정에서 'children'에 특별한 처리를 하지 않습니다. 시작 태그와 종료 태그 사이에 콘텐츠를 중첩하는 문법적 편의 기능(Content)은 명시적으로 로 전달하는 것과 동일합니다.

이것이 작동하는 이유는 React에서 props로 전달된 요소(children 포함)는 부모 컴포넌트에서 생성되며 자식 컴포넌트에서는 단순히 참조되기 때문입니다. 자식 컴포넌트가 재렌더링될 때 동일한 요소 참조를 사용하기 때문에, React는 전달된 참조(children 또는 기타)가 변경되지 않았다면 재렌더링이 필요하지 않다는 것을 알고 있습니다.

이것이 작동하는 이유: 요소, 재조화 및 props

이 패턴이 왜 효과적인지 이해하려면 React의 재합치기(reconciliation) 방식이 어떻게 작동하는지 살펴봐야 합니다:

  1. 컴포넌트가 재렌더링될 때 React는 컴포넌트 함수를 호출하고 요소 트리를 반환받습니다.
  2. React는 Object.is() 비교를 사용하여 이 새로운 트리와 이전 트리를 비교합니다.
  3. 요소 참조가 이전과 이후에 동일하다면 React는 해당 트리 분기를 재렌더링하지 않을 수 있습니다.

컴포넌트를 자식 요소나 다른 프로퍼티로 전달할 때, 해당 요소들은 부모 컴포넌트의 범위 내에서 생성됩니다. 자식 컴포넌트는 이미 생성된 요소들에 대한 참조만 받습니다. 자식이 재렌더링될 때 이 참조들은 변경되지 않기 때문에 React는 이를 재렌더링하지 않을 수 있습니다.

커스텀 훅의 숨겨진 위험

성능에 대해 논의하는 동안, 커스텀 훅과 관련된 일반적인 함정을 언급할 가치가 있습니다:

// This can cause performance issues
const useModalDialog = () => {
  const [isOpen, setIsOpen] = useState(false);

  return {
    isOpen,
    open: () => setIsOpen(true),
    close: () => setIsOpen(false),
  };
};

const App = () => {
  const { isOpen, open, close } = useModalDialog();

  return (
    <div>
      <Button onClick={open}>Open</Button>
      {isOpen && <ModalDialog onClose={close} />}
      <VerySlowComponent />
    </div>
  );
};

이 패턴은 깔끔해 보이지만, 훅 내부의 상태 변경이 앱 전체를 재렌더링하게 만든다는 사실을 숨기고 있습니다. 훅은 상태 효과를 마법처럼 격리하지 않습니다 - 그저 추상화할 뿐입니다.

해결책? 우리가 논의해 온 동일한 구성 패턴입니다:

const ModalDialogController = () => {
  const { isOpen, open, close } = useModalDialog();

  return (
    <>
      <Button onClick={open}>Open</Button>
      {isOpen && <ModalDialog onClose={close} />}
    </>
  );
};

const App = () => {
  return (
    <div>
      <ModalDialogController />
      <VerySlowComponent />
    </div>
  );
};

핵심 요점

  1. 렌더 트리 이해: React는 상태 변경이 발생한 위치에서 아래로 재렌더링 흐름이 진행됩니다.
  2. 상태를 하위 수준으로 이동: 상태를 실제로 필요한 컴포넌트에 최대한 가깝게 배치하세요.
  3. 구성 패턴 사용: 불필요한 재렌더링을 방지하기 위해 컴포넌트를 프로퍼티나 자식으로 전달하세요.
  4. 훅 사용 시 주의: 훅은 재렌더링을 격리하지 않습니다. 단순히 상태 관리를 추상화할 뿐입니다.
  5. 메모이제이션은 마지막에 고려하세요: React.memo, useMemo, useCallback은 컴포넌트 구조를 최적화한 후에 사용하세요.

이 패턴들은 React의 구성적 특성 및 Clean Architecture의 원칙과 완벽히 일치합니다. 이는 명확한 책임 분담, 관심사의 분리, 자연스럽게 최적화된 성능을 갖춘 컴포넌트를 생성합니다.

결론

React.memo와 다른 메모화 도구는 그 역할을 하지만, 성능 문제의 첫 번째 해결책으로 사용해서는 안 됩니다. React의 렌더링 모델을 이해하고 구성 패턴을 수용하면 성능과 유지보수성이 모두 우수한 애플리케이션을 구축할 수 있습니다.

다음에 React에서 성능 문제를 만나면 메모화를 시도하기 전에 스스로에게 물어보세요: “상태 변경의 영향을 격리하기 위해 컴포넌트 구조를 재구성할 수 있을까요?” 이 질문의 답은 더 단순하고 우아한 해결책으로 이끌 수 있습니다.

React 애플리케이션에서 가장 효과적인 성능 최적화 패턴은 무엇이었나요? 댓글로 경험을 공유해 주시면 감사하겠습니다!

참고글 : [번역] React.memo 완벽 해부: 언제 쓸모 있고 언제 쓸모없는가

profile
So that my future self will not be ashamed of myself.

0개의 댓글