[React] 성능 최적화: React.memo보다 먼저 고려할 컴포지션 패턴

원문: Beyond React.memo: Smarter Ways to Optimize Performance

 

소개

React 성능 최적화와 관련하여 개발자가 가장 먼저 찾는 도구는 `React.memo`입니다. 이는 리렌더링 문제를 발견했을 때 잡는 망치인데, 갑자기 모든 것이 못처럼 보입니다. 하지만 많은 경우에 React의 구성적 특성에 더 잘 부합하는 더 간단하고 우아한 솔루션이 있다고 하면 어떨까요?

오늘은 React가 컴포넌트를 렌더링하는 방법에 대한 몇 가지 기본 개념을 살펴보고, 메모이제이션의 복잡성과 문제없이 성능을 크게 향상시킬 수 있는 컴포지션 패턴을 공유하고자 합니다.

 

리렌더링 미스터리 

일반적인 시나리오부터 시작하겠습니다: 버튼으로 트리거되는 모달 대화창과 같은 간단한 기능을 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는 대화 상자와 관련이 없는 모든 느린 컴포넌트를 포함해 전체 앱 컴포넌트와 그 안의 모든 것을 다시 렌더링 합니다.

 

메모이제이션 반사 작용

일반적인 대응은 `React.memo`를 사용하는 것입니다:

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


이 방법은 효과가 있지만 복잡성을 유발합니다. 의존성을 주의 깊게 관리해야 하고, 이벤트 핸들러에 `useCallback`을 추가해야 하며, 메모를 잊어버렸을 때 발생할 수 있는 버그를 처리해야 합니다. 하나의 해결책이긴 하지만 항상 가장 우아한 해결책은 아닙니다.

 

React의 렌더링 모델 이해하기 

더 나은 솔루션에 대해 알아보기 전에 몇 가지 기본 개념을 명확히 해보겠습니다:

  1. 컴포넌트 vs 엘리먼트: 컴포넌트는 React 엘리먼트를 반환하는 함수입니다. 엘리먼트는 화면에 표시되어야 하는 것을 설명하는 객체입니다.
  2. 리렌더링: 상태가 변경되면 React는 컴포넌트 함수를 다시 호출하고 반환된 엘리먼트를 비교하여 어떤 DOM에 업데이트가 필요한지 결정합니다.
  3. 큰 오해: 많은 개발자는 "컴포넌트는 props가 변경되면 다시 렌더링한다"고 생각합니다. 이는 사실과 다릅니다. 컴포넌트는 `React.memo`로 래핑되지 않는 한 부모 컴포넌트가 다시 렌더링할 때에도 props가 변경되었는지 여부에 관계없이 다시 렌더링됩니다.

 

상태 하향 이동: 컴포지션 솔루션 

모든 것을 메모하는 대신 다음과 같은 우아한 패턴을 고려해 보세요:

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`만 다시 렌더링됩니다. 메모이제이션이 필요 없습니다!

이 패턴은 밥 아저씨의 "클린 아키텍처"의 원칙, 특히 단일 책임 원칙과 완벽하게 일치합니다. 이제 각 컴포넌트는 보다 명확하고 집중적인 책임을 맡게 됩니다.

 

Props로서의 Children: 컴포지션의 힘 

전체 콘텐츠를 다시 렌더링하지 않고 스크롤 이벤트에 따라 위치를 업데이트해야 하는 스크롤 가능한 컨테이너의 다른 시나리오를 살펴봅시다:

// 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이 일반적인 prop일 뿐이며, React는 렌더링 중에 특별한 처리를 하지 않는다는 점입니다. 여는 태그와 닫는 태그 사이에 콘텐츠를 중첩하는 구문 설탕 `<Component>Content</Component>`은 `<Component children={Content} />`로 명시적으로 전달하는 것과 동일합니다.

이는 props로 전달된 React 엘리먼트(자식 포함)가 부모 컴포넌트에서 생성되고 자식에서 간단히 참조되기 때문에 작동합니다. 자식이 다시 렌더링할 때 동일한 엘리먼트 참조를 사용하므로 React는 전달된 참조(자식 또는 기타)가 변경되지 않는 한 다시 렌더링할 필요가 없다는 것을 알고 있습니다.

 

이것이 작동하는 이유: 엘리먼트, Reconciliation, Props

이 패턴이 왜 효과적인지 이해하려면 React의 재조정이 어떻게 작동하는지 살펴볼 필요가 있습니다:

  1. 컴포넌트가 다시 렌더링되면 React는 컴포넌트 함수를 호출하고 요소 트리를 다시 가져옵니다.
  2. React는 `Object.is()` 비교를 사용해 이 새 트리와 이전 트리를 비교합니다.
  3. 엘리먼트 참조가 이전과 이후가 같으면 React는 트리의 해당 브랜치를 다시 렌더링하는 것을 건너뛸 수 있습니다.

컴포넌트를 children이나 다른 props로 전달하면 해당 요소는 부모 컴포넌트의 스코프에 생성됩니다. 자식 컴포넌트는 이미 생성된 요소에 대한 참조만 받습니다. 자식이 다시 렌더링할 때 이러한 참조는 변경되지 않으므로 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. 컴포지션 패턴 사용: 컴포넌트를 props나 children으로 전달하여 불필요한 리렌더링을 방지하세요.
  4. 훅에 주의하세요: 훅은 리렌더링을 분리하는 것이 아니라 상태 관리를 추상화할 뿐입니다.
  5. 메모이제이션은 마지막에 고려하세요: 컴포넌트 구조를 최적화한 후에만 `React.memo`, `useMemo`, `useCallback`을 사용하세요.

이러한 패턴은 React의 구성적 특성과 클린 아키텍처의 원칙에 완벽하게 부합합니다. 이러한 패턴을 사용하면 컴포넌트의 책임이 명확해지고, 문제가 더 잘 분리되며, 자연스럽게 성능이 최적화됩니다.

 

결론

`React.memo`와 다른 메모이제이션 도구도 나름의 역할이 있지만, 성능 문제에 대한 첫 번째 해결책이 되어서는 안 됩니다. React의 렌더링 모델을 이해하고 컴포지션 패턴을 수용하면 성능과 유지보수가 모두 가능한 애플리케이션을 구축할 수 있습니다.

다음에 React에서 성능 문제가 발생하면 메모를 하기 전에 스스로에게 물어보세요: "상태 변경의 영향을 분리하기 위해 컴포넌트를 재구성할 수 있을까?"라고 자문해 보세요. 이 질문에 대한 답은 더 간단하고 우아한 해결책으로 이어질 수 있습니다.