[번역] React 리렌더링: 컴포넌트가 50번씩 다시 렌더링되는 이유
Soshy·
"사용자가 느끼는 프론트엔드 성능" 시리즈의 두 번째 글입니다. 앱이 느린 건 React가 느려서가 아닙니다. 아무도 요청하지 않은 렌더링 작업을 하고 있기 때문입니다.
느려터진 페이지에서 React DevTools의 프로파일러를 켜고 검색창에 글자 하나를 입력해 봅니다. 그러면 상태(state) 하나가 바뀌었을 뿐인데 컴포넌트 백 개가 다시 렌더링되면서 페이지 전체가 번쩍이는 걸 보고 경악하게 됩니다. 타이핑이 버벅이고 UI가 끊깁니다. 그리고 그 책임은 React가 뒤집어씁니다.
React는 문제가 아닙니다. React는 여러분이 시키는 대로 정확히 리렌더링(re-render)할 뿐입니다. 문제는 기본적으로 상태나 props가 바뀔 때마다 해당 컴포넌트와 그 하위 트리 전체를 리렌더링한다는 점이고, 대부분의 앱은 의도치 않게 필요 이상으로 리렌더링을 유발하고 있다는 것입니다. 세 가지 원인과 해결책을 보여드리겠습니다.
리렌더링은 공짜가 아닙니다. 한 번 일어날 때마다 컴포넌트 함수를 실행하고, 결과물을 diff하고, 자식들을 순회합니다. 이 과정이 키 입력 한 번에 50번씩 발생한다면 그 비용은 실재하는 것이며, 거의 대부분 피할 수 있는 비용입니다.
첫 단계: 직접 눈으로 확인하기
어떤 컴포넌트가 다시 렌더링되는지 추측하지 마세요. React DevTools의 Profiler(플레임 차트로 무엇이 왜 렌더링됐는지 정확히 보여줍니다)로 측정하거나, 간단히 로그를 찍어보세요.
function ExpensiveList({ items }) {
console.log("ExpensiveList rendered"); // how often does this REALLY fire?
return items.map(/* ... */);
}
전혀 관련 없는 입력창에 키를 입력할 때마다 이 컴포넌트가 로그를 찍는다면, 낭비되는 지점을 찾은 겁니다. 이제 원인을 고쳐봅시다.
원인 #1: 상태가 너무 높은 위치에 있다
가장 흔한 원인은 이겁니다. 자주 바뀌는 상태가 정작 그 값에 관심 없는 컴포넌트들보다 더 위쪽에 놓여 있어서, 상태가 업데이트될 때마다 그 컴포넌트들까지 전부 다시 렌더링되는 것입니다.
// BAD: `query` changes on every keystroke, re-rendering the WHOLE page
function Page() {
const [query, setQuery] = useState("");
return (
<>
<SearchBox value={query} onChange={setQuery} />
<ExpensiveDashboard /> {/* re-renders on every keystroke for no reason */}
</>
);
}
해결책은 상태를 콜로케이션(colocate), 즉 그 상태가 필요한 가장 작은 컴포넌트로 끌어내리는 것입니다. 그러면 자주 바뀌는 값이라도 작은 하위 트리만 다시 렌더링됩니다.
// GOOD: query state lives inside Search; the dashboard never sees it
function Page() {
return (<><Search /><ExpensiveDashboard /></>);
}
function Search() {
const [query, setQuery] = useState(""); // change stays local
return <SearchBox value={query} onChange={setQuery} />;
}
상태를 아래로 내리는 것은 웬만한 메모이제이션(memoization)보다 효과가 큰 경우가 많고, 심지어 비용도 들지 않습니다.
원인 #2: 렌더링마다 새로 생기는 객체/함수 identity
React는 props를 참조(reference)로 비교합니다. 객체, 배열, 함수를 인라인으로 만들면 렌더링될 때마다 참조가 바뀌므로, 메모이제이션된 자식 컴포넌트는 props가 바뀐 줄 착각하게 됩니다.
// `style` and `onClick` are NEW objects/functions every render
<Child style={{ color: "red" }} onClick={() => doThing(id)} />
useMemo와 useCallback으로 identity를 고정해서, 값이 바뀌지 않았다면 참조도 그대로 유지되게 해야 합니다.
const style = useMemo(() => ({ color: "red" }), []);
const onClick = useCallback(() => doThing(id), [id]);
<Child style={style} onClick={onClick} />
이건 메모이제이션된 컴포넌트에 전달되는 props이거나 의존성 배열(dependency array)에 쓰이는 경우에만 의미가 있습니다. 무작정 여기저기 적용할 게 아니라, 메모이제이션을 무력화시키는 지점에만 적용하세요.
원인 #3: 비용이 큰 하위 트리에 메모이제이션 경계가 없다
기본적으로 자식 컴포넌트는 props가 동일하더라도 부모가 리렌더링될 때마다 함께 다시 렌더링됩니다. 실제로 비용이 큰 컴포넌트는 React.memo로 감싸서, props가 바뀌지 않았을 때는 리렌더링을 건너뛰게 만드세요.
const ExpensiveDashboard = React.memo(function ExpensiveDashboard({ data }) {
// only re-renders when `data` actually changes
return /* heavy chart */;
});
React.memo + 안정적인 props(원인 #2 해결)의 조합이야말로 부모의 리렌더링이 비용이 큰 자식으로 전파되는 걸 막아줍니다. 둘 중 하나만 있으면 아무 효과가 없습니다.
해결책: 올바른 순서로 적용하기
비용이 적게 드는 것부터 복잡한 것 순으로 적용하세요.
- 상태 콜로케이션 — 자주 바뀌는 상태를 아래로 내립니다. 비용이 들지 않으면서 효과는 가장 큽니다.
- 비용이 큰 경계에 메모 적용 — 무거운 하위 트리에
React.memo를 적용합니다. - props 안정화 —
useMemo/useCallback으로 메모가 실제로 작동하게 만듭니다. - 다시 프로파일링 — 리렌더링 횟수가 줄었는지 확인합니다. 감으로 최적화하지 마세요.
중요한 건, useMemo를 코드베이스 전체에 무작정 뿌리는 것부터 시작하면 안 된다는 점입니다. 먼저 프로파일링해서 실제 낭비를 찾고, 그것만 고치세요. 성급한 메모이제이션은 복잡도를 늘리고 그 자체로도 오버헤드를 만듭니다.
정리하자면
리렌더링 폭풍은 세 가지에서 비롯됩니다. 너무 높은 곳에 있는 상태, 불안정한 props identity, 그리고 비용이 큰 작업 주변에 메모이제이션 경계가 없다는 것. 순서대로 고치세요. 먼저 콜로케이션하고, 경계에 메모를 적용하고, props를 안정화한 다음, 매번 프로파일러로 검증하세요. 키 입력 한 번에 50번씩 리렌더링되던 페이지가 두 번으로 줄어들고, 타이핑 지연도 사라집니다.
결론
React의 기본 동작은 넉넉하게 리렌더링하면서, 정말 중요한 경계는 개발자가 직접 그어줄 거라고 믿는 것입니다. React 앱이 버벅이는 건 거의 항상 React 자체가 느려서가 아니라, 상태가 너무 높은 곳에 있거나 props의 identity가 계속 바뀌어서 아무도 필요로 하지 않는 렌더링 작업이 일어나기 때문입니다. 프로파일링으로 진실을 확인하고, 상태를 아래로 내리고, 비용이 큰 부분 주변에 메모 경계를 긋고, props를 안정적으로 유지하세요. 그러면 React는 원래 약속했던 그대로, 실제로 바뀐 것만 업데이트합니다.
이 글은 "사용자가 느끼는 프론트엔드 성능" 시리즈의 두 번째 편입니다. 다음 편의 주제는 이미지입니다. 웹 전체를 통틀어 가장 간단하면서도 가장 자주 무시되는 성능 개선 포인트죠.
여러분의 앱에서 필요 이상으로 자주 리렌더링되는 컴포넌트는 무엇인가요? 👇