TL;DR

  • 연산 시간이 오래 걸리는 값을 메모할 땐 useMemo()를 사용하자.
/** To Do 목록을 담고 있는 리스트 컴포넌트 */
function TodoList({ todos, tag }) {
  /** 컴포넌트에 실제로 표시할 To Do 목록 */
  const visibleTodos = useMemo(() => {
    /** 현재 todos에서 tag를 가지고 있는 todo만 골라 필터링 */
    const filteredTodos = filterTodos(todos, tag);
 
    // useMemo에 의해 메모됨, 아래 배열의 todos와 tag가 변경될 경우 이 값도 새로 연산
    return filteredTodos;
  }, [todos, tag]);
 
  // ...
}
  • React 컴포넌트를 메모하려면 React.memo()를 사용하자.
    • 메모된 컴포넌트는 props가 변경될 때만 다시 렌더링 된다.
import { memo } from "react";
 
/**
 * React.memo를 통해 메모한 컴포넌트, 매개변수로 메모할 함수형 컴포넌트를 넣는다.
 * 전달받은 프로퍼티 name이 변경될 때만 다시 렌더링 된다.
 */
const MemoedGreeting = memo(function Gretting({ name }) {
  return <h1>Hello, {name}!</h1>
});
  • 컴포넌트의 일부 vs 컴포넌트 그 자체
    • useMemo()는 함수형 컴포넌트 안에서 로직 일부의 결과를 메모할 때 사용한다.
    • React.memo()는 함수형 컴포넌트 그 자체를 메모할 때 사용한다.
  • 다만, 메모이제이션이 항상 좋은 것은 아니다.
    • 메모이제이션이 그냥 불필요한 비교 과정과 오버헤드 추가에 불과할 수도 있다.
    • 무작정 쓰지 말고 생각하고 씁시다.

이하 그리 중요하진 않은 내용들

내 코드를 나 혼자서만 계속 보고 있다는 것은 많은 사실을 놓치게 만든다. 예를 들면, 내가 당연하다는 듯이 계속 써왔던 방법이 사실은 안티 패턴에 가깝다던가. 사실 나도 내가 React를 언제부터, 어떻게 배워서 써왔는지 기억이 잘 안 난다. 이것저것 많이 건드려본 것 치고는 기본기가 약하다는 뜻이다. 안그래도 요즘 React/Next.js 기반으로 블로그를 리모델링할 계획을 짜고 있는데, 이런 실력으론 고생깨나 하겠구먼. React에 대해 공부를 열심히 더 해야겠다.

오늘은 며칠 전에 발견한 나의 안티 패턴을 수정하면서, React에서 메모이제이션(Memoization)을 더 잘하는 방법에 대해서 알아보자.

메모이제이션(Memoization)

Memo

