Queueing a Series of State Updates

김동현·2026년 3월 15일

title: 여러 State 업데이트 큐에 담기 (Queueing a Series of State Updates)

여러분, state 변수를 설정(set)하면 새로운 렌더링이 큐(대기열)에 들어간다는 사실, 알고 계셨나요? 하지만 때로는 다음 렌더링을 큐에 넣기 전에 값에 대해 여러 작업을 수행하고 싶을 때가 있을 거예요. 이를 제대로 다루기 위해서는 React가 state 업데이트를 어떻게 일괄 처리(batching)하는지 이해하는 것이 정말 중요하답니다! 자, 그럼 시작해볼까요?

  • "일괄 처리(batching)"가 무엇인지, 그리고 React가 여러 state 업데이트를 처리하기 위해 이를 어떻게 사용하는지
  • 동일한 state 변수에 여러 업데이트를 연속으로 적용하는 방법

React는 state 업데이트를 일괄 처리합니다 (React batches state updates) {/react-batches-state-updates/}

아래 코드를 보면, "+3" 버튼을 클릭하면 setNumber(number + 1)을 세 번 호출하니까 카운터가 세 번 증가할 것이라고 예상하실 수 있어요.

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

하지만 이전 섹션에서 배운 것을 떠올려 보세요. 각 렌더링의 state 값은 고정되어 있습니다. 따라서 첫 번째 렌더링의 이벤트 핸들러 내부에서 number의 값은 setNumber(1)을 몇 번 호출하든 상관없이 항상 0입니다. 즉, 아래와 같이 동작하게 되는 거죠.

setNumber(0 + 1);
setNumber(0 + 1);
setNumber(0 + 1);

👨‍🏫 강사의 부연 설명:
우리가 눈으로 보기엔 코드가 3번 순차적으로 실행되는 것 같지만, React가 해당 렌더링 시점에 가지고 있는 number라는 스냅샷은 변하지 않아요! 마치 사진을 찍어둔 것처럼 0이라는 값에 멈춰있는 상태에서 setNumber(1)만 세 번 외친 꼴이 되는 겁니다.

하지만 여기에 영향을 미치는 또 다른 중요한 요소가 하나 더 있어요. React는 이벤트 핸들러의 모든 코드가 실행될 때까지 기다렸다가 state 업데이트를 처리합니다. 이것이 바로 이 모든 setNumber() 호출이 끝난 후에야 리렌더링이 일어나는 이유랍니다.

이 과정은 마치 레스토랑에서 웨이터가 주문을 받는 것과 비슷해요. 웨이터는 여러분이 첫 번째 요리를 말하자마자 주방으로 달려가지 않죠! 대신 여러분이 주문을 끝마칠 때까지 기다려주고, 주문을 중간에 변경하게도 해주고, 심지어 같은 테이블에 있는 다른 사람들의 주문까지 한 번에 다 받습니다.

An elegant cursor at a restaurant places and order multiple times with React, playing the part of the waiter. After she calls setState() multiple times, the waiter writes down the last one she requested as her final order.

이렇게 하면 너무 많은 리렌더링을 유발하지 않고도 여러 state 변수를 (심지어 여러 컴포넌트에서) 업데이트할 수 있어요. 하지만 이는 동시에 이벤트 핸들러와 그 안의 코드가 완전히 끝난 에야 UI가 업데이트된다는 의미이기도 합니다. 이러한 동작을 일괄 처리(batching)라고 부르며, 덕분에 여러분의 React 앱이 훨씬 더 빠르게 실행될 수 있어요. 또한, 일부 변수만 업데이트된 혼란스러운 "반쪽짜리" 렌더링을 처리해야 하는 수고도 덜어주죠.

👨‍🏫 강사의 부연 설명:
18버전부터 React는 더 똑똑해졌어요! 예전에는 이벤트 핸들러 내부에서만 일괄 처리를 보장했지만, 이제는 Promise, setTimeout, 네이티브 이벤트 핸들러 등 어디에서든 자동으로 일괄 처리를(Auto Batching) 해준답니다. 성능 최적화를 알아서 해주니 우리는 편하게 코드만 짜면 되는 거죠!

