[번역] 메모이제이션 고지전

이춘구·2023년 10월 3일
15

translation

목록 보기
9/11

Photo by Michiel Annaert

TkDodoThe Uphill Battle of Memoization을 번역한 글입니다.


React.memo를 사용하기 전에 해봐야 할 것들을 알려주는 좋은 글들은 이미 많습니다. Dan의 before you memo와 Kent의 simple trick to optimize React re-renders는 모두 좋은 글이죠.

상태를 아래로 옮기거나 내용을 위로 끌어올려 컴포넌트 합성으로 문제를 해결한다는 아이디어인데, 컴포넌트 합성은 React의 자연스러운 멘탈 모델이니까 훌륭한 방법이죠. Dan이 언급하듯이 이제는 현실이 된 서버 컴포넌트에서도 잘 작동할 겁니다.

하지만 제가 읽은 글들 대부분에서는 그 이유가 빠져있었습니다. 네, 합성은 좋은 방법이죠. 그런데 React.memo를 사용하는 건 뭐가 문제인 걸까요? 왜 합성만큼 좋지 못한 걸까요?

자, 제 생각은 이렇습니다.

메모이제이션은 망가지기 너무 쉽다.

복습하자면, React는 컴포넌트 트리를 렌더링할 때 모든 자식에 대해 하향식으로 렌더링합니다. 일단 렌더링이 시작되면 우리가 멈출 방법은 없습니다. 렌더링은 상태가 화면에 정확하게 반영되고 있다는 걸 확실하게 해주니 보통은 장점입니다. 게다가 렌더링은 일반적으로 빠르죠.

물론 예외도 있습니다. 우리 모두에겐 바꾸기 힘든 이유로 인해 원하는 만큼 빠르게 렌더링 되지 않는 컴포넌트가 있죠. 다행히도 React는 렌더링을 "중단"할 수 있고, "같은 것"을 발견하면 그렇게 합니다. 그러니까 애초에 내용을 끌어올리는 기법 같은 게 통할 수 있는 겁니다.

function App() {
  return (
    <ColorPicker>
      <p>Hello, world!</p>
      <ExpensiveTree />
    </ColorPicker>
  )
}

function ColorPicker({ children }) {
  const [color, setColor] = React.useState('red')
  return (
    <div style={{ color }}>
      <input
        value={color}
        onChange={(event) => setColor(event.target.value)}
      />
      {children}
    </div>
  )
}

children의 참조값이 항상 같다면, React는 렌더링을 단축할 수 있습니다. color가 변경돼도 ExpensiveTree는 리렌더링 되지 않죠.

다른 해결책은 하나의 컴포넌트 내에서 전부 렌더링 되도록 하되, ExpensiveTree 컴포넌트를 React.memo로 감싸는 것입니다.

function App() {
  const [color, setColor] = useState('red')
  return (
    <div style={{ color }}>
      <input
        value={color}
        onChange={(event) => setColor(event.target.value)}
      />
      <p>Hello, world!</p>
      <ExpensiveTree />
    </div>
  )
}

function ExpensiveComponent() {
  return <div>I'm expensive!</div>
}

const ExpensiveTree = React.memo(ExpensiveComponent)

컴포넌트를 React.memo로 감싸면, React는 prop이 변경되지 않는 한 해당 컴포넌트(와 그의 자식들)의 렌더링을 건너뜁니다. 이렇게 하면 컴포넌트 구성을 변경하는 것과 같은 결과를 얻을 수 있지만, 나중에 망가지기 훨씬 쉽습니다.

컴포넌트가 메모이제이션 되면 React는 각각의 prop을 Object.is로 비교합니다. prop이 변경되지 않았다면 리렌더링이 생략될 수 있죠. 현재 예시의 컴포넌트에는 prop이 없으므로 리렌더링이 생략됩니다. prop으로 원시값을 사용해도 잘 생략되겠지만 함수, 객체, 배열을 사용하면 잘 안될 겁니다.

ExpensiveComponent에 prop으로 style을 전달함으로써 잘못된 게 없어 보이는 변경 사항을 만들어 보겠습니다.

function ExpensiveComponent({ style }) {
  return <div style={style}>I'm expensive!</div>
}

const ExpensiveTree = React.memo(ExpensiveComponent)