메모이제이션(Memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거해 프로그램 실행 속도를 빠르게 하는 기술이다. 라고 위키피디아에서 설명하고 있다. 알고리즘 분야 공부의 첫 번째 관문인 다이나믹 프로그래밍 기법에서 핵심적으로 다루는 테크닉이다. 피보나치수열 계산 알고리즘이 대표적인 사례인데 굳이 다루진 않을 생각이니까 궁금하면 검색해 보자. 한 가지 의외의 사실은 이게 원래 있던 단어를 가져온 게 아니라 1960년대에 나온 조어(造語)라는 점이다.

프론트엔드, 즉 GUI 어플리케이션을 만드는 기술인 리액트(React)에선 함수형 컴포넌트로 패러다임을 전환하면서 이 메모이제이션 개념을 적극적으로 도입하고 있다. 자바스크립트 함수로 컴포넌트를 정의하고 렌더링 한다는 것은, 어플리케이션이 실행되는 동안 그 함수가 반복적으로 재실행된다는 것을 의미한다. 렌더링은 화면 한 번 그리고 끝인 과정이 아니기 때문에. 그 함수 안에는 굳이 매번 다시 실행될 필요가 없는 로직들도 들어 있을 것이고, 함수가 다시 실행될 때마다 값이 초기화되어 버린다면 도저히 써먹을 수가 없는 변수들도 있을 것이다. 그런 다양한 목적의 코드 연산들을 저장하고 그것이 필요한 때에만 다시 실행되도록 만들어주는 함수형 리액트 컴포넌트의 추상화된 API가 바로 훅(Hook)이다. 사실 훅은 메모이제이션이라고 설명하기보단 라이프사이클 동안 로직의 재사용을 가능하게 해 준다고 설명하는 게 더 바람직하겠지만.

/** To Do 목록을 담고 있는 리스트 컴포넌트 */
function TodoList({ todos, tag }) {
  /** 컴포넌트에 실제로 표시할 To Do 목록 */
  const visibleTodos = useMemo(() => {
    /** 현재 todos에서 tag를 가지고 있는 todo만 골라 필터링 */
    const filteredTodos = filterTodos(todos, tag);
 
    // useMemo에 의해 메모됨, 아래 배열의 todos와 tag가 변경될 경우 이 값도 새로 연산
    return filteredTodos;
  }, [todos, tag]);
 
  // ...
}

리액트 기본 훅 중에서도 가장 직접적으로 메모이제이션과 맞닿아 있는 훅이 바로 useMemo()이다. useMemo()는 메모하고자 하는 값을 계산할 콜백 함수와 의존성 배열을 매개변수로 받는다. 이 훅이 포함된 컴포넌트가 최초로 로드될 때 전달받은 콜백이 실행되며 그 로직의 결과로 반환된 값이 메모된다. 그리고 의존성 배열에 등록된 변수가 바뀔 때마다 콜백이 재실행되며 변화된 환경에서의 결괏값이 새로 메모된다. 참 쉽죠? 이처럼 useMemo()는 개념적으로 어려울 게 없는 기초적인 리액트 훅이다.

컴포넌트는 메모 못해?

리액트의 일반적인 컴포넌트는 부모 컴포넌트가 재렌더링 될 때 따라 재렌더링 된다. 그런데 리액트를 사용해 보면 굳이 부모의 상태가 변경되었다고 자식의 상태까지 업데이트될 필요는 없는 경우가 수두룩할 것이다. 이럴 때 컴포넌트 또한 메모의 대상이 될 수 있다.

import { memo } from "react";

useMemo()로 값을 메모하는 것처럼 컴포넌트를 메모하고 싶다면 React.memo() API를 사용하자. 리액트의 최상위 API 중 하나인 memo(), 함수명 자체가 워낙 흔히 쓰이는 단어라 그런지 굳이 해당 메서드 하나만 따로 가져와 쓰지 않고 React.memo() 형태로 쓰는 자료가 많더라. 아무튼, 이렇게 사용하면 된다.

/**
 * React.memo를 통해 메모한 컴포넌트, 매개변수로 메모할 함수형 컴포넌트를 넣는다.
 * 전달받은 프로퍼티 name이 변경될 때만 다시 렌더링 된다.
 */
const MemoedGreeting = memo(function Gretting({ name }) {
  return <h1>Hello, {name}!</h1>
});

훅에서 의존성 배열을 사용해 메모가 반응할 변수들을 결정했듯, memo()로 메모가 된 컴포넌트는 프로퍼티에 대해 렌더링 의존성을 가진다. 다시 말해, memo()로 메모가 된 컴포넌트는 부모의 변화에 반응하지 않고 프로퍼티의 변화에만 반응하게 된다. 위 <Greeting /> 컴포넌트의 경우 프로퍼티 name이 바뀔 때만 컴포넌트가 다시 렌더링 될 것이다.

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)

memo()에는 arePropsEqual라는 두 번째 인자를 줄 수도 있다. 이름 그대로 프로퍼티들이 이전과 똑같은지 아닌지를 판단하는 함수이다. 동일 값 비교가 복잡한 객체를 프로퍼티로 쓸 때 로직을 직접 구현해서 전달하면 된다.

흔히들 React.memo()고차 컴포넌트(Higher Order Component, HOC)라고 설명한다. 고차 컴포넌트는 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수를 의미하는데, 사실 최근 리액트에선 이 표현을 잘 사용하진 않는다. 업데이트된 memo()의 공식 문서에서도 고차 컴포넌트를 딱히 언급하고 있지는 않다. 대충 React.memo()는 컴포넌트를 메모하는 방법이라는 정도로 이해해 두자. 이처럼 memo() 역시 어렵지 않은 리액트 API이다.

useMemo로 컴포넌트 메모하기

주의! 이러지 마세요.

학부생을 보는 교수님
(이케이케 하면 되려나...)

