Skip to content
This page has been auto-translated and may contain errors.View in English

메모이제이션

렌더링 챕터가 이 챕터의 기초가 되는 멘탈 모델입니다. 상태가 바뀌면 React는 컴포넌트와 그 아래의 모든 것을 다시 실행하는데, 대부분의 렌더링이 빠르기 때문에 보통은 문제가 없습니다. 하지만 항상 그런 것은 아닙니다. 컴포넌트 상단의 계산이 눈에 띌 정도로 오래 걸리거나, 컴포넌트 서브트리가 충분히 비싸서 매 키스트로크마다 전부 다시 렌더링하면 앱이 둔해 보일 수 있습니다.

이런 측정 가능한 경우들을 위해 React는 useMemo, memo, useCallback 세 가지 도구를 제공하며, 이들은 모두 한 가지를 변형한 것입니다. 이전 렌더링의 결과를 기억했다가 다시 작업하는 것을 건너뛸 수 있게 해줍니다. 이 도구들을 잘 사용하려면 먼저 JavaScript가 두 값이 "같은지"를 어떻게 판단하는지 알아야 합니다.

값 타입, 참조 타입, 그리고 참조 동일성

JavaScript는 값을 두 가지 종류로 나눕니다. 값 타입(또는 원시 타입)은 불린, 숫자, 문자열, 그리고 덜 자주 만나는 것들입니다: undefined, null, symbol, bigint. 두 값이 같은 타입의 같은 값을 가지면 동일합니다: 1 === 1은 참입니다. 참조 타입(또는 복합 타입)은 객체, 배열, 함수입니다. 두 참조 타입은 정확히 메모리 안의 같은 것일 때만 동일합니다. 겉으로 같아 보이는 것은 아무것도 도움이 되지 않습니다.

jsx
const a = { name: '김철수' }
const b = { name: '김철수' }

console.log(Object.is(a, b)) // false: 메모리에 있는 두 개의 서로 다른 객체

const c = a
console.log(Object.is(a, c)) // true: 둘 다 같은 객체를 가리킴

Object.is는 React가 내부적으로 이런 비교를 할 때 사용하는 것이며, 여기서는 ===과 거의 동일하게 작동합니다. {} !== {}도 마찬가지입니다. 두 개의 빈 객체 리터럴은 두 개의 서로 다른 할당이므로 검사를 통과하지 못합니다. 이를 참조 동일성이라고 부르며, 이것이 이 전체 챕터의 핵심 개념입니다.

이제 렌더링과 연결해봅시다. 컴포넌트는 함수이고, 매 렌더링마다 그 본문을 맨 위부터 다시 실행합니다. 그 본문에서 생성된 모든 객체, 배열, 함수는 매 렌더링마다 새로 생깁니다:

jsx
function App() {
  const styles = { backgroundColor: 'black' } // 매 렌더링마다 새로운 객체
  function increment() { /* ... */ }          // 매 렌더링마다 새로운 함수
  // ...
}

내용은 렌더링 간에 같을 수 있지만, 참조는 절대 같지 않습니다. "이 props가 바뀌었나?"를 묻는 모든 비교가 Object.is로 확인하면 매번 '예'라고 답할 것입니다. 그 다음 모든 것은 이런 참조가 언제 바뀌는지 제어하는 것에 관한 것입니다.

useMemo: 비싼 계산 건너뛰기

메모이제이션의 첫 번째 용도는 참조와 아직 무관합니다. 컴포넌트 최상단에 있는 계산은 매 렌더링마다 실행되며, 그 계산이 전혀 건드리지 않는 상태로 인한 렌더링도 마찬가지입니다. 큰 상품 목록을 정렬하는 데 눈에 띌 정도의 시간이 걸린다면, 같은 컴포넌트의 다른 곳에 있는 카운터 버튼이 버벅거립니다. 이유 없이 매번 정렬을 다시 실행하기 때문입니다.

useMemo는 effect와 같은 형태로 이를 해결합니다: 함수와 의존성 배열입니다. React는 함수를 호출하고, 반환값을 기억한 다음, 나중의 렌더링에서는 의존성 배열의 아무것도 바뀌지 않은 한 함수를 다시 호출하지 않고 기억한 값을 돌려줍니다.

jsx
import { useMemo, useState } from 'react'

