React 리렌더링을 줄이는 실전 기준: memo, useMemo, useCallback은 언제 써야 할까
빠른 답
- 리렌더링은 React의 정상 동작입니다. 먼저 React DevTools Profiler나
ProfilerAPI로 느린 컴포넌트와 상호작용을 확인하는 편이 좋습니다. memo는 부모가 자주 렌더링되지만 자식의props가 자주 같게 유지되는 경우에 효과가 있습니다.useMemo는 비싼 계산 결과를 캐시할 때,useCallback은 함수 참조 변화가 자식 리렌더링이나 Effect 재실행으로 이어질 때 씁니다.- 메모이제이션에도 비교 비용, 메모리 비용, 의존성 관리 비용이 있으므로 모든 값과 함수에 붙이는 방식은 대체로 이득이 작습니다.
목차
선택 기준 매트릭스
React 리렌더링 흐름
React 컴포넌트는 state, props, context가 바뀌면 다시 렌더링될 수 있습니다. 여기서 렌더링은 곧바로 브라우저 화면을 다시 그린다는 뜻이 아니라, React가 컴포넌트 함수를 다시 호출해 다음 UI 결과를 계산한다는 뜻에 가깝습니다.
흐름은 보통 다음 순서로 이어집니다.
- 이벤트 발생: 사용자가 입력하거나 클릭합니다.
- 상태 변경:
setState계열 호출로 업데이트가 예약됩니다. - 렌더링 계산: React가 영향을 받는 컴포넌트 함수를 다시 실행합니다.
- 커밋: 이전 결과와 새 결과의 차이를 실제 DOM에 반영합니다.
- 페인트: 브라우저가 변경된 화면을 그립니다.
성능 문제가 되는 지점은 “렌더링이 일어났다” 자체가 아니라 “렌더링 계산이 너무 비싸다” 또는 “상태가 너무 넓은 범위에 있어 관련 없는 컴포넌트까지 자주 계산된다”인 경우가 많습니다. 그래서 리렌더링 최적화는 렌더 횟수를 0으로 만드는 작업이라기보다, 데이터 흐름과 상태 소유권을 좁히는 작업에 가깝습니다.
문제가 되는 리렌더링
부모 컴포넌트가 렌더링되면 자식 컴포넌트도 기본적으로 다시 렌더링됩니다. 이 동작은 React의 단방향 데이터 흐름과 맞물려 있습니다. 부모가 새 데이터를 계산하면 자식도 그 데이터를 기준으로 UI를 다시 계산하는 편이 예측 가능하기 때문입니다.
다만 다음 상황에서는 체감 성능 문제가 생기기 쉽습니다.
- 큰 목록을 렌더링하는 컴포넌트가 입력 한 글자마다 다시 계산됩니다.
- 차트, 에디터, 캔버스 주변 UI처럼 렌더 비용이 큰 자식 컴포넌트가 부모의 사소한 상태 변경에 끌려갑니다.
- 부모가 매번 새 객체나 새 함수를 만들어
props로 넘기기 때문에memo가 있어도 비교가 매번 실패합니다. - 서버에서 받은 원본 데이터를 여러 컴포넌트가 각자 파생 상태로 복사해 두고, 동기화 Effect가 연쇄 업데이트를 만듭니다.
특히 마지막 경우는 메모이제이션으로 덮기 어렵습니다. 원본 상태와 파생 상태의 소유자가 여러 곳으로 흩어지면 렌더링 횟수보다 데이터 정합성이 먼저 흔들립니다.
현재 기준과 오래된 설명의 차이
2026년 4월 기준 React 공식 문서는 최신 버전을 19.2로 안내합니다. 릴리스 목록에는 19.2.1 같은 패치 버전도 보이지만, 문서는 메이저와 마이너 기준으로 최신 문서를 유지하는 방식입니다. API를 확인할 때는 현재 react.dev 문서를 기준으로 보는 편이 좋습니다.
예전 설명에서는 “렌더링이 많으면 useCallback과 useMemo를 많이 붙인다”는 식의 조언이 자주 등장했습니다. 지금 기준으로는 부족한 설명입니다. React 공식 문서는 memo, useMemo, useCallback을 모두 성능 최적화 수단으로 다루며, 코드가 이 최적화 없이는 제대로 동작하지 않는다면 먼저 데이터 모델링 문제를 확인하라고 설명합니다.
또 하나의 차이는 React Compiler입니다. React 문서는 Compiler가 컴포넌트와 값을 자동으로 메모이즈해 수동 memo, useMemo, useCallback 필요성을 줄일 수 있다고 안내합니다. 다만 모든 프로젝트에 자동 적용되는 기능은 아닙니다. 빌드에 Compiler를 통합했는지, 프로젝트 코드가 지원되는 패턴인지, eslint-plugin-react-hooks의 Compiler 관련 규칙을 사용하는지 확인해야 합니다.
레거시 코드에서는 클래스 컴포넌트의 PureComponent나 shouldComponentUpdate 중심으로 최적화를 설명하는 경우도 있습니다. 지금도 오래된 클래스 컴포넌트 코드에서는 의미가 있지만, 함수 컴포넌트 중심의 새 코드에서는 memo, 상태 배치, 컴포넌트 분리, Profiler 측정을 함께 보는 편이 더 실용적입니다.
공식 문서는 아래에서 확인할 수 있습니다.
- React 버전 정책: https://react.dev/versions
- React
memo문서: https://react.dev/reference/react/memo - React
useMemo문서: https://react.dev/reference/react/useMemo - React
useCallback문서: https://react.dev/reference/react/useCallback - React
Profiler문서: https://react.dev/reference/react/Profiler - React 19.2 릴리스 노트: https://react.dev/blog/2025/10/01/react-19-2
상태 소유권부터 정리하기
리렌더링 최적화를 시작할 때 가장 먼저 볼 것은 memo가 아니라 상태가 어디에 있는지입니다. 상태를 너무 위로 올리면 부모의 작은 변경이 넓은 하위 트리를 다시 계산하게 됩니다. 반대로 여러 곳에 중복해서 저장하면 Effect로 동기화하는 코드가 늘어나고, 연쇄 업데이트가 생기기 쉽습니다.
예를 들어 검색어 입력 상태는 검색 영역에만 필요할 수 있습니다. 그런데 페이지 최상단에 검색어를 두면 헤더, 사이드바, 목록, 통계 컴포넌트가 모두 입력 이벤트에 반응하는 구조가 됩니다. 이런 경우에는 memo를 많이 붙이기보다 검색어 상태를 검색 영역 가까이에 두고, 실제로 필요한 결과만 아래로 내려보내는 편이 단순합니다.
흔한 안티패턴은 원본 데이터에서 바로 계산할 수 있는 값을 별도 상태로 저장하는 방식입니다.
function ProductPage({ products }) {
const [query, setQuery] = useState("");
const [filteredProducts, setFilteredProducts] = useState(products);
useEffect(() => {
setFilteredProducts(
products.filter((product) => product.name.includes(query))
);
}, [products, query]);
return ProductList({ products: filteredProducts, onSearch: setQuery });
}
위 코드는 검색어가 바뀔 때 한 번 렌더링되고, Effect에서 filteredProducts를 다시 설정하면서 한 번 더 렌더링될 수 있습니다. filteredProducts는 원본 products와 query에서 계산 가능한 파생 값이므로 상태로 둘 이유가 약합니다.
function ProductPage({ products }) {
const [query, setQuery] = useState("");
const filteredProducts = useMemo(() => {
return products.filter((product) => product.name.includes(query));
}, [products, query]);
return ProductList({ products: filteredProducts, onSearch: setQuery });
}
계산량이 작다면 useMemo 없이 바로 계산해도 충분합니다. products가 크고 필터링 비용이 실제로 부담될 때 useMemo를 붙이는 식으로 접근하면 코드가 과하게 복잡해지는 일을 줄일 수 있습니다.
자식 컴포넌트 리렌더링 줄이기
다음 예시는 부모의 theme 상태가 바뀔 때마다 비싼 목록 컴포넌트까지 다시 렌더링되는 상황입니다. items는 그대로인데 부모가 새 배열과 새 함수를 매번 만들어 넘기면 자식 입장에서는 props가 바뀐 것처럼 보입니다.
function Dashboard({ items }) {
const [theme, setTheme] = useState("light");
const selectedIds = useMemo(() => {
return items.filter((item) => item.selected).map((item) => item.id);
}, [items]);
const handleSelect = useCallback((id) => {
console.log("selected", id);
}, []);
const toggleTheme = () => {
setTheme((current) => (current === "light" ? "dark" : "light"));
};
return PageShell({
theme,
onToggleTheme: toggleTheme,
content: MemoizedItemList({
items,
selectedIds,
onSelect: handleSelect,
}),
});
}
const MemoizedItemList = memo(function ItemList({ items, selectedIds, onSelect }) {
expensiveRenderWork(items);
return renderItems({ items, selectedIds, onSelect });
});
여기서 적용한 기준은 세 가지입니다. 첫째, theme처럼 목록 렌더링과 직접 관계없는 상태는 목록 props에서 빼냈습니다. 둘째, 계산 비용이 있는 selectedIds는 items가 바뀔 때만 다시 계산하게 했습니다. 셋째, memo로 감싼 자식에게 전달되는 콜백은 useCallback으로 참조를 안정화했습니다.
반대로 렌더 비용이 거의 없는 작은 컴포넌트나 memo로 감싸지 않은 자식에게 넘기는 단순 함수에는 useCallback의 효과가 제한적입니다. 함수 참조를 캐시하는 일에도 의존성 배열 비교와 코드 이해 비용이 따라오기 때문입니다.
린트와 구성 기준
메모이제이션은 의존성 배열을 잘못 쓰면 오래된 값을 참조하는 버그로 이어질 수 있습니다. 그래서 프로젝트에는 React Hooks 린트 규칙을 켜 두는 편이 안전합니다. React 19.2 릴리스 노트에는 eslint-plugin-react-hooks v6와 flat config 기준 변화도 함께 언급되어 있으므로, 새 프로젝트라면 현재 설정 방식을 확인하는 것이 좋습니다.
import reactHooks from "eslint-plugin-react-hooks";
export default [
{
files: ["src/**/*.{js,jsx,ts,tsx}"],
plugins: {
"react-hooks": reactHooks,
},
rules: {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
},
},
];
이 설정은 useMemo, useCallback, useEffect 안에서 사용하는 반응형 값이 의존성 배열에 빠졌는지 확인하는 데 도움을 줍니다. 리렌더링을 줄이기 위해 의존성을 일부러 빼면, 당장은 렌더 횟수가 줄어 보일 수 있지만 오래된 props나 state를 참조하는 버그가 생길 수 있습니다.
Profiler로 병목 확인하기
렌더링 시간을 코드에서 직접 남기고 싶다면 React의 Profiler API를 사용할 수 있습니다. 개발 중 특정 하위 트리만 감싸서 actualDuration과 baseDuration을 비교하면 메모이제이션이 효과를 내는지 확인할 수 있습니다.
import { Profiler } from "react";
function onRender(id, phase, actualDuration, baseDuration) {
console.log(
`[render] ${id} phase=${phase} actual=${actualDuration.toFixed(2)}ms base=${baseDuration.toFixed(2)}ms`
);
}
function App({ items }) {
return Profiler({
id: "ItemList",
onRender,
children: ItemListContainer({ items }),
});
}
Profiler를 켜고 검색어를 입력했을 때 다음과 같은 로그가 나온다고 가정해 보겠습니다.
[render] SearchPage phase=update actual=4.12ms base=15.80ms
[render] ItemList phase=update actual=13.47ms base=14.20ms
[render] ChartPanel phase=update actual=18.92ms base=19.10ms
filter products: 0.42ms
filter products: 8.76ms
filter products: 9.14ms
이 출력에서는 ChartPanel이 검색 입력과 직접 관련이 없는데도 매번 18ms 안팎으로 렌더링되고 있습니다. 이 경우 먼저 볼 질문은 “왜 검색어 상태 변경이 차트까지 닿는가”입니다. 차트에 검색어가 필요 없다면 상태 위치나 컴포넌트 경계를 조정하는 편이 memo를 추가하는 것보다 더 근본적인 해결이 될 수 있습니다.
baseDuration은 최적화가 없을 때의 대략적인 렌더링 비용에 가깝고, actualDuration은 이번 업데이트에서 실제로 사용한 렌더링 시간입니다. actualDuration이 의미 있게 줄었다면 memo나 useMemo가 특정 상호작용에서 효과를 낸 것입니다.
반대로 계산 시간이 0.42ms 수준이라면 useMemo를 붙여도 사용자가 체감하기 어렵습니다. 입력할 때마다 8~9ms 계산이 반복되고 목록 렌더링까지 겹친다면 계산 결과 캐시, 목록 가상화, 상태 위치 조정 같은 선택지를 함께 볼 만합니다.
흔한 오해와 주의사항
memo는 “절대 다시 렌더링하지 않는다”는 장치가 아닙니다. 자식의 자체 상태가 바뀌거나, 자식이 읽는 context가 바뀌면 memo로 감싼 컴포넌트도 다시 렌더링됩니다. 또한 props 중 하나라도 매번 새 참조라면 얕은 비교에서 달라진 값으로 판단됩니다.
useMemo도 값을 영구 저장하는 기능이 아닙니다. React 공식 문서는 useMemo를 성능 최적화 수단으로 다루며, 의미상 반드시 보존되어야 하는 값에는 상태나 ref가 더 맞을 수 있습니다. 렌더링 결과가 useMemo 없이는 틀어진다면 최적화 문제가 아니라 데이터 모델링 문제일 가능성이 큽니다.
useCallback은 함수 실행을 막지 않습니다. 함수 정의의 참조를 캐시할 뿐입니다. 아래처럼 의존성을 비워 두면 함수 참조는 안정적으로 보일 수 있지만, 내부에서 사용하는 값은 오래된 상태로 남을 수 있습니다.
function Editor({ title, body }) {
const handleSave = useCallback(() => {
saveDraft(title, body);
}, []);
return SaveButton({ onClick: handleSave });
}
이런 코드는 린트 경고를 무시하고 작성된 경우가 많습니다. 리렌더링을 줄이기 위해 의존성을 빼는 방식은 성능 최적화라기보다 버그를 숨기는 방식에 가깝습니다. title과 body가 저장 함수에 필요하다면 의존성 배열에 포함하거나, 상태 구조와 저장 흐름을 다시 정리하는 편이 안전합니다.
원문 참고
https://www.maeil-mail.kr/question/79
댓글
댓글 쓰기