기본 콘텐츠로 건너뛰기

React 리렌더링을 줄이는 실전 기준: memo, useMemo, useCallback은 언제 써야 할까

React 리렌더링을 줄이는 실전 기준: memo, useMemo, useCallback은 언제 써야 할까

빠른 답

  • 리렌더링은 React의 정상 동작입니다. 먼저 React DevTools Profiler나 Profiler API로 느린 컴포넌트와 상호작용을 확인하는 편이 좋습니다.
  • memo는 부모가 자주 렌더링되지만 자식의 props가 자주 같게 유지되는 경우에 효과가 있습니다.
  • useMemo는 비싼 계산 결과를 캐시할 때, useCallback은 함수 참조 변화가 자식 리렌더링이나 Effect 재실행으로 이어질 때 씁니다.
  • 메모이제이션에도 비교 비용, 메모리 비용, 의존성 관리 비용이 있으므로 모든 값과 함수에 붙이는 방식은 대체로 이득이 작습니다.

선택 기준 매트릭스

상황
부모만 자주 바뀌고 자식 props 는 그대로라면 memo 로 자식 렌더링을 건너뛸 수 있습니다.
상황
큰 배열 필터링, 정렬, 집계처럼 렌더 중 계산이 무겁다면 useMemo 로 계산 결과를 재사용합니다.
상황
memo 로 감싼 자식에게 콜백을 넘기는데 함수 참조가 매번 바뀐다면 useCallback 을 고려합니다.
조건
객체, 배열, 함수가 매 렌더마다 새로 만들어진다면 props 형태를 줄이거나 참조를 안정화해야 memo 가 의미를 가집니다.
조건
입력값, hover, 모달 열림 같은 일시적 UI 상태가 너무 위에 있다면 메모이제이션보다 상태 위치 조정과 컴포넌트 분리가 먼저입니다.
상황
React Compiler를 도입한 환경이라면 수동 메모이제이션 필요성이 줄 수 있지만, 빌드 설정과 지원 범위를 먼저 확인해야 합니다.

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 문서를 기준으로 보는 편이 좋습니다.

예전 설명에서는 “렌더링이 많으면 useCallbackuseMemo를 많이 붙인다”는 식의 조언이 자주 등장했습니다. 지금 기준으로는 부족한 설명입니다. React 공식 문서는 memo, useMemo, useCallback을 모두 성능 최적화 수단으로 다루며, 코드가 이 최적화 없이는 제대로 동작하지 않는다면 먼저 데이터 모델링 문제를 확인하라고 설명합니다.

또 하나의 차이는 React Compiler입니다. React 문서는 Compiler가 컴포넌트와 값을 자동으로 메모이즈해 수동 memo, useMemo, useCallback 필요성을 줄일 수 있다고 안내합니다. 다만 모든 프로젝트에 자동 적용되는 기능은 아닙니다. 빌드에 Compiler를 통합했는지, 프로젝트 코드가 지원되는 패턴인지, eslint-plugin-react-hooks의 Compiler 관련 규칙을 사용하는지 확인해야 합니다.

레거시 코드에서는 클래스 컴포넌트의 PureComponentshouldComponentUpdate 중심으로 최적화를 설명하는 경우도 있습니다. 지금도 오래된 클래스 컴포넌트 코드에서는 의미가 있지만, 함수 컴포넌트 중심의 새 코드에서는 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는 원본 productsquery에서 계산 가능한 파생 값이므로 상태로 둘 이유가 약합니다.

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에서 빼냈습니다. 둘째, 계산 비용이 있는 selectedIdsitems가 바뀔 때만 다시 계산하게 했습니다. 셋째, 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 안에서 사용하는 반응형 값이 의존성 배열에 빠졌는지 확인하는 데 도움을 줍니다. 리렌더링을 줄이기 위해 의존성을 일부러 빼면, 당장은 렌더 횟수가 줄어 보일 수 있지만 오래된 propsstate를 참조하는 버그가 생길 수 있습니다.

Profiler로 병목 확인하기

렌더링 시간을 코드에서 직접 남기고 싶다면 React의 Profiler API를 사용할 수 있습니다. 개발 중 특정 하위 트리만 감싸서 actualDurationbaseDuration을 비교하면 메모이제이션이 효과를 내는지 확인할 수 있습니다.

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이 의미 있게 줄었다면 memouseMemo가 특정 상호작용에서 효과를 낸 것입니다.

반대로 계산 시간이 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 });
}

이런 코드는 린트 경고를 무시하고 작성된 경우가 많습니다. 리렌더링을 줄이기 위해 의존성을 빼는 방식은 성능 최적화라기보다 버그를 숨기는 방식에 가깝습니다. titlebody가 저장 함수에 필요하다면 의존성 배열에 포함하거나, 상태 구조와 저장 흐름을 다시 정리하는 편이 안전합니다.

원문 참고

https://www.maeil-mail.kr/question/79

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...