[글또] 상태가 변화하는 과정을 Deep하게 공부해보기
배경
우리가 React를 사용하면서 제일 중요한 것이 데이터의 변화, 즉 상태를 관리하는 것이다.
이러한 상태를 잘 관리하기 위해 React에서 상태의 변화를 먼저 알아보는 시간을 가졌다.
초기 트리거 단계
상태 선언
useState를 이용해 상태를 생성 (useReducer, useState 등 다양한 방법으로 상태를 생성할 수 있다.)
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(1);
setCount((prev) => prev + 1);
setTimeout(() => {
setCount((prev) => prev + 1);
}, 1000);
};
}
- 초기 렌더링: HooksDispatcherOnMount.useState (mountState)
- 업데이트 렌더링: HooksDispatcherOnUpdate.useState (updateState)
초기 렌더링에는 ReactSharedInternals.H가 아닌 ReactCurrentDispatcher.current를 통해 적절한 디스패처에 접근한다.
이 디스패처는 renderWithHooks 함수 내에서 컴포넌트의 렌더링 단계에 따라 적절히 설정된다.
업데이트 객체 생성
React는 업데이트가 발생할 때마다 Update 객체를 생성하는데, 실제 생성은 아래처럼 된다.
const update = {
lane: SyncLane,
tag: UpdateState, // 업데이트 타입으로 해당 에시에서는 state
payload: newState, // 새로운 상태 또는 상태를 계산하는 함수
next: null, // 다음 업데이트에 대한 참조
eventTime: getCurrentTime(), // 업데이트 우선순위
};
업데이트 큐에 추가
// 업데이트 큐의 구조
interface UpdateQueue<State> {
baseState: State; // 기본 상태 값 - 마지막으로 커밋된 상태
firstBaseUpdate: Update<State> | null; // 업데이트 연결 리스트의 첫 번째 업데이트
lastBaseUpdate: Update<State> | null; // 업데이트 연결 리스트의 마지막 업데이트
shared: {
pending: Update<State> | null; // 아직 처리되지 않은 대기 중인 업데이트
};
effects: Array<Effect> | null; // 사이드 이펙트 목록
}
상태 업데이트는 단순히 발생하는 대로 처리되지 않고, 큐를 통해 체계적으로 관리된다.
이때 큐 처리는 초기화를 우선으로 하고, 업데이트를 연결하게 된다.
업데이트 연결을 할 때 첫 업데이트/기존 업데이트 상황에 따라 연결 리스트의 구조가 다르다.
function enqueueUpdate(fiber, queue, update) {
const pending = queue.shared.pending;
if (pending === null) {
update.next = update;
} else {
update.next = pending.next;
pending.next = update;
}
queue.shared.pet nding = update;
}
- 각 상태마다 독립적인 업데이트 큐 유지한다.
- 업데이트의 순서와 우선순위 보장
- 일괄 처리(batching)를 통한 성능 최적화
React는 성능 최적화를 위해 가능한 경우 업데이트를 즉시 처리하려 시도한다.
if (
fiber.lanes === NoLanes &&
(alternate === null || alternate.lanes === NoLanes)
) {
// 현재 진행 중인 다른 업데이트가 없다면
const lastRenderedReducer = queue.lastRenderedReducer;
const currentState = queue.lastRenderedState;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (Object.is(eagerState, currentState)) {
// 상태가 실제로 변경되지 않았다면 업데이트 취소
return false;
}
}
다른 진행 중인 업데이트를 확인하고 즉시 상태 계산 가능 여부 판단한다.
만약 불필요한 업데이트이거나, 상태가 변경되지 않았다면 조기 종료, 업데이트 취소한다.
업데이트에서 중요한 특징
update 객체의 이점
- 모든 상태 업데이트의 추적이 가능하고, 필요한 경우 변경을 취소하거나 재시도 가능하다
- update객체의
action - 의도된 변경사항
,next - 다음 업데이트와 연결
- update객체의
- 중요한 업데이트를 우선적으로 처리하고, 덜 중요한 업데이트는 나중에 업데이트 하도록 지연시킨다
- update객체의
lane - 우선순위 정보
,revertLane - 복구 정보
- update객체의
- 가능한 경우 상태를 미리 계산하고 불필요한 재 랜더링을 방지해 최적화와 일관성을 유지한다
- update객체의
hasEagerState - 즉시 처리 가능 여부
,eagerState - 미리 계산된 상태
- update객체의
- 모든 상태 업데이트의 추적이 가능하고, 필요한 경우 변경을 취소하거나 재시도 가능하다
배치 처리 (Batching) : 여러 상태 업데이트를 하나의 리렌더링으로 그룹화하는 프로세스
- 불필요한 렌더링을 최소화하고, 메모리 할당을 감소 등의 장점이 있다
- React 18에서는 모든 업데이트가 자동으로 배치 처리된다.
트랜잭션 처리 : 여러 업데이트를 하나의 원자적 단위로 처리하여 일관성을 보장하는 것
- 원자성, 일관성, 격리성이 장점이다
renderWithHooks
는 컴포넌트가 처음 렌더링되는지(HooksDispatcherOnMount) 또는 업데이트 중인지(HooksDispatcherOnUpdate)에 따라 적절한 훅 디스패처를ReactCurrentDispatcher.current
에 설정된다.- 이에 따라 React는 디스패처를 선택적으로 사용해 훅의 동작 방식을 결정하는 것을 알 수 있다.
current === null || current.memoizedState === null
일 경우, 초기 렌더링에 사용할 디스패처를 설정 이후 Component(props)가 실행되면서 컴포넌트 내부의 useState가 호출된다.
React Fiber hooks 살펴보자
function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook(); // 새로운 훅을 생성하여 링크드 리스트 형태로 관리되는 훅 리스트에 추가하고, 첫 훅일 경우 현재 Fiber 노드의 memoizedState에 연결
if (typeof initialState === 'function') {
// 함수형 초기값이 주어졌을 경우 계산하여 initialState에 저장 (게으른 초기화)
const initialStateInitializer = initialState;
initialState = initialStateInitializer();
}
hook.memoizedState = hook.baseState = initialState; // memoizedState와 baseState에 초기 상태를 설정
const queue: UpdateQueue<S, BasicStateAction<S>> = {
// 상태 업데이트를 관리할 queue 객체를 생성하고 설정
pending: null, // 상태 변경 요청이 대기 중인 리스트. null -> 대기 중인 상태 업데이트가 없다
lanes: NoLanes, // 우선순위 정보. 초기에는 NoLanes 로 설정됨
dispatch: null,
lastRenderedReducer: basicStateReducer, // 상태 업데이트 시 기존 상태와 업데이트 상태를 비교하고 처리하는 데 사용될 준비값
lastRenderedState: initialState,
};
/*
상태 변경을 위한 dispatch 함수(setState 역할)를 생성하여 반환
현재 Fiber와 연결된 상태에서 상태 업데이트가 발생할 수 있도록 준비
setState를 호출할 때 이 dispatch 함수가 실행되어 queue에 업데이트 요청이 추가될 것임
*/
const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
null,
currentlyRenderingFiber,
queue,
): any);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];
}
스케줄링 단계
우선순위 지정 (Lanes 시스템)
여기서 Lane은 비트 마스크를 사용하여 우선순위를 표현하는 방식, 각 비트는 특정 우선순위 레벨을 나타낸다.
우선 순위는 오른쪽으로 갈수록 낮아진다.
SyncLane(동기업데이트) → InputContinuousLane(사용자 입력과 관련) → DefaultLane(일반적인 상태 업데이트) → TransitionLane(UI 전환 효과, 지연효과) → IdleLane(백그라운드 작업)
업데이트 배치 처리
배치 처리 메커니즘과 스케줄러의 긴밀한 상호작용을 기반으로 React의 성능과 사용자 경험을 최적화한다.
배치 처리 메커니즘
- 여러 상태 업데이트를 그룹화하여 객체 생성하고 우선순위를 할당한다. 이 후 업데이트 큐에 순차적으로 추가된다.
- 배치 컨텍스트로 경계 설정하고, 비동기/동기 컨텍스트 구분하며 트랙잭션 단위를 정의한다.
스케줄러 작동 방식
- 작업 스케줄링은 우선 순위 기반으로 큐 관리, 리소스 사용 최적화를 위해 실행 시점에 결정된다.
- 실행컨텍스트를 통해 브라우저 이벤트 루프와 통합된다.
불필요한 리렌더링 방지
메모리제이션 활용(useMemo, useCallback)과 선택적 렌더링(필요한 props만 전달해 불필요한 객체 생성 방지)을 기반으로 불필요한 리렌더링을 방지한다.
재조정(Reconciliation) 단계
상태 변화 → UI 변화로 변환 과정이다.
변경된 부분만 효율적으로 변화하는 것이 중점이다.
새로운 상태 계산
React는 이전 상태와 들어온 업데이트들을 보고, 최종적으로 어떤 상태가 되어야 하는지 파악한 후 순서대로 처리해 최종값이 정상적으로 나오도록 고려한다.
Virtual DOM 비교
현재 화면에 보이는 내용을 나타내는 Virtual DOM과 새로운 상태 기반으로 만들어진 새로운 Virtual DOM을 비교한다.
React는 Diff 알고리즘을 기반으로 바뀐 부분만 찾아 내서 변경한다. 그렇기에 ('key' prop) 이 중요하다.
필요한 업데이트 결정
비교가 끝나면 React는 실제로 어떤 부분을 업데이트해야 할지 결정
// 1. 직접 값 설정 - 즉시 계산 가능
setState(5);
// 2. 함수형 업데이트 - 이전 상태 필요
setState((prev) => prev + 1);
텍스트만 변경되었다면 해당 텍스트만 업데이트한다.
하지만 요소의 속성만 변경되었다면 속성만 업데이트하고, 요소가 완전히 변경되었다면 해당 부분을 새로 생성한다.
Fiber 트리 구성
Fiber 아키텍쳐에 대해서는 2주차_Fiber 아키텍쳐를 확인해보면 좋겠다.
워낙 양이 방대하기에 Fiber트리에 대한 자료를 참고해도 좋다.
커밋(Commit) 단계
변경사항을 실제 DOM에 안전하게 적용 및 부수 효과 관리한다. 그리고 메모리를 효율적으로 관리하는 단계도 커밋 단계에서 이루어진다.
동기적 실행, 부수 효과 관리, 성능 최적화가 중심이기때문에 성능과 안정성에 직접적인 영향을 끼친다.
실제 DOM 업데이트
삭제될 노드를 처리 → 새로운 노드를 마운트 → 기존 노드 업데이트 순서대로 DOM 업데이트를 진행한다.
DOM 조작 최적화를 위한 작업은 아래와 같다.
- 변경사항을 하나의 배치로 처리
- 레이아웃 트리거 최소화
- 브라우저 리페인트 최적화
부수 효과(Effects) 실행
useLayoutEffect (레이아웃 효과) | useEffect (패시브 효과) |
---|---|
DOM 변경 직후, 브라우저가 화면을 그리기 전 동기적 실행 | 브라우저가 화면을 그린 후 비동기적으로 실행 |
React가 DOM을 업데이트한 직후, 브라우저 페인팅 전 발생 | React가 DOM을 업데이트하고 브라우저가 화면 그린 후 발생 |
DOM 업데이트 → useLayoutEffect → 브라우저 페인팅 | DOM 업데이트 → 브라우저 페인팅 → useEffect |
참조 정리
이전 ref 값의 .current 업데이트 처리한다.
useEffect cleanup 함수 실행으로 이전 참조 정리하고, 불필요한 클로저 참조 제거한다.
대부분의 정리는 5단계인 완료 단계에서 이루어진다.
완료 단계
리소스 누수 방지, 안정적인 성능 유지, 일관된 사용자 경험 제공을 목적으로 적절한 클린업, 메모리 관리, 그리고 다음 업데이트 준비한다.
클린업 함수 실행
클린업의 목적은 이전 렌더링의 부수 효과 정리, 리소스 누수 방지, 상태 일관성 유지 등이 있다.
클린업 함수는 이펙트 클린업 => 이벤트 정리 => 외부 연결 정리
순서대로 동작한다.
useEffect(() => {
// 구독 설정
const subscription = dataSource.subscribe();
// 클린업 함수
return () => {
// 다음 이펙트 실행 전 또는 언마운트 시 호출
subscription.unsubscribe();
};
}, [dataSource]);
메모리 정리
참조 정리는 DOM 참조 해제(함수형 ref, 객체 ref)와 컴포넌트 인스턴스 정리)와 캐시 관리, 가비지 컬렉션 준비를 통해 메모리를 정리를 의미한다.
다음 업데이트 준비
상태를 초기화하고 최적화 준비를 끝낸다. (상태를 초기화하는 것이 아닌 다음 상태를 받아올 준비를 하기에 이 과정이 제일 중요하다.)
이것도 깊게 생각해보면 업데이트 큐 초기화를 진행하고 우선순위 시스템을 새로 준비한다. 준비과정들은 아래와 같다.
- 위에서 설명했던 레인(Lane) 할당하고 작업 우선순위 설정한다.
- 작업 스케줄링 준비 (작업 큐 초기화, 스케줄링 상태 리셋)
- 성능 최적화 준비 (메모리 모니터링, 성능 메트릭 초기화)
- 실행 컨텍스트 준비 (렌더 컨텍스트 초기화)
- 동시성 모드 준비 (시간 분할 설정, 인터럽트 처리 준비)
중요 포인트
최적화를 위해
Object.is
(없을 경우 대비하여 polyfill 함수 사용) 를 사용해 함수 현재 상태와 새 상태를 비교한다.- 필요할 때만 리렌더링을 예약한다.
- 객체를 비교할 때
Object.is
는 두 객체의 참조가 같은지만 확인하고, 객체가 동일한 참조를 가지면 상태가 바뀌지 않은 것으로 간주하여 리렌더링을 하지 않는다.
React는 컴포넌트의 렌더링 상태에 따라 디스패처를 선택해 훅의 동작 방식을 결정한다.
초기 상태를 설정할 때
게으른 초기화(lazy initialization)
방식을 사용한다.초기값이 함수로 전달되면, 최초 렌더링 시에만(
mountState
) 해당 함수를 호출하여 계산한다.컴포넌트가
setState
를 호출할 때마다 해당 Fiber의 상태가 정확히 업데이트되도록,bind
를 사용해 현재 렌더링 중인 컴포넌트의Fiber 노드
와업데이트 큐
를 고정한다.setState
가 호출되면, 상태 변경 요청이업데이트 큐
에 추가되어 예약된다. 이 큐는 상태 업데이트 요청들을 쌓아 두었다가 한 번의 렌더링 주기에 일괄 처리할 수 있도록 한다.