function ProductList({ productsData }) {
  const [count, setCount] = useState(0)

  const sortedProducts = useMemo(() => {
    return [...productsData].sort((a, b) => a.name.localeCompare(b.name))
  }, [productsData])

  // ...
}

의존성 배열은 계산이 읽는 모든 것을 포함합니다. 여기서는 productsData뿐입니다. count를 바꾸면 컴포넌트가 다시 렌더링되지만, React는 productsData가 같다는 것을 보고 정렬을 완전히 건너뜁니다. 새로운 productsData 배열만 재계산을 트리거합니다. 이 과정의 데모에서, 정렬이 매 클릭마다 눈에 띄는 딜레이에서 데이터를 건드리지 않은 렌더링에서는 0밀리초로 줄어들었습니다.

memo: 자식 렌더링 건너뛰기

useMemo는 한 컴포넌트 내에서 값을 캐시합니다. memo는 한 단계 위에서 작동합니다. 전체 컴포넌트의 렌더링을 건너뛸 수 있습니다. 이것은 고차 컴포넌트로, 컴포넌트를 받아 업그레이드된 버전을 반환하는 함수입니다. 구형 코드베이스에서는 React.memo로 작성된 것을 종종 볼 수 있습니다. 현대의 import는 이름이 지정되어 있습니다.

jsx
import { memo } from 'react'

function Product({ name, styles }) {
  // 비싼 컴포넌트 본문
}

export default memo(Product)

업그레이드된 컴포넌트는 이전 props를 들어오는 props와 비교합니다. 모든 props가 같으면, React는 그 부모 렌더링에서 이것을 다시 렌더링하지 않고, 렌더링했을 서브트리도 함께 건너뜁니다(아래에서 다룬 예외가 있음). 이 과정의 데모 앱에서, 한 컴포넌트를 memo로 감싸는 것이 상태 변경을 31번의 렌더링에서 1번으로 줄였습니다. 건드리지 않은 전체 서브트리가 한 가지 결정으로 건너뛰어졌기 때문입니다.

"같다"는 것은 얕은 동일성을 의미합니다. 각 props는 Object.is로 비교됩니다. 문자열이나 숫자 props는 값으로 비교되고 예상대로 작동합니다. 그리고 바로 여기서 문제가 시작됩니다.

memo가 조용히 작동을 멈추는 이유

memo로 감싼 컴포넌트에 객체 props를 전달하면, 최적화가 조용히 죽습니다:

jsx
function App() {
  const [darkMode, setDarkMode] = useState(false)
  const [count, setCount] = useState(0)

  const styles = {
    backgroundColor: darkMode ? '#222' : '#fff',
    color: darkMode ? '#fff' : '#000',
  }

  return <Product styles={styles} /* ... */ />
}

count가 바뀔 때마다 App이 다시 렌더링되고 새로운 styles 객체를 만듭니다. 내부 값은 같지만, 참조 동일성은 상관하지 않습니다: Object.is(oldStyles, newStyles)는 거짓이고, memo는 props가 바뀌었다고 결론짓고, Product는 어쨌든 다시 렌더링됩니다. 아무것도 에러를 내지 않고, 아무것도 경고하지 않습니다. 메모이제이션은 전혀 작동하지 않습니다.

이것이 useMemo의 두 번째 역할입니다. 비교가 성공할 수 있도록 렌더링 간의 참조를 유지합니다. 객체 생성을 감싸고 실제로 변하는 의존성을 줍니다:

jsx
const styles = useMemo(() => ({
  backgroundColor: darkMode ? '#222' : '#fff',
  color: darkMode ? '#fff' : '#000',
}), [darkMode])

이제 stylesdarkMode가 바뀔 때까지 매 렌더링마다 같은 객체입니다(참조로). memo 검사가 통과하고, 카운터만 움직일 때 Product는 건너뜁니다. 두 도구가 함께 작동합니다: 자식의 memo는 "props가 바뀌었나?"를 묻고, 부모의 useMemo는 그 답이 '아니오'일 수 있도록 확보합니다.

