useMemo는 리렌더링 사이에 계산 결과를 캐시할 수 있게 해주는 React Hook이에요.
const cachedValue = useMemo(calculateValue, dependencies)
💡 참고
React Compiler는 값과 함수를 자동으로 메모이제이션해서, 수동으로
useMemo를 호출해야 하는 필요성을 줄여줘요. 컴파일러를 사용하면 메모이제이션을 자동으로 처리할 수 있어요.부연설명: React Compiler는 아직 실험적인 기능이지만, 미래에는 개발자가 직접
useMemo를 신경 쓰지 않아도 React가 자동으로 최적화를 처리해줄 거예요.
useMemo(calculateValue, dependencies) {/usememo/}리렌더링 사이에 계산을 캐시하려면 컴포넌트의 최상위 레벨에서 useMemo를 호출하세요:
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
calculateValue: 캐시하려는 값을 계산하는 함수예요. 순수해야 하고, 인자를 받지 않아야 하며, 어떤 타입의 값이든 반환할 수 있어야 해요. React는 초기 렌더링 중에 여러분의 함수를 호출할 거예요. 다음 렌더링에서는, 지난 렌더링 이후로 dependencies가 변경되지 않았다면 React가 같은 값을 다시 반환해요. 그렇지 않으면, calculateValue를 호출하고, 그 결과를 반환하며, 나중에 재사용할 수 있도록 저장해요.
dependencies: calculateValue 코드 안에서 참조하는 모든 반응형 값들의 목록이에요. 반응형 값에는 props, state, 그리고 컴포넌트 본문 안에서 직접 선언된 모든 변수와 함수가 포함돼요. 린터가 React용으로 설정되어 있다면, 모든 반응형 값이 의존성으로 올바르게 지정되었는지 검증해줄 거예요. 의존성 목록은 항목 수가 일정해야 하고, [dep1, dep2, dep3]처럼 인라인으로 작성해야 해요. React는 각 의존성을 이전 값과 Object.is 비교를 사용해서 비교해요.
초기 렌더링에서는, useMemo가 인자 없이 calculateValue를 호출한 결과를 반환해요.
다음 렌더링에서는, 의존성이 변경되지 않았다면 지난 렌더링에서 이미 저장된 값을 반환하거나, calculateValue를 다시 호출하고 calculateValue가 반환한 결과를 반환해요.
useMemo는 Hook이기 때문에, 컴포넌트의 최상위 레벨이나 여러분이 직접 만든 Hook 안에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 만약 그런 곳에서 사용해야 한다면, 새로운 컴포넌트를 추출하고 state를 그 안으로 옮기세요.
Strict Mode에서는, React가 의도치 않은 불순함을 찾는 데 도움이 되도록 계산 함수를 두 번 호출할 거예요. 이건 개발 환경에서만 나타나는 동작이고 프로덕션에는 영향을 주지 않아요. 계산 함수가 순수하다면(그래야 하는 것처럼), 이게 로직에 영향을 주지 않을 거예요. 두 번의 호출 중 하나의 결과는 무시돼요.
React는 특정한 이유가 없는 한 캐시된 값을 버리지 않아요. 예를 들어, 개발 환경에서는 컴포넌트 파일을 편집하면 React가 캐시를 버려요. 개발 환경과 프로덕션 환경 모두에서, 컴포넌트가 초기 마운트 중에 일시 중단(suspend)되면 React가 캐시를 버릴 거예요. 미래에는 React가 캐시를 버리는 것을 활용하는 더 많은 기능을 추가할 수도 있어요--예를 들어, React가 미래에 가상화된 리스트에 대한 내장 지원을 추가한다면, 가상화된 테이블 뷰포트 밖으로 스크롤된 항목의 캐시를 버리는 게 합리적일 거예요. useMemo를 오직 성능 최적화로만 의존한다면 괜찮을 거예요. 그렇지 않다면, state 변수나 ref가 더 적절할 수 있어요.
💡 참고
이렇게 반환값을 캐시하는 것을 메모이제이션(memoization)이라고도 하는데, 그래서 이 Hook의 이름이
useMemo예요.부연설명: "메모이제이션"은 함수의 결과를 기억해뒀다가 같은 입력이 들어오면 다시 계산하지 않고 저장된 결과를 돌려주는 최적화 기법이에요.
리렌더링 사이에 계산을 캐시하려면, 컴포넌트의 최상위 레벨에서 useMemo 호출로 감싸세요:
import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
useMemo에 두 가지를 전달해야 해요:
() =>처럼 인자를 받지 않고, 계산하려는 것을 반환하는 계산 함수초기 렌더링에서는, useMemo로부터 얻을 값이 계산을 호출한 결과가 될 거예요.
이후의 모든 렌더링에서, React는 의존성을 지난 렌더링 때 전달한 의존성과 비교할 거예요. 의존성 중 어느 것도 변경되지 않았다면(Object.is로 비교), useMemo는 이전에 이미 계산한 값을 반환해요. 그렇지 않으면, React가 계산을 다시 실행하고 새로운 값을 반환해요.
다시 말해, useMemo는 의존성이 변경될 때까지 리렌더링 사이에 계산 결과를 캐시해요.
이게 언제 유용한지 예시를 통해 살펴볼게요.
기본적으로, React는 리렌더링할 때마다 컴포넌트의 전체 본문을 다시 실행해요. 예를 들어, 이 TodoList가 state를 업데이트하거나 부모로부터 새로운 props를 받으면, filterTodos 함수가 다시 실행될 거예요:
function TodoList({ todos, tab, theme }) {
const visibleTodos = filterTodos(todos, tab);
// ...
}
보통은, 대부분의 계산이 매우 빠르기 때문에 문제가 되지 않아요. 하지만, 큰 배열을 필터링하거나 변환하고 있거나, 비용이 많이 드는 계산을 하고 있다면, 데이터가 변경되지 않았을 때 다시 하는 것을 건너뛰고 싶을 거예요. todos와 tab 둘 다 지난 렌더링 때와 같다면, 계산을 앞서처럼 useMemo로 감싸면 이전에 이미 계산한 visibleTodos를 재사용할 수 있어요.
이런 종류의 캐싱을 메모이제이션이라고 해요.
💡 참고
useMemo는 오직 성능 최적화로만 의존해야 해요. 코드가useMemo없이 작동하지 않는다면, 근본적인 문제를 먼저 찾아서 고치세요. 그런 다음 성능을 개선하기 위해useMemo를 추가할 수 있어요.
일반적으로, 수천 개의 객체를 생성하거나 반복하지 않는 한, 아마 비용이 많이 들지 않을 거예요. 더 확신을 얻고 싶다면, 코드 조각에서 소요된 시간을 측정하기 위해 콘솔 로그를 추가할 수 있어요:
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
측정하려는 인터랙션을 수행하세요(예: 입력창에 타이핑하기). 그러면 콘솔에 filter array: 0.15ms 같은 로그가 표시될 거예요. 전체 로그 시간이 상당한 양(예: 1ms 이상)이 된다면, 그 계산을 메모이제이션하는 게 합리적일 수 있어요. 실험으로, 계산을 useMemo로 감싸서 해당 인터랙션에 대한 전체 로그 시간이 감소했는지 확인할 수 있어요:
console.time('filter array');
const visibleTodos = useMemo(() => {
return filterTodos(todos, tab); // todos와 tab이 변경되지 않았다면 건너뛰어요
}, [todos, tab]);
console.timeEnd('filter array');
useMemo는 첫 번째 렌더링을 더 빠르게 만들지 않아요. 업데이트 시 불필요한 작업을 건너뛰는 데만 도움이 돼요.
여러분의 머신이 사용자의 머신보다 빠를 가능성이 높다는 걸 명심하세요. 그래서 인위적인 속도 저하로 성능을 테스트하는 게 좋아요. 예를 들어, Chrome은 이를 위해 CPU Throttling 옵션을 제공해요.
또한 개발 환경에서 성능을 측정하면 가장 정확한 결과를 얻을 수 없다는 점도 주의하세요. (예를 들어, Strict Mode가 켜져 있으면, 각 컴포넌트가 한 번이 아니라 두 번 렌더링되는 것을 볼 거예요.) 가장 정확한 타이밍을 얻으려면, 프로덕션용으로 앱을 빌드하고 사용자가 가진 것과 같은 기기에서 테스트하세요.
💡 부연설명: 성능 최적화는 실제 사용자 환경에서 측정해야 의미가 있어요. 개발자 도구나 고성능 컴퓨터에서는 문제가 없어 보여도, 실제 사용자의 저사양 기기에서는 느릴 수 있거든요.
앱이 이 사이트처럼 대부분의 인터랙션이 거칠다면(페이지나 전체 섹션을 교체하는 것처럼), 메모이제이션은 보통 불필요해요. 반면에, 앱이 드로잉 에디터 같고 대부분의 인터랙션이 세밀하다면(도형을 이동하는 것처럼), 메모이제이션이 매우 도움이 될 수 있어요.
useMemo로 최적화하는 게 가치 있는 경우는 몇 가지뿐이에요:
useMemo에 넣는 계산이 눈에 띄게 느리고, 의존성이 거의 변경되지 않는 경우.memo로 감싼 컴포넌트에 prop으로 전달하는 경우. 값이 변경되지 않았다면 리렌더링을 건너뛰고 싶을 거예요. 메모이제이션을 하면 의존성이 같지 않을 때만 컴포넌트가 리렌더링되도록 할 수 있어요.useMemo 계산 값이 그것에 의존할 수도 있어요. 또는 useEffect에서 이 값에 의존하고 있을 수도 있고요.다른 경우에는 계산을 useMemo로 감싸는 이점이 없어요. 그렇게 해도 큰 해가 되지는 않기 때문에, 일부 팀은 개별 케이스에 대해 생각하지 않고 가능한 한 많이 메모이제이션하기로 선택해요. 이 접근 방식의 단점은 코드가 덜 읽기 쉬워진다는 거예요. 또한, 모든 메모이제이션이 효과적인 것은 아니에요: "항상 새로운" 값 하나만으로도 전체 컴포넌트의 메모이제이션을 무너뜨리기에 충분해요.
실제로는, 몇 가지 원칙을 따르면 많은 메모이제이션을 불필요하게 만들 수 있어요:
특정 인터랙션이 여전히 느리게 느껴진다면, React Developer Tools 프로파일러를 사용해서 어떤 컴포넌트가 메모이제이션으로 가장 많은 이점을 얻을지 확인하고, 필요한 곳에 메모이제이션을 추가하세요. 이러한 원칙들은 컴포넌트를 디버그하고 이해하기 쉽게 만들어주기 때문에, 어떤 경우든 따르는 게 좋아요. 장기적으로, 우리는 세밀한 메모이제이션을 자동으로 수행하는 방법을 연구하고 있어서 이 문제를 완전히 해결할 거예요.
💡 부연설명: 간단히 정리하면,
useMemo는 꼭 필요한 곳에만 사용하고, 대신 컴포넌트 구조와 로직을 잘 설계하는 게 더 중요하다는 거예요. 무분별하게 모든 곳에useMemo를 쓰는 것보다, 왜 느린지 근본 원인을 파악하고 구조적으로 해결하는 게 더 나은 접근이에요.
useMemo로 재계산 건너뛰기 {/skipping-recalculation-with-usememo/}이 예시에서는, filterTodos 구현이 인위적으로 느려졌어요. 렌더링 중에 호출하는 JavaScript 함수가 진짜로 느릴 때 어떤 일이 일어나는지 볼 수 있도록요. 탭을 전환하고 테마를 토글해보세요.
탭을 전환하면 느리게 느껴지는데, 느려진 filterTodos가 다시 실행되도록 강제하기 때문이에요. tab이 변경되었기 때문에 전체 계산이 다시 실행되어야 하는 게 당연해요. (궁금하다면 왜 두 번 실행되는지 여기에 설명되어 있어요.)
테마를 토글해보세요. useMemo 덕분에, 인위적인 속도 저하에도 불구하고 빨라요! 느린 filterTodos 호출이 건너뛰어졌는데, useMemo에 의존성으로 전달한 todos와 tab 둘 다 지난 렌더링 이후로 변경되지 않았기 때문이에요.
// src/App.js
import { useState } from 'react';
import { createTodos } from './utils.js';
import TodoList from './TodoList.js';
const todos = createTodos();
export default function App() {
const [tab, setTab] = useState('all');
const [isDark, setIsDark] = useState(false);
return (
<>
<button onClick={() => setTab('all')}>
All
</button>
<button onClick={() => setTab('active')}>
Active
</button>
<button onClick={() => setTab('completed')}>
Completed
</button>
<br />
<label>
<input
type="checkbox"
checked={isDark}
onChange={e => setIsDark(e.target.checked)}
/>
Dark mode
</label>
<hr />
<TodoList
todos={todos}
tab={tab}
theme={isDark ? 'dark' : 'light'}
/>
</>
);
}
// src/TodoList.js
import { useMemo } from 'react';
import { filterTodos } from './utils.js'
export default function TodoList({ todos, theme, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
return (
<div className={theme}>
<p><b>Note: <code>filterTodos</code> is artificially slowed down!</b></p>
<ul>
{visibleTodos.map(todo => (
<li key={todo.id}>
{todo.completed ?
<s>{todo.text}</s> :
todo.text
}
</li>
))}
</ul>
</div>
);
}
// src/utils.js
export function createTodos() {
const todos = [];
for (let i = 0; i < 50; i++) {
todos.push({
id: i,
text: "Todo " + (i + 1),
completed: Math.random() > 0.5
});
}
return todos;
}
export function filterTodos(todos, tab) {
console.log('[ARTIFICIALLY SLOW] Filtering ' + todos.length + ' todos for "' + tab + '" tab.');
let startTime = performance.now();
while (performance.now() - startTime < 500) {
// Do nothing for 500 ms to emulate extremely slow code
}
return todos.filter(todo => {
if (tab === 'all') {
return true;
} else if (tab === 'active') {
return !todo.completed;
} else if (tab === 'completed') {
return todo.completed;
}
});
}
label {
display: block;
margin-top: 10px;
}
.dark {
background-color: black;
color: white;
}
.light {
background-color: white;
color: black;
}
이 예시에서도, filterTodos 구현이 인위적으로 느려졌어요. 렌더링 중에 호출하는 JavaScript 함수가 진짜로 느릴 때 어떤 일이 일어나는지 볼 수 있도록요. 탭을 전환하고 테마를 토글해보세요.
이전 예시와 달리, 테마를 토글하는 것도 이제 느려요! 왜냐하면 이 버전에는 useMemo 호출이 없기 때문에, 인위적으로 느려진 filterTodos가 매 리렌더링마다 호출돼요. theme만 변경되어도 호출돼요.
// src/TodoList.js
import { filterTodos } from './utils.js'
export default function TodoList({ todos, theme, tab }) {
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
<ul>
<p><b>Note: <code>filterTodos</code> is artificially slowed down!</b></p>
{visibleTodos.map(todo => (
<li key={todo.id}>
{todo.completed ?
<s>{todo.text}</s> :
todo.text
}
</li>
))}
</ul>
</div>
);
}
하지만, 여기 인위적인 속도 저하가 제거된 같은 코드가 있어요. useMemo의 부재가 눈에 띄나요, 아니면 그렇지 않나요?
// src/TodoList.js
import { filterTodos } from './utils.js'
export default function TodoList({ todos, theme, tab }) {
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
<ul>
{visibleTodos.map(todo => (
<li key={todo.id}>
{todo.completed ?
<s>{todo.text}</s> :
todo.text
}
</li>
))}
</ul>
</div>
);
}
// src/utils.js
export function createTodos() {
const todos = [];
for (let i = 0; i < 50; i++) {
todos.push({
id: i,
text: "Todo " + (i + 1),
completed: Math.random() > 0.5
});
}
return todos;
}
export function filterTodos(todos, tab) {
console.log('Filtering ' + todos.length + ' todos for "' + tab + '" tab.');
return todos.filter(todo => {
if (tab === 'all') {
return true;
} else if (tab === 'active') {
return !todo.completed;
} else if (tab === 'completed') {
return todo.completed;
}
});
}
상당히 자주, 메모이제이션 없는 코드도 잘 작동해요. 인터랙션이 충분히 빠르다면, 메모이제이션이 필요 없을 수도 있어요.
utils.js에서 todo 항목의 개수를 늘려보고 동작이 어떻게 바뀌는지 확인해볼 수 있어요. 이 특정 계산은 처음부터 그렇게 비싸지 않았지만, todos의 개수가 크게 증가하면, 대부분의 오버헤드는 필터링보다는 리렌더링에 있을 거예요. 아래에서 계속 읽으면서 useMemo로 리렌더링을 어떻게 최적화할 수 있는지 확인하세요.
💡 부연설명: 위 예시들을 통해 알 수 있는 건,
useMemo는 계산 자체가 비쌀 때 의미가 있다는 거예요. 계산이 빠르다면useMemo를 사용하는 오버헤드가 오히려 더 클 수도 있어요. 항상 실제로 측정해보고 적용하는 게 중요해요.
경우에 따라, useMemo는 자식 컴포넌트의 리렌더링 성능을 최적화하는 데도 도움이 될 수 있어요. 설명하기 위해, 이 TodoList 컴포넌트가 visibleTodos를 자식 List 컴포넌트에 prop으로 전달한다고 해볼게요:
export default function TodoList({ todos, tab, theme }) {
// ...
return (
<div className={theme}>
<List items={visibleTodos} />
</div>
);
}
theme prop을 토글하면 앱이 잠깐 멈춘다는 걸 알아챘는데, JSX에서 <List />를 제거하면 빠르게 느껴져요. 이건 List 컴포넌트를 최적화할 가치가 있다고 알려줘요.
기본적으로, 컴포넌트가 리렌더링되면, React는 모든 자식을 재귀적으로 리렌더링해요. 그래서 TodoList가 다른 theme으로 리렌더링될 때, List 컴포넌트도 같이 리렌더링돼요. 리렌더링하는 데 많은 계산이 필요하지 않은 컴포넌트에는 괜찮아요. 하지만 리렌더링이 느리다는 걸 확인했다면, memo로 감싸서 props가 지난 렌더링과 같을 때 리렌더링을 건너뛰도록 List에게 알려줄 수 있어요:
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
이 변경으로, List는 모든 props가 지난 렌더링과 같다면 리렌더링을 건너뛸 거예요. 여기서 계산을 캐싱하는 게 중요해져요! useMemo 없이 visibleTodos를 계산했다고 상상해보세요:
export default function TodoList({ todos, tab, theme }) {
// 테마가 변경될 때마다, 이건 다른 배열이 될 거예요...
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
{/* ... 그래서 List의 props는 절대 같지 않을 것이고, 매번 리렌더링될 거예요 */}
<List items={visibleTodos} />
</div>
);
}
위 예시에서, filterTodos 함수는 항상 다른 배열을 생성해요, {} 객체 리터럴이 항상 새로운 객체를 생성하는 것과 비슷하게요. 보통은 문제가 되지 않지만, List props가 절대 같지 않을 것이고, memo 최적화가 작동하지 않을 거라는 걸 의미해요. 여기서 useMemo가 유용해져요:
export default function TodoList({ todos, tab, theme }) {
// React에게 리렌더링 사이에 계산을 캐시하라고 알려주세요...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...이 의존성들이 변경되지 않는 한...
);
return (
<div className={theme}>
{/* ...List는 같은 props를 받고 리렌더링을 건너뛸 수 있어요 */}
<List items={visibleTodos} />
</div>
);
}
visibleTodos 계산을 useMemo로 감싸면, 리렌더링 사이에 같은 값을 갖도록 보장해요 (의존성이 변경될 때까지). 특정한 이유가 없다면 계산을 useMemo로 감쌀 필요는 없어요. 이 예시에서 이유는, memo로 감싼 컴포넌트에 전달하기 때문이고, 이게 리렌더링을 건너뛸 수 있게 해줘요. useMemo를 추가하는 다른 이유들도 이 페이지에서 더 설명되어 있어요.
List를 memo로 감싸는 대신, <List /> JSX 노드 자체를 useMemo로 감쌀 수도 있어요:
export default function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
const children = useMemo(() => <List items={visibleTodos} />, [visibleTodos]);
return (
<div className={theme}>
{children}
</div>
);
}
동작은 같을 거예요. visibleTodos가 변경되지 않았다면, List는 리렌더링되지 않을 거예요.
<List items={visibleTodos} /> 같은 JSX 노드는 { type: List, props: { items: visibleTodos } } 같은 객체예요. 이 객체를 생성하는 건 매우 저렴하지만, React는 그 내용이 지난번과 같은지 아닌지 모르죠. 그래서 기본적으로 React는 List 컴포넌트를 리렌더링할 거예요.
하지만, React가 이전 렌더링과 정확히 같은 JSX를 보면, 컴포넌트를 리렌더링하려고 시도하지 않아요. JSX 노드는 불변(immutable)이거든요. JSX 노드 객체는 시간이 지나도 변경될 수 없기 때문에, React는 리렌더링을 건너뛰는 게 안전하다는 걸 알아요. 하지만, 이게 작동하려면, 노드가 코드상 같아 보이는 게 아니라 실제로 같은 객체여야 해요. 이 예시에서 useMemo가 하는 일이 바로 이거예요.
JSX 노드를 useMemo로 수동으로 감싸는 건 편리하지 않아요. 예를 들어, 조건부로 할 수 없어요. 그래서 보통 JSX 노드를 감싸는 대신 컴포넌트를 memo로 감싸는 거예요.
💡 부연설명: JSX 노드를
useMemo로 감싸는 방법도 있지만, 실무에서는 거의 사용하지 않아요. 대신memo로 컴포넌트를 감싸는 게 훨씬 직관적이고 관리하기 쉬워요.
useMemo와 memo로 리렌더링 건너뛰기 {/skipping-re-rendering-with-usememo-and-memo/}이 예시에서, List 컴포넌트가 인위적으로 느려졌어요. 렌더링하는 React 컴포넌트가 진짜로 느릴 때 어떤 일이 일어나는지 볼 수 있도록요. 탭을 전환하고 테마를 토글해보세요.
탭을 전환하면 느리게 느껴지는데, 느려진 List가 리렌더링되도록 강제하기 때문이에요. tab이 변경되었기 때문에 사용자의 새로운 선택을 화면에 반영해야 하므로 당연해요.
다음으로, 테마를 토글해보세요. memo와 함께 useMemo 덕분에, 인위적인 속도 저하에도 불구하고 빨라요! List가 리렌더링을 건너뛴 이유는 visibleTodos 배열이 지난 렌더링 이후로 변경되지 않았기 때문이에요. visibleTodos 배열이 변경되지 않은 이유는 useMemo에 의존성으로 전달한 todos와 tab 둘 다 지난 렌더링 이후로 변경되지 않았기 때문이에요.
// src/TodoList.js
import { useMemo } from 'react';
import List from './List.js';
import { filterTodos } from './utils.js'
export default function TodoList({ todos, theme, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
return (
<div className={theme}>
<p><b>Note: <code>List</code> is artificially slowed down!</b></p>
<List items={visibleTodos} />
</div>
);
}
// src/List.js
import { memo } from 'react';
const List = memo(function List({ items }) {
console.log('[ARTIFICIALLY SLOW] Rendering <List /> with ' + items.length + ' items');
let startTime = performance.now();
while (performance.now() - startTime < 500) {
// Do nothing for 500 ms to emulate extremely slow code
}
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.completed ?
<s>{item.text}</s> :
item.text
}
</li>
))}
</ul>
);
});
export default List;
이 예시에서도, List 구현이 인위적으로 느려졌어요. 렌더링하는 React 컴포넌트가 진짜로 느릴 때 어떤 일이 일어나는지 볼 수 있도록요. 탭을 전환하고 테마를 토글해보세요.
이전 예시와 달리, 테마를 토글하는 것도 이제 느려요! 왜냐하면 이 버전에는 useMemo 호출이 없기 때문에, visibleTodos가 항상 다른 배열이고, 느려진 List 컴포넌트가 리렌더링을 건너뛸 수 없어요.
// src/TodoList.js
import List from './List.js';
import { filterTodos } from './utils.js'
export default function TodoList({ todos, theme, tab }) {
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
<p><b>Note: <code>List</code> is artificially slowed down!</b></p>
<List items={visibleTodos} />
</div>
);
}
하지만, 여기 인위적인 속도 저하가 제거된 같은 코드가 있어요. useMemo의 부재가 눈에 띄나요, 아니면 그렇지 않나요?
// src/TodoList.js
import List from './List.js';
import { filterTodos } from './utils.js'
export default function TodoList({ todos, theme, tab }) {
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
<List items={visibleTodos} />
</div>
);
}
// src/List.js
import { memo } from 'react';
function List({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.completed ?
<s>{item.text}</s> :
item.text
}
</li>
))}
</ul>
);
}
export default memo(List);
상당히 자주, 메모이제이션 없는 코드도 잘 작동해요. 인터랙션이 충분히 빠르다면, 메모이제이션이 필요 없어요.
프로덕션 모드에서 React를 실행하고, React Developer Tools를 비활성화하고, 앱 사용자가 가진 것과 비슷한 기기를 사용해야 실제로 앱을 느리게 만드는 게 무엇인지에 대한 현실적인 감각을 얻을 수 있다는 걸 명심하세요.
때때로, Effect 안에서 값을 사용하고 싶을 수 있어요:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const options = {
serverUrl: 'https://localhost:1234',
roomId: roomId
}
useEffect(() => {
const connection = createConnection(options);
connection.connect();
// ...
이건 문제를 만들어요. 모든 반응형 값은 Effect의 의존성으로 선언되어야 해요. 하지만, options를 의존성으로 선언하면, Effect가 채팅방에 계속 다시 연결하게 될 거예요:
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // 🔴 문제: 이 의존성은 매 렌더링마다 변경돼요
// ...
이를 해결하려면, Effect에서 호출해야 하는 객체를 useMemo로 감싸면 돼요:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const options = useMemo(() => {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}, [roomId]); // ✅ roomId가 변경될 때만 변경돼요
useEffect(() => {
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [options]); // ✅ options가 변경될 때만 변경돼요
// ...
이렇게 하면 roomId가 같다면 리렌더링 사이에 options 객체가 같음을 보장해요. useMemo가 캐시된 객체를 반환하는 경우를 말하는 거예요.
하지만, useMemo는 성능 최적화이지 의미론적 보장이 아니기 때문에, 특정한 이유가 있다면 React가 캐시된 값을 버릴 수도 있어요. 이것도 effect가 다시 실행되게 할 거예요. 그래서 함수 의존성의 필요성을 제거하는 게 더 나아요. 객체를 Effect 안으로 이동시키면 돼요:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
const options = { // ✅ useMemo나 객체 의존성이 필요 없어요!
serverUrl: 'https://localhost:1234',
roomId: roomId
}
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ roomId가 변경될 때만 변경돼요
// ...
이제 코드가 더 간단해졌고 useMemo가 필요 없어요. Effect 의존성 제거하기에 대해 더 알아보세요.
💡 부연설명: 가능하면 의존성 자체를 줄이는 게 최선이에요. 객체나 함수를 Effect 밖에서 만들어서 의존성에 추가하는 것보다, Effect 안에서 만들면 의존성에서 빼낼 수 있어요. 이게 코드도 더 명확하고 버그도 줄어요.
컴포넌트 본문에서 직접 생성된 객체에 의존하는 계산이 있다고 해볼게요:
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 주의: 컴포넌트 본문에서 생성된 객체에 대한 의존성
// ...
이런 식으로 객체에 의존하는 건 메모이제이션의 의미를 무너뜨려요. 컴포넌트가 리렌더링되면, 컴포넌트 본문 안의 모든 코드가 다시 실행돼요. searchOptions 객체를 생성하는 코드 줄도 매 리렌더링마다 실행될 거예요. searchOptions가 useMemo 호출의 의존성이고, 매번 다르기 때문에, React는 의존성이 다르다는 걸 알고, 매번 searchItems를 다시 계산할 거예요.
이를 고치려면, 의존성으로 전달하기 전에 searchOptions 객체 자체를 메모이제이션할 수 있어요:
function Dropdown({ allItems, text }) {
const searchOptions = useMemo(() => {
return { matchMode: 'whole-word', text };
}, [text]); // ✅ text가 변경될 때만 변경돼요
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // ✅ allItems나 searchOptions가 변경될 때만 변경돼요
// ...
위 예시에서, text가 변경되지 않았다면, searchOptions 객체도 변경되지 않을 거예요. 하지만, 더 나은 해결책은 searchOptions 객체 선언을 useMemo 계산 함수 안으로 옮기는 거예요:
function Dropdown({ allItems, text }) {
const visibleItems = useMemo(() => {
const searchOptions = { matchMode: 'whole-word', text };
return searchItems(allItems, searchOptions);
}, [allItems, text]); // ✅ allItems나 text가 변경될 때만 변경돼요
// ...
이제 계산이 text에 직접 의존해요 (문자열이고 "우연히" 달라질 수 없어요).
💡 부연설명: 객체를
useMemo밖에서 만들면, 매 렌더링마다 새로운 객체가 생성되어서useMemo의 의존성이 항상 변경돼요. 대신 객체를useMemo안으로 옮기면, 실제로 필요한 원시 값들만 의존성으로 관리할 수 있어요.
Form 컴포넌트가 memo로 감싸져 있다고 해볼게요. 함수를 prop으로 전달하고 싶어요:
export default function ProductPage({ productId, referrer }) {
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}
return <Form onSubmit={handleSubmit} />;
}
{}가 다른 객체를 생성하는 것처럼, function() {} 같은 함수 선언과 () => {} 같은 표현식은 매 리렌더링마다 다른 함수를 생성해요. 그 자체로, 새로운 함수를 생성하는 건 문제가 아니에요. 피해야 할 것이 아니에요! 하지만, Form 컴포넌트가 메모이제이션되어 있다면, 아마도 props가 변경되지 않았을 때 리렌더링을 건너뛰고 싶을 거예요. 항상 다른 prop은 메모이제이션의 의미를 무너뜨릴 거예요.
useMemo로 함수를 메모이제이션하려면, 계산 함수가 다른 함수를 반환해야 해요:
export default function Page({ productId, referrer }) {
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
};
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
이건 투박해 보여요! 함수를 메모이제이션하는 건 충분히 흔해서 React가 그것을 위한 전용 Hook을 제공해요. 추가로 중첩된 함수를 작성하지 않으려면 함수를 useMemo 대신 useCallback으로 감싸세요:
export default function Page({ productId, referrer }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails
});
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
위 두 예시는 완전히 동등해요. useCallback의 유일한 이점은 안에 추가 중첩 함수를 작성하지 않아도 된다는 거예요. 그 외에는 아무것도 하지 않아요. useCallback에 대해 더 읽어보세요.
💡 부연설명:
useMemo(() => (orderDetails) => {...})와useCallback((orderDetails) => {...})는 완전히 같은 동작을 해요.useCallback은 함수 메모이제이션을 위한 편의 Hook일 뿐이에요. 코드가 더 깔끔하게 보이는 장점이 있어요.
Strict Mode에서는, React가 함수 중 일부를 한 번이 아니라 두 번 호출할 거예요:
function TodoList({ todos, tab }) {
// 이 컴포넌트 함수는 매 렌더링마다 두 번 실행될 거예요.
const visibleTodos = useMemo(() => {
// 이 계산은 의존성 중 하나라도 변경되면 두 번 실행될 거예요.
return filterTodos(todos, tab);
}, [todos, tab]);
// ...
이건 예상된 것이고 코드를 망가뜨리지 않아야 해요.
이 개발 환경 전용 동작은 컴포넌트를 순수하게 유지하는 데 도움이 돼요. React는 호출 중 하나의 결과를 사용하고, 다른 호출의 결과는 무시해요. 컴포넌트와 계산 함수가 순수하다면, 이게 로직에 영향을 주지 않을 거예요. 하지만, 만약 실수로 불순하다면, 이게 실수를 알아차리고 고치는 데 도움이 돼요.
예를 들어, 이 불순한 계산 함수는 prop으로 받은 배열을 변경해요:
const visibleTodos = useMemo(() => {
// 🚩 실수: prop을 변경하고 있어요
todos.push({ id: 'last', text: 'Go for a walk!' });
const filtered = filterTodos(todos, tab);
return filtered;
}, [todos, tab]);
React가 함수를 두 번 호출하기 때문에, todo가 두 번 추가되는 걸 알아챌 거예요. 계산은 기존 객체를 변경하면 안 되지만, 계산 중에 생성한 새로운 객체를 변경하는 건 괜찮아요. 예를 들어, filterTodos 함수가 항상 다른 배열을 반환한다면, 그 배열을 대신 변경할 수 있어요:
const visibleTodos = useMemo(() => {
const filtered = filterTodos(todos, tab);
// ✅ 맞아요: 계산 중에 생성한 객체를 변경하고 있어요
filtered.push({ id: 'last', text: 'Go for a walk!' });
return filtered;
}, [todos, tab]);
순수성에 대해 더 알아보려면 컴포넌트를 순수하게 유지하기를 읽어보세요.
또한, 변경 없이 객체를 업데이트하고 배열을 업데이트하는 가이드를 확인하세요.
useMemo 호출이 객체를 반환해야 하는데 undefined를 반환해요 {/my-usememo-call-is-supposed-to-return-an-object-but-returns-undefined/}이 코드는 작동하지 않아요:
// 🔴 () => {로 화살표 함수에서 객체를 반환할 수 없어요
const searchOptions = useMemo(() => {
matchMode: 'whole-word',
text: text
}, [text]);
JavaScript에서 () => {는 화살표 함수 본문을 시작하기 때문에, { 중괄호가 객체의 일부가 아니에요. 그래서 객체를 반환하지 않고, 실수로 이어져요. ({와 })처럼 괄호를 추가해서 고칠 수 있어요:
// 이건 작동하지만, 누군가 다시 망가뜨리기 쉬워요
const searchOptions = useMemo(() => ({
matchMode: 'whole-word',
text: text
}), [text]);
하지만, 여전히 혼란스럽고 누군가 괄호를 제거해서 망가뜨리기 너무 쉬워요.
이 실수를 피하려면, 명시적으로 return 문을 작성하세요:
// ✅ 이건 작동하고 명시적이에요
const searchOptions = useMemo(() => {
return {
matchMode: 'whole-word',
text: text
};
}, [text]);
useMemo의 계산이 다시 실행돼요 {/every-time-my-component-renders-the-calculation-in-usememo-re-runs/}두 번째 인자로 의존성 배열을 지정했는지 확인하세요!
의존성 배열을 잊어버리면, useMemo는 매번 계산을 다시 실행할 거예요:
function TodoList({ todos, tab }) {
// 🔴 매번 재계산: 의존성 배열이 없어요
const visibleTodos = useMemo(() => filterTodos(todos, tab));
// ...
두 번째 인자로 의존성 배열을 전달한 수정된 버전이에요:
function TodoList({ todos, tab }) {
// ✅ 불필요하게 재계산하지 않아요
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
이게 도움이 되지 않는다면, 문제는 의존성 중 적어도 하나가 이전 렌더링과 다르다는 거예요. 의존성을 콘솔에 수동으로 로깅해서 이 문제를 디버그할 수 있어요:
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
console.log([todos, tab]);
그런 다음 콘솔에서 서로 다른 리렌더링의 배열을 우클릭하고 둘 다에 대해 "Store as a global variable"을 선택할 수 있어요. 첫 번째가 temp1로, 두 번째가 temp2로 저장되었다고 가정하면, 브라우저 콘솔을 사용해서 두 배열의 각 의존성이 같은지 확인할 수 있어요:
Object.is(temp1[0], temp2[0]); // 배열 사이에 첫 번째 의존성이 같나요?
Object.is(temp1[1], temp2[1]); // 배열 사이에 두 번째 의존성이 같나요?
Object.is(temp1[2], temp2[2]); // ... 그리고 모든 의존성에 대해 ...
메모이제이션을 망가뜨리는 의존성을 찾았다면, 그것을 제거할 방법을 찾거나, 그것도 메모이제이션하세요.
useMemo를 호출해야 하는데, 허용되지 않아요 {/i-need-to-call-usememo-for-each-list-item-in-a-loop-but-its-not-allowed/}Chart 컴포넌트가 memo로 감싸져 있다고 해볼게요. ReportList 컴포넌트가 리렌더링될 때 목록의 모든 Chart의 리렌더링을 건너뛰고 싶어요. 하지만, 반복문 안에서 useMemo를 호출할 수 없어요:
function ReportList({ items }) {
return (
<article>
{items.map(item => {
// 🔴 이런 식으로 반복문 안에서 useMemo를 호출할 수 없어요:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure key={item.id}>
<Chart data={data} />
</figure>
);
})}
</article>
);
}
대신, 각 항목에 대한 컴포넌트를 추출하고 개별 항목에 대한 데이터를 메모이제이션하세요:
function ReportList({ items }) {
return (
<article>
{items.map(item =>
<Report key={item.id} item={item} />
)}
</article>
);
}
function Report({ item }) {
// ✅ 최상위 레벨에서 useMemo를 호출하세요:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure>
<Chart data={data} />
</figure>
);
}
또는, useMemo를 제거하고 대신 Report 자체를 memo로 감쌀 수 있어요. item prop이 변경되지 않으면, Report가 리렌더링을 건너뛸 것이고, 그래서 Chart도 리렌더링을 건너뛸 거예요:
function ReportList({ items }) {
// ...
}
const Report = memo(function Report({ item }) {
const data = calculateReport(item);
return (
<figure>
<Chart data={data} />
</figure>
);
});
💡 부연설명: Hook은 반복문, 조건문, 중첩 함수 안에서 호출할 수 없다는 React의 규칙을 기억하세요. 반복문에서 각 항목을 최적화하고 싶다면, 항목마다 새로운 컴포넌트를 만들고 그 컴포넌트의 최상위 레벨에서 Hook을 사용하는 게 올바른 패턴이에요.