React는 클릭과 같은 여러 개의 의도적인 이벤트에 대해서는 일괄 처리를 하지 않아요. 즉, 각 클릭은 개별적으로 처리됩니다. 안심하세요. React는 일반적으로 안전할 때만 일괄 처리를 수행하거든요. 예를 들어, 첫 번째 버튼 클릭이 폼을 비활성화한다면, 두 번째 클릭은 폼을 다시 제출하지 않도록 보장해 준답니다.

다음 렌더링 전에 동일한 state를 여러 번 업데이트하기 {/updating-the-same-state-multiple-times-before-the-next-render/}

흔한 사용 사례는 아니지만, 만약 다음 렌더링이 일어나기 전에 동일한 state 변수를 여러 번 업데이트하고 싶다면 어떻게 해야 할까요? setNumber(number + 1)처럼 다음 state 값을 전달하는 대신, 큐에 있는 이전 state를 기반으로 다음 state를 계산하는 함수를 전달할 수 있어요. 예를 들면 setNumber(n => n + 1)처럼요! 이는 React에게 단순히 값을 대체하는 것이 아니라 "이 state 값으로 무언가를 해라"라고 지시하는 방법입니다.

자, 이제 카운터를 다시 증가시켜 볼까요?

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

여기서 n => n + 1업데이터 함수(updater function)라고 불러요. 이 함수를 state 설정 함수(setter)에 전달하면 다음과 같은 일이 일어납니다.

  1. React는 이벤트 핸들러의 다른 모든 코드가 실행된 후 이 함수가 처리되도록 큐에 넣습니다.
  2. 다음 렌더링 중에 React는 이 큐를 순회하며 최종적으로 업데이트된 state를 여러분에게 제공합니다.
setNumber(n => n + 1);
setNumber(n => n + 1);
setNumber(n => n + 1);

이벤트 핸들러를 실행하는 동안 React가 이 코드 줄들을 어떻게 처리하는지 살펴볼까요?

  1. setNumber(n => n + 1): n => n + 1은 함수입니다. React는 이것을 큐에 추가합니다.
  2. setNumber(n => n + 1): n => n + 1은 함수입니다. React는 이것을 큐에 추가합니다.
  3. setNumber(n => n + 1): n => n + 1은 함수입니다. React는 이것을 큐에 추가합니다.

다음 렌더링 중에 여러분이 useState를 호출하면, React는 이 큐를 쭈욱 훑어봅니다. 이전 number state는 0이었으므로, React는 이를 첫 번째 업데이터 함수에 n 인자로 전달합니다. 그런 다음 React는 이전 업데이터 함수의 반환 값을 가져와서 다음 업데이터 함수에 n으로 전달하는 방식을 반복합니다.

큐에 추가된 업데이트n반환값
n => n + 100 + 1 = 1
n => n + 111 + 1 = 2
n => n + 122 + 1 = 3

React는 3을 최종 결과로 저장하고 useState에서 이를 반환합니다.

이것이 바로 위의 예제에서 "+3"을 클릭했을 때 값이 올바르게 3만큼 증가하는 이유랍니다!

👨‍🏫 강사의 부연 설명:
일반적인 값 업데이트와 업데이터 함수(콜백 함수) 업데이트의 차이를 아시겠나요?
setState(값)은 "이제부터 이 값으로 덮어써!"라고 한다면,
setState(이전값 => 새로운값)은 "네가 지금 들고 있는 가장 최신 값에다가 이 동작을 수행해!"라고 지시하는 거예요. 그래서 동일한 state를 연속해서 변경해야 할 때는 무조건 업데이터 함수를 사용해야 안전합니다.

state를 교체한 후에 업데이트하면 어떻게 될까요? {/what-happens-if-you-update-state-after-replacing-it/}

이 이벤트 핸들러는 어떨까요? 다음 렌더링에서 number는 어떤 값이 될 것이라고 생각하시나요?

<button onClick={() => {
  setNumber(number + 5);
  setNumber(n => n + 1);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
      }}>Increase the number</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