사실 굳이 둘을 비교할 것도 없이 컴포넌트에는 React.memo(), 그 외에는 useMemo()를 쓰면 된다. 하지만 내가 둘을 제대로 구분해 사용해 오지 못했기 때문에, 반성의 의미로 한 번 비교를 해봐야겠다. 그런 의미에서 지금부터는 나의 민망한 과거를 되살펴 보자. 과거 당시 내 생각은 이랬다.

const MyButton = <button>{ myValue }</button> 같은 표현식. 그러니까 상숫값, 배열, 함수, 객체 등등 여타 다른 값들처럼 사용할 수 있는 JSX 문법의 엘리먼트 표현식을 useMemo로(혹은 useCallback으로) 메모해서 컴포넌트를 메모할 수 있지 않을까?

사실 역사를 따지면 React.memo()useMemo()보다 먼저 나온 기술이지만, 애석하게도 나는 훅밖에 알지 못했다. 나의 리액트에 대한 학습은 On-demand 방식으로 이루어지고 있었는데(고상하게 말하면 이렇고, 그냥 모르는 게 있을 때마다 구글과 SO와 GPT의 도움을 받고 있다는 뜻) 개발을 하다가 마침 메모이제이션에 대한 요구가 생겼고, 내 머릿 속 캐시 서버 안에는 메모이제이션이라는 키워드는 useMemo()와 매핑되어 있었고... 나는 기어코 useMemo()로 컴포넌트를 메모하는 코드를 작성해 버리는데...

function App() {
  const [isOK, setIsOK] = useState(false);
  const [count, setCount] = useState(0);
 
  // ...
 
  const MemoedCounter = useMemo(() => {
    return () => (
      <button onClick={() => setCount(cnt => cnt + 1)}>
        {count}
      </button>
    );
  }, [count]);
 
  // ...
 
  return (
    <div>
      {/* ... */}
      <MemoedCounter />
      {/* ... */}
    </div>
  );
}

(걱정하지 마세요, 안전하게 연출된 코드입니다.)

함수를 내보내고 있으니 useCallback()으로 구현해도 동일하게 동작할 것이다. 중요한 건 그게 아니지만. 아무튼 무엇이 문제일까. isOK 상태가 변경되어 <App />이 다시 렌더링 되어도 count 상태에 대해서만 의존성을 가지는 <MemoedCounter />는 다시 렌더링 되지 않기를 원하는 코드다. 하지만 여기서 메모된 것은 <MemoedCounter /> 컴포넌트 그 자체가 아니라 컴포넌트를 생성하는 함수이다. <App />이 다시 렌더링 되면 그 함수는 다시 실행된다. 다시 말해, 이렇게 해도 isOK가 변할 때마다 <MemoedCounter />는 다시 렌더링 될 뿐이다.

  const MemoedCounter = useMemo(() => {
    return (
      <button onClick={() => setCount(cnt => cnt + 1)}>
        {count}
      </button>
    );
  }, [count]);
 
  // ...
 
  return (
    <div>
      {/* ... */}
      {MemoedCounter}
      {/* ... */}
    </div>
  )

(집에선 따라 하지 마세요.)

그러면 이렇게 하면 어떨까? 컴포넌트를 생성하는 함수를 메모하는 게 아니라 컴포넌트의 엘리먼트 표현식을 메모한다. 그리고 <App /> 컴포넌트가 반환하는 엘리먼트 부분에 일반 변수를 쓰듯이 메모한 컴포넌트를 사용한다. 적어도 이 방법은 예상한 대로 동작하는 것처럼 보인다. isOK가 변경되어 <App />이 다시 렌더링 되어도 <MemoedCounter />는 다시 렌더링 되지 않는다. useMemo() 안에서 렌더링 작업이 미리 이뤄진 것과 비슷하다고 볼 수 있을 것 같다. 하지만 이 방법도 완벽하지는 않다. 이래선 프로퍼티 전달은 어떻게 하지? 애초에 저 이상한 컴포넌트 사용법도 불편하기만 하다.

조금 더 자세한 코드 예제를 보면서 useMemo()로 컴포넌트를 메모하면 안 되는 이유를 알아보자.

