
React에서 “언제, 왜, 어떻게” 리렌더링이 일어나는지 정확히 이해하면 성능 최적화와 불필요한 복잡도 감소에 큰 도움을 줍니다. 이 글은 리렌더링의 이론을 체계적으로 정리하고, 실행 가능한 예시와 실무 체크리스트로 마무리합니다.
state) 변경, 상위 컴포넌트 렌더, context 값 변경, key 변경, 외부 스토어 구독 변경 등으로 “업데이트 필요”가 발생합니다. React는 내부 업데이트 큐에 변경을 기록합니다.useLayoutEffect/ref 효과가 동기적으로, useEffect는 비동기적으로 실행됩니다.setState 호출: 해당 컴포넌트가 다시 렌더됩니다. 동일 값으로 설정하면 Object.is 비교로 건너뜁니다.React.memo로 자식을 메모이즈하고 props가 같으면 건너뜁니다.context 값 변경: 해당 컨텍스트를 구독(useContext)하는 모든 소비자가 다시 렌더됩니다. Provider의 value 참조가 안정적이어야 불필요한 렌더를 줄일 수 있습니다.useSelector, Zustand useStore 등은 선택자 비교 결과가 달라질 때만 리렌더됩니다.key 변경: React가 다른 노드로 인식하여 마운트/언마운트가 일어납니다.발생하지 않는 경우
useRef 변경: .current를 변경해도 렌더를 트리거하지 않습니다.setState를 호출하지 않음: 참조 동일성이 유지되면 React는 변경을 모릅니다. 항상 불변 업데이트를 사용하세요.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 비교를 돕습니다.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의 비교/저장 비용도 존재합니다. 핫스팟에만 적용하세요.value의 참조 안정화: value={{ a, b }}는 부모 렌더마다 새 객체가 됩니다. useMemo(() => ({ a, b }), [a, b])로 고정하세요.setCount(c => c)는 의미 없습니다. bail-out을 신뢰하되 의도치 않은 동일성 유지 버그에 주의하세요.value 참조는 안정적인가?useMemo로 감싸졌는가? 이벤트 핸들러 참조는 useCallback인가?key와 가상화가 적용되어 있는가?flushSync로 분리하세요.React.memo, 참조 안정화, 컨텍스트 분할, 비용 큰 계산의 메모이즈를 상황에 맞게 적용하고 Profiler로 검증하세요.