React useState 업데이트 함수의 비밀: 최신 상태를 참조하는 마법 (디버깅으로 더 쉽게!)

React useState 업데이트 함수의 비밀: 최신 상태를 참조하는 마법 (디버깅으로 더 쉽게!)

React에서 가장 많이 쓰이는 훅 중 하나인 useState!

 

그 안에는 업데이트 함수라는 게 존재하는데요, 오늘은 이 업데이트 함수를 useState내부 작동 원리 관점에서 깊이 파헤쳐 보겠습니다.

 

특히, React 소스 코드 분석과 디버깅을 통해 좀 더 쉽고 확실하게 이해해 보도록 하죠!

 

들어가기 전에: useState 업데이트, 왜 다르게 동작할까?

문자열 타입의 state가 있다고 가정하고, 현재 문자열에 새로운 문자를 추가하는 함수를 만들어 보겠습니다.

const [inputString, setInputString] = useState("");

 

아래의 경우, 둘 다 같은 결과("A")가 나올 텐데요.

// 패턴 1
const handleChange = () => {
  setInputString(inputString + "A");
};

// 패턴 2
const handleChange = () => {
  setInputString((prev) => prev + "A");
};

 

그렇다면 아래의 경우는 어떨까요?

// inputString === "" 이라고 가정합니다
// 패턴 1
const handleChange = () => {
  setInputString(inputString + "A");
  setInputString(inputString + "B");
};

// 패턴 2
const handleChange = () => {
  setInputString((prev) => prev + "A");
  setInputString((prev) => prev + "B");
};

 

결과는 패턴 1이 "B", 패턴 2가 "AB"가 됩니다. 언뜻 보면 둘 다 같은 결과가 나올 것 같지만, 왜 다를까요?

 

이유는 setInputString으로 업데이트한 값이 실제 state에 반영되는 시점이 다음 렌더링 때이기 때문입니다.

 

따라서 패턴 1에서 참조하는 inputString의 상태는 handleChange의 처리가 모두 끝날 때까지 이전 상태를 유지합니다.

 

마치 스냅샷처럼요!

 

반면, 패턴 2에서 참조하는 prev항상 최신 상태를 참조할 수 있습니다. "현재" 상태를 참조하는 것이죠.

 

실제 동작을 보면 이해가 더 쉬운데요.

// 패턴 1
const handleChange = () => {
  setInputString(inputString + "A"); // setInputString("" + "A") => "A"를 큐에 저장
  setInputString(inputString + "B"); // setInputString("" + "B") => "B"를 큐에 저장, 이전 "A"는 무시됨
};

// 패턴 2
const handleChange = () => {
  setInputString((prev) => prev + "A"); // setInputString(("") => "" + "A") => "A"를 큐에 저장
  setInputString((prev) => prev + "B"); // setInputString(("A") => "A" + "B") => "AB"를 큐에 저장, 이전 "A"를 사용
};

 

여기까지는 React를 사용하시는 분들이라면 "당연한 거 아니야?"라고 생각하실 내용일 수 있습니다.

 

하지만, 왜 이렇게 동작하는지는 궁금하셨을 겁니다. 이제 그 궁금증을 React 소스 코드와 함께 풀어보겠습니다!

 

React 소스 코드로 알아보는 useState의 비밀

이제 setInputString을 실행했을 때 React 내부에서 어떤 일이 일어나는지, React 소스 코드를 직접 살펴보며 파헤쳐 보겠습니다.

 

주의: React 소스 코드는 복잡하고 변경될 수 있습니다. 이 설명은 이해를 돕기 위한 목적으로 단순화되었으며, 특정 버전(v18.2.0)을 기준으로 작성되었습니다.

 

1. useState 함수 호출

우리가 useState를 사용하는 코드에서 시작해 보겠습니다.

const [text, setText] = useState("");

 

이 코드는 React.js (혹은 react.development.js) 파일 안에 있는 useState 함수를 호출합니다. useState 함수는 내부적으로 resolveDispatcher()를 호출하여 현재 훅을 처리할 dispatcher를 가져옵니다.

