web/React.js

useState 동작 원리

aphyrince 2025. 11. 9. 13:40
반응형

리액트의 useState 훅에 대해 궁금해. 함수 컴포넌트는 매번 함수를 실행해 렌더링을 수행하고, 그때마다 함수 내의 변수가 초기화 되잖아. useState가 반환하는 value와 setter 중에 setter는 클로져를 이용해서 global.states[index]의 값을 바꾸는 건 알겠는데, value는 call by value 식으로 반환하니까 항상 같은 값을 가져야 하는 거 아니야? useState가 혹시 렌더링때마다 새로 실행되나?


아주 정확한 질문이에요 — 이건 React의 렌더링과 훅의 내부 구조를 정확히 이해해야만 풀리는 부분이에요.
핵심 포인트는 다음 문장으로 요약됩니다:

useState()는 매 렌더링마다 새로 실행되지만, 내부적으로 React가 **훅의 상태 저장소(예: global state array)**를 유지하기 때문에 value는 "이전 렌더에서 저장된 값"을 꺼내오는 구조입니다.

하나씩 천천히 풀어볼게요 👇


💡 1. 함수 컴포넌트는 매 렌더링마다 새로 실행된다

맞아요. 함수 컴포넌트는 상태가 바뀌면 전체 함수가 다시 실행돼요:

