원문: 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을 추가해야 할 수도 있고, 메모이제이션을 잊어버릴 경우 발생할 수 있는 버그를 처리해야 합니다. 이는 해결책이지만 항상 가장 우아한 방법은 아닙니다.
더 나은 해결책으로 넘어가기 전에 기본 개념을 명확히 해보겠습니다:
컴포넌트 vs 요소: 컴포넌트는 React 요소를 반환하는 함수입니다. 요소는 화면에 표시되어야 할 내용을 설명하는 객체입니다.
재렌더링: 상태가 변경되면 React는 컴포넌트 함수를 다시 호출하고 반환된 요소를 비교하여 필요한 DOM 업데이트를 결정합니다.
큰 오해: 많은 개발자는 “프로퍼티가 변경되면 컴포넌트가 재렌더링된다”고 믿습니다. 이는 정확하지 않습니다. 컴포넌트는 부모가 재렌더링되면 프로퍼티가 변경되었는지 여부와 관계없이 재렌더링됩니다 - 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 또는 기타)가 변경되지 않았다면 재렌더링이 필요하지 않다는 것을 알고 있습니다.
이 패턴이 왜 효과적인지 이해하려면 React의 재합치기(reconciliation) 방식이 어떻게 작동하는지 살펴봐야 합니다:
컴포넌트를 자식 요소나 다른 프로퍼티로 전달할 때, 해당 요소들은 부모 컴포넌트의 범위 내에서 생성됩니다. 자식 컴포넌트는 이미 생성된 요소들에 대한 참조만 받습니다. 자식이 재렌더링될 때 이 참조들은 변경되지 않기 때문에 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>
);
};
이 패턴들은 React의 구성적 특성 및 Clean Architecture의 원칙과 완벽히 일치합니다. 이는 명확한 책임 분담, 관심사의 분리, 자연스럽게 최적화된 성능을 갖춘 컴포넌트를 생성합니다.
React.memo와 다른 메모화 도구는 그 역할을 하지만, 성능 문제의 첫 번째 해결책으로 사용해서는 안 됩니다. React의 렌더링 모델을 이해하고 구성 패턴을 수용하면 성능과 유지보수성이 모두 우수한 애플리케이션을 구축할 수 있습니다.
다음에 React에서 성능 문제를 만나면 메모화를 시도하기 전에 스스로에게 물어보세요: “상태 변경의 영향을 격리하기 위해 컴포넌트 구조를 재구성할 수 있을까요?” 이 질문의 답은 더 단순하고 우아한 해결책으로 이끌 수 있습니다.
React 애플리케이션에서 가장 효과적인 성능 최적화 패턴은 무엇이었나요? 댓글로 경험을 공유해 주시면 감사하겠습니다!