// React.js (simplified)
function useState(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

2. resolveDispatcher()ReactCurrentDispatcher

resolveDispatcher()ReactCurrentDispatcher.current를 반환하는데, 이는 현재 어떤 렌더링 단계에 있는지에 따라 다른 dispatcher를 가리킵니다.

// ReactCurrentDispatcher.js (simplified)
const ReactCurrentDispatcher = {
  current: null,
};

3. dispatcher의 종류

React는 렌더링 단계에 따라 다른 dispatcher를 사용합니다.

  • Mount 시 (최초 렌더링): HooksDispatcherOnMount
  • Update 시 (재렌더링): HooksDispatcherOnUpdate

4. HooksDispatcherOnMount.useState (최초 렌더링)

컴포넌트가 처음 렌더링될 때는 HooksDispatcherOnMountuseState가 사용됩니다. 이 함수는 mountState를 호출합니다.

// ReactFiberHooks.js (simplified)
const HooksDispatcherOnMount = {
  // ...
  useState: mountState,
  // ...
};

function mountState(initialState) {
  const hook = mountWorkInProgressHook(); // 새로운 훅 객체 생성

  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  hook.memoizedState = hook.baseState = initialState;
  const queue = {
    pending: null,
    interleaved: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;

  const dispatch = (queue.dispatch = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ));
  return [hook.memoizedState, dispatch];
}

 

mountState에서는 다음과 같은 작업이 수행됩니다:

  1. mountWorkInProgressHook(): 새로운 훅 객체를 생성하고, memoizedState에 초기값을 저장합니다.
  2. queue 객체 생성: 업데이트를 관리하는 큐를 생성합니다. 이 큐는 나중에 setState가 호출될 때 사용됩니다.
  3. dispatch 함수 생성: dispatchSetState 함수를 바인딩하여 반환합니다. 이 dispatch 함수가 바로 우리가 setText로 사용하는 함수입니다.

 

5. HooksDispatcherOnUpdate.useState (재렌더링)

컴포넌트가 업데이트될 때는 HooksDispatcherOnUpdateuseState가 사용됩니다. 이 함수는 updateState를 호출합니다.

// ReactFiberHooks.js (simplified)
const HooksDispatcherOnUpdate = {
  // ...
  useState: updateState,
  // ...
};

function updateState() {
  return updateReducer(basicStateReducer);
}

 

updateStateupdateReducer를 호출하고, basicStateReducer를 인자로 전달합니다.

 

6. updateReducer: 업데이트 큐 처리의 핵심

updateReducer큐에 있는 업데이트들을 처리하여 새로운 상태를 계산하는 역할을 합니다.

// ReactFiberHooks.js (simplified)
function updateReducer(reducer, initialArg, init) {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;

  // ... 생략 (다른 큐 관련 처리) ...

  const pendingQueue = queue.pending;
  let first = pendingQueue !== null ? pendingQueue.next : null;

  let newState = hook.baseState;

  if (first !== null) {
    let update = first;
      do {
          const action = update.action;
          newState = reducer(newState, action);
        update = update.next;
      } while (update !== null && update !== first);
  }

  hook.memoizedState = newState;

  const dispatch = queue.dispatch;
  return [newState, dispatch];
}

 

updateReducer에서는 다음과 같은 작업이 수행됩니다:

  1. updateWorkInProgressHook(): 현재 작업 중인 훅 객체를 가져옵니다.
  2. queue.pending: 큐에 보류 중인 업데이트가 있는지 확인합니다.
  3. 반복문을 통해 업데이트 처리: 보류 중인 업데이트가 있으면 do...while 루프를 돌면서 reducer (여기서는 basicStateReducer)를 사용하여 newState를 계산합니다. 이 부분이 핵심입니다! newState이전 업데이트의 결과를 기반으로 계속해서 업데이트됩니다.
  4. hook.memoizedState 업데이트: 최종적으로 계산된 newStatehook.memoizedState에 저장합니다.
  5. [newState, dispatch] 반환: 업데이트된 상태와 dispatch 함수를 반환합니다.

 

7. basicStateReducer: 업데이트 적용

basicStateReducer는 실제로 상태를 업데이트하는 함수입니다.

// ReactFiberHooks.js (simplified)
function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(state) : action;
}

 