버튼 컴포넌트 8개를 모셔왔다. 역할은 각각 다음과 같다.

  • isGood 상태 토글 버튼 : 아래 카운팅 버튼들의 렌더링 여부를 결정하는 상태값 (Bad 버튼)
  • isOK 상태 토글 버튼 : 카운팅 버튼들과 아무런 관련이 없는 상태값 (NOT OK 버튼)
  • 카운팅 버튼들 6개 : count 상태를 변경하고 그 값을 버튼 내부에 표시하는 컴포넌트
    • (A) React.memo()를 사용해 메모한 버튼 컴포넌트
    • (B) useMemo()로 함수를 메모한 버튼 컴포넌트 (프로퍼티 사용)
    • (C) useMemo()로 함수를 메모한 버튼 컴포넌트 (의존성 배열 사용)
    • (D) useMemo()로 엘리먼트를 메모한 버튼 컴포넌트
    • (E) (D) 방식을 커스텀 훅으로 분리한 버튼 컴포넌트
    • (F) 메모되지 않은 평범한 버튼 컴포넌트

(콘솔에서 Info 메시지만 보이도록 설정하면 더 편하게 볼 수 있어요.)

useMemo()로 컴포넌트를 메모하면 조건부 렌더링에 불리하다. 최초 화면에서 콘솔 출력이 어떻게 나오는지 보자. (D)와 (E) 방식은 <App /> 컴포넌트의 최초 렌더링 중 실행된 useMemo() 안에서 함께 렌더링되어버린다. 당장 필요하지도 않은 컴포넌트를 미리 준비해두는 꼴이다.

한 가지 더, isGood 토글 버튼을 누른 후 콘솔 창에서 React DevTools 탭을 선택해 보자. (D)와 (E) 방식은 React DevTools에서 추적되지 않는다. (B)나 (C) 방식 처럼 익명 컴포넌트로 인식되지도 않는다. 리액트의 컴포넌트로 인식되는 것이 아니라, 평범한 HTML <button /> 엘리먼트로 인식되고 있다.

isGood이 토글된 이후 isOK 토글 버튼을 눌러보면 제대로 메모되지 못한 (B)와 (C), 그리고 메모하지 않은 (F) 방식은 전혀 상관없는 상태 isOK의 변경에도 다시 렌더링 되는 모습을 볼 수 있다. 화면상에선 완전히 동일한 결과를 내보내는 6개의 컴포넌트지만, 렌더링 과정은 이처럼 제각각이다.

// node_modules/react/cjs/react.development.js
 
