고급 패턴과 성능 최적화: Custom Hooks 설계와 메모이제이션
Custom Hooks: 나만의 레고 블록 만들기
여러 컴포넌트에서
useState와 useEffect를 조합한 비슷한 코드가 계속 반복된다면 어떻게 해야 할까요? 리액트에서는 이러한 상태 관리 로직만 따로 빼내어 재사용 가능한 함수로 만들 수 있습니다. 이를 커스텀 훅(Custom Hook)이라고 부릅니다.
🚨 커스텀 훅의 절대 규칙
함수 이름이 반드시
use로 시작해야 합니다. (예: useFetch, useInput). 그래야만 리액트가 이 함수 내부에서 다른 Hook의 사용을 허용하고, 라이프사이클을 정상적으로 추적할 수 있습니다.import ResponsiveApp from './ResponsiveApp';
export default function App() {
return (
<div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
<ResponsiveApp />
</div>
);
}메모이제이션(Memoization): 세상에서 가장 쉬운 이해
영수증으로 이해하는 메모이제이션
복잡한 수학 공식을 풀어야 한다고 상상해보세요. 어제 "745 × 382"의 정답을 힘들게 계산해서 영수증(메모장)에 적어두었습니다.
오늘 누군가 또 "745 × 382"를 물어본다면? 다시 계산할 필요 없이 영수증에 적힌 답을 그대로 읽어주면 됩니다. (이것이 useMemo 입니다!)
하지만 "745 × 383"을 물어본다면? 조건(의존성 배열)이 달라졌으므로 영수증을 버리고 새로 계산해야 합니다.
useMemo: 비싼 계산의 결과값(답안지)을 캐싱하여, 조건이 안 바뀌면 재사용합니다.useCallback: 함수 자체(계산하는 방법론)를 캐싱하여, 컴포넌트가 렌더링될 때마다 함수가 새로 생성되는 것을 막습니다.React.memo: 컴포넌트 자체를 통째로 씌워, 부모가 렌더링되어도 내가 받는 Props가 안 바뀌었으면 나는 렌더링 안 하겠다고 선언합니다.
useMemo / useCallback 동작 원리
import React, { useState, useMemo, useCallback } from 'react';
import HeavyListItem from './HeavyListItem';
export default function App() {
const [items, setItems] = useState([
{ id: 1, text: '사과' }, { id: 2, text: '바나나' }, { id: 3, text: '포도' }
]);
const [text, setText] = useState(''); // 타이핑을 위한 상태
// 2. useCallback으로 함수 주소 캐싱
// 만약 useCallback을 안 쓰면, 타이핑(text 변경)할 때마다 App이 리렌더링되면서
// handleRemove 함수가 새로 만들어지고 -> HeavyListItem의 props가 바뀐 것으로 인식되어
// 아이템 수십 개가 모조리 리렌더링되는 대참사가 발생합니다.
const handleRemove = useCallback((id) => {
setItems((prevItems) => prevItems.filter(item => item.id !== id));
}, []); // 의존성이 없으므로 최초 생성된 함수 주소가 끝까지 유지됨
// 3. useMemo로 무거운 연산 값 캐싱
const expensiveCalculationCount = useMemo(() => {
console.log("무거운 연산 수행 중..."); // items가 바뀔 때만 실행됨
return items.filter(item => item.text.includes('사')).length;
}, [items]);
return (
<div style={{ padding: '2rem', background: '#f8fafc', borderRadius: '8px' }}>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="아무거나 입력해보세요 (타이핑해도 아이템 리렌더링 안됨)"
style={{ width: '100%', padding: '10px', marginBottom: '20px', borderRadius: '6px', border: '1px solid #cbd5e1' }}
/>
<p style={{ fontWeight: 'bold', color: '#334155' }}>💡 '사' 글자가 포함된 과일 수: {expensiveCalculationCount}</p>
<div style={{ background: '#fff', borderRadius: '8px', overflow: 'hidden', border: '1px solid #e2e8f0' }}>
{items.map(item => (
<HeavyListItem key={item.id} item={item} onRemove={handleRemove} />
))}
</div>
</div>
);
}