state Update Queue, Batching, Lanes

keemsebeen·2025년 5월 15일
post-thumbnail

왜 다음 코드는 count가 기대만큼 증가하지 않을까요?

const handleClick = () => {
  setCount(count + 1);
  setCount(count + 1);
  setCount(count + 1);
};

반면, 아래 코드는 기대대로 작동합니다.

const handleClick = () => {
  setCount(c => c + 1);
  setCount(c => c + 1);
  setCount(c => c + 1);
};

처음에는 단순히 문법 차이일 거라 생각했지만, 이 차이는 React 내부의 업데이트 큐, 배칭 처리, 레인 스케줄링과 밀접한 관련이 있었습니다.

이 글은 이 궁금증에서 출발하여 리액트 상태 업데이트의 작동 원리를 살펴본 기록입니다.

업데이트 큐가 무엇인가요?

업데이트 큐는 리액트에서 상태 변경을 추적하고 관리하는 내부 자료구조입니다. 컴포넌트에서 상태 업데이트가 발생할 때마다 리액트는 이 업데이트를 즉시 적용하지 않고, 업데이트 큐에 추가합니다.

특징

  • 각 상태 업데이트는 큐에 "업데이트 객체"로 저장됩니다.
  • 업데이트 객체들은 연결 리스트(linked list) 형태로 관리됩니다.
  • 각 업데이트 객체는 action(새 상태 값 또는 업데이터 함수), lane(우선순위) 등의 정보를 포함합니다.

상태 업데이트 처리 과정

  1. setState 호출 시 업데이트 객체 생성
    • setState 또는 dispatch 호출 시, 리액트는 새 Update 객체를 생성합니다.
  2. 해당 객체를 업데이트 큐에 추가
    • 생성된 업데이트 객체는 pending 필드가 가리키는 원형 연결 리스트에 추가됩니다.
  3. 🔥스케쥴링🔥에 따라 큐의 업데이트를 처리
    • 업데이트의 우선순위에 기반하여 처리 일정이 계획됩니다. 이 정보는 lanes 필드에 반영됩니다.
  4. 리듀서 함수를 통해 새 상태 계산
    • 큐에서 업데이트를 가져옵니다.
    • lastRenderedReducer 함수를 사용하여 새 상태를 계산합니다.
    • lastRenderedState를 새 상태로 업데이트합니다.
    • 필요한 경우 컴포넌트를 다시 렌더링합니다.
  5. 상태 변경 후 필요 시 렌더링

pending , lanes, lastRenderedReducer ,lastRenderedState ..? 공부하다 보니 처음보는 단어들이 너무 많았습니다. 호기심에 리액트 오픈소스를 보고 공부한 내용을 기록해보았습니다.

UpdateQueue Types

export type UpdateQueue<S, A> = {
  pending: Update<S, A> | null,
  lanes: Lanes,
  dispatch: (A => mixed) | null,
  lastRenderedReducer: ((S, A) => S) | null,
  lastRenderedState: S | null,
}

UpdateQueue 타입은 리액트의 파이버 아키텍처에서 상태 업데이트를 관리하는 핵심 자료구조입니다. 이 구조체를 통해 리액트는 컴포넌트의 상태 변경을 추적하고 적절한 시점에 처리합니다. 각 필드의 역할과 중요성을 자세히 살펴보겠습니다.

제네릭 타입

  • S: 상태(State)의 타입
  • A: 액션(Action)의 타입

pending: Update<S,A> | null

처리 대기 중인 업데이트들의 원형 연결 리스트(circular linked list)의 마지막 업데이트를 가리키는 포인터입니다.

상세 설명

  • null일 경우: 대기 중인 업데이트가 없음을 의미합니다.
  • Update 객체가 할당된 경우: 하나 이상의 처리 대기 중인 업데이트가 있음을 나타냅니다.
  • 원형 연결 리스트 구조: 마지막 업데이트의 next 필드는 첫 번째 업데이트를 가리킵니다.

