728x90
React에서 “언제, 왜, 어떻게” 리렌더링이 일어나는지 정확히 이해하면 성능 최적화와 불필요한 복잡도 감소에 큰 도움을 줍니다. 이 글은 리렌더링의 이론을 체계적으로 정리하고, 실행 가능한 예시와 실무 체크리스트로 마무리합니다.
핵심 개념: 렌더링 파이프라인 3단계
- Trigger: 상태(
state
) 변경, 상위 컴포넌트 렌더,context
값 변경,key
변경, 외부 스토어 구독 변경 등으로 “업데이트 필요”가 발생합니다. React는 내부 업데이트 큐에 변경을 기록합니다. - Render: 변경된 상태로 함수 컴포넌트를 다시 호출하여 새로운 VDOM(Fiber 트리)을 만듭니다. 이전 트리와 비교(diff)하지만, 이 단계에서는 실제 DOM 조작이 없습니다.
- Commit: diff 결과를 실제 DOM에 최소 변경으로 반영합니다. 레이아웃/페인팅이 발생하고 사용자가 변화를 보게 됩니다. 이때
useLayoutEffect
/ref 효과가 동기적으로,useEffect
는 비동기적으로 실행됩니다.
언제 리렌더링이 발생하는가
- 자신의
setState
호출: 해당 컴포넌트가 다시 렌더됩니다. 동일 값으로 설정하면Object.is
비교로 건너뜁니다. - 부모가 렌더되면 자식도 기본적으로 렌더: 단,
React.memo
로 자식을 메모이즈하고 props가 같으면 건너뜁니다. context
값 변경: 해당 컨텍스트를 구독(useContext
)하는 모든 소비자가 다시 렌더됩니다. Provider의value
참조가 안정적이어야 불필요한 렌더를 줄일 수 있습니다.- 외부 스토어 변경: Redux
useSelector
, ZustanduseStore
등은 선택자 비교 결과가 달라질 때만 리렌더됩니다. key
변경: React가 다른 노드로 인식하여 마운트/언마운트가 일어납니다.
발생하지 않는 경우
useRef
변경:.current
를 변경해도 렌더를 트리거하지 않습니다.- 변이만 하고
setState
를 호출하지 않음: 참조 동일성이 유지되면 React는 변경을 모릅니다. 항상 불변 업데이트를 사용하세요.
React 18+ Auto Batching: 여러 상태 변경을 1번 렌더로
React 18부터는 이벤트 핸들러뿐 아니라 setTimeout
, Promise
, 네이티브 이벤트 등 대부분의 비동기 컨텍스트에서도 상태 변경이 자동으로 배치됩니다.
import { useState } from 'react'
export function App() {
const [a, setA] = useState(0)
const [b, setB] = useState(0)
const handleClick = () => {
// 둘 다 하나의 렌더로 합쳐짐 (자동 배칭)
setA(v => v + 1)
setB(v => v + 1)
}
setTimeout(() => {
// 비동기 컨텍스트에서도 자동 배칭
setA(v => v + 1)
setB(v => v + 1)
}, 1000)
return (
<button onClick={handleClick}>a: {a}, b: {b}</button>
)
}
필요 시 렌더를 강제로 분리하려면 flushSync
를 사용할 수 있습니다(과용 금지).
import { flushSync } from 'react-dom'
flushSync(() => setA(v => v + 1))
setB(v => v + 1) // 별도의 렌더
불필요한 리렌더 줄이기: 실전 패턴
React.memo
로 자식 컴포넌트 메모이즈: 부모 렌더 시에도 props가 같으면 자식 렌더를 건너뜁니다.- 핸들러/객체의 참조 안정화:
useCallback
,useMemo
로 콜백과 계산 결과의 참조를 고정하여 memo 비교를 돕습니다. - 컨텍스트 분할: 넓은 Provider 하나보다는, 변경 빈도에 따라 Context를 분리합니다.
- 무거운 계산 메모이즈: 비용 큰 계산은
useMemo
로, 입력이 바뀔 때만 다시 수행합니다. - 리스트 최적화: 안정적인
key
, 가상 스크롤(virtualization),useDeferredValue
로 입력 지연 처리.
실행 가능한 예시: 부모 렌더가 잦아도 자식 렌더 막기
import { memo, useCallback, useState } from 'react'
type ChildProps = {
onAdd: () => void
value: number
}
const Child = memo(function Child({ onAdd, value }: ChildProps) {
console.log('Child render')
return (
<div>
<p>value: {value}</p>
<button onClick={onAdd}>Add</button>
</div>
)
})
export function Parent() {
const [count, setCount] = useState(0)
const [value, setValue] = useState(0)
// 참조가 안정적이어야 Child가 매 렌더마다 다시 그려지지 않음
const handleAdd = useCallback(() => setValue(v => v + 1), [])
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Re-render parent: {count}</button>
<Child onAdd={handleAdd} value={value} />
</div>
)
}
설명
Parent
가count
로 자주 렌더되어도,Child
의 props 참조가 동일하면React.memo
가 비교를 통과해 렌더를 생략합니다.handleAdd
를useCallback
으로 고정하지 않으면 매번 새로운 함수 참조가 전달되어Child
가 다시 렌더됩니다.
실무 주의사항과 흔한 실수
- 불변 업데이트 확보: 배열/객체는 반드시 새 참조로 갱신하세요. 예:
setItems(prev => prev.map(...))
,setObj(prev => ({ ...prev, a: 1 }))
. - 과도한 메모이제이션 금지:
React.memo
,useMemo
,useCallback
의 비교/저장 비용도 존재합니다. 핫스팟에만 적용하세요. - Context
value
의 참조 안정화:value={{ a, b }}
는 부모 렌더마다 새 객체가 됩니다.useMemo(() => ({ a, b }), [a, b])
로 고정하세요. - 동일 값 setState는 렌더 생략:
setCount(c => c)
는 의미 없습니다. bail-out을 신뢰하되 의도치 않은 동일성 유지 버그에 주의하세요. - Profiler로 실측: 추측 대신 React DevTools Profiler로 실제 렌더 비용을 측정하고 개선을 검증하세요.
체크리스트
- 상태는 최소화했는가? 파생 가능한 값은 계산으로 대체했는가?
- 부모 렌더가 자식 렌더를 유발하지 않도록 메모이제이션이 필요한가?
- 컨텍스트 범위를 적절히 분할했는가?
value
참조는 안정적인가? - 비싼 연산은
useMemo
로 감싸졌는가? 이벤트 핸들러 참조는useCallback
인가? - 리스트 렌더에 안정적인
key
와 가상화가 적용되어 있는가?
결론: 개념 정리 + 적용 가이드
- 리렌더는 Trigger → Render → Commit의 파이프라인으로 진행됩니다. 실제 DOM 변경은 Commit에서만 일어납니다.
- React 18의 자동 배칭으로 대부분의 상태 변경은 1번 렌더로 합쳐집니다. 필요할 때만
flushSync
로 분리하세요. - 성능 최적화의 핵심은 “불필요한 리렌더 원인 제거”입니다.
React.memo
, 참조 안정화, 컨텍스트 분할, 비용 큰 계산의 메모이즈를 상황에 맞게 적용하고 Profiler로 검증하세요.
728x90
'Frontend Development' 카테고리의 다른 글
실무에서 꼭 알아야 할 JWT 저장소 보안 패턴과 공격 탐지 방법 (0) | 2025.09.13 |
---|---|
Next.js SSR 페이지 풀 페이지 캐싱 (0) | 2025.09.08 |
useEffect에서 setInterval이 상태를 못 따라오는 이유 (stale closure) (0) | 2025.09.07 |
React Error Boundary: 왜 아직도 클래스일까? (0) | 2025.09.07 |
setTimeout vs Promise.then vs queueMicrotask (0) | 2025.09.05 |