안녕하세요! 프론트엔드 개발자를 꿈꾸시는 분들께 도움이 될 만한 내용을 준비했습니다. 오늘은 React의 가장 기본적인 훅인 useState의 내부 동작 원리에 대해 깊이 있게 살펴보겠습니다. React 18.2.0 버전을 기준으로 작성되었으며, 최신 버전에서는 일부 구현이 변경되었을 수 있습니다.
가장 기본적인 카운터 앱을 통해 useState의 사용법을 살펴보겠습니다
import { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count => count + 1)}>
click {count}
</button>
</div>
);
}
위 코드에서 useState(0)은 초기값이 0인 상태 변수를 생성하고, count와 이를 업데이트할 수 있는 함수 setCount를 반환합니다. 이제 이 간단한 인터페이스 뒤에서 어떤 일이 일어나는지 살펴보겠습니다.
컴포넌트가 처음 렌더링될 때, useState는 내부적으로 mountState 함수를 호출합니다
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function') {
initialState = initialState();
}
hook.memoizedState = hook.baseState = initialState;
const queue: UpdateQueue<S, BasicStateAction<S>> = {
pending: null,
lanes: NoLanes,
dispatch: null,
lastRenderedReducer: basicStateReducer,
lastRenderedState: (initialState: any),
};
hook.queue = queue;
const dispatch: Dispatch<BasicStateAction<S>> =
(queue.dispatch = (dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue,
): any));
return [hook.memoizedState, dispatch];
}
여기서 주요 단계는 다음과 같습니다
mountWorkInProgressHook()을 통해 새로운 훅을 생성합니다.hook.memoizedState와 hook.baseState에 초기 상태값을 저장합니다.dispatch 함수(setState)를 설정합니다. 이는 dispatchSetState 함수를 현재 렌더링 중인 Fiber와 바인딩한 것입니다.[hook.memoizedState, dispatch]를 반환합니다.중요한 점은 useState가 반환하는 setter 함수(setCount)는 사실 dispatchSetState 함수를 현재 Fiber 노드와 바인딩한 것이라는 점입니다.
위에서 살펴본 바와 같이, setState는 내부적으로 바인딩된 dispatchSetState 함수입니다. 이 함수가 호출될 때 어떤 일이 발생하는지 살펴보겠습니다
function dispatchSetState<S, A>(
fiber: Fiber,
queue: UpdateQueue<S, A>,
action: A,
) {
const lane = requestUpdateLane(fiber);
const update: Update<S, A> = {
lane,
action,
hasEagerState: false,
eagerState: null,
next: (null: any),
};
if (isRenderPhaseUpdate(fiber)) {
enqueueRenderPhaseUpdate(queue, update);
} else {
const alternate = fiber.alternate;
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// 빠른 경로 최적화 시도
const lastRenderedReducer = queue.lastRenderedReducer;
if (lastRenderedReducer !== null) {
try {
const currentState: S = (queue.lastRenderedState: any);
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// 같은 상태값이면 리렌더링을 스케줄링하지 않음
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
return;
}
} catch (error) {
// 오류 억제
}
}
}
const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
if (root !== null) {
const eventTime = requestEventTime();
scheduleUpdateOnFiber(root, fiber, lane, eventTime);
entangleTransitionUpdate(root, queue, lane);
}
}
}
주요 단계는 다음과 같습니다
enqueueConcurrentHookUpdate).scheduleUpdateOnFiber).중요한 점은 setState를 호출해도 상태 값이 즉시 업데이트되지 않는다는 것입니다. 대신, 업데이트는 큐에 추가되고 React 스케줄러에 의해 리렌더링이 스케줄링됩니다. 실제 상태 업데이트는 다음 렌더링 때 발생합니다.
리렌더링이 발생하면, 이전에 큐에 추가된 업데이트를 처리하고 새로운 상태 값을 계산합니다. 이 과정은 updateState 함수에서 이루어집니다
function updateState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
return updateReducer(basicStateReducer, (initialState: any));
}
function updateReducer<S, I, A>(
reducer: (S, A) => S,
initialArg: I,
init?: I => S,
): [S, Dispatch<A>] {
const hook = updateWorkInProgressHook();
const queue = hook.queue;
if (queue === null) {
throw new Error(
'Should have a queue. This is likely a bug in React. Please file an issue.',
);
}
queue.lastRenderedReducer = reducer;
const current: Hook = (currentHook: any);
let baseQueue = current.baseQueue;
const pendingQueue = queue.pending;
if (pendingQueue !== null) {
// 새로운 업데이트가 있으면 baseQueue에 병합
if (baseQueue !== null) {
const baseFirst = baseQueue.next;
const pendingFirst = pendingQueue.next;
baseQueue.next = pendingFirst;
pendingQueue.next = baseFirst;
}
current.baseQueue = baseQueue = pendingQueue;
queue.pending = null;
}
if (baseQueue !== null) {
// 처리할 큐가 있음
const first = baseQueue.next;
let newState = current.baseState;
let newBaseState = null;
let newBaseQueueFirst = null;
let newBaseQueueLast = null;
let update = first;
do {
// 업데이트 처리 로직
const shouldSkipUpdate = isHiddenUpdate
? !isSubsetOfLanes(getWorkInProgressRootRenderLanes(), updateLane)
: !isSubsetOfLanes(renderLanes, updateLane);
if (shouldSkipUpdate) {
// 우선순위가 충분하지 않으면 이 업데이트를 건너뜀
// 새 baseQueue에 추가
// ... 생략 ...
} else {
// 이 업데이트는 충분한 우선순위를 가짐
if (newBaseQueueLast !== null) {
// ... 생략 ...
}
// 업데이트 처리
if (update.hasEagerState) {
newState = ((update.eagerState: any): S);
} else {
const action = update.action;
newState = reducer(newState, action);
}
}
update = update.next;
} while (update !== null && update !== first);
if (newBaseQueueLast === null) {
newBaseState = newState;
} else {
newBaseQueueLast.next = (newBaseQueueFirst: any);
}
// 새 상태가 현재 상태와 다른 경우에만 작업 표시
if (!is(newState, hook.memoizedState)) {
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = newState;
hook.baseState = newBaseState;
hook.baseQueue = newBaseQueueLast;
queue.lastRenderedState = newState;
}
if (baseQueue === null) {
queue.lanes = NoLanes;
}
const dispatch: Dispatch<A> = (queue.dispatch: any);
return [hook.memoizedState, dispatch];
}
주요 단계는 다음과 같습니다
updateWorkInProgressHook()을 호출하여 이전에 생성된 훅을 가져옵니다.이 과정에서 주목할 점은 실제 상태 업데이트가 setState 호출 시점이 아닌 다음 렌더링 과정에서 이루어진다는 것입니다.
setState 함수는 다음 렌더링을 위해 상태 변수만 업데이트합니다. setState 함수를 호출한 후 상태 변수를 읽으면, 화면에 표시되기 전의 이전 값을 얻게 됩니다.
이는 쉽게 이해할 수 있습니다. 우리가 이미 살펴본 것처럼, setState는 다음 틱에 리렌더링을 스케줄링하고, 상태 업데이트는 실제로 useState에서 이루어지지 setState에서 이루어지지 않기 때문입니다.
React.dev에 명시된 대로, 새 값이 Object.is 비교에 의해 결정된 현재 상태와 동일한 경우, React는 컴포넌트와 그 자식 컴포넌트의 리렌더링을 건너뜁니다. 그러나 일부 상황에서는 React가 여전히 리렌더링을 발생시킬 수 있습니다.
이 동작을 이해하기 위해서는 dispatchSetState 내부의 조기 최적화(early bailout) 조건을 살펴봐야 합니다:
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// 빠른 경로 최적화 로직
}
이상적으로는 훅의 대기 중인 업데이트 큐와 baseQueue가 비어 있는지 확인하는 것이 가장 좋지만, 현재 구현에서는 실제로 리렌더링을 시작하기 전까지 이를 알 수 없습니다. 따라서 간단한 검사로 fiber 노드에 업데이트가 없는지 확인합니다.
그러나 부작용이 있습니다. enqueueUpdate 함수에서 업데이트를 큐에 추가할 때, 현재 fiber와 alternate fiber 모두 dirty로 표시됩니다
fiber.lanes = mergeLanes(fiber.lanes, lane);
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.lanes = mergeLanes(alternate.lanes, lane);
}
그리고 lanes 클리어링은 실제 리렌더링인 beginWork()에서만 발생합니다
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
// ...
workInProgress.lanes = NoLanes;
// ...
}
이로 인해 업데이트가 스케줄링되면, dirty lanes 플래그의 완전한 클리어링은 적어도 2번의 리렌더링 후에만 이루어집니다. 이는 첫 번째 업데이트 후에도 여전히 어떤 fiber가 dirty로 표시되어 있어서, 같은 값으로 두 번째 setState를 호출해도 리렌더링이 발생할 수 있다는 것을 의미합니다.
React는 상태 업데이트를 일괄 처리합니다. 모든 이벤트 핸들러가 실행되고 set 함수를 호출한 후에 화면을 업데이트합니다. 이는 단일 이벤트 동안 여러 번의 리렌더링을 방지합니다. 화면을 더 일찍 업데이트해야 하는 경우(예: DOM에 접근해야 하는 경우), flushSync를 사용할 수 있습니다.
이전 슬라이드에서 설명한 것처럼, 업데이트는 실제로 처리되기 전에 임시 저장되고, 나중에 함께 처리됩니다.
React의 useState 훅 내부 동작에 대해 자세히 살펴보았습니다. 핵심 내용을 정리하면
초기 렌더링(마운트):
setState 호출 시:
리렌더링 시:
주의해야 할 특성:
이러한 내부 동작을 이해하면 React 컴포넌트를 더 효과적으로 작성하고 최적화할 수 있습니다. 특히 상태 업데이트의 비동기적 특성과 배치 처리 메커니즘을 이해하는 것이 중요합니다.
React 개발에 더 깊이 몰입하고 싶다면, 이러한 내부 메커니즘을 이해하는 것이 큰 도움이 될 것입니다. 이를 통해 예상치 못한 동작을 디버깅하고 성능을 최적화하는 데 더 나은 접근 방식을 가질 수 있습니다.