function Counter() {
  const [count, setCount] = useState(0);
  console.log("렌더링됨");
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

버튼을 누를 때마다 Counter() 함수가 처음부터 끝까지 다시 실행됩니다.
즉, useState(0)도 매번 호출돼요.


⚙️ 2. 그런데 왜 count 값이 유지될까?

React는 내부적으로 “훅 상태 저장소”를 가지고 있어요.
아주 단순하게 표현하면 이렇게 동작합니다:

let hooks = [];
let index = 0;

function useState(initialValue) {
  const currentIndex = index;

  // 첫 렌더링이면 초기값 저장
  if (hooks[currentIndex] === undefined) {
    hooks[currentIndex] = initialValue;
  }

  const setState = (newValue) => {
    hooks[currentIndex] = newValue;
    render(); // 다시 렌더
  };

  const value = hooks[currentIndex];
  index++;

  return [value, setState];
}

➡️ 즉, value는 매 렌더마다 “hooks[currentIndex]”에서 가져오는 겁니다.
이 배열(hooks)은 React 내부에서 컴포넌트 인스턴스 단위로 유지돼요.
그래서 함수는 새로 실행되더라도, 그 컴포넌트에 대응하는 hook 값은 이전 렌더에서 유지된 상태를 그대로 참조합니다.


🔁 3. 그래서 useState는 “새로 실행되지만” 저장소에서 이전 값을 꺼내온다

  • 매 렌더마다 useState()는 실행된다.
  • 하지만 React는 useState의 호출 순서와 인덱스를 기억해서
    이전 렌더의 value를 그대로 돌려준다.
  • 그러니까 value는 “call by value”로 복사되는 게 아니라,
    React가 내부적으로 “같은 위치의 상태”를 참조해서 꺼내주는 거예요.

📦 4. setter는 클로저를 이용해 그 인덱스를 기억한다

당신이 정확히 짚은 부분이에요 👍
setState는 클로저로 index를 기억하기 때문에,
이후에 setState가 호출되면 React 내부 저장소의 정확한 위치의 값만 업데이트하고 리렌더를 트리거합니다.


⚖️ 정리하면

항목 동작 방식

useState() 매 렌더마다 새로 호출됨
value React 내부의 훅 저장소에서 꺼내오는 값
setValue() 클로저로 index를 기억하여 저장소의 해당 위치값 변경
React 내부 저장소 렌더 사이에도 유지되는 구조 (컴포넌트 인스턴스별로)

🔍 비유로 보자면

  • 함수 컴포넌트는 "스크립트"를 매번 처음부터 다시 읽는 배우처럼 행동하지만,
  • useState는 그 배우에게 **“기억노트”**를 줘서 지난번 연기의 상태를 다시 참고할 수 있게 해주는 역할이에요.

혹시 원하신다면 실제 React가 내부적으로 어떻게 Fiber 구조에 memoizedState를 저장하는지도 코드 수준에서 보여드릴까요? (리액트 v18 소스 기반으로)


useState()가 계속 실행되는 거면, index도 계속 늘어나는 거 아니야? 새롭게 실행된 useState는 비어있는 마지막 index값만 접근할 수 있는 거잖아


아주 예리한 지적이에요 👏
이게 바로 “React가 렌더링마다 index를 초기화하는 이유”이자,
훅이 “항상 같은 순서로 호출되어야 하는 이유”이기도 합니다.

하나씩 구조적으로 설명드릴게요.


🔁 1. useState()는 렌더마다 새로 호출된다

그렇죠.
함수 컴포넌트가 다시 실행될 때, 내부의 useState()들도 다시 호출됩니다.

그럼 이런 코드가 있다고 해봅시다 👇

function Counter() {
  const [a, setA] = useState(0);  // ①
  const [b, setB] = useState(0);  // ②
  ...
}

React 내부적으로는 이렇게 저장돼요 (단순화 버전):

let hooks = []; // 상태 저장소
let index = 0;

function render() {
  index = 0;  // 👈 매 렌더링마다 0으로 초기화
  Counter();
}

function useState(initialValue) {
  const currentIndex = index;
  if (hooks[currentIndex] === undefined) {
    hooks[currentIndex] = initialValue;
  }
  const value = hooks[currentIndex];
  const setValue = (newValue) => {
    hooks[currentIndex] = newValue;
    render();
  };
  index++; // 다음 훅을 위해 증가
  return [value, setValue];
}

⚙️ 2. React는 렌더 시작 시 index를 0으로 리셋한다

그래서 useState()가 새로 실행되더라도,
매 렌더링이 시작할 때마다 index가 0으로 돌아갑니다.

  • 첫 번째 useState() → hooks[0]
  • 두 번째 useState() → hooks[1]
  • 세 번째 useState() → hooks[2]
  • ...

이 순서로 항상 같은 인덱스를 접근하기 때문에,
이전 렌더의 값과 정확히 대응시킬 수 있는 거예요.


🚫 3. 그래서 훅 호출 순서가 바뀌면 React가 오류를 낸다

만약 이런 코드가 있으면?

function Example({ flag }) {
  if (flag) {
    const [a, setA] = useState(0); // 조건문 안에 useState ❌
  }
  const [b, setB] = useState(0);
}

이러면 어떤 렌더에서는 a가 있고, 어떤 렌더에서는 a가 없게 됩니다.
즉, index 순서가 꼬여서 React의 내부 훅 배열과 맞지 않게 돼요.

React가 “Hooks can only be called in the same order” 에러를 던지는 이유가 바로 이것입니다.


🔍 4. 정리하면

단계 설명

함수 컴포넌트 재렌더 함수 전체가 다시 실행됨
useState() 호출 매번 새로 호출됨
React 내부 렌더 시작 시 hookIndex = 0으로 초기화
호출될 때마다 hookIndex를 1씩 증가시켜 hooks[hookIndex] 참조
결과 이전 렌더의 값과 1:1로 정확히 매칭됨

💡 요약

useState()는 매번 새로 실행되지만, React가 렌더링 시작 시 hook 인덱스를 0으로 리셋하고, 각 호출의 순서를 정확히 유지하기 때문에
항상 동일한 state 슬롯을 참조할 수 있습니다.


원하신다면 이 메커니즘이 실제 React Fiber 내부에서
workInProgress.memoizedState로 구현된 코드 구조도 예시로 보여드릴까요?
(리액트 v18의 ReactFiberHooks.js 일부를 기반으로 설명드릴 수 있습니다.)


useCallback이나 useMemo 등의 최적화용 훅에 의해 useState가 실행되지 않을 수도 있잖아. 이러면 useState의 실행순서가 바뀌는데 뭐야?


정말 핵심을 찌르셨어요 👏👏
이 질문은 “훅의 실행 순서 보장 원리”와 “조건적 실행 방지 메커니즘”을 완전히 이해해야만 풀립니다.
한마디로 정리하자면 —

useCallback / useMemo는 내부적으로 최적화(메모이제이션)를 하더라도, 그 안에 있는 훅 호출 자체는 ‘렌더링 때마다 동일한 순서로’ 반드시 실행됩니다.

즉, useState의 순서가 절대로 바뀌지 않게 React가 설계되어 있습니다.


🚫 전제: 훅은 “조건문/조기 반환” 등으로 호출 순서가 달라지면 안 된다

React가 훅 호출 순서를 추적하는 방식은 단순합니다:

hookIndex = 0;
const [a] = useState(...); // hooks[0]
const [b] = useState(...); // hooks[1]
useEffect(...);            // hooks[2]

→ 그래서 훅이 **“언제 실행되는가”가 아니라 “몇 번째로 실행되는가”**가 중요해요.

즉,

  • 어떤 훅이 이전 렌더엔 실행됐는데 이번엔 안 된다면 순서가 꼬입니다.
  • 따라서 React는 훅이 항상 같은 순서, 같은 깊이에서 호출되도록 강제합니다.

⚙️ useMemo나 useCallback의 내부 구조는 다르다

겉보기엔 useMemo가 "메모이제이션" 때문에 내부 실행을 생략할 수도 있을 것 같죠?
하지만 실제로는 그렇지 않습니다.

React의 내부를 단순화하면 다음과 같아요:

function useMemo(factory, deps) {
  const [lastDeps, lastValue] = getHookState(); // 내부 훅 상태
  if (!lastDeps || !areDepsSame(lastDeps, deps)) {
    const nextValue = factory();
    setHookState([deps, nextValue]);
    return nextValue;
  }
  return lastValue;
}

👉 핵심 포인트

  • useMemo 자체는 항상 호출됩니다.
  • 단지 내부의 factory() 함수 실행을 “조건적으로 건너뛸” 뿐이에요.
  • 즉, useMemo가 “렌더링 중 실행되지 않는다”는 건 메모이제이션된 값의 재계산만 건너뛰는 것이지,
    훅 호출 자체를 건너뛰는 건 아닙니다.

✅ 그래서 이런 코드는 안전합니다

function Component({ items }) {
  const [count, setCount] = useState(0);
  const memoizedList = useMemo(() => {
    return items.map(item => <Item key={item.id} />);
  }, [items]);

  return <div>{count}</div>;
}
  • useState → index 0
  • useMemo → index 1
  • 이 순서는 렌더마다 항상 유지됩니다.
  • useMemo 내부의 factory() 실행은 deps가 안 바뀌면 생략되지만,
    useMemo() 훅 자체는 호출되므로 인덱스는 바뀌지 않습니다.

❌ 반대로 이런 건 위험합니다

function Component({ flag }) {
  if (flag) {
    useState(0); // flag가 false면 호출 안 됨 ❌
  }
  useEffect(() => {}, []); // 호출 순서 꼬임
}

이 경우엔 실제 훅 호출 수 자체가 달라지므로,
React는 Hook order mismatch 오류를 냅니다.


🧩 요약 정리

구분 훅 호출 여부 내부 동작

useState 항상 실행됨 상태 저장/반환
useEffect 항상 실행됨 deps 비교 후 실행 예약
useMemo 항상 실행됨 deps 같으면 factory() 생략
useCallback 항상 실행됨 deps 같으면 기존 함수 반환
❌ if, for, return으로 훅 건너뛰기 실행 순서가 깨져 오류 발생  

즉 👇

React의 모든 훅(useState, useMemo, useCallback, useEffect 등)은 “렌더링 중 반드시 호출”되며, 실행 순서를 절대로 건너뛰지 않습니다.

단지 그 내부 로직(factory() 실행, 이펙트 등록 등)만 의존성에 따라 최적화될 뿐이에요.


원하신다면 실제로 React 18의 ReactFiberHooks.js 내부에서 useMemo가 mountMemo / updateMemo로 분기되는 부분의 코드를 인용해서, “실행은 항상 되지만 factory만 생략되는 구조”를 보여드릴까요?


리액트는 useMemo나 useCallback 에 준 콜백함수 내에 훅이 있는지 없는지 어떻게 알아? 구문 분석이라도 하는건가?


정말 좋은 질문이에요. 이건 React 내부의 훅 시스템이 **“런타임에 훅 호출을 추적하는 방식”**과 관련된 아주 깊은 부분입니다.
요약부터 말씀드리면 —

❌ React는 useMemo나 useCallback에 준 콜백 함수의 내부를 구문 분석하지 않습니다.

✅ 대신 React는 **훅이 호출되는 시점(run time)**을 추적해서, “현재 실행 중인 컴포넌트의 훅 호출 순서”를 기억하고 비교합니다.


⚙️ React는 “훅 호출을 감시”하지 “코드를 분석”하지 않는다

React는 코드를 AST로 파싱하거나, 콜백 함수의 내용을 검사하지 않습니다.
그건 너무 비효율적이고, 자바스크립트 런타임 환경에서는 불가능에 가깝습니다.

대신 React는 렌더링 중에 다음과 같은 런타임 상태를 유지합니다:

let currentlyRenderingFiber = null;
let workInProgressHook = null;
  • currentlyRenderingFiber → 지금 렌더 중인 컴포넌트를 가리키는 객체 (Fiber)
  • workInProgressHook → 현재 실행 중인 훅의 연결 리스트 노드

그리고 useState, useMemo, useEffect 같은 훅이 실행될 때마다 React가 이 정보를 업데이트해요.


🔍 즉, React는 “훅을 호출하는 순간”만 추적한다

예를 들어 이런 코드가 있다고 해봅시다:

function MyComponent() {
  const [count] = useState(0);

  const memo = useMemo(() => {
    useEffect(() => {}, []); // ❌ 여기에 훅이 있으면 에러
    return count * 2;
  }, []);

  return <div>{memo}</div>;
}

이때 React는 useMemo의 콜백 함수를 즉시 실행하지 않습니다.
(의존성이 바뀌었을 때만 실행하죠.)

그런데 만약 useMemo가 이 콜백을 렌더링 중 실행하려 했다면, 내부에서 다시 useEffect()를 호출하는 순간 React의 훅 추적기가 작동해서 이렇게 판단합니다:

“지금 훅을 호출했는데, 현재 실행 중인 컴포넌트가 아닌 useMemo 콜백 안이네?
훅은 컴포넌트 최상위나 커스텀 훅 안에서만 호출되어야 해!”

그래서 이 경우 React는 “Invalid hook call” 에러를 던집니다.
즉, React는 콜백 안에 훅이 정적으로 들어있는지를 아는 게 아니라,
실제로 호출됐는지를 런타임에서 감지하는 겁니다.


📦 React 내부 구조 (간단한 버전)

React 18의 내부 훅 시스템은 대략 이렇게 동작합니다 (의사 코드):

function useState(initialState) {
  // 1️⃣ 현재 렌더링 중인 컴포넌트를 확인
  const fiber = currentlyRenderingFiber;
  if (fiber === null) throw new Error("Invalid hook call");

  // 2️⃣ 훅 노드(연결 리스트) 생성
  const hook = { memoizedState: initialState, next: null };

  // 3️⃣ 훅을 현재 Fiber의 훅 리스트에 연결
  if (workInProgressHook === null) {
    fiber.memoizedState = hook;
  } else {
    workInProgressHook.next = hook;
  }
  workInProgressHook = hook;

  // 4️⃣ 값과 setter 반환
  const setState = (newState) => {
    hook.memoizedState = newState;
    scheduleUpdate(fiber);
  };
  return [hook.memoizedState, setState];
}

즉:

  • 훅이 호출될 때마다 React는 “현재 어떤 컴포넌트의 훅이 실행 중인지”를 알고 있습니다.
  • 만약 그 시점이 컴포넌트 함수나 커스텀 훅 내부가 아니라면, 에러를 던집니다.
  • useMemo, useCallback 내부 콜백은 렌더 시점이 아니라 별도로 실행되므로, 그 안에서 훅을 호출하면 “현재 렌더링 중인 컴포넌트 없음” 상태가 되어 바로 에러가 납니다.

🚫 그래서 이런 코드가 금지됩니다

useMemo(() => {
  useState(0); // ❌
  return 1;
}, []);

→ React는 이 시점에 “현재 렌더링 중인 Fiber 없음”을 감지하고
Invalid Hook Call 에러를 발생시킵니다.


✅ 반면 이런 건 OK

function useCustomHook() {
  const [value, setValue] = useState(0); // ✅ 커스텀 훅 내부 OK
  return [value, setValue];
}

function MyComponent() {
  const [count, setCount] = useState(0); // ✅ 컴포넌트 최상위 OK
  const memo = useMemo(() => count * 2, [count]); // ✅ 콜백 안에서는 훅 X
  return <div>{memo}</div>;
}

🧠 요약

질문 답변

React는 useMemo 콜백 내부의 훅을 “분석”하나요? ❌ 아니요. 구문 분석(AST) 같은 건 하지 않습니다.
그럼 React는 어떻게 훅 호출 위치를 감지하나요? ✅ 렌더링 중 “훅 호출 시점”을 추적해서, 현재 어떤 컴포넌트에서 실행 중인지 확인합니다.
useMemo 콜백 안에서 훅을 쓰면 왜 에러가 나나요? ✅ 그 시점에 currentlyRenderingFiber가 null이라 “컴포넌트 외부 훅 호출”로 판단되기 때문입니다.

 

반응형