- Published on
React 렌더링 최적화 실전 가이드
목차
- 렌더링 최적화가 필요한 이유
- 불필요한 리렌더링 원인 파악
- React.memo 활용
- useMemo와 useCallback
- Context API 성능 최적화
- 리스트 렌더링 최적화
- 상태 관리 최적화
- 실전 최적화 체크리스트
렌더링 최적화가 필요한 이유
렌더링이란?
React에서 렌더링은 컴포넌트 함수를 실행해서 JSX를 반환하는 과정입니다.
function MyComponent() {
console.log('렌더링 발생!'); // 이 로그가 찍힐 때마다 렌더링
return <div>Hello</div>;
}
언제 렌더링이 발생하나?
- State가 변경될 때
- Props가 변경될 때
- 부모 컴포넌트가 렌더링될 때
- Context 값이 변경될 때
문제: 불필요한 렌더링
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveChild /> {/* count와 무관하지만 매번 리렌더링됨 */}
</div>
);
}
function ExpensiveChild() {
console.log('ExpensiveChild 렌더링'); // count 변경 시마다 찍힘
// 무거운 계산...
return <div>...</div>;
}
결과: count가 바뀔 때마다 ExpensiveChild도 리렌더링됨
불필요한 리렌더링 원인 파악
React DevTools Profiler 사용
1. 설치
Chrome/Firefox Extension: React Developer Tools
2. 프로파일링 시작
- DevTools → Profiler 탭
- 🔴 녹화 시작
- 앱 조작 (버튼 클릭 등)
- ⏹️ 녹화 중지
3. 결과 분석
- Ranked Chart: 렌더링 시간이 긴 컴포넌트 순위
- Flame Graph: 컴포넌트 트리별 렌더링 시간
- Why did this render?: 렌더링 원인 표시
콘솔 로그로 확인
function MyComponent({ data }) {
console.log('MyComponent 렌더링', { data });
return <div>{data.name}</div>;
}
주의: 프로덕션에서는 제거!
React.memo 활용
기본 사용법
React.memo는 props가 변경되지 않으면 리렌더링을 건너뜁니다.
❌ Before:
function ExpensiveComponent({ data }) {
console.log('ExpensiveComponent 렌더링');
// 무거운 계산...
return <div>{data.name}</div>;
}
function Parent() {
const [count, setCount] = useState(0);
const data = { name: 'John' }; // 매번 새 객체 생성
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent data={data} /> {/* 매번 리렌더링 */}
</div>
);
}
✅ After:
const ExpensiveComponent = React.memo(({ data }) => {
console.log('ExpensiveComponent 렌더링');
return <div>{data.name}</div>;
});
function Parent() {
const [count, setCount] = useState(0);
const data = useMemo(() => ({ name: 'John' }), []); // 메모이제이션
return (
<div>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveComponent data={data} /> {/* data 변경 시에만 리렌더링 */}
</div>
);
}
커스텀 비교 함수
기본적으로 React.memo는 얕은 비교(shallow comparison)를 합니다.
const MyComponent = React.memo(
({ user }) => {
return <div>{user.name}</div>;
},
(prevProps, nextProps) => {
// true를 반환하면 리렌더링 건너뜀
// false를 반환하면 리렌더링 실행
return prevProps.user.id === nextProps.user.id;
}
);
React.memo를 사용하지 말아야 할 때
1. Props가 자주 변경되는 경우
// ❌ 나쁜 예: 매번 props가 바뀌므로 memo 의미 없음
const LiveClock = React.memo(({ time }) => {
return <div>{time}</div>;
});
function App() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(interval);
}, []);
return <LiveClock time={time} />;
}
2. 컴포넌트가 간단한 경우
// ❌ 불필요: 렌더링 비용보다 memo 비용이 더 클 수 있음
const SimpleButton = React.memo(({ label }) => {
return <button>{label}</button>;
});
3. 항상 새로운 참조를 받는 경우
// ❌ 나쁜 예: 매번 새 함수가 전달됨
function Parent() {
return <MemoizedChild onClick={() => console.log('clicked')} />;
}
useMemo와 useCallback
useMemo: 계산 결과 메모이제이션
언제 사용?
- 무거운 계산이 있을 때
- 참조 동일성이 중요할 때
❌ Before:
function ProductList({ products, filter }) {
// 매 렌더링마다 재계산됨
const filteredProducts = products.filter((p) => p.category === filter);
return (
<div>
{filteredProducts.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
✅ After:
function ProductList({ products, filter }) {
// filter나 products가 바뀔 때만 재계산
const filteredProducts = useMemo(() => {
console.log('필터링 실행');
return products.filter((p) => p.category === filter);
}, [products, filter]);
return (
<div>
{filteredProducts.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
useCallback: 함수 메모이제이션
언제 사용?
- 함수를 자식 컴포넌트에 props로 전달할 때
- 함수가 useEffect 의존성 배열에 있을 때
❌ Before:
function TodoList({ todos }) {
const handleDelete = (id) => {
// 삭제 로직...
};
return (
<div>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
// 매 렌더링마다 새 함수가 전달됨
))}
</div>
);
}
const TodoItem = React.memo(({ todo, onDelete }) => {
return (
<div>
{todo.title}
<button onClick={() => onDelete(todo.id)}>삭제</button>
</div>
);
});
✅ After:
function TodoList({ todos }) {
const handleDelete = useCallback((id) => {
// 삭제 로직...
}, []); // 의존성 없으면 한 번만 생성
return (
<div>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onDelete={handleDelete} />
))}
</div>
);
}
안티패턴: 과도한 사용
❌ 나쁜 예:
function MyComponent() {
// 불필요한 useMemo
const value = useMemo(() => 1 + 1, []);
// 불필요한 useCallback
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
return <button onClick={handleClick}>{value}</button>;
}
언제 사용하지 말아야 할까?
- 간단한 계산
- 컴포넌트 내부에서만 사용되는 함수
- 의존성 배열이 매번 바뀌는 경우
Context API 성능 최적화
문제: Context 값 변경 시 모든 Consumer 리렌더링
❌ Before:
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState({ name: 'John', age: 30 });
const [theme, setTheme] = useState('light');
const value = {
user,
setUser,
theme,
setTheme,
};
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
function UserName() {
const { user } = useContext(UserContext);
console.log('UserName 렌더링'); // theme 변경 시에도 리렌더링됨!
return <div>{user.name}</div>;
}
function ThemeSwitcher() {
const { theme, setTheme } = useContext(UserContext);
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
해결 1: Context 분리
✅ After:
// User Context
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState({ name: 'John', age: 30 });
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
// Theme Context (분리)
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// 사용
function UserName() {
const { user } = useContext(UserContext);
console.log('UserName 렌더링'); // user 변경 시에만 렌더링
return <div>{user.name}</div>;
}
해결 2: Context Selector 패턴
// use-context-selector 라이브러리 사용
import { createContext, useContextSelector } from 'use-context-selector';
const UserContext = createContext();
function UserName() {
// user.name만 선택
const userName = useContextSelector(UserContext, (v) => v.user.name);
console.log('UserName 렌더링'); // user.name 변경 시에만 렌더링
return <div>{userName}</div>;
}
해결 3: 상태 관리 라이브러리 사용
Context API가 복잡해지면 Zustand, Jotai 같은 라이브러리 고려:
// Zustand 예시
import create from 'zustand';
const useStore = create((set) => ({
user: { name: 'John', age: 30 },
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}));
function UserName() {
// user만 구독
const user = useStore((state) => state.user);
console.log('UserName 렌더링'); // user 변경 시에만 렌더링
return <div>{user.name}</div>;
}
리스트 렌더링 최적화
1. 올바른 key 사용
❌ 나쁜 예:
// 인덱스를 key로 사용 (항목 순서가 바뀌면 문제)
{
todos.map((todo, index) => <TodoItem key={index} todo={todo} />);
}
// 랜덤 값을 key로 사용 (매번 새로 생성됨)
{
todos.map((todo) => <TodoItem key={Math.random()} todo={todo} />);
}
✅ 좋은 예:
// 고유 ID 사용
{
todos.map((todo) => <TodoItem key={todo.id} todo={todo} />);
}
2. 리스트 아이템 메모이제이션
const TodoItem = React.memo(({ todo, onToggle, onDelete }) => {
console.log('TodoItem 렌더링:', todo.id);
return (
<div>
<input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} />
<span>{todo.title}</span>
<button onClick={() => onDelete(todo.id)}>삭제</button>
</div>
);
});
function TodoList({ todos }) {
const handleToggle = useCallback((id) => {
// 토글 로직...
}, []);
const handleDelete = useCallback((id) => {
// 삭제 로직...
}, []);
return (
<div>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={handleToggle} onDelete={handleDelete} />
))}
</div>
);
}
3. Virtual List (긴 리스트)
수천 개 이상의 아이템이 있을 때는 가상화(Virtualization) 사용:
import { FixedSizeList } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => <div style={style}>{items[index].name}</div>;
return (
<FixedSizeList
height={600} // 보이는 영역 높이
itemCount={items.length}
itemSize={50} // 각 아이템 높이
width="100%"
>
{Row}
</FixedSizeList>
);
}
라이브러리:
react-window(가볍고 빠름)react-virtualized(더 많은 기능)@tanstack/react-virtual(최신)
상태 관리 최적화
1. 상태를 가까운 곳에 배치
❌ Before:
function App() {
const [searchQuery, setSearchQuery] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div>
<Header /> {/* searchQuery 변경 시 리렌더링됨 */}
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<ProductList query={searchQuery} />
<Footer /> {/* searchQuery 변경 시 리렌더링됨 */}
</div>
);
}
✅ After:
function App() {
return (
<div>
<Header />
<SearchSection /> {/* 상태를 이 안으로 이동 */}
<Footer />
</div>
);
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState('');
return (
<div>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<ProductList query={searchQuery} />
</div>
);
}
2. 상태 분리
❌ Before:
function Form() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
});
const handleChange = (field, value) => {
setFormData({ ...formData, [field]: value });
// 한 필드만 바뀌어도 전체 formData가 새 객체로 생성됨
};
return (
<div>
<NameInput value={formData.name} onChange={(v) => handleChange('name', v)} />
<EmailInput value={formData.email} onChange={(v) => handleChange('email', v)} />
<MessageInput value={formData.message} onChange={(v) => handleChange('message', v)} />
</div>
);
}
✅ After:
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
return (
<div>
<NameInput value={name} onChange={setName} />
<EmailInput value={email} onChange={setEmail} />
<MessageInput value={message} onChange={setMessage} />
</div>
);
}
3. 불변성 유지
❌ 나쁜 예:
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
todos.push({ id: Date.now(), text }); // ❌ 직접 수정
setTodos(todos); // React가 변경 감지 못함
};
}
✅ 좋은 예:
function TodoList() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text }]); // ✅ 새 배열 생성
};
const toggleTodo = (id) => {
setTodos(
todos.map(
(todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo) // ✅ 새 객체 생성
)
);
};
}
Immer 라이브러리 활용:
import { useImmer } from 'use-immer';
function TodoList() {
const [todos, setTodos] = useImmer([]);
const addTodo = (text) => {
setTodos((draft) => {
draft.push({ id: Date.now(), text }); // ✅ 직접 수정해도 됨 (Immer가 처리)
});
};
}
실전 최적화 체크리스트
측정 먼저, 최적화는 나중에
1. 프로파일링
// React DevTools Profiler 사용
// 또는
import { Profiler } from 'react';
function App() {
const onRenderCallback = (
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime,
interactions
) => {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
};
return (
<Profiler id="App" onRender={onRenderCallback}>
<MyApp />
</Profiler>
);
}
최적화 우선순위
1. 가장 먼저 확인할 것:
- 불필요한 상태 업데이트
- 너무 넓은 범위의 상태
- Context 값이 매번 새로 생성되는지
2. 컴포넌트 레벨:
- 무거운 컴포넌트에만
React.memo적용 - Props로 전달되는 함수에
useCallback - Props로 전달되는 객체/배열에
useMemo
3. 리스트:
- 올바른
key사용 - 리스트 아이템 메모이제이션
- 1000개 이상이면 가상화 고려
성능 측정 도구
1. React DevTools Profiler
- 컴포넌트별 렌더링 시간
- 렌더링 원인 파악
2. Chrome DevTools Performance
- 전체 성능 프로파일
- JavaScript 실행 시간
- 레이아웃/페인트 시간
3. Lighthouse
- 전체 앱 성능 점수
- FCP, LCP, TTI 측정
주의사항
⚠️ 조기 최적화는 악의 근원
- 성능 문제가 실제로 있을 때만 최적화
- 측정 없이 추측하지 않기
⚠️ 과도한 메모이제이션
- 모든 컴포넌트에
memo적용 ❌ - 모든 함수에
useCallback❌ - 간단한 계산에
useMemo❌
⚠️ 의존성 배열 주의
// ❌ 나쁜 예: 의존성 누락
const fetchData = useCallback(() => {
fetch(`/api/data?id=${userId}`); // userId 의존성 누락
}, []); // ESLint 경고 무시하지 말기!
// ✅ 좋은 예
const fetchData = useCallback(() => {
fetch(`/api/data?id=${userId}`);
}, [userId]);
실전 사례
사례 1: 검색 필터
문제: 타이핑할 때마다 전체 상품 목록 필터링
function ProductSearch() {
const [products] = useState(/* 1000개 상품 */);
const [query, setQuery] = useState('');
// ❌ 매 타이핑마다 1000개 필터링
const filtered = products.filter((p) => p.name.includes(query));
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductList products={filtered} />
</div>
);
}
해결:
import { useDeferredValue, useMemo } from 'react';
function ProductSearch() {
const [products] = useState(/* 1000개 상품 */);
const [query, setQuery] = useState('');
// 1. Debounce된 값 사용
const deferredQuery = useDeferredValue(query);
// 2. 메모이제이션
const filtered = useMemo(() => {
return products.filter((p) => p.name.includes(deferredQuery));
}, [products, deferredQuery]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ProductList products={filtered} />
</div>
);
}
사례 2: 중첩된 리스트
문제: 카테고리별 상품 목록
function CategoryList({ categories }) {
return (
<div>
{categories.map((category) => (
<div key={category.id}>
<h2>{category.name}</h2>
{category.products.map((product) => (
<ProductCard key={product.id} product={product} />
// ❌ 최상위 state 변경 시 모든 ProductCard 리렌더링
))}
</div>
))}
</div>
);
}
해결:
// 카테고리를 별도 컴포넌트로 분리
const Category = React.memo(({ category }) => {
return (
<div>
<h2>{category.name}</h2>
{category.products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
});
const ProductCard = React.memo(({ product }) => {
return <div>{product.name}</div>;
});
function CategoryList({ categories }) {
return (
<div>
{categories.map((category) => (
<Category key={category.id} category={category} />
))}
</div>
);
}
마치며
핵심 원칙
- 측정 먼저 - DevTools Profiler 활용
- 상태를 가까이 - 필요한 곳에만 상태 배치
- 적절한 메모이제이션 - 무거운 계산, 자주 전달되는 props에만
- Context 분리 - 자주 바뀌는 값과 그렇지 않은 값 분리
- 올바른 key - 리스트 렌더링 최적화의 기본
다음 단계
- React 공식 문서의 Performance 섹션
- React DevTools Profiler 가이드
- useDeferredValue 공식 문서
작성일: 2025-11-17 마지막 수정: 2025-11-17