이 이벤트 핸들러가 React에게 지시하는 내용은 다음과 같습니다.

  1. setNumber(number + 5): 현재 number0이므로 setNumber(0 + 5)가 됩니다. React는 큐에 "5로 교체(replace with 5)"를 추가합니다.
  2. setNumber(n => n + 1): n => n + 1은 업데이터 함수입니다. React는 해당 함수를 큐에 추가합니다.

다음 렌더링 중에 React는 state 큐를 훑어봅니다.

큐에 추가된 업데이트n반환값
"5로 교체"0 (사용되지 않음)5
n => n + 155 + 1 = 6

React는 6을 최종 결과로 저장하고 useState에서 이를 반환합니다.

눈치채셨을 수도 있지만, setState(5)는 실제로는 setState(n => 5)처럼 작동하는데 단지 n이 사용되지 않을 뿐이에요!

state를 업데이트한 후에 교체하면 어떻게 될까요? {/what-happens-if-you-replace-state-after-updating-it/}

한 가지 예제만 더 해보죠. 다음 렌더링에서 number가 어떻게 될지 예상해 보세요!

<button onClick={() => {
  setNumber(number + 5);
  setNumber(n => n + 1);
  setNumber(42);
}}>
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Increase the number</button>
    </>
  )
}
button { display: inline-block; margin: 10px; font-size: 20px; }
h1 { display: inline-block; margin: 10px; width: 30px; text-align: center; }

이벤트 핸들러를 실행하는 동안 React가 이 코드 줄들을 어떻게 처리하는지는 다음과 같습니다.

  1. setNumber(number + 5): number0이므로 setNumber(0 + 5)가 됩니다. React는 큐에 "5로 교체"를 추가합니다.
  2. setNumber(n => n + 1): n => n + 1은 업데이터 함수입니다. React는 해당 함수를 큐에 추가합니다.
  3. setNumber(42): React는 큐에 "42로 교체"를 추가합니다.

다음 렌더링 중에 React는 state 큐를 처리합니다.

큐에 추가된 업데이트n반환값
"5로 교체"0 (사용되지 않음)5
n => n + 155 + 1 = 6
"42로 교체"6 (사용되지 않음)42

그런 다음 React는 42를 최종 결과로 저장하고 useState에서 이를 반환하게 되죠.

요약하자면, setNumber state 설정 함수에 전달하는 값에 따라 React가 어떻게 생각하는지 정리해 볼 수 있어요.

  • 업데이터 함수 (예: n => n + 1)는 큐에 추가됩니다.
  • 그 외의 모든 값 (예: 숫자 5)은 이미 큐에 대기 중인 내용을 무시하고 "5로 교체"를 큐에 추가합니다.

이벤트 핸들러 실행이 완료된 후, React는 리렌더링을 트리거합니다. 리렌더링을 하는 동안 React는 큐를 처리하게 되죠. 업데이터 함수는 렌더링 중에 실행되므로, 업데이터 함수는 반드시 순수(pure)해야 하며 오직 결과만을 반환(return)해야 합니다. 업데이터 함수 내부에서 state를 설정하려고 하거나 다른 사이드 이펙트(side effects)를 실행하려고 하면 안 돼요. 엄격 모드(Strict Mode)에서 React는 여러분이 실수한 부분을 쉽게 찾을 수 있도록 각 업데이터 함수를 두 번 실행합니다(물론 두 번째 결과는 버립니다).

👨‍🏫 강사의 부연 설명:
"순수해야 한다"는 것은 동일한 입력이 주어졌을 때 항상 동일한 출력을 반환해야 한다는 뜻이에요. 업데이터 함수 안에서 서버에 데이터를 보내거나, 외부 변수를 수정하는 등의 부수 효과(Side Effect)를 일으키면 예상치 못한 버그를 만날 수 있으니 꼭 명심하세요!

명명 규칙 (Naming conventions) {/naming-conventions/}

업데이터 함수의 인자 이름은 보통 해당 state 변수의 첫 글자를 따서 짓는 것이 관례입니다.

setEnabled(e => !e);
setLastName(ln => ln.reverse());
setFriendCount(fc => fc * 2);

