React 상태관리의 내부 동작 원리 - useState 편

Woody·3일 전
0

개요

학습 목표:

  • React가 컴포넌트별 독립적인 상태를 유지하는 방법 이해
  • Hooks의 호출 순서가 중요한 이유 파악
  • 상태 변경이 리렌더링으로 이어지는 메커니즘 이해
  • Batching의 동작 원리와 최적화 방법 습득

1. useState는 어디에 상태를 저장하는가?

1.1 Fiber 아키텍처

React는 각 컴포넌트마다 Fiber라는 자바스크립트 객체를 생성합니다. Fiber는 DOM이 아닌 React 내부의 자료구조입니다.

function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

function App() {
  return (
    <>
      <Counter /> {/* Fiber 객체 #1 */}
      <Counter /> {/* Fiber 객체 #2 */}
    </>
  );
}

Fiber 구조:

App Fiber
  ├─ Counter Fiber #1
  │   └─ memoizedState → Hook {value: 0, next: null}
  │
  └─ Counter Fiber #2
      └─ memoizedState → Hook {value: 0, next: null}

1.2 Hooks는 연결 리스트(Linked List)로 저장

각 Fiber 객체는 memoizedState라는 필드를 가지며, 이는 Hook 연결 리스트의 첫 번째 노드를 가리킵니다.

여러 개의 Hook을 사용하는 경우:

function Component() {
  const [count, setCount] = useState(0);      // Hook 1
  const [name, setName] = useState('React');  // Hook 2
  const [age, setAge] = useState(25);         // Hook 3
}

내부 구조:

Fiber.memoizedState → Hook1 {value: 0, next: Hook2}
                       ↓
                     Hook2 {value: 'React', next: Hook3}
                       ↓
                     Hook3 {value: 25, next: null}

1.3 왜 연결 리스트를 사용하는가?

이유:

  • 동적 추가/삭제가 효율적
  • 렌더링할 때마다 순회하면서 Hook을 처리
  • 배열보다 중간 삽입/삭제가 빠름

2. Hooks의 호출 순서와 Rules of Hooks

2.1 호출 순서에 의존하는 이유

React는 Hook의 이름이나 ID를 사용하지 않고, 오직 호출 순서에만 의존합니다.

왜 변수명으로 구분하지 않을까?

JavaScript는 런타임에 변수명 정보가 사라지기 때문입니다:

const [count, setCount] = useState(0);
// 컴파일 후 변수명은 없어짐

2.2 순서가 바뀌면 발생하는 문제

function Counter() {
  if (Math.random() > 0.5) {
    const [count, setCount] = useState(0);  // 조건부 Hook!
  }
  const [name, setName] = useState('React');
  
  return <div>{name}</div>;
}

문제 상황:

// 첫 번째 렌더링 (조건 true)
Hook 1: count (0)
Hook 2: name ('React')

// 두 번째 렌더링 (조건 false)
Hook 1: name ('React')  // 어? 이전엔 count였는데!

React는 hookIndex를 0으로 리셋하고, useState가 호출될 때마다 증가시키면서 해당 인덱스의 값을 가져옵니다. 순서가 바뀌면 Hook 1번 위치에 저장된 값(0)을 name에게 줘버려서 타입도 꼬이고 값도 엉망이 됩니다.

2.3 Rules of Hooks

React는 이 문제를 방지하기 위해 두 가지 규칙을 강제합니다:

  1. 최상위에서만 Hook 호출

    • 조건문, 반복문, 중첩 함수 내부에서 Hook 호출 금지
    • 매 렌더링마다 동일한 순서로 호출되도록 보장
  2. React 함수에서만 Hook 호출

    • React 함수 컴포넌트 또는 커스텀 Hook에서만 사용
    • 일반 JavaScript 함수에서 사용 금지

올바른 예:

function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('React');
  
  if (count > 10) {
    // 조건문은 Hook 호출 후에!
    return <div>Too many!</div>;
  }
  
  return <div>{count} - {name}</div>;
}

잘못된 예:

function Counter() {
  if (condition) {
    const [count, setCount] = useState(0);  // ❌ 조건부 Hook
  }
  
  for (let i = 0; i < 5; i++) {
    const [value, setValue] = useState(i);  // ❌ 반복문 안의 Hook
  }
}

2.4 ESLint 플러그인

React는 이 규칙을 자동으로 검사하는 ESLint 플러그인을 제공합니다:

npm install eslint-plugin-react-hooks

3. 상태 변경과 리렌더링 메커니즘

3.1 상태는 즉시 변경되지 않는다

const [count, setCount] = useState(0);

console.log(count);  // 0
setCount(5);
console.log(count);  // 여전히 0!

setCount(5)를 호출해도 즉시 변경되지 않습니다.

3.2 업데이트 큐(Update Queue)

