메모이제이션 및 리렌더링 최적화
웹 애플리케이션의 성능은 초기 로딩 속도뿐만 아니라, 사용자 상호작용에 따른 응답성(Responsiveness) 과 부드러운 UI 업데이트에도 크게 좌우됩니다. React 기반의 Next.js 애플리케이션에서 이러한 응답성을 저해하는 주요 원인 중 하나는 불필요한 리렌더링(Re-rendering) 입니다. 컴포넌트가 필요 이상으로 자주 또는 비효율적으로 리렌더링되면 애플리케이션의 성능 저하로 이어질 수 있습니다.
이 절에서는 React에서 불필요한 리렌더링을 방지하고 성능을 최적화하기 위한 핵심 기법인 메모이제이션(Memoization) 에 대해 알아보고, Next.js 환경에서 React.memo
, useMemo
, useCallback
훅을 어떻게 효과적으로 사용하는지 상세히 살펴보겠습니다.
리렌더링과 성능 문제
React 컴포넌트는 다음과 같은 경우에 리렌더링됩니다.
state
가 변경될 때: 컴포넌트의 로컬 상태(useState
)가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.props
가 변경될 때: 부모 컴포넌트로부터 전달받은props
가 변경되면 해당 컴포넌트와 그 자식 컴포넌트들이 리렌더링됩니다.- 부모 컴포넌트가 리렌더링될 때: React의 기본 동작은 부모 컴포넌트가 리렌더링되면,
props
가 변경되지 않았더라도 모든 자식 컴포넌트들이 함께 리렌더링되는 것입니다. context
가 변경될 때: 컴포넌트가 구독하고 있는Context
의 값이 변경되면 해당 컨텍스트를 사용하는 모든 컴포넌트들이 리렌더링됩니다.forceUpdate()
호출: (권장되지 않음)
문제는 props
가 실제로 변경되지 않았음에도 부모의 리렌더링으로 인해 자식 컴포넌트가 불필요하게 리렌더링되는 경우입니다. 이 불필요한 리렌더링이 많아질수록 가상 DOM 비교(Reconciliation) 및 실제 DOM 업데이트 과정에서 오버헤드가 발생하여 애플리케이션의 응답성이 떨어질 수 있습니다.
메모이제이션(Memoization)이란?
메모이제이션은 컴퓨터 프로그래밍에서 사용되는 최적화 기법 중 하나로, 함수의 결과 값을 저장(캐싱)해 두었다가 동일한 입력이 다시 발생하면 함수를 다시 실행하는 대신 저장된 결과 값을 즉시 반환하는 방식입니다. 이를 통해 불필요한 계산을 줄여 성능을 향상시킬 수 있습니다.
React에서는 이 메모이제이션 원리를 사용하여 컴포넌트의 리렌더링을 제어하고, 값(변수)이나 함수(콜백)의 불필요한 재생성을 방지합니다.
React.memo
로 컴포넌트 리렌더링 방지
React.memo
는 고차 컴포넌트(Higher-Order Component, HOC)로, 래핑된 컴포넌트의 props
가 변경되지 않았다면 리렌더링을 건너뛰도록 지시합니다. props
변경 여부를 얕은 비교(Shallow Comparison) 방식으로 확인합니다.
사용 시점: 순수(Pure) 함수형 컴포넌트이며, props
가 자주 변경되지 않지만 부모 컴포넌트의 리렌더링으로 인해 불필요하게 자주 리렌더링되는 경우에 효과적입니다.
실습: React.memo
적용
-
부모 컴포넌트 생성 (
src/app/memo-test/page.tsx
): 이 컴포넌트는 매 초마다 상태가 업데이트되어 리렌더링됩니다.src/app/memo-test/page.tsx // src/app/memo-test/page.tsx "use client"; // 클라이언트 컴포넌트 import { useState, useEffect } from 'react'; import MemoizedChild from '@/components/MemoizedChild'; // 메모이제이션된 자식 컴포넌트 import RegularChild from '@/components/RegularChild'; // 일반 자식 컴포넌트 export default function MemoTestPage() { const [count, setCount] = useState(0); const [text, setText] = useState('Hello'); useEffect(() => { const interval = setInterval(() => { setCount(prevCount => prevCount + 1); }, 1000); // 1초마다 count 업데이트 (부모 리렌더링 유발) return () => clearInterval(interval); }, []); const memoizedValue = 'Memoized Value'; // 값이 변하지 않음 const memoizedCallback = () => { // 함수가 변하지 않음 (useCallback 사용 시 더 좋음) console.log('Memoized Callback executed'); }; console.log('부모 컴포넌트 리렌더링:', count); return ( <div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #ff9800', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}> <h1 style={{ color: '#ff9800', textAlign: 'center', marginBottom: '20px' }}>메모이제이션 테스트</h1> <p style={{ textAlign: 'center', fontSize: '1.2em' }}> 부모 카운트: <span style={{ fontWeight: 'bold' }}>{count}</span> </p> <div style={{ display: 'flex', justifyContent: 'space-around', marginTop: '30px' }}> <div style={{ flex: 1, padding: '15px', border: '1px solid #ccc', margin: '10px', borderRadius: '8px', backgroundColor: '#e9ffe9' }}> <h2>일반 자식 컴포넌트</h2> <RegularChild data={text} /> {/* data prop이 변하지 않음 */} </div> <div style={{ flex: 1, padding: '15px', border: '1px solid #ccc', margin: '10px', borderRadius: '8px', backgroundColor: '#e0f7fa' }}> <h2>메모이제이션된 자식</h2> <MemoizedChild data={memoizedValue} onClick={memoizedCallback} /> </div> </div> <button onClick={() => setText(prev => prev === 'Hello' ? 'World' : 'Hello')} style={{ display: 'block', margin: '20px auto', padding: '10px 20px', backgroundColor: '#6200ea', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }} > 텍스트 변경 (일반 자식 props 변경) </button> </div> ); }
-
메모이제이션된 자식 컴포넌트 생성 (
src/components/MemoizedChild.tsx
)src/components/MemoizedChild.tsx // src/components/MemoizedChild.tsx import React from 'react'; interface MemoizedChildProps { data: string; onClick: () => void; } // React.memo로 컴포넌트를 래핑하여 props가 변경될 때만 리렌더링되도록 함 const MemoizedChild = React.memo(function MemoizedChild({ data, onClick }: MemoizedChildProps) { console.log('메모이제이션된 자식 컴포넌트 리렌더링'); return ( <div style={{ border: '1px dashed #007bff', padding: '10px', marginTop: '10px' }}> <p>데이터: <span style={{ fontWeight: 'bold', color: '#007bff' }}>{data}</span></p> <button onClick={onClick} style={{ padding: '8px 12px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}> 클릭 (Memoized) </button> <p style={{ fontSize: '0.8em', color: '#555', marginTop: '10px' }}> (부모 카운트가 변경되어도 데이터나 onClick 함수가 변하지 않으면 리렌더링되지 않음) </p> </div> ); }); export default MemoizedChild;
-
일반 자식 컴포넌트 생성 (
src/components/RegularChild.tsx
)src/components/RegularChild.tsx // src/components/RegularChild.tsx interface RegularChildProps { data: string; } function RegularChild({ data }: RegularChildProps) { console.log('일반 자식 컴포넌트 리렌더링'); return ( <div style={{ border: '1px dashed #28a745', padding: '10px', marginTop: '10px' }}> <p>데이터: <span style={{ fontWeight: 'bold', color: '#28a745' }}>{data}</span></p> <p style={{ fontSize: '0.8em', color: '#555', marginTop: '10px' }}> (부모 카운트가 변경될 때마다 리렌더링됨) </p> </div> ); } export default RegularChild;
결과 확인:
개발자 도구의 콘솔을 보면, 부모 컴포넌트 리렌더링
과 일반 자식 컴포넌트 리렌더링
은 매 초마다 출력되지만, 메모이제이션된 자식 컴포넌트 리렌더링
은 초기 1회만 출력되거나 text
상태가 변경될 때만 출력되는 것을 확인할 수 있습니다.
useMemo
를 사용한 값 메모이제이션
useMemo
훅은 계산 비용이 높은 값을 메모이제이션하여, 특정 의존성 배열(deps
)의 값이 변경될 때만 해당 값을 다시 계산하도록 합니다. 이는 컴포넌트 렌더링 시마다 반복적으로 수행되는 복잡한 계산을 최적화하는 데 유용합니다.
사용 시점: 렌더링 과정에서 복잡한 연산(예: 큰 배열 필터링/정렬, 복잡한 객체 생성)이 필요한 경우.
실습: useMemo
적용
// src/app/memo-test/page.tsx (위의 부모 컴포넌트에서 수정)
// ... (기존 임포트 및 상태) ...
export default function MemoTestPage() {
const [count, setCount] = useState(0);
const [items, setItems] = useState([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
useEffect(() => {
const interval = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
// 1. useMemo를 사용하여 `expensiveCalculation` 결과 메모이제이션
// `count`가 변경될 때만 이 계산을 다시 수행합니다.
const expensiveResult = useMemo(() => {
console.log('복잡한 계산 실행 (useMemo)');
// 실제로는 여기에 복잡한 연산이 들어감
return items.filter(item => item % 2 === 0).reduce((acc, curr) => acc + curr + count, 0);
}, [count, items]); // 의존성 배열: count 또는 items가 변할 때만 재계산
console.log('부모 컴포넌트 리렌더링:', count);
return (
<div style={{ /* ... 스타일 */ }}>
{/* ... (기존 UI) ... */}
<h2 style={{ textAlign: 'center', marginTop: '30px' }}>useMemo 테스트</h2>
<p style={{ textAlign: 'center' }}>
복잡한 계산 결과: <span style={{ fontWeight: 'bold', color: '#8800ff' }}>{expensiveResult}</span>
</p>
<button
onClick={() => setItems(prev => [...prev, prev.length + 1])}
style={{ display: 'block', margin: '20px auto', padding: '10px 20px', backgroundColor: '#6200ea', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}
>
아이템 추가 (useMemo 재계산 유발)
</button>
</div>
);
}
결과 확인:
콘솔에서 복잡한 계산 실행 (useMemo)
은 count
가 변경될 때마다 출력되지 않고, 초기 렌더링 시와 '아이템 추가' 버튼을 눌러 items
상태가 변경될 때만 출력되는 것을 확인할 수 있습니다.
useCallback
을 사용한 함수 메모이제이션
useCallback
훅은 함수 자체를 메모이제이션하여, 컴포넌트가 리렌더링될 때마다 새로운 함수 인스턴스가 생성되는 것을 방지합니다. 이는 자식 컴포넌트에 콜백 함수를 props
로 전달할 때 특히 중요합니다. 함수 인스턴스가 계속 새로 생성되면 React.memo
로 감싸진 자식 컴포넌트가 props
가 변경되었다고 판단하여 불필요하게 리렌더링될 수 있기 때문입니다.
사용 시점: React.memo
로 최적화된 자식 컴포넌트에 콜백 함수를 props
로 전달할 때.
실습: useCallback
적용
// src/app/memo-test/page.tsx (위의 부모 컴포넌트에서 수정)
// ... (기존 임포트 및 상태) ...
export default function MemoTestPage() {
// ... (기존 count, items 상태) ...
// 1. useCallback을 사용하여 콜백 함수 메모이제이션
// `useCallback`의 의존성 배열이 비어있으므로 이 함수는 최초 1회만 생성됩니다.
const handleClick = useCallback(() => {
console.log('버튼이 클릭되었습니다.');
// 여기서는 count 상태를 사용하지 않으므로 의존성 배열에 count를 넣을 필요 없음
}, []);
console.log('부모 컴포넌트 리렌더링:', count);
return (
<div style={{ /* ... 스타일 */ }}>
{/* ... (기존 UI) ... */}
<div style={{ flex: 1, padding: '15px', border: '1px solid #ccc', margin: '10px', borderRadius: '8px', backgroundColor: '#e0f7fa' }}>
<h2>메모이제이션된 자식</h2>
<MemoizedChild
data="Memoized Value"
onClick={handleClick} // useCallback으로 감싼 함수 전달
/>
</div>
{/* ... (나머지 UI) ... */}
</div>
);
}
src/components/MemoizedChild.tsx
는 그대로 사용됩니다.
결과 확인:
MemoizedChild
컴포넌트의 onClick
props
로 useCallback
으로 감싼 handleClick
함수를 전달하면, 부모 컴포넌트가 리렌더링될 때마다 handleClick
함수가 새로 생성되지 않으므로, MemoizedChild
는 data
prop이 변하지 않는 한 리렌더링되지 않습니다.
메모이제이션 사용 시 고려사항 및 팁
- 남용 금지: 메모이제이션은 분명 성능 최적화에 도움이 되지만, 그 자체로도 오버헤드를 가집니다 (메모리 사용, 비교 연산). 따라서 필요한 곳에만 적용해야 합니다. 모든 컴포넌트, 값, 함수에 무분별하게 적용하는 것은 오히려 성능을 저하시킬 수 있습니다.
- 성능 측정: 메모이제이션을 적용하기 전에 그리고 적용한 후에 실제 성능 개선이 있는지 프로파일링 도구 (React DevTools Profiler, Chrome Lighthouse)를 사용하여 측정하는 것이 중요합니다.
- 의존성 배열 정확성:
useMemo
와useCallback
의 의존성 배열(deps
)을 정확하게 지정하는 것이 매우 중요합니다.- 누락: 의존성이 누락되면 오래된(Stale) 값을 사용하거나,
React.memo
가 기대한 대로 작동하지 않을 수 있습니다. - 과도한 포함: 필요 없는 의존성을 포함하면 메모이제이션이 너무 자주 무효화되어 최적화 효과가 사라지거나 오히려 악화될 수 있습니다.
- 누락: 의존성이 누락되면 오래된(Stale) 값을 사용하거나,
- 객체 및 배열 비교:
React.memo
는props
를 얕게 비교합니다.props
로 객체나 배열이 전달될 때, 내용이 같더라도 참조가 달라지면React.memo
는 변경되었다고 판단하여 리렌더링합니다. 이 경우useMemo
나useCallback
을 사용하여 참조 동등성을 유지하거나,React.memo
의 두 번째 인자로 커스텀 비교 함수를 제공해야 할 수 있습니다. - Server Components와의 관계: Next.js App Router의 Server Components는 서버에서 한 번만 렌더링되므로,
React.memo
,useMemo
,useCallback
은 클라이언트 컴포넌트 ("use client"
) 에서만 의미가 있습니다. Server Components는 리렌더링 개념이 없으므로 이러한 훅들을 사용할 필요가 없습니다.
메모이제이션은 React 애플리케이션의 렌더링 성능을 미세 조정하는 강력한 도구입니다. 적절하게 사용하여 사용자에게 더 빠르고 부드러운 상호작용 경험을 제공하세요.