useState를 매일 쓰지만, setCount를 세 번 호출하면 왜 3이 아니라 1만 증가할까요? React는 state를 내부적으로 어떻게 관리하고 있을까요?

개념 정의

useState는 함수형 컴포넌트에 상태(state)를 추가 하는 가장 기본적인 훅입니다. 내부적으로는 클로저, 큐 기반 배칭, Object.is 비교 등 여러 메커니즘이 합쳐져 동작합니다.

왜 필요한가

함수형 컴포넌트는 매 렌더링마다 함수가 다시 호출됩니다. 일반 변수로는 렌더링 사이에 값을 유지할 수 없습니다.

JSX
// ❌ 일반 변수 — 매 렌더링마다 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를 이해하는 가장 핵심적인 개념입니다.

JSX
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에 기반하여 업데이트해야 할 때는 함수형 업데이트 를 사용합니다.

JSX
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 업데이트를 하나의 렌더링으로 묶습니다.

JSX
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를 사용합니다.

JSX
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로 비교합니다.

JSX
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의 주요 동작입니다.

JAVASCRIPT
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

초기값 계산이 비용이 큰 경우, ** 함수를 전달 **하면 첫 렌더링에서만 실행됩니다.

JSX
// ❌ 매 렌더링마다 실행됨 (결과는 첫 번째만 사용)
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 올바르게 업데이트하기

객체

JSX
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: '부산' },
});

배열

JSX
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" 문제가 발생합니다.

JSX
// ❌ 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는 "변경 없음"으로 판단하여 리렌더링을 건너뜁니다.

JSX
// ❌ 같은 참조 → 리렌더링 안 됨
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 initializeruseState(() => compute()) — 비용이 큰 초기화에 사용
flushSync배칭을 우회하여 즉시 렌더링 (성능에 불리, 드물게 사용)

"왜 setState를 세 번 호출했는데 1만 증가하지?"의 답은 클로저에 있습니다. 각 렌더링이 독립적인 스냅샷이라는 점을 이해하면 state 동작이 예측 가능해집니다.

댓글 로딩 중...