children은 다른 props와 같고, 이것이 사람들을 붙잡습니다. 부모에서 작성한 JSX 자식은 매 부모 렌더링마다 새로운 element 객체이므로, <Card><Chart /></Card>Card에 새로운 children 참조를 매번 전달하고 Cardmemo는 절대 성공하지 못합니다. 다른 props가 얼마나 안정적이든 상관없습니다. 렌더링이 설명하는 구조 패턴은 반대편에서 작동합니다. element가 상태를 소유한 컴포넌트 위에서 생성되면, 그 컴포넌트의 재렌더링은 같은 element 객체를 보고 메모이제이션 없이 그 브랜치를 건너뜁니다.

memo가 건너뛰지 않는 것

memo는 부모에서 도착하는 props만 관장합니다. 메모이제이션된 컴포넌트는 자신의 상태가 바뀔 때와 읽는 context가 업데이트될 때 여전히 다시 렌더링됩니다. Context는 건너뛴 서브트리를 통과하기도 합니다. 변경된 context를 소비하는 자손은 그 위의 메모이제이션된 래퍼가 건너뛰어졌어도 다시 렌더링됩니다.

useCallback: 함수용 useMemo

함수도 참조 타입이므로, callback props는 정확히 같은 방식으로 memo를 깹니다. 컴포넌트 본문에 function increment() {...}를 정의하고 아래로 전달하면, 자식은 부모의 매 렌더링마다 새로운 함수 참조를 받습니다. useCallback은 정확히 이 목적으로 존재합니다. 함수 자체를 메모이제이션합니다.

jsx
const [selectedProduct, setSelectedProduct] = useState(null)

const chooseProduct = useCallback((id) => {
  console.log('New product selected')
  setSelectedProduct(id)
}, [])

chooseProductmemo로 감싼 자식에 아래로 전달하면, 최적화가 유지됩니다. useCallback(fn, deps)는 의존성이 바뀔 때까지 렌더링 간에 같은 함수 참조를 유지합니다. useMemo와의 관계는 밀접합니다. useMemo에서는 기억된 값이 함수가 반환하는 것인 반면, useCallback에서는 기억된 값이 전달하는 함수입니다.

기억해야 할 세부 사항 하나: setSelectedProduct 같은 상태 setter 함수는 이미 안정적인 정체성을 가지고 있으며, React가 보장합니다. setter를 raw로 props로 아래로 전달하는 것은 useCallback 없이 안전합니다. 이 hook은 setter를 먼저 추가 작업을 하는 자신의 함수로 감싼 경우에 그 가치를 발휘합니다.

안정적인 함수 참조는 memo 너머에도 중요합니다. 함수가 useEffect 의존성 배열에 나타나면, 매 렌더링마다 새로운 참조는 effect가 매 렌더링마다 다시 실행된다는 뜻입니다. effects가 이를 다룹니다. useCallback도 이것을 조용히 합니다.

먼저 측정하세요

이 중 아무것이든 사용하기 전에 성능 문제가 실제로 있는지 확인하세요. 렌더링이 그 이유를 제시하고 워크플로를 설명합니다. 대부분의 렌더링은 저렴하고, 아무것도 바꾸지 않는 렌더링은 절대 DOM에 도달하지 않으며, 스로틀된 CPU를 가진 Profiler가 실제로 비용이 드는 것들을 찾는 방법입니다. data.length 같은 간단한 계산을 메모이제이션하는 것은 저장하는 것보다 더 많은 비용이 들립니다. 캐싱과 비교 자체가 작업이기 때문입니다. 측정할 수 있는 것만 수정하고, 렌더링의 개수를 줄이는 데 에너지를 쏟기 전에 느린 렌더링을 먼저 수정하세요.

버전 참고

2025년 10월에 1.0에 도달한 React Compiler는 이 챕터가 다루는 대부분을 자동화합니다. 이것은 빌드 시점에 컴포넌트를 분석하고 자동으로 메모이제이션을 삽입하므로, 손으로 작성한 useMemo, useCallback, memo는 이것을 채택한 프로젝트에서 대부분 불필요해집니다. 개념은 여전히 이해할 가치가 있습니다. 기존 코드베이스가 이런 hooks로 가득 찬 이유도 있고, compiler가 당신을 대신해 하는 것을 알면 그 작동이 신비로운 대신 읽을 수 있게 되기 때문입니다.