시간이 지나면서 prop이 추가되는 건 컴포넌트가 변화하는 일반적인 방식이죠. 문제는 ExpensiveTree 컴포넌트의 소비자가 이 컴포넌트는 메모이제이션 되어 있다는 사실을 반드시 알지는 못한다는 겁니다. 이러나 저러나 메모이제이션은 성능 최적화이며 구현상의 디테일일 뿐이죠.

이제 ExpensiveTree 컴포넌트를 렌더링할 때 prop으로 인라인 스타일을 추가해 보겠습니다.

<ExpensiveTree style={{ backgroundColor: 'blue' }} />

우리의 실수로 메모이제이션을 망쳤습니다. 왜냐하면 style prop은 렌더링 될 때마다 새로운 객체가 되기 때문이죠. React에게는 prop이 변경된 것처럼 보이니까 렌더링을 건너뛸 수 없는 겁니다.

물론 style prop을 React.useMemo로 감싸서 해결할 수 있습니다.

function App() {
  const memoizedStyle = React.useMemo(
    () => ({ backgroundColor: 'blue' }),
    []
  )

  return <ExpensiveTree style={memoizedStyle} />
}

이 예제는 간단해서 가능했지만, 메모이제이션이 필요한 prop이 더 많다면 코드가 어떨지 상상해 보세요. 코드를 추론하기 더 어렵게 만들 테고 소비자가 실제로 메모이제이션을 할 거라는 보장도 없습니다.

style 자체가 ExpensiveTree를 렌더링하는 컴포넌트의 prop으로 들어오면 훨씬 더 어려워집니다.

function App({ style }) {
  const memoizedStyle = React.useMemo(() => style, [style])

  return <ExpensiveTree style={memoizedStyle} />
}

이 메모이제이션은 실질적으로 아무 소용이 없습니다. App에 전달되는 style이 인라인 객체인지 아닌지 알 수 없으니 App 내부에서 메모이제이션 하는 건 무의미하죠. App을 호출하는 쪽에서 안정적인 참조값을 만들어줘야 합니다.

{children}

더 안 좋은 건 성능 개선을 망가뜨릴 수 있는 방법이 이거 하나가 아니라는 것입니다. 메모이제이션 된 컴포넌트가 children을 받는다면 예상대로 동작하지 않습니다.

function App() {
  return (
    <ExpensiveTree>
      <p>안녕, 세상아!</p>
    </ExpensiveTree>
  )
}

function ExpensiveComponent({ children }) {
  return (
    <div>
      나 비싸요!
      {children}
    </div>
  )
}

const ExpensiveTree = React.memo(ExpensiveComponent)

어후... 인정해야겠네요. 저는 이게 메모이제이션을 망가뜨린다는 걸 오랫동안 몰랐습니다. 그런데 왜 그런 걸까요? 항상 똑같은, 안정적인 <p> 태그를 자식으로 전달하고 있잖아요, 맞죠? 글쎄요, 사실은 그렇지 않습니다. JSX는 렌더링할 때마다 새로운 객체를 생성하는 React.createElement의 문법적 설탕(syntactic sugar)일 뿐입니다. 그러니 똑같은 <p> 태그로 보여도 참조값은 다른 거죠.

메모이제이션 된 컴포넌트에 전달할 자식도 물론 useMemo로 감쌀 수 있지만, 지금쯤이면 우리가 거의 이길 수 없는 고지전을 하고 있다는 사실을 깨달았을 겁니다. 다음 사람이 메모이제이션 된 컴포넌트의 prop에 빈 객체나 배열을 fallback 값으로 넘겨주면 다시 원점으로 돌아가게 되거든요.

//💥 왜 햄보칼 수가 없어! 😭
<ExpensiveTree someProp={someStableArray ?? []} />

대안

React.memo를 사용하는 건 좀 지뢰밭 같으니, 제시된 대안 중 하나를 선택하는 게 훨씬 더 나은 것 같습니다. 그런데 가끔은 컴포넌트를 메모이제이션 할 수 밖에 없을 때도 있습니다. 트위터에서 발견한 건데 이 글에 대한 아이디어를 떠올리게 해준 사례입니다.

저는 React의 성능을 최적화해야 할 일이 거의 없어요.

그런데 거대한 테이블 5개랑 요약 표시줄이 있는 페이지가 있는데요. 테이블 하나가 변경되면 전부 다 렌더링 됩니다. 이게 느리더라고요.

해결 방법:
1. 각 테이블을 memo로 감쌌습니다.
2. 전달되는 함수를 useCallback으로 감쌌습니다.