만약 코드가 좀 더 길어지더라도 명확하게 쓰는 것을 선호하신다면, setEnabled(enabled => !enabled)처럼 state 변수 이름을 그대로 반복하거나, setEnabled(prevEnabled => !prevEnabled)처럼 prev 같은 접두사를 사용하는 것도 흔히 쓰이는 좋은 방법이에요.

  • state를 설정하더라도 기존 렌더링 안의 변수 값은 변경되지 않고, 단지 새로운 렌더링을 요청할 뿐입니다.
  • React는 이벤트 핸들러 실행이 끝난 후에 state 업데이트를 처리합니다. 이를 일괄 처리(batching)라고 합니다.
  • 단일 이벤트 안에서 어떤 state를 여러 번 업데이트하려면, setNumber(n => n + 1)과 같은 업데이터 함수를 사용할 수 있습니다.

요청 카운터 고치기 {/fix-a-request-counter/}

여러분은 사용자가 한 번에 예술 작품에 대한 여러 주문을 제출할 수 있는 예술품 마켓플레이스 앱을 작업하고 있습니다. 사용자가 "Buy(구매)" 버튼을 누를 때마다 "Pending(대기 중)" 카운터가 1씩 증가해야 합니다. 3초 후에는 "Pending" 카운터가 감소하고, "Completed(완료됨)" 카운터가 증가해야 합니다.

하지만 현재 "Pending" 카운터가 의도한 대로 작동하지 않고 있어요. "Buy"를 누르면 -1로 감소해버립니다 (이런 일은 불가능해야 하죠!). 게다가 빠르게 두 번 클릭하면 두 카운터 모두 예측할 수 없게 동작하는 것 같습니다.

왜 이런 일이 발생할까요? 두 카운터의 버그를 모두 고쳐보세요.

import { useState } from 'react';

export default function RequestTracker() {
  const [pending, setPending] = useState(0);
  const [completed, setCompleted] = useState(0);

  async function handleClick() {
    setPending(pending + 1);
    await delay(3000);
    setPending(pending - 1);
    setCompleted(completed + 1);
  }

  return (
    <>
      <h3>
        Pending: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy     
      </button>
    </>
  );
}

function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

handleClick 이벤트 핸들러 내에서 pendingcompleted의 값은 클릭 이벤트가 발생했을 당시의 값에 고정되어 있습니다. 첫 번째 렌더링에서 pending0이었으므로, setPending(pending - 1)은 결국 setPending(-1)이 되어버리고, 이는 잘못된 동작이죠. 클릭할 때 결정된 구체적인 값으로 강제 설정하는 것이 아니라 카운터를 증가시키거나 감소시키고 싶은 것이기 때문에, 대신 업데이터 함수를 전달하여 해결할 수 있습니다.

import { useState } from 'react';

export default function RequestTracker() {
  const [pending, setPending] = useState(0);
  const [completed, setCompleted] = useState(0);

  async function handleClick() {
    setPending(p => p + 1);
    await delay(3000);
    setPending(p => p - 1);
    setCompleted(c => c + 1);
  }

  return (
    <>
      <h3>
        Pending: {pending}
      </h3>
      <h3>
        Completed: {completed}
      </h3>
      <button onClick={handleClick}>
        Buy     
      </button>
    </>
  );
}