setState를 호출하면:

  1. 업데이트 객체 생성: 새로운 상태 값을 포함
  2. Fiber의 업데이트 큐에 추가: 즉시 처리되지 않음
  3. 스케줄러 대기: React의 스케줄러가 처리할 때까지 대기
setState(5) → 업데이트 객체 생성 → Fiber 큐에 추가 → 스케줄러 → 렌더링

3.3 Batching (일괄 처리)

React는 여러 상태 업데이트를 모아서 한 번만 렌더링합니다.

function handleClick() {
  setCount(count + 1);     // 큐에 추가
  setName('React');        // 큐에 추가
  setAge(25);              // 큐에 추가
  
  // 이벤트 핸들러가 끝난 후 → 단 1번만 렌더링!
}

Batching의 이점:

  1. 성능 최적화: 불필요한 렌더링 방지
  2. UI 일관성: "반쯤 완성된" 렌더링 방지
// Batching이 없다면?
setFirstName('John');   // 렌더링 1: John Doe (깜빡)
setLastName('Smith');   // 렌더링 2: John Smith

// Batching이 있으면
setFirstName('John');   // 큐에 추가
setLastName('Smith');   // 큐에 추가
// → 1번만 렌더링: John Smith

3.4 React 18의 Automatic Batching

React 17 이전:

  • ✅ 이벤트 핸들러 내부: batching O
  • ❌ Promise, setTimeout 내부: batching X (각각 렌더링)

React 18 이후:

  • ✅ 모든 경우에 자동 batching
// React 17: 2번 렌더링
setTimeout(() => {
  setCount(1);  // 렌더링 1
  setName('A'); // 렌더링 2
}, 1000);

// React 18: 1번 렌더링
setTimeout(() => {
  setCount(1);  // 큐에 추가
  setName('A'); // 큐에 추가
  // → 1번만 렌더링
}, 1000);

4. Batching과 Stale Closure 문제

4.1 문제 상황

const [count, setCount] = useState(0);

function handleClick() {
  setCount(count + 1);  // setCount(0 + 1)
  setCount(count + 1);  // setCount(0 + 1)
  setCount(count + 1);  // setCount(0 + 1)
}

// 기대: count = 3
// 실제: count = 1

왜 1일까?

setCount 호출이 동일한 count 값(0)을 참조하기 때문입니다. 모두 "1로 교체"를 큐에 추가하게 됩니다.

4.2 해결책: Updater Function (함수형 업데이트)

const [count, setCount] = useState(0);

function handleClick() {
  setCount(c => c + 1);  // 0 + 1 = 1
  setCount(c => c + 1);  // 1 + 1 = 2
  setCount(c => c + 1);  // 2 + 1 = 3
}

// 결과: count = 3 ✅

동작 원리:

함수를 전달하면 React는 큐의 이전 결과를 다음 함수에 전달합니다.

큐: [c => c + 1, c => c + 1, c => c + 1]

처리:
1. 0 → (c => c + 1) → 1
2. 1 → (c => c + 1) → 2
3. 2 → (c => c + 1) → 3

최종 결과: 3

4.3 언제 함수형 업데이트를 사용해야 하는가?

사용해야 하는 경우:

  • 이전 상태를 기반으로 새 상태를 계산할 때
  • 같은 상태를 여러 번 업데이트할 때
  • useEffect 의존성 배열에서 상태를 제거하고 싶을 때
// ❌ 나쁜 예: count를 의존성에 추가해야 함
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);
  return () => clearInterval(id);
}, [count]);

// ✅ 좋은 예: 의존성 배열이 비어있어도 됨
useEffect(() => {
  const id = setInterval(() => {
    setCount(c => c + 1);
  }, 1000);
  return () => clearInterval(id);
}, []);

5. 핵심 정리

useState의 내부 동작

  1. 저장 위치: Fiber 객체의 memoizedState (연결 리스트)
  2. 식별 방법: 호출 순서에 의존 (이름/ID 사용 안 함)
  3. Rules of Hooks: 최상위에서만, React 함수에서만 호출
  4. 비동기 업데이트: setState는 즉시 반영되지 않음
  5. Batching: 여러 업데이트를 모아서 1번만 렌더링
  6. 함수형 업데이트: 이전 상태를 정확히 참조하려면 함수 사용

주요 개념

// Fiber 구조
Fiber {
  memoizedState: Hook1 → Hook2 → Hook3 → null,
  updateQueue: [update1, update2, ...],
  ...
}

// Hook 구조
Hook {
  memoizedState: value,
  next: nextHook
}

// 업데이트 흐름
setState → Update Queue → Scheduler → Batching → Reconciliation → Render

베스트 프랙티스

  1. 항상 최상위에서 Hook 호출
  2. 함수형 업데이트 활용 (setState(prev => ...))
  3. ESLint 플러그인 사용으로 규칙 위반 자동 감지
  4. React 18 이상 사용으로 자동 batching 활용

참고 자료

profile
프론트엔드 개발자로 살아가기

0개의 댓글