React 앱 리렌더링 줄이는 법: 80% 성능 개선 실전 가이드
React 앱 리렌더링 줄이는 법: 80% 성능 개선 실전 가이드
예쁜 React 앱 하나 만들었어요. 코드도 깔끔하고, 컴포넌트도 잘 나눴고, 다 잘 돌아가요. 근데 뭔가 이상해요. 폼에 타이핑하면 좀 느려요. 리스트 스크롤할 때 끊겨요. 모달 여는 게 한 박자 느려요. 앱이... 뭔가 무거워요.
React DevTools Profiler 켜봤더니 충격이에요. 글자 하나 쳤는데 컴포넌트가 47번이나 렌더링돼요. 버튼 한 번 클릭했는데 200개가 넘는 컴포넌트가 업데이트돼요. 상태 하나 바꿨더니 앱 전체가 반짝반짝해요.
리렌더링 문제예요. 그리고 여러분만 겪는 게 아니에요.
이게 React에서 제일 흔한 성능 문제인데, 제일 잘못 알려진 문제이기도 해요. 많은 개발자들이 React.memo, useMemo, useCallback을 여기저기 뿌려요. 뭔가 효과가 있길 바라면서요. 근데 그러면 대부분 더 느려져요.
이 글에서는 왜 컴포넌트가 리렌더링되는지 제대로 파헤치고, 성능을 갉아먹는 패턴들을 잡아내고, 실제로 렌더링 횟수를 80% 줄인 방법을 공유할게요. 원리를 이해하고 정확히 고치는 법이에요.
리렌더링이 언제 일어나는지 알아야 해요
최적화하려면 먼저 언제 리렌더링이 일어나는지 알아야 해요. 규칙은 간단해요:
1. 상태가 바뀌면 리렌더링
useState나 useReducer로 상태가 바뀌면, 그 컴포넌트가 리렌더링돼요:
function Counter() { const [count, setCount] = useState(0); // 클릭할 때마다 Counter가 리렌더링 return <button onClick={() => setCount(c => c + 1)}>{count}</button>; }
이건 당연한 거고 필요한 거예요. 고칠 필요 없어요.
2. 부모가 리렌더링되면 자식도 따라가요
부모 컴포넌트가 리렌더링되면, props가 바뀌었든 안 바뀌었든 모든 자식도 같이 리렌더링돼요:
function Parent() { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(c => c + 1)}>Count: {count}</button> {/* ExpensiveChild는 count랑 상관없는데도 */} {/* count 바뀔 때마다 같이 리렌더링됨 */} <ExpensiveChild /> </div> ); } function ExpensiveChild() { // 부모가 리렌더링될 때마다 여기도 실행됨 console.log('ExpensiveChild rendered'); return <div>렌더링 비용이 비싼 컴포넌트</div>; }
성능 문제의 90%가 여기서 나와요. 부모한테 상태가 있으니까 리렌더링되는 건데, 자식은 그 상태 신경 안 써요. 근데 React는 그걸 모르니까 같이 리렌더링시켜요.
3. Context가 바뀌면 구독하는 애들 전부 리렌더링
useContext(SomeContext) 쓰는 컴포넌트는 그 context 값이 바뀌면 전부 리렌더링돼요:
const ThemeContext = createContext({ theme: 'light' }); function App() { const [theme, setTheme] = useState('light'); const [user, setUser] = useState(null); // 문제: user만 바꿨는데 theme 쓰는 애들도 리렌더링됨 // value 객체 자체가 새로 만들어지니까 return ( <ThemeContext.Provider value={{ theme, user, setUser }}> <ThemedButton /> {/* user 바뀌면 리렌더링! */} </ThemeContext.Provider> ); }
두 번째로 큰 성능 문제 원인이에요. context 값이 너무 자주 바뀌거나 너무 많은 걸 담고 있을 때 생겨요.
문제 찾기: React DevTools Profiler
뭘 고치기 전에 데이터가 필요해요. React DevTools 열고 Profiler 탭으로 가세요.
1단계: 느린 동작 녹화하기
"Start profiling" 누르고 느리다고 느껴지는 동작을 해보세요. 타이핑하거나, 스크롤하거나, 모달 열거나. 그 다음 프로파일링 멈추세요.
2단계: 그래프 분석하기
Flamegraph에서 볼 수 있는 것들:
- 어떤 컴포넌트가 렌더링됐는지 (색칠된 바)
- 얼마나 걸렸는지 (바 너비)
- 왜 렌더링됐는지 (마우스 올리면 나와요)
이런 거 찾으세요:
- 안 바뀌어야 하는데 바뀐 컴포넌트 (회색이어야 할 게 노란색)
- 같은 컴포넌트가 여러 번 렌더링됨 (반복되는 바)
- 비싼 컴포넌트가 자꾸 렌더링됨 (넓은 바가 계속 나타남)
3단계: 렌더링 하이라이트 켜기
React DevTools 설정에서 "Highlight updates when components render" 켜세요. 그 다음 앱이랑 상호작용해보세요. 리렌더링되는 컴포넌트가 반짝여요. 글자 하나 쳤는데 앱 전체가 반짝이면, 문제 찾은 거예요.
흔한 실수들 (그리고 고치는 법)
실수 1: 렌더링할 때마다 새 객체 만들기
제일 흔한 실수예요. 렌더링할 때 새 객체나 배열을 만들면 자식이 매번 "새로운" props를 받아요:
// ❌ 이러면 안 돼요: 렌더링할 때마다 새 배열, 새 객체 function TodoList({ todos }) { return ( <List items={todos.filter(t => !t.completed)} // 매번 새 배열 config={{ showDates: true }} // 매번 새 객체 /> ); } // ✅ 이렇게 하세요: 참조 유지하기 function TodoList({ todos }) { const activeTodos = useMemo( () => todos.filter(t => !t.completed), [todos] ); const config = useMemo( () => ({ showDates: true }), [] // 빈 배열 = 절대 안 바뀜 ); return <List items={activeTodos} config={config} />; }
config가 진짜 안 바뀌는 거면, 아예 컴포넌트 밖으로 빼세요:
// 제일 좋은 방법: 아예 밖에 두기 const LIST_CONFIG = { showDates: true }; function TodoList({ todos }) { const activeTodos = useMemo( () => todos.filter(t => !t.completed), [todos] ); return <List items={activeTodos} config={LIST_CONFIG} />; }
실수 2: 함수를 인라인으로 넘기기
함수를 인라인으로 넘기면 렌더링할 때마다 새 함수가 생겨요:
// ❌ 이러면 안 돼요: 렌더링마다 새 함수 function TodoItem({ todo, onToggle }) { return ( <Checkbox checked={todo.completed} onChange={() => onToggle(todo.id)} // 매번 새 함수 /> ); } // ✅ 이렇게 하세요: 함수 참조 유지하기 function TodoItem({ todo, onToggle }) { const handleToggle = useCallback( () => onToggle(todo.id), [todo.id, onToggle] ); return ( <Checkbox checked={todo.completed} onChange={handleToggle} /> ); }
참고: useCallback은 자식 컴포넌트가 React.memo로 감싸져 있거나 그 함수를 의존성 배열에서 쓸 때만 의미 있어요. 아니면 그냥 오버헤드만 추가하는 거예요.
실수 3: 상태를 너무 위로 올리기
상태는 쓰는 곳이랑 최대한 가까이 있어야 해요:
// ❌ 이러면 안 돼요: 폼 상태가 App에 있으면 다 리렌더링됨 function App() { const [formData, setFormData] = useState({ name: '', email: '' }); return ( <div> <Header /> {/* 글자 하나 칠 때마다 리렌더링 */} <Sidebar /> {/* 글자 하나 칠 때마다 리렌더링 */} <Form formData={formData} setFormData={setFormData} /> <Footer /> {/* 글자 하나 칠 때마다 리렌더링 */} </div> ); } // ✅ 이렇게 하세요: 상태를 쓰는 곳에 두기 function App() { return ( <div> <Header /> <Sidebar /> <Form /> {/* 상태가 여기 있음 */} <Footer /> </div> ); } function Form() { const [formData, setFormData] = useState({ name: '', email: '' }); // Form이랑 자식들만 리렌더링됨 return (/* ... */); }
실수 4: Context 값 객체를 매번 새로 만들기
Context 값은 참조로 비교돼요. 렌더링할 때마다 새 객체를 만들면, 구독하는 모든 컴포넌트가 리렌더링돼요:
// ❌ 이러면 안 돼요: 렌더링마다 새 객체 function AuthProvider({ children }) { const [user, setUser] = useState(null); return ( <AuthContext.Provider value={{ user, setUser, isLoggedIn: !!user }}> {children} </AuthContext.Provider> ); } // ✅ 이렇게 하세요: useMemo로 감싸기 function AuthProvider({ children }) { const [user, setUser] = useState(null); const value = useMemo( () => ({ user, setUser, isLoggedIn: !!user }), [user] // user 바뀔 때만 새로 만듦 ); return ( <AuthContext.Provider value={value}> {children} </AuthContext.Provider> ); }
실수 5: 모든 걸 하나의 Context에 넣기
다 넣어버리면 하나만 바뀌어도 전부 리렌더링돼요:
// ❌ 이러면 안 돼요: 다 때려넣은 context const AppContext = createContext({ user: null, theme: 'light', notifications: [], sidebarOpen: false, // ... 20개 더 }); // 이거 쓰는 컴포넌트는 뭐 하나만 바뀌어도 리렌더링됨 // ✅ 이렇게 하세요: 바뀌는 빈도에 따라 분리 const UserContext = createContext(null); // 거의 안 바뀜 const ThemeContext = createContext('light'); // 거의 안 바뀜 const NotificationContext = createContext([]); // 자주 바뀜 const UIContext = createContext({}); // 인터랙션할 때 바뀜
고급 패턴들
패턴 1: Children을 props로 넘기기
상태 있는 부모 안에서 자식을 직접 렌더링하지 말고, props로 넘기세요:
// ❌ 이러면 안 돼요: 부모 상태 바뀌면 자식도 리렌더링 function Modal({ isOpen }) { const [position, setPosition] = useState({ x: 0, y: 0 }); if (!isOpen) return null; return ( <div style={{ top: position.y, left: position.x }}> <ExpensiveContent /> {/* 드래그할 때마다 리렌더링 */} </div> ); } // ✅ 이렇게 하세요: children으로 넘기기 function Modal({ isOpen, children }) { const [position, setPosition] = useState({ x: 0, y: 0 }); if (!isOpen) return null; return ( <div style={{ top: position.y, left: position.x }}> {children} {/* 참조가 안정적이라서 리렌더링 안 됨 */} </div> ); } // 사용법: <Modal isOpen={isOpen}> <ExpensiveContent /> </Modal>
왜 되냐면, children이 Modal 안에서 만들어지는 게 아니라 바깥에서 만들어지기 때문이에요. Modal 상태가 바뀌어도 children 참조는 그대로예요.
패턴 2: 상태 있는 부분 따로 빼기
상태랑 UI가 섞여있으면, 상태 있는 부분을 따로 컴포넌트로 빼세요:
// ❌ 이러면 안 돼요: 마우스 움직일 때마다 리스트 전체 리렌더링 function ItemList({ items }) { const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> <Cursor position={mousePos} /> {items.map(item => ( <ExpensiveItem key={item.id} item={item} /> {/* 마우스 움직일 때마다 리렌더링! */} ))} </div> ); } // ✅ 이렇게 하세요: 상태 있는 부분 분리 function ItemList({ items }) { return ( <div> <CursorTracker /> {/* 자기 상태 알아서 관리 */} {items.map(item => ( <ExpensiveItem key={item.id} item={item} /> ))} </div> ); } function CursorTracker() { const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setMousePos({ x: e.clientX, y: e.clientY })}> <Cursor position={mousePos} /> </div> ); }
패턴 3: Context 일부만 구독하기
Context 값 전체가 아니라 일부만 필요하면, 상태 관리 라이브러리를 쓰세요:
// 문제: Context 어디가 바뀌어도 다 리렌더링됨 function UserAvatar() { const { user } = useContext(AppContext); // notifications가 바뀌어도 리렌더링됨 return <img src={user.avatar} />; } // 해결: 셀렉터 있는 라이브러리 쓰기 (Zustand, Jotai, Redux 등) import { create } from 'zustand'; const useStore = create((set) => ({ user: null, theme: 'light', notifications: [], setUser: (user) => set({ user }), })); function UserAvatar() { // user 바뀔 때만 리렌더링 const user = useStore((state) => state.user); return <img src={user?.avatar} />; }
패턴 4: 긴 리스트 가상화
100개 넘는 아이템 리스트면 가상화하세요:
// ❌ 이러면 안 돼요: 만 개 다 렌더링 function MessageList({ messages }) { return ( <div className="messages"> {messages.map(msg => ( <Message key={msg.id} message={msg} /> ))} </div> ); } // ✅ 이렇게 하세요: 보이는 것만 렌더링 import { Virtuoso } from 'react-virtuoso'; function MessageList({ messages }) { return ( <Virtuoso data={messages} itemContent={(index, msg) => <Message message={msg} />} /> ); }
가상화 라이브러리 추천:
- react-virtuoso: 채팅 같은 UI에 좋아요
- @tanstack/react-virtual: 커스텀하기 좋아요
- react-window: 가볍고 검증됐어요
최적화 안 해도 되는 경우
성능 최적화는 공짜가 아니에요:
- 코드가 복잡해져요
- 디버깅이 어려워져요
- 버그가 생길 수 있어요
- 시간이 들어요
이럴 땐 최적화 안 해도 돼요:
- 컴포넌트가 빠르게 렌더링됨 (16ms 이내)
- 거의 리렌더링 안 됨
- 유저가 느리다고 안 함
- Profiler에서 문제 안 보임
React는 원래 빨라요. Virtual DOM이 잘 최적화돼 있어요. 대부분의 리렌더링은 비용이 싸요. 문제가 확실할 때만 최적화하세요.
실제 결과: 80% 성능 개선
실제 프로덕션 앱에서 이 원칙들을 적용했어요:
적용 전:
- 인터랙션당 평균 847개 컴포넌트 렌더링
- 입력 지연 120ms
- 스크롤할 때 끊김
한 것들:
- 폼 상태를 폼 안으로 옮김 (-40% 렌더링)
- 거대한 Context 5개로 분리 (-25% 렌더링)
- 비싼 계산 useMemo로 감쌈 (-10% 렌더링)
- 메인 리스트 가상화 (-15% 렌더링, 끊김 해결)
적용 후:
- 인터랙션당 평균 156개 컴포넌트 렌더링 (81% 감소)
- 입력 지연 12ms
- 부드러운 60fps 스크롤
수정하는 데 2일, 프로파일링에 1일 걸렸어요. 문제 원인 찾는 게 제일 어려웠어요.
디버깅 체크리스트
성능 문제 만나면 이거 순서대로 확인해보세요:
- 먼저 Profiler 돌리기 - 진짜 문제가 뭔지 확인
- 새 객체/배열 props 확인 - 제일 흔한 원인
- Context 사용 확인 - 너무 자주 바뀌나?
- 상태 위치 확인 - 너무 높이 올라가 있나?
- 리스트 확인 - 가상화 없이 수백 개 렌더링하나?
- 고친 후 다시 측정 - 진짜 나아졌나?
마무리
리렌더링 자체는 적이 아니에요. 쓸데없는 리렌더링이 적이에요. React는 원래 빠른데, 어떤 업데이트가 의미 있는지는 우리가 알려줘야 해요.
좋은 최적화는 컴포넌트 트리를 이해하는 데서 나와요:
- 상태는 쓰는 곳 가까이에 두기
- Context는 바뀌는 빈도별로 분리하기
- Children 패턴으로 업데이트 격리하기
- useMemo/useCallback은 필요한 곳에만
- 긴 리스트는 가상화하기
제일 중요한 건: Profiler로 확인하세요. 감으로 하지 말고요.
유저는 코드가 예쁜지 안 봐요. 앱이 빠른지만 느껴요. 이제 그 경험을 줄 수 있어요.