기계적으로, useMemo는 반환값과 의존성 배열을 React의 내부 트리에 있는 컴포넌트의 슬롯에 저장합니다. 다음 렌더링에서 새로운 의존성 배열을 저장된 것과 비교하며 Object.is로 요소별로 비교합니다. 불일치가 있으면 함수를 다시 실행하고 둘 다 교체합니다. 이렇게 하면 의존성 배열 자체도 참조 동일성의 대상이 됩니다. 매 렌더링마다 새로운 참조를 얻는 객체 의존성은 내부에서 메모이제이션을 무효화하며, 이 때문에 안정화가 보통 컴포넌트를 통해 위로 계단식으로 작용합니다.

memo는 props 객체의 각 키 전체에 대해 같은 Object.is 비교를 실행합니다. 이것은 old와 new props를 받아 직접 동일성을 정의할 수 있는 선택적 두 번째 인자를 받으며, React 문서가 맞게 지적하듯이 거의 절대 이것이 필요할 일이 없을 것입니다.

더 날카로운 모서리는 비교가 컴포넌트당 전부 아니면 없음이라는 것입니다. 10개 중 안정적이지 않은 props 하나가 자식을 매번 다시 렌더링하므로, memo 래퍼는 자신이 받는 가장 안정적이지 않은 props만큼만 좋고, 절대 발화하지 않는 래퍼는 Profiler에서 계속 다시 렌더링되는 서브트리로 나타납니다.

두 가지 경계를 기억하세요. useCallback(fn, deps)는 정확히 useMemo(() => fn, deps)이므로, 하나에 참인 것은 다른 하나에도 참입니다. 그리고 useMemo는 의미론적 보장이라기보다는 성능 힌트입니다. React는 예를 들어 컴포넌트가 초기 마운트 중 suspend될 때나 개발에서 파일을 편집할 때마다 캐시된 값을 버릴 권리를 예약하므로, 함수가 다시 실행되어도 코드는 올바르게 유지되어야 합니다. 렌더링을 건너 뛰지 않고 생존해야 하는 것들은 state나 ref에 저장하세요. 같은 안정적-정체성 추론이 context provider 값에 적용되며, 그 챕터의 deep dive가 이미 다룹니다.

Juno안정적인 참조는 작업을 건너뜁니다 React는 매 렌더링마다 컴포넌트의 전체 본문을 다시 실행하므로, 그 안의 느린 계산이 반복해서 실행됩니다. useMemo는 React가 답을 기억했다가 입력이 바뀔 때까지 재사용하게 합니다. memo는 전체 컴포넌트에 대해 비슷한 것을 합니다. props가 이전 렌더링과 같으면, React는 렌더링을 건너뜁니다.

까다로운 부분은 겉으로 같아 보이는 두 객체나 함수가 JavaScript에서 여전히 다르게 계산된다는 것이며, 이것이 이런 도구들이 때때로 서로를 필요로 하는 이유입니다. 그리고 이들을 사용하기 전에, 뭔가 실제로 느린지 확인하세요.

Juno안정적인 참조는 작업을 건너뜁니다 비싼 계산을 useMemo로 감싸고 실제 입력을 의존성으로 주고, 비싼 자식을 memo로 감싸서 props가 바뀌지 않았을 때 건너뜁니다.

그 다음 기억하세요. 컴포넌트 본문에서 생성된 객체와 함수는 매 렌더링마다 새로운 참조이므로, memo 자식이 이들을 받으면 어쨌든 다시 렌더링됩니다. useMemo로 객체를 안정화하고 useCallback으로 함수를 안정화하세요. 상태 setters는 이미 안정적입니다.

스로틀된 CPU로 먼저 측정하세요. 대부분의 재렌더링은 최적화할 가치가 없는 비용이 듭니다.

Juno안정적인 참조는 작업을 건너뜁니다 여기 모든 것은 Object.is로 축약됩니다. 의존성 배열은 요소별로 비교되고, memo는 props를 얕게 비교하며, 한 곳의 안정적이지 않은 참조는 전체 체인을 깹니다. useCallback(fn, deps)useMemo(() => fn, deps)이고, useMemo는 React가 버릴 수 있는 힌트이므로, 캐시와 무관하게 정확성을 유지하세요.

React Compiler는 이제 빌드 시점에 이 분석을 자동화합니다. 수동 hooks은 기존 코드와 compiler가 당신을 대신해 하는 것을 이해하기 위해 중요합니다.

다음: 코드 분할. 다른 레버인 번들 크기가 차례입니다.