lanes: Lanes

업데이트 큐와 관련된 모든 업데이트의 우선순위(lanes) 정보를 비트마스크 형태로 저장합니다.

dispatch: (A => mixed) | null

상태 업데이트를 트리거하는 함수 참조입니다. useState 또는 useReducer의 반환값 중 두 번째 요소(setter 함수)가 이 함수를 호출합니다.

lastRenderedReducer: ((S, A) => S) | null

마지막으로 사용된 리듀서 함수의 참조입니다. 이 함수는 현재 상태와 액션을 받아 새 상태를 계산합니다.

  • useReducer의 경우: 사용자가 제공한 리듀서 함수입니다.
  • useState의 경우: 기본 상태 리듀서 함수(BasicStateReducer)가 사용됩니다.

lastRenderedState: S | null

마지막으로 렌더링된 상태 값의 참조입니다. 리액트는 이 값을 캐싱하여 불필요한 재계산을 방지합니다.

업데이트 큐는 이제 알겠는데, 왜 업데이터 함수는 값이 변경되는건데요?

setCount(count + 1)setCount(c => c + 1)의 차이는 단순히 구문적인 것이 아니라 무언가 있을거라 생각했습니다. 이는 결국 리액트의 스케줄링 패러다임과 직접적으로 관련이 있음을 알 수 있었습니다.

정적 업데이트 (setCount(count + 1))

정적 업데이트에서는 setState 호출 시점의 값을 기반으로 새 상태 값을 계산하고, 계산된 값이 업데이트 객체의 action 필드에 저장됩니다.

function handleClick() {
// 업데이트 1: { lane: DefaultLane, action: 1, next: update2 }
  setCount(count + 1);

// 업데이트 2: { lane: DefaultLane, action: 1, next: update3 }
  setCount(count + 1);

// 업데이트 3: { lane: DefaultLane, action: 1, next: update1 }
  setCount(count + 1);
}

처리 과정

  1. 리듀서에 count = 0action = 1을 전달 → 결과 1
  2. 리듀서에 count = 1action = 1을 전달 → 결과 1 (이전과 동일)
  3. 리듀서에 count = 1action = 1을 전달 → 결과 1 (변화 없음)

최종 상태: 1

함수형 업데이트 (setCount(c => c + 1))

함수형 업데이트에서는 상태 값을 직접 저장하는 대신, 업데이트를 계산할 함수 자체가 action 필드에 저장됩니다.

// 렌더링 시점에 count = 0일 때
function handleClick() {
// 업데이트 1: { lane: DefaultLane, action: (c) => c + 1, next: update2 }
  setCount(c => c + 1);

// 업데이트 2: { lane: DefaultLane, action: (c) => c + 1, next: update3 }
  setCount(c => c + 1);

// 업데이트 3: { lane: DefaultLane, action: (c) => c + 1, next: update1 }
  setCount(c => c + 1);
}

처리 과정:

  1. 리듀서에 count = 0action = (c) => c + 1을 전달 → 함수 실행 결과 1
  2. 리듀서에 count = 1action = (c) => c + 1을 전달 → 함수 실행 결과 2
  3. 리듀서에 count = 2action = (c) => c + 1을 전달 → 함수 실행 결과 3

최종 상태: 3

리듀서 함수의 역할

내부 리듀서 함수는 다음과 같은 형태로 구현되어 있습니다.

function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}
  • action이 함수인 경우 → 현재 상태(state)를 인자로 전달하여 즉시 함수를 실행
  • action이 값인 경우 → 해당 값을 그대로 새 상태로 사용

이 메커니즘이 정적 업데이트와 함수형 업데이트의 결과 차이를 만들어내는 핵심입니다.

Batching

배치란 여러 상태 업데이트를 그룹화하여 한 번의 렌더링 사이클에서 처리하는 기법입니다. React는 성능 최적화를 위해 여러 상태 업데이트를 배치로 그룹화합니다.