훨!씬! 빨라졌습니다.

Cory House의 트윗

위 트윗의 컴포넌트 트리는 아마 이런 모습일 겁니다(간결함을 위해 테이블을 5개가 아니라 2개만 사용했습니다).

function App() {
  const [state, setState] = React.useState({
    table1Data: [],
    table2Data: [],
  })

  return (
    <div>
      <Table1 data={state.table1Data} />
      <Table2 data={state.table2Data} />
      <SummaryBar />
    </div>
  )
}

state는 두 테이블의 데이터를 모두 갖고 있고, SummaryBar는 모든 데이터에 접근할 수 있어야 합니다. 상태를 테이블 내부로 옮길 수도 없고, 컴포넌트를 다른 방식으로 구성할 수도 없습니다. 메모이제이션이 유일한 선택지인 것 같네요.

렌더링을 시작하지 마세요

렌더링이 시작되면 멈출 방법이 없다고 했던 거 기억하시나요? 그건 여전히 사실입니다. 하지만 렌더링이 시작되는 것을 애초에 막을 수 있다면 어떨까요? 🤔

stateApp의 최상위에 있는 게 아니라면, 트리가 변경될 때마다 전체 트리를 리렌더링할 필요가 없을 겁니다. 최상위가 아니라면 어디에 있을 수 있을까요? 아래로 옮길 수 없다는 건 이미 확인했으니까 옆으로 옮겨봅시다. React의 바깥으로요.

이게 바로 대부분의 상태 관리 도구들이 하는 겁니다. 상태 관리 도구들은 상태를 React 바깥에 저장하고, 컴포넌트 트리 중 변경 사항에 대해 알아야 하는 부분의 리렌더링을 외과 수술하듯 정교하게 트리거하죠. 이전에 React Query를 사용해 보신 적이 있다면, 거기에서 일어나는 일이 바로 이런 겁니다. 이 기법을 사용하지 않으면 원하는 것보다 훨씬 더 많은 리렌더링을 보게 될 겁니다.

네, 맞아요. 제가 제시하는 대안은 효과적인 상태 관리자를 투입하는 겁니다. 저에게 제일 익숙한 zustand를 사용해 보겠습니다.

const useTableStore = create((set) => ({
  table1Data: [],
  table2Data: [],
  actions: {...}
}))

export const useTable1Data = () =>
  useTableStore((state) => state.table1Data)
export const useTable2Data = () =>
  useTableStore((state) => state.table2Data)
export const useSummaryData = () =>
  useTableStore((state) =>
    calculateSummary(state.table1Data, state.table2Data)
  )

이제 모든 컴포넌트가 관심 있는 상태를 내부적으로 구독할 수 있으니, 어떠한 하향식 렌더링이라도 전부 피할 수 있습니다. table2Data가 업데이트 돼도 Table1은 리렌더링 되지 않는다는 거죠. 이건 테이블들을 메모이제이션 하는 것만큼 효과적인데다, 새로운 prop을 추가하면 성능에 안 좋은 영향을 미칠 수도 있다는 함정에 빠지지 않을 겁니다.

해결책

인정합니다. 여기서 다룬 모든 해결책들은 좋지 않습니다. 일반적으로 메모이제이션은 코드를 읽기 어렵게 만들고 잘못되기도 쉽기 때문에 저에겐 최악의 선택지입니다. 외부 상태 관리자를 사용하면 조금 더 낫지만, 어쨌든 App에 종속성이 존재하게 되죠. 컴포넌트를 구성하는 방식을 조정하는 게 그래도 최선이지만 항상 가능한 건 아닙니다.

게임의 규칙을 바꿀 수 있다면 그게 정말 좋은 해결책일 겁니다. 꽤 오랫동안 stage 2에 머물러 있는 ECMAScript의 proposal인 레코드와 튜플은 함수에는 도움되지 않겠지만 배열과 객체를 다루는 데 도움될 겁니다. 이것과 관련해서 Sebastien Lorber좋은 글이 있습니다.

React 팀도 모든 것을 자동으로 메모이제이션 하는 것으로 예상되는 React Forget이라는 컴파일러를 개발하고 있음을 암시했습니다. 그게 준비가 되면 표면적인 오류 없이 React.memo의 성능 최적화를 취할 수 있을 겁니다.

profile
프런트엔드 개발자

0개의 댓글