function delay(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

이렇게 하면 카운터를 증가시키거나 감소시킬 때, 클릭 당시의 state가 아닌 가장 최신의 state를 기준으로 연산이 수행되도록 보장할 수 있어요!

state 큐를 직접 구현해보기 {/implement-the-state-queue-yourself/}

이번 챌린지에서는 React의 아주 작은 일부분을 처음부터 직접 재구현해 볼 거예요! 말만 거창할 뿐이지 생각보다 어렵지 않답니다.

샌드박스 미리보기를 스크롤해 보세요. 네 개의 테스트 케이스가 표시되어 있는 것을 볼 수 있습니다. 이 케이스들은 앞서 이 페이지에서 살펴본 예제들과 동일합니다. 여러분의 임무는 각 케이스에 대해 올바른 결과를 반환하도록 getFinalState 함수를 구현하는 것입니다. 함수를 올바르게 구현하면 네 개의 테스트가 모두 통과될 거예요.

여러분은 두 개의 인자를 받게 됩니다. baseState는 초기 상태(예: 0)이고, queue는 추가된 순서대로 들어있는 숫자(예: 5)와 업데이터 함수(예: n => n + 1)가 섞인 배열입니다.

여러분의 목표는 이 페이지에 있는 표에서 보여준 것처럼 최종 state를 반환하는 것입니다! 화이팅!

만약 막막하다면, 다음 코드 구조에서 출발해 보세요.

export function getFinalState(baseState, queue) {
  let finalState = baseState;

  for (let update of queue) {
    if (typeof update === 'function') {
      // TODO: 업데이터 함수를 적용하세요
    } else {
      // TODO: state를 교체하세요
    }
  }

  return finalState;
}

비어있는 줄을 채워보세요!

// src/processQueue.js
export function getFinalState(baseState, queue) {
  let finalState = baseState;

  // TODO: 큐를 사용해서 무언가를 처리하세요...

  return finalState;
}
// src/App.js
import { getFinalState } from './processQueue.js';

function increment(n) {
  return n + 1;
}
increment.toString = () => 'n => n+1';

export default function App() {
  return (
    <>
      <TestCase
        baseState={0}
        queue={[1, 1, 1]}
        expected={1}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          increment,
          increment,
          increment
        ]}
        expected={3}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
        ]}
        expected={6}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
          42,
        ]}
        expected={42}
      />
    </>
  );
}

function TestCase({
  baseState,
  queue,
  expected
}) {
  const actual = getFinalState(baseState, queue);
  return (
    <>
      <p>Base state: <b>{baseState}</b></p>
      <p>Queue: <b>[{queue.join(', ')}]</b></p>
      <p>Expected result: <b>{expected}</b></p>
      <p style={{
        color: actual === expected ?
          'green' :
          'red'
      }}>
        Your result: <b>{actual}</b>
        {' '}
        ({actual === expected ?
          'correct' :
          'wrong'
        })
      </p>
    </>
  );
}

이것이 바로 이 페이지에서 설명한, React가 최종 state를 계산하기 위해 사용하는 정확한 알고리즘이랍니다!

// src/processQueue.js
export function getFinalState(baseState, queue) {
  let finalState = baseState;

  for (let update of queue) {
    if (typeof update === 'function') {
      // 업데이터 함수를 적용합니다.
      finalState = update(finalState);
    } else {
      // 다음 state를 교체합니다.
      finalState = update;
    }
  }

  return finalState;
}
// src/App.js
import { getFinalState } from './processQueue.js';

function increment(n) {
  return n + 1;
}
increment.toString = () => 'n => n+1';

export default function App() {
  return (
    <>
      <TestCase
        baseState={0}
        queue={[1, 1, 1]}
        expected={1}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          increment,
          increment,
          increment
        ]}
        expected={3}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
        ]}
        expected={6}
      />
      <hr />
      <TestCase
        baseState={0}
        queue={[
          5,
          increment,
          42,
        ]}
        expected={42}
      />
    </>
  );
}

function TestCase({
  baseState,
  queue,
  expected
}) {
  const actual = getFinalState(baseState, queue);
  return (
    <>
      <p>Base state: <b>{baseState}</b></p>
      <p>Queue: <b>[{queue.join(', ')}]</b></p>
      <p>Expected result: <b>{expected}</b></p>
      <p style={{
        color: actual === expected ?
          'green' :
          'red'
      }}>
        Your result: <b>{actual}</b>
        {' '}
        ({actual === expected ?
          'correct' :
          'wrong'
        })
      </p>
    </>
  );
}

이제 React의 이 핵심 부분이 어떻게 작동하는지 완전히 이해하셨네요! 축하합니다. 👏


사이트맵 (Sitemap)

모든 문서 페이지 개요

제가 번역해 드린 내용이 React 학습에 도움이 되셨나요? 추가로 설명이 필요한 부분이 있다면 언제든 편하게 물어보세요!

profile
프론트에_가까운_풀스택_개발자

0개의 댓글