React의 리렌더링은 왜 일어나고 실무에서는 어떻게 줄일까
React로 개발하다 보면 어느 순간부터 이런 생각을 하게 됩니다.
- "왜 입력창 하나 바꿨는데 리스트 전체가 다시 그려지지?"
- "분명
memo를 썼는데 왜 계속 렌더링되지?" - "리렌더링은 무조건 나쁜 건가?"
- "도대체 어디서부터 최적화를 시작해야 하지?"
이 질문은 자연스럽습니다. React를 처음 공부할 때는 상태와 props 흐름이 더 중요하고, 리렌더링은 어느 정도 나중 문제처럼 느껴지기 때문입니다. 하지만 실무에서는 리스트가 길어지고, 자식 컴포넌트가 무거워지고, 필터링이나 계산 로직이 붙기 시작하면서 리렌더링이 체감 성능 문제로 이어지는 순간이 분명히 옵니다.
문제는 여기서 많은 경우 최적화를 너무 빨리, 혹은 너무 막연하게 시작한다는 점입니다.
- 무조건
React.memo부터 붙이고 - 모든 함수를
useCallback으로 감싸고 - 모든 계산을
useMemo로 감싸고 - 그런데 왜 빨라졌는지는 잘 모르는 상태
이 글에서는 이런 식으로 정리해보겠습니다.
React에서 리렌더링은 왜 일어나는지- 어떤 리렌더링은 괜찮고, 어떤 리렌더링은 줄일 가치가 있는지
Todo List예제를 기준으로- 처음에는 일부러 비효율적으로 만들고
- 그다음 실무에서 자주 쓰는 방식으로 순차적으로 개선해보겠습니다
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
React에서 리렌더링 자체는 정상 동작입니다.- 문제는 "변화와 관계없는 컴포넌트까지 계속 다시 렌더링되는 경우"입니다.
- 최적화의 출발점은
memo가 아니라 state를 어디에 둘지, props를 어떻게 흘릴지를 먼저 보는 것입니다. - 그다음 필요할 때
React.memo,useCallback,useMemo를 붙이는 편이 보통 더 낫습니다.
즉, 리렌더링 최적화는 훅 몇 개를 외우는 문제가 아니라, 변화가 필요한 범위만 다시 그리게 만드는 설계 문제에 더 가깝습니다.
리렌더링은 왜 일어날까?
가장 단순하게 말하면 컴포넌트는 보통 아래 상황에서 다시 렌더링됩니다.
- 자신의 state가 바뀔 때
- 부모가 다시 렌더링되면서 자식도 함께 렌더링될 때
- context 값이 바뀔 때
- 상위에서 내려오는 props가 바뀔 때
여기서 중요한 포인트는 두 번째입니다.
많은 사람이 처음에는 "내 props가 안 바뀌었는데 왜 다시 렌더링되지?"라고 느낍니다. 하지만 기본적으로 부모가 다시 렌더링되면 자식 함수도 다시 실행될 수 있습니다.
즉, 아래 같은 구조에서:
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((prev) => prev + 1)}>+</button>
<Child />
</>
);
}count가 바뀌면 Parent가 다시 렌더링되고, Child도 다시 렌더링될 수 있습니다.
이건 버그가 아니라 기본 동작입니다.
모든 리렌더링을 막아야 할까?
그렇지는 않습니다.
이 부분이 정말 중요합니다.
리렌더링은 React가 UI를 최신 상태로 맞추는 정상 메커니즘입니다. 아주 가벼운 컴포넌트라면 다시 렌더링돼도 거의 문제가 없습니다.
실제로 줄일 가치가 있는 경우는 보통 아래처럼 조금 더 구체적입니다.
- 리스트 아이템 수가 많을 때
- 자식 컴포넌트 하나하나가 무거울 때
- 입력 한 번에 큰 트리가 계속 다시 그려질 때
- 필터링, 정렬, 계산 로직이 반복될 때
- 애니메이션이나 체감 반응성이 중요한 화면일 때
즉, "리렌더링이 일어났다"가 문제라기보다, 불필요하게 넓은 범위가 자주 다시 렌더링된다가 문제인 경우가 많습니다.
예제: 일부러 비효율적으로 만든 Todo List
먼저 가장 단순한 Todo List를 만들어보겠습니다.
import { useState } from 'react';
type Todo = {
id: number;
text: string;
completed: boolean;
};
const initialTodos: Todo[] = [
{ id: 1, text: '글 초안 작성', completed: false },
{ id: 2, text: '코드 예제 정리', completed: false },
{ id: 3, text: '배포 체크', completed: false },
];
export default function TodoPage() {
const [todos, setTodos] = useState(initialTodos);
const [newTodo, setNewTodo] = useState('');
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const filteredTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
const handleAddTodo = () => {
if (!newTodo.trim()) return;
setTodos((prev) => [
...prev,
{
id: Date.now(),
text: newTodo,
completed: false,
},
]);
setNewTodo('');
};
const handleToggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
};
return (
<div>
<h1>Todo List</h1>
<input
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button onClick={handleAddTodo}>추가</button>
<div>
<button onClick={() => setFilter('all')}>전체</button>
<button onClick={() => setFilter('active')}>미완료</button>
<button onClick={() => setFilter('completed')}>완료</button>
</div>
<ul>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={() => handleToggleTodo(todo.id)} />
))}
</ul>
</div>
);
}
function TodoItem({ todo, onToggle }: { todo: Todo; onToggle: () => void }) {
console.log('render todo:', todo.id);
return (
<li>
<label>
<input type="checkbox" checked={todo.completed} onChange={onToggle} />
<span>{todo.text}</span>
</label>
</li>
);
}이 코드는 기능적으로는 충분히 동작합니다.
하지만 아래 상황을 떠올려보면 문제가 보입니다.
- 입력창에 한 글자만 입력해도 부모가 다시 렌더링됩니다
- 필터 버튼을 눌러도 부모가 다시 렌더링됩니다
- 그때마다 모든
TodoItem이 다시 렌더링될 수 있습니다
즉, 실제로 바뀐 것은 newTodo 문자열이거나 filter 값인데, 리스트 전체도 같이 다시 그려지는 구조입니다.
먼저 확인해야 할 것: 무엇이 진짜 문제인가
이 시점에서 바로 memo부터 붙이고 싶어질 수 있습니다. 그런데 그 전에 먼저 질문해야 합니다.
- 지금 어떤 state가 어디에 있는가?
- 그 state 변화가 어떤 범위까지 렌더링을 유발하는가?
- 실제로 매번 다시 그릴 필요가 없는 부분은 어디인가?
이걸 먼저 보지 않으면 최적화가 아니라 패치가 되기 쉽습니다.
1단계. state 위치부터 다시 본다
가장 먼저 손볼 수 있는 것은 newTodo 상태입니다.
지금 구조에서는 입력창의 state가 부모인 TodoPage에 있습니다. 그래서 사용자가 입력할 때마다 페이지 전체가 다시 렌더링됩니다.
이걸 별도 컴포넌트로 분리해보겠습니다.
function AddTodoForm({ onAddTodo }: { onAddTodo: (text: string) => void }) {
const [value, setValue] = useState('');
const handleSubmit = () => {
if (!value.trim()) return;
onAddTodo(value);
setValue('');
};
return (
<div>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="할 일을 입력하세요"
/>
<button onClick={handleSubmit}>추가</button>
</div>
);
}부모는 이렇게 바뀝니다.
function TodoPage() {
const [todos, setTodos] = useState(initialTodos);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const handleAddTodo = (text: string) => {
setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }]);
};
// ...생략
return (
<div>
<h1>Todo List</h1>
<AddTodoForm onAddTodo={handleAddTodo} />
{/* ... */}
</div>
);
}이렇게 하면 입력 중인 문자열은 AddTodoForm 안에서만 바뀌므로, 타이핑할 때마다 TodoPage 전체가 다시 렌더링되는 일을 줄일 수 있습니다.
이 단계가 중요한 이유는, 최적화의 첫 번째 답이 종종 memo가 아니라 state를 더 가까운 곳으로 내리는 것이기 때문입니다.
2단계. React.memo로 변화 없는 TodoItem 다시 그리지 않기
그다음은 리스트 아이템입니다.
TodoItem은 각 항목이 꽤 독립적이고, todo 하나가 바뀌지 않았다면 다시 렌더링될 이유가 적습니다. 이럴 때 React.memo가 잘 맞습니다.
const TodoItem = React.memo(function TodoItem({
todo,
onToggle,
}: {
todo: Todo;
onToggle: () => void;
}) {
console.log('render todo:', todo.id);
return (
<li>
<label>
<input type="checkbox" checked={todo.completed} onChange={onToggle} />
<span>{todo.text}</span>
</label>
</li>
);
});이제 직관적으로는 "변하지 않은 todo는 안 그려지겠지"라고 느낄 수 있습니다.
하지만 여기서 한 가지 함정이 남아 있습니다.
3단계. memo만으로는 부족할 수 있다
현재 부모에서 TodoItem을 렌더링하는 코드를 다시 보면:
{
filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={() => handleToggleTodo(todo.id)} />
));
}문제는 onToggle={() => handleToggleTodo(todo.id)}입니다.
부모가 다시 렌더링될 때마다 이 함수는 새로 만들어집니다. 즉, todo 객체가 그대로여도 onToggle prop은 매번 새로운 참조가 됩니다.
그러면 React.memo를 써도 props가 완전히 같다고 보기 어려워져서 다시 렌더링될 수 있습니다.
즉, memo는 강력하지만, props 참조가 계속 새로 만들어지면 기대만큼 효과가 나오지 않을 수 있다는 점을 기억해야 합니다.
4단계. 핸들러를 안정화한다
이 문제를 풀기 위해 자주 쓰는 방식이 useCallback입니다.
먼저 부모의 토글 함수를 안정화합니다.
const handleToggleTodo = useCallback((id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
}, []);그리고 TodoItem 인터페이스도 조금 바꿔보겠습니다.
const TodoItem = React.memo(function TodoItem({
todo,
onToggle,
}: {
todo: Todo;
onToggle: (id: number) => void;
}) {
console.log('render todo:', todo.id);
return (
<li>
<label>
<input type="checkbox" checked={todo.completed} onChange={() => onToggle(todo.id)} />
<span>{todo.text}</span>
</label>
</li>
);
});부모에서는 이렇게 넘깁니다.
{
filteredTodos.map((todo) => <TodoItem key={todo.id} todo={todo} onToggle={handleToggleTodo} />);
}이 구조가 더 나은 이유는:
- 부모에서 항목마다 새 함수를 만들지 않고
- 같은
handleToggleTodo참조를 내려보낼 수 있고 todo가 안 바뀐 항목은memo로 더 잘 건너뛸 수 있기 때문입니다
즉, useCallback은 혼자서 성능을 마법처럼 개선하는 훅이 아니라, memoized child에 안정적인 함수 props를 넘길 때 의미가 커지는 도구에 가깝습니다.
5단계. 리스트 계산이 무거울 때는 useMemo
현재 예제의 filteredTodos 계산은 작기 때문에 큰 부담이 아닐 수 있습니다.
const filteredTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});하지만:
- 아이템 수가 많거나
- 정렬과 검색이 같이 붙거나
- 변환 비용이 큰 계산이라면
매 렌더링마다 이 연산이 반복되는 것이 부담이 될 수 있습니다.
이럴 때는 useMemo를 검토할 수 있습니다.
const filteredTodos = useMemo(() => {
return todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
}, [todos, filter]);다만 여기서도 중요한 건, useMemo를 무조건 붙이는 것이 아니라 계산 비용이 실제로 있는가를 먼저 보는 것입니다.
작은 배열 하나 필터링하는 정도라면 오히려 메모이제이션 자체가 더 복잡할 수 있습니다.
6단계. Todo List를 조금 더 구조적으로 나누기
실무에서는 보통 리스트와 툴바를 더 분리합니다.
function TodoPage() {
const [todos, setTodos] = useState(initialTodos);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const handleAddTodo = useCallback((text: string) => {
setTodos((prev) => [...prev, { id: Date.now(), text, completed: false }]);
}, []);
const handleToggleTodo = useCallback((id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo))
);
}, []);
const filteredTodos = useMemo(() => {
return todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
}, [todos, filter]);
return (
<div>
<h1>Todo List</h1>
<AddTodoForm onAddTodo={handleAddTodo} />
<FilterTabs filter={filter} onChange={setFilter} />
<TodoList todos={filteredTodos} onToggle={handleToggleTodo} />
</div>
);
}리스트도 분리할 수 있습니다.
const TodoList = React.memo(function TodoList({
todos,
onToggle,
}: {
todos: Todo[];
onToggle: (id: number) => void;
}) {
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={onToggle} />
))}
</ul>
);
});이쯤 오면 최적화 포인트는 단순히 훅을 추가한 것이 아니라:
- state를 더 적절한 위치로 옮기고
- 변경 범위를 줄이고
- props 참조를 안정화하고
- 무거운 부분만 선택적으로 메모이제이션한 것
으로 설명할 수 있습니다.
즉, 좋은 리렌더링 최적화는 API 암기가 아니라 변화의 전파 범위를 설계하는 과정입니다.
실무에서 정말 자주 하는 오해
정리하면 아래 오해가 정말 많습니다.
1. React.memo만 붙이면 끝난다
아닙니다. 함수 props나 객체 props가 매번 새로 만들어지면 기대한 만큼 막히지 않을 수 있습니다.
2. useCallback은 무조건 성능 최적화다
아닙니다. 단독으로는 큰 의미가 없을 때가 많습니다. 보통 memoized child와 같이 봐야 합니다.
3. useMemo는 붙일수록 좋다
아닙니다. 작은 계산이라면 오히려 코드만 복잡해질 수 있습니다.
4. 리렌더링이 일어나면 무조건 문제다
아닙니다. 가벼운 컴포넌트라면 그냥 다시 렌더링되는 편이 더 단순하고 안전할 수 있습니다.
5. 최적화는 훅으로만 해결된다
아닙니다. 실제로 가장 큰 차이는 state 위치, 컴포넌트 분리, props 설계에서 나오는 경우가 많습니다.
언제부터 최적화를 시작하면 좋을까?
실무에서는 보통 아래 순서가 더 안전합니다.
- 먼저 느린지 확인한다
- 어떤 상호작용에서 느린지 확인한다
- 어떤 컴포넌트가 자주 다시 렌더링되는지 본다
- state 위치와 props 흐름을 먼저 본다
- 그다음
memo,useCallback,useMemo를 검토한다
즉, 최적화는 추측보다 측정과 구조 개선이 먼저입니다.
무엇으로 확인하면 좋을까?
가볍게는:
console.log('render')- React DevTools Profiler
만으로도 많은 힌트를 얻을 수 있습니다.
특히 Profiler를 보면:
- 어떤 컴포넌트가 얼마나 자주 렌더링되는지
- 왜 렌더링됐는지
- 최적화 전후 차이가 있는지
를 더 구체적으로 볼 수 있습니다.
즉, 체감상 느린 것 같다는 감각을 코드 레벨로 확인하는 습관이 중요합니다.
Todo List에서 기억하면 좋은 핵심
이번 예제로 다시 압축하면 이렇습니다.
- 입력 state를 부모에 두면 타이핑마다 전체가 다시 렌더링될 수 있습니다
- state를 더 가까운 컴포넌트로 내리면 변화 범위를 줄일 수 있습니다
- 리스트 아이템은
React.memo로 최적화하기 좋습니다 - 하지만 함수 props가 매번 새로 만들어지면
memo효과가 약해질 수 있습니다 useCallback은 이런 함수 props 안정화에 도움이 됩니다useMemo는 계산이 실제로 비쌀 때 검토하는 편이 좋습니다
즉, 리렌더링 최적화는 "훅을 얼마나 많이 붙였는가"가 아니라, 변화와 무관한 부분을 얼마나 덜 다시 그리게 만들었는가로 봐야 합니다.
같이 보면 좋은 글
- React의 리렌더링은 왜 일어날까: 기본 동작 방식부터 다시 이해하기
- Compound Component 패턴은 무엇이고 React에서 어떻게 적용할까
- controlled vs uncontrolled component는 무엇이고 언제 무엇을 선택할까
- Render Props vs Custom Hooks vs HOC, 언제 무엇을 선택할까
- JavaScript의 비동기 동작은 어떻게 이해해야 할까
결론
React의 리렌더링은 막아야 할 적이 아니라, 먼저 이해해야 할 기본 동작입니다.
짧게 정리하면:
- 리렌더링 자체는 정상이고
- 문제는 변화와 관계없는 범위까지 자주 다시 그려지는 경우이며
- 실무에서는
memo보다 먼저 state 위치와 props 흐름을 보는 편이 보통 더 중요합니다
결국 좋은 최적화는 무조건 다시 그리지 않게 만드는 것이 아니라, 필요한 부분만 다시 그리게 만드는 구조를 만드는 것에 가깝습니다.