예시 코드

function handleClick() {
  setCount(count + 1);
  setName("Alice");
  // 이 시점에는 두 업데이트 모두 아직 적용되지 않음
}

해당 코드에서 setCount 를 첫번째 업데이트로 예약하고, setCount 를 두번째 업데이트로 예약합니다. 결과적으로 두 상태 업데이트를 모아서 handleClick 함수가 완료된 후 한 번의 렌더링만 수행합니다.

Batching 처리를 왜하는데요?

  1. 성능 최적화
  • 각 상태 변경마다 렌더링하면 불필요한 계산과 DOM 조작이 반복되어 성능이 저하됩니다.
  • 특히 복잡한 컴포넌트 트리에서는 렌더링 비용이 매우 높을 수 있습니다.
  1. 일관된 UI 상태 유지
  • 여러 상태가 동시에 변경될 때 중간 상태를 사용자에게 보여주지 않음으로써 일관된 UI를 제공합니다.
  1. 불필요한 렌더링 방지
  • 동일한 이벤트 핸들러 내에서 여러 상태 업데이트가 발생할 때, 각각을 별도로 처리하면 중복 렌더링이 발생합니다.

배칭 이전에는 어떻게 했는데요?

Batching은 리액트 18부터 나온 기능입니다. 이전에는 이를 어떻게 처리했을까요?

리액트 18 이전

function handleClick() {
  setCount(count + 1); 
  setName("Alice");   
}

setTimeout(() => {
  setCount(count + 1); // 렌더링 발생
  setName("Alice");    // 다시 렌더링 발생
}, 0);

React 17까지는 동기 이벤트 핸들러 내에서만 자동 배칭 적용됐습니다. 따라서 이벤트 핸들러 안에서 두 업데이트는 함께 배치되어 한 번만 렌더링되었습니다.

그러나 비동기 콜백에서는 배칭이 적용되지 않아 렌더링이 2번 발생하는 경우가 존재했습니다.

리액트 18 이후

setTimeout(() => {
  setCount(count + 1); // 배치 처리됨
  setName("Alice");    // 배치 처리됨
}, 0);

리액트 18에서는 자동 배칭이 모든 업데이트에 적용됐습니다. 따라서 렌더링이 한번만 발생하게 됩니다.

배칭과 업데이트 큐의 관계

배칭도 결국 업데이트 큐를 기반으로 작동합니다. 따라서

  • 이벤트 핸들러나 콜백 내의 모든 setState 호출은 각각의 업데이트 객체를 생성합니다.
  • 이러한 업데이트 객체들은 동일한 우선순위(lane)로 업데이트 큐에 추가됩니다.
  • 리액트는 작업 단위가 완료되면 큐의 모든 업데이트를 한 번에 처리합니다.
  • 여러 상태 변수에 대한 업데이트도 단일 렌더링 과정에서 함께 적용됩니다.

이걸.. 왜 알아야하죠?

저도 알기 싫었는데요. 알아봅시다.

안정적인 상태 관리

함수형 업데이트와 정적 업데이트의 차이를 이해하면 예측 가능한 상태 변화를 구현할 수 있습니다.

// 프로미스 체인이나 비동기 함수에서 안전하게 상태 업데이트
fetchData().then(response => {
// 최신 상태를 기반으로 업데이트하므로 안전
  setItems(currentItems => [...currentItems, response.newItem]);
});

성능 최적화

배칭을 이해하면 불필요한 렌더링을 방지하고 애플리케이션 성능을 개선할 수 있습니다.

function handleSubmit() {
// 여러 상태 업데이트를 한 번에 처리하여 렌더링 최적화
  setSubmitting(true);
  setFormData(initialData);
  setErrors({});
// 하나의 렌더링만 발생
}

복잡한 상태 로직 구현

업데이트 큐 메커니즘을 이해하면 복잡한 상태 관리를 더 효과적으로 구현할 수 있습니다.

