useState 내부 동작 — 클로저, 배칭, 그리고 Object.is
useState를 매일 쓰지만, setCount를 세 번 호출하면 왜 3이 아니라 1만 증가할까요? React는 state를 내부적으로 어떻게 관리하고 있을까요?
개념 정의
useState는 함수형 컴포넌트에 상태(state)를 추가 하는 가장 기본적인 훅입니다. 내부적으로는 클로저, 큐 기반 배칭, Object.is 비교 등 여러 메커니즘이 합쳐져 동작합니다.
왜 필요한가
함수형 컴포넌트는 매 렌더링마다 함수가 다시 호출됩니다. 일반 변수로는 렌더링 사이에 값을 유지할 수 없습니다.
// ❌ 일반 변수 — 매 렌더링마다 0으로 초기화
function Counter() {
let count = 0;
return <button onClick={() => count++}>{count}</button>; // 항상 0
}
// ✅ useState — 렌더링 사이에 값 유지
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
내부 동작
클로저 캡처
이것이 useState를 이해하는 가장 핵심적인 개념입니다.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// handleClick이 생성되는 시점의 count 값을 "캡처"
console.log(count); // 이 렌더에서의 count 값
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 세 번 모두 같은 count(0)를 참조
// 결과: count는 1이 됨 (0 + 1 = 1)
}
return <button onClick={handleClick}>{count}</button>;
}
각 렌더링은 자신만의 state 값을 가진 스냅샷 입니다. 이벤트 핸들러는 생성 시점의 state를 클로저로 캡처합니다.
함수형 업데이트
이전 state에 기반하여 업데이트해야 할 때는 함수형 업데이트 를 사용합니다.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
// 함수형 업데이트: 이전 state를 인자로 받음
setCount(c => c + 1); // 0 → 1
setCount(c => c + 1); // 1 → 2
setCount(c => c + 1); // 2 → 3
// 결과: count는 3이 됨
}
return <button onClick={handleClick}>{count}</button>;
}
함수형 업데이트에서 c는 큐에서 가장 최근의 pending state 를 참조하므로, 연속 호출이 올바르게 누적됩니다.
React 18 자동 배칭 (Automatic Batching)
React는 여러 state 업데이트를 하나의 렌더링으로 묶습니다.
function Form() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
const [loading, setLoading] = useState(false);
// 이벤트 핸들러 — 배칭됨 (React 17에서도)
function handleSubmit() {
setName('홍길동');
setAge(25);
setLoading(true);
// 리렌더링 1번만 발생
}
// setTimeout — React 18부터 배칭됨
function handleAsync() {
setTimeout(() => {
setName('이순신');
setAge(30);
setLoading(false);
// React 17: 리렌더링 3번 (각 setState마다)
// React 18: 리렌더링 1번 (자동 배칭)
}, 1000);
}
// fetch — React 18부터 배칭됨
async function handleFetch() {
const data = await fetchData();
setName(data.name);
setAge(data.age);
// React 18: 리렌더링 1번
}
}
배칭을 우회해야 할 때
드물지만 즉시 렌더링이 필요한 경우 flushSync를 사용합니다.
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// 여기서 DOM이 이미 업데이트됨
flushSync(() => {
setFlag(f => !f);
});
// 여기서 또 DOM이 업데이트됨
}
flushSync는 성능에 좋지 않으므로 정말 필요한 경우에만 사용합니다.
Object.is 비교
setState를 호출해도 **값이 같으면 리렌더링이 발생하지 않습니다 **. React는 Object.is로 비교합니다.
function Example() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: '홍길동' });
// ✅ 같은 값 — 리렌더링 스킵
setCount(0); // Object.is(0, 0) === true
// ❌ 같은 내용이지만 새 객체 — 리렌더링 발생
setUser({ name: '홍길동' });
// Object.is({ name: '홍길동' }, { name: '홍길동' }) === false
// ❌ 객체를 직접 변경 — React가 변경을 감지하지 못함
user.name = '이순신';
setUser(user);
// Object.is(user, user) === true → 리렌더링 안 됨!
// ✅ 새 객체를 만들어서 전달
setUser({ ...user, name: '이순신' });
}
Object.is의 주요 동작입니다.
Object.is(1, 1); // true
Object.is('a', 'a'); // true
Object.is(null, null); // true
Object.is(NaN, NaN); // true (===와 다른 점)
Object.is(0, -0); // false (===와 다른 점)
Object.is({}, {}); // false — 참조가 다름
Object.is([], []); // false — 참조가 다름
Lazy Initializer
초기값 계산이 비용이 큰 경우, ** 함수를 전달 **하면 첫 렌더링에서만 실행됩니다.
// ❌ 매 렌더링마다 실행됨 (결과는 첫 번째만 사용)
const [data, setData] = useState(expensiveComputation());
// ✅ 첫 렌더링에서만 실행
const [data, setData] = useState(() => expensiveComputation());
// 실전 예시: localStorage에서 초기값 로드
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem('theme');
return saved ? JSON.parse(saved) : 'light';
});
객체/배열 state 올바르게 업데이트하기
객체
const [form, setForm] = useState({ name: '', email: '', age: 0 });
// ❌ 직접 변경
form.name = '홍길동';
setForm(form);
// ✅ 스프레드로 새 객체 생성
setForm({ ...form, name: '홍길동' });
// ✅ 중첩 객체도 새로 생성해야 함
const [user, setUser] = useState({
name: '홍길동',
address: { city: '서울', zip: '12345' },
});
setUser({
...user,
address: { ...user.address, city: '부산' },
});
배열
const [items, setItems] = useState([1, 2, 3]);
// 추가
setItems([...items, 4]);
// 삭제
setItems(items.filter(item => item !== 2));
// 수정
setItems(items.map(item => item === 2 ? 20 : item));
// 정렬 (원본을 변경하므로 복사 필수)
setItems([...items].sort((a, b) => b - a));
주의할 점
Stale Closure — 가장 흔한 실수
setInterval이나 setTimeout 안에서 state를 참조하면, 클로저에 캡처된 ** 생성 시점의 값 **만 보입니다. 값이 바뀌어도 클로저 안의 값은 그대로이므로 "stale closure" 문제가 발생합니다.
// ❌ count는 항상 0 (클로저에 캡처된 초기값)
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
// ✅ 함수형 업데이트로 해결 — 항상 최신 값 기준
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
객체 직접 변경 — React가 변경을 감지하지 못함
Object.is는 ** 참조 동등성 **을 비교합니다. 객체를 직접 변경(mutate)한 뒤 같은 참조를 setState에 전달하면, React는 "변경 없음"으로 판단하여 리렌더링을 건너뜁니다.
// ❌ 같은 참조 → 리렌더링 안 됨
user.name = '이순신';
setUser(user); // Object.is(user, user) === true
// ✅ 새 객체를 생성하여 전달
setUser({ ...user, name: '이순신' });
lazy initializer에 함수 호출을 직접 전달
useState(expensiveComputation())은 매 렌더링마다 함수를 ** 실행 **합니다(결과는 첫 번째만 사용). 함수를 ** 전달 **해야 첫 렌더링에서만 실행됩니다.
정리
| 항목 | 설명 |
|---|---|
| 클로저 캡처 | 각 렌더링은 자신만의 state 스냅샷을 가진 독립적인 클로저 |
| 함수형 업데이트 | setCount(c => c + 1) — 이전 state 기반 업데이트에 필수 |
| 자동 배칭 | React 18부터 setTimeout, Promise 등 ** 모든 곳 **에서 적용 |
| Object.is 비교 | 참조 동등성 비교 — 객체/배열은 새 참조 생성 필요 |
| lazy initializer | useState(() => compute()) — 비용이 큰 초기화에 사용 |
| flushSync | 배칭을 우회하여 즉시 렌더링 (성능에 불리, 드물게 사용) |
"왜 setState를 세 번 호출했는데 1만 증가하지?"의 답은 클로저에 있습니다. 각 렌더링이 독립적인 스냅샷이라는 점을 이해하면 state 동작이 예측 가능해집니다.