이 함수는 action이 함수이면 action(state)를 호출하고, 그렇지 않으면 action을 그대로 반환합니다.

 

즉, 우리가 setState에 함수를 전달하면 그 함수에 현재 상태를 인자로 전달하여 실행하고, 값을 전달하면 그 값을 새로운 상태로 사용합니다.

 

8. dispatchSetState: 업데이트를 큐에 추가하고 재렌더링 예약

dispatchSetState는 우리가 setText를 호출했을 때 실행되는 함수입니다. 이 함수는 새로운 업데이트 객체를 생성하여 큐에 추가하고, 재렌더링을 스케줄링합니다.

// ReactFiberHooks.js (simplified)
function dispatchSetState(fiber, queue, action) {
  // ... 생략 (업데이트 객체 생성 및 큐에 추가) ...

  const root = scheduleUpdateOnFiber(fiber, lane, eventTime);

  // ... 생략 (다른 처리) ...
}

 

dispatchSetState에서 중요한 부분은 scheduleUpdateOnFiber를 호출하여 재렌더링을 스케줄링하는 것입니다.

 

디버깅으로 업데이트 과정 확인하기

이제 React 내부에서 어떤 일이 일어나는지 알았으니, 디버깅을 통해 실제 동작을 확인해 보겠습니다.

 

아래와 같은 state와 "ABCDE"를 만들 수 있는 함수가 있다고 가정합니다.

const [text, setText] = React.useState("");

const handleChangeText = () => {
  debugger; // 디버깅을 위한 중단점
  setText(text + "A");
  setText((prev) => prev + "B");
  setText(text + "C");
  setText((prev) => prev + "D");
  setText((prev) => prev + "E");
};

 

handleChangeText 함수 안에 debugger;를 추가하여 중단점을 설정했습니다.

 

이제 브라우저 개발자 도구에서 코드를 한 단계씩 실행하며 updateReducer의 동작을 관찰할 수 있습니다.

 

updateReducer 함수 안에서 do...while 루프가 실행될 때, actionnewState의 변화를 추적하면 다음과 같은 결과를 확인할 수 있습니다.

// 1번째 반복
action:  "A"
prev: ""
new:  "A"

// 2번째 반복
action: (prev) => prev + 'B'
prev:  "A"
new:  "AB"

// 3번째 반복
action:  "C"
prev:  "AB"
new:  "C"

// 4번째 반복
action: (prev) => prev + 'D'
prev: "C"
new: "CD"

// 5번째 반복
action: (prev) => prev + 'E'
prev: "CD"
new: "CDE"

 

반복할 때마다 newState최신 값으로 업데이트하고, 업데이트 함수에서는 그 값을 사용하는 방식입니다.

 

최종적으로 newState는 "CDE"가 됩니다. 즉, 마지막 렌더링 시 text의 값은 "CDE"로 업데이트 됩니다.

 

결론

  • useState는 내부적으로 dispatcher를 통해 훅을 처리합니다.
  • 최초 렌더링 시에는 mountState가 호출되어 훅 객체와 큐를 생성하고, dispatch 함수를 반환합니다.
  • 재렌더링 시에는 updateState가 호출되고, updateReducer큐에 있는 업데이트들을 basicStateReducer를 사용하여 순차적으로 처리하여 새로운 상태를 계산합니다.
  • dispatchSetState (setText)는 새로운 업데이트를 큐에 추가하고 재렌더링을 스케줄링합니다.
  • 업데이트 함수는 updateReducer의 반복문 안에서 basicStateReducer에 의해 순차적으로 실행되기 때문에 항상 최신 상태를 참조할 수 있습니다.
  • React 소스 코드 분석디버깅을 통해 useState의 동작 원리를 더 명확하게 이해할 수 있습니다.

이제 useState 업데이트 함수의 비밀을 좀 더 명확하게 이해하셨기를 바랍니다.

 

React의 내부 동작을 이해하는 것은 더 나은 React 개발자가 되는 데 큰 도움이 될 것입니다.