function incrementMultipleTimes(times) {
// 함수형 업데이트로 여러 번의 증가를 안전하게 처리
  for (let i = 0; i < times; i++) {
    setCount(c => c + 1);// 항상 최신 상태 기반으로 업데이트
  }
}

디버깅 및 문제 해결

상태 업데이트 동작을 정확히 이해하면 상태 관련 버그를 더 쉽게 진단하고 해결할 수 있습니다:

// 예상과 다른 동작. count 값이 아직 업데이트되지 않아서 같은 값으로 설정됨
function brokenIncrement() {
  setCount(count + 1);
  setCount(count + 1);
}

// 수정된 버전. 이전 업데이트를 기반으로 새로운 값 계산
function fixedIncrement() {
  setCount(c => c + 1);
  setCount(c => c + 1);
}

레인(Lanes) 모델: 우선순위 기반 스케줄링의 핵심

앞서 계속 lane 이라는 단어가 등장했는데요. 이게 무엇일까요? 또 궁금증에 리액트 오픈소스를 뒤져봤어요.

리액트 17부터 도입된 레인 모델은 업데이트 우선순위의 표현 방식입니다. 레인은 비트마스크로 표현되며, 여러 우선순위를 동시에 표현할 수 있습니다.

export const SyncLane: Lane =/*                        */ 0b0000000000000000000000000000001;
export const InputContinuousLane: Lane =/*             */ 0b0000000000000000000000000001000;
export const DefaultHydrationLane: Lane = /*            */ 0b0000000000000000000000000010000;
export const DefaultLane: Lane =/*                     */ 0b0000000000000000000000000100000;
export const TransitionLane1: Lane =/*                 */ 0b0000000000000000000000100000000;
export const IdleLane: Lane =/*                        */ 0b0100000000000000000000000000000;

이 모델은 다음과 같은 특징이 있습니다.

  1. 동시성 모드(Concurrent Mode)

    레인 모델은 React의 동시성 렌더링을 가능하게 합니다. 높은 우선순위(InputContinuousLane)의 업데이트는 진행 중인 낮은 우선순위 렌더링을 중단하고 먼저 처리됩니다.

  2. 차등적 업데이트

    모든 상태 업데이트가 동등하게 생성되지 않습니다. 사용자 상호작용(클릭, 타이핑)은 InputContinuousLane으로, 일반 상태 업데이트는 DefaultLane으로, 전환 효과는 TransitionLane으로 스케줄링됩니다.

레인이 중요한가요?

레인은 단순히 비트마스크 기반의 우선순위 시스템을 넘어, 리액트가 사용자 경험을 최우선으로 고려하여 업데이트 흐름을 제어하는 중요한 모델이라는 생각이 들었어요. 복잡한 UI에서도 사용자의 입력에 즉각적으로 반응하고, 전환 애니메이션은 부드럽게, 무거운 작업은 뒤로 미루는 이러한 작업은 레인 모델이 있기에 가능해진 것이죠.

개발자로서 우리가 직접 레인을 설정하거나 조작할 일은 드물지만, startTransition, useDeferredValue, 혹은 이벤트 우선순위를 고려한 설계와 같은 기능들을 이해할 때 레인 모델의 개념이 중요할 것 같다는 생각을 하게 되었습니다. 그리고 이러한 API들이 어떻게 작동하는지 이해할 때 그것을 더 효과적으로 사용할 수도 있고요.

결국 이 모델을 이해함으로써 우리는 단순히 리액트를 “사용하는 개발자”를 넘어, 리액트가 어떤 기준으로 어떤 렌더링을 선택하고, 어떤 순서로 처리하는지 이해할 수 있는 개발자가 되는 것이라 생각합니다.

profile
프론트엔드 개발자 김세빈입니다. 👩🏻‍💻

2개의 댓글

comment-user-thumbnail
2025년 5월 16일

레인을 좋아하는 세빈씨..

1개의 답글