function memo(type, compare) {
  {
    if (!isValidElementType(type)) {
      error('memo: The first argument must be a component. Instead ' + 'received: %s', type === null ? 'null' : typeof type);
    }
  }
 
  var elementType = {
    $$typeof: REACT_MEMO_TYPE, // <=== ⭐️⭐️⭐️
    type: type,
    compare: compare === undefined ? null : compare
  };
 
  // ...
var REACT_ELEMENT_TYPE = Symbol.for('react.element');
var REACT_PORTAL_TYPE = Symbol.for('react.portal');
var REACT_FRAGMENT_TYPE = Symbol.for('react.fragment');
var REACT_STRICT_MODE_TYPE = Symbol.for('react.strict_mode');
var REACT_PROFILER_TYPE = Symbol.for('react.profiler');
var REACT_PROVIDER_TYPE = Symbol.for('react.provider');
var REACT_CONTEXT_TYPE = Symbol.for('react.context');
var REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref');
var REACT_SUSPENSE_TYPE = Symbol.for('react.suspense');
var REACT_SUSPENSE_LIST_TYPE = Symbol.for('react.suspense_list');
var REACT_MEMO_TYPE = Symbol.for('react.memo');
var REACT_LAZY_TYPE = Symbol.for('react.lazy');
var REACT_OFFSCREEN_TYPE = Symbol.for('react.offscreen');

React 소스 코드를 훑어보면서 한 가지 유추할 수 있는 사실은, React 프레임워크 내부에서도 memo()로 만든 컴포넌트는 꽤나 특별한 위치에 있는 것 같다는 점이다. memo() 만든 컴포넌트는 리액트 18버전 기준 단 13개만이 존재하는 심볼 중 하나를 부여받는다. 리액트 코어 모듈 안에서 컴포넌트를 처리하는 로직은 이 심볼을 인식해 로직의 분기를 나눌 것이다.

아무튼 이렇게 복잡하게 파헤쳐봤지만 결국 정답은 간단하다. 컴포넌트를 메모할 땐 React.memo()를 쓰자.

애초에 왜 메모를 하려고 하십니까?

메모에 필요한 것
메모에 필요한 것... 메모지... 볼펜... 팔힘...

여기부턴 조금 논쟁적인 이야기를 해보자. 메모는 공짜가 아니다. useCallback()이나 useMemo()를 남용하지 말아야 한다는 글을 많이 찾아볼 수 있다. 개인적으로 useCallback()의 경우는 참조 동일성의 문제 때문에 자주 사용하게 되는데, useMemo()의 경우엔 아직도 정확히 언제 사용해야 하는 건지 감이 잘 안 잡힌다. React.memo()를 사용한 컴포넌트의 메모 또한 크게 다르지 않을 것이다. 기껏 메모했는데, 프로퍼티가 너무 자주 바뀌어 버리면 메모는 그저 불필요한 코드 추가와 메모리 소모에 불과해진다.

useMemo공식문서
`useMemo()` 리액트 공식 문서

"비싼 연산"이라는 표현도 조금 모호한 면이 있기 때문에, 리액트 공식 문서에서는 이렇게 1ms라는 구체적인 수치를 제안하기도 한다. 하지만 이것도 예제일 뿐이고, 여러 환경에서 테스트해 봐야 한다는 점도 명시하고 있다.

싸움꾼
출처는 여기

그리고 계속 자료를 찾다가 아주 흥미로운, 문제적이라고도 할 수 있을 만한 주장을 발견하기도 했다. "복잡하게 생각하지 말고 그냥 항상 React.memo()를 쓰세요." upvote 수가 꽤 많은 게 신빙성이 있어 보이는가? 이 사람은 아주 공격적으로 자신의 주장을 펼치고 있는데, 같은 주장을 리액트 공식 Github 레포지토리에서 주장한 글은 비추천을 더 많이 받고 있다. 근데 또 무작정 이 사람이 공격적으로 말한다고 이 주장이 틀렸다고 봐야 할까? Redux의 개발자 Mark Erikson은 React.memo()를 광범위하게 사용하면 전반적인 앱 렌더링의 완성도가 높아질 것으로 생각하는 입장을 가진 채 트위터에서 다른 사용자와 논쟁했던 사실을 자신의 블로그에서 밝히고 있다.

리액트 공식 문서에서도 이 주제를 언급하고 있다. 메모가 불필요한 경우도 있지만, 그렇다고 모든 컴포넌트를 메모한다고 해도 크게 해가 되는 것도 아니기 때문에 일부 팀은 개별 사례를 생각하지 않고 가능한 한 모든 컴포넌트를 메모하는 컨벤션을 채택하기도 한다고 한다. (리액트 공식이 그랬어요 저는 잘 몰라요) 물론 공식 문서도 이 방법을 마냥 긍정하고 있지는 않다. 코드 가독성도 문제고, 크게 해가 되는 건 아니라는 말은 어쨌든 해가 되긴 한다는 의미이기도 하니까. 워낙 뜨거운 감자이긴 했는지 리액트에서 직접 세분된 메모를 자동으로 수행하는 방법을 연구하고 있다고도 한다.

밥아저씨

오랜만에 뵙는 밥 아저씨. 일련의 논쟁을 대충 한 번 훑고 나니까 과거 책에서 본 인상 깊었던 구절이 생각난다. 소프트웨어 개발이라는 게 사실 한 번 뚝딱하고 나면 끝나버리는 건 아니니깐.

목표를 달성하려면 빈틈없이 지켜봐야 한다.

내가 배운 것

  • 리액트를 몇 년 썼는데 인제야 배웠습니다 React.memo()
  • 리액트 내부 로직에선 컴포넌트를 심볼로 구분하고 있다.
    리액트 18버전 기준 컴포넌트를 구분하는 심볼은 총 13개.
  • 블로그에 CodeSandbox를 도입해 봤다.
  • 맥북으로 환경 바꾸면서 React Dev Tools를 누락하고 있었더라고. 뭔가 허전하다 했어.

그리고 글을 작성하면서 도움이 된 레퍼런스들은 다음과 같다. 정독을 해야 하는데 자꾸 필요한 부분만 체리피킹 하는 기분이야...