본문 바로가기

Frontend

prev와 batch를 통한 상태 업데이트

React로 상태를 업데이트하다 보면 한 번쯤 이런 코드를 써본 적이 있을 것이다.

setState(state + 1);
setState(state + 1);

 

두 번 더했으니까 2가 오르겠지? 라고 기대하지만, 실제로는 그렇지 않은 경우가 많다.
이 글에서는 React의 batching(배칭) 특성과 그로 인해 생기는 stale state 문제, 그리고 이를 해결하는 함수형 업데이트(prev) 패턴을, 실무에서 겪은 필터 UI 예제를 통해 정리한다.

 

예제 시나리오: 필터 자동 선택 + 자동 pin

다음과 같은 필터 리스트가 있다고 하자.

type Filter = {
  id: number;
  label: string;
  selected: boolean;
  pinned: boolean;
};

const initialFilters: Filter[] = [
  { id: 1, label: 'Material', selected: false, pinned: false },
  { id: 2, label: 'Plant', selected: false, pinned: false },
  { id: 3, label: 'Vendor', selected: false, pinned: false },
  { id: 4, label: 'Region', selected: false, pinned: false },
];

 

요구사항은 다음과 같다.

  1. 버튼을 누르면
    • label이 Plant, Vendor인 필터를 자동으로 selected=true로 만든다.
  2. 그 뒤에
    • selected=true인 필터만 pinned=true로 만든다.

즉, 버튼 한 번에 자동 선택 → 선택된 필터 pin 처리를 하고 싶은 상황이다.

 

전체 컴포넌트 코드

'use client';

import { useState } from 'react';

type Filter = {
  id: number;
  label: string;
  selected: boolean;
  pinned: boolean;
};

const initialFilters: Filter[] = [
  { id: 1, label: 'Material', selected: false, pinned: false },
  { id: 2, label: 'Plant', selected: false, pinned: false },
  { id: 3, label: 'Vendor', selected: false, pinned: false },
  { id: 4, label: 'Region', selected: false, pinned: false },
];

export default function FilterBatchingPage() {
  const [attributeFilters, setAttributeFilters] = useState<Filter[]>(initialFilters);

  // prev 안 쓰는 실무 버그 버전
  const badAutoSelectAndPin = () => {
    // 1) 자동 선택
    setAttributeFilters(
      attributeFilters.map((f) =>
        f.label === 'Plant' || f.label === 'Vendor'
          ? { ...f, selected: true }
          : f
      )
    );

    // 2) 선택된 것 pinned 처리
    // ⚠️ 여기서 attributeFilters는 "업데이트 전(old) 값"
    setAttributeFilters(
      attributeFilters.map((f) =>
        f.selected ? { ...f, pinned: true } : f
      )
    );
  };

  // prev 쓰는 정상 버전
  const goodAutoSelectAndPin = () => {
    // 1) 자동 선택
    setAttributeFilters((prev) =>
      prev.map((f) =>
        f.label === 'Plant' || f.label === 'Vendor'
          ? { ...f, selected: true }
          : f
      )
    );

    // 2) 선택된 것 pinned 처리
    setAttributeFilters((prev) =>
      prev.map((f) =>
        f.selected ? { ...f, pinned: true } : f
      )
    );
  };

  const reset = () => setAttributeFilters(initialFilters);

  return (
    <div className="p-6 space-y-4">
      <h1 className="text-xl font-bold">
        실무형 batching/stale state 테스트(필터 자동 선택 + pin)
      </h1>

      <div className="flex gap-2">
        <button onClick={badAutoSelectAndPin} className="px-3 py-1 border rounded">
          prev 없이 자동선택+핀
        </button>

        <button onClick={goodAutoSelectAndPin} className="px-3 py-1 border rounded">
          prev로 자동선택+핀
        </button>

        <button onClick={reset} className="px-3 py-1 border rounded">
          reset
        </button>
      </div>

      <ul className="space-y-2">
        {attributeFilters.map((f) => (
          <li
            key={f.id}
            className={`p-2 border rounded flex justify-between ${
              f.pinned ? 'bg-yellow-50' : ''
            }`}
          >
            <span>{f.label}</span>
            <span className="text-sm text-gray-600">
              selected: {String(f.selected)} / pinned: {String(f.pinned)}
            </span>
          </li>
        ))}
      </ul>

      <p className="text-sm text-gray-600">
        기대값: Plant, Vendor가 selected=true 되고, 그 둘만 pinned=true가 되어야 한다.
      </p>
    </div>
  );
}

 

기대 결과

버튼 클릭 시 기대하는 최종 상태는 다음과 같다.

  • Plant, Vendor
    → selected: true, pinned: true
  • Material, Region
    → selected: false, pinned: false

이제 bad / good 두 버전을 비교해 보자.

 

1. prev 없이 자동선택 + pin: 왜 틀리는가?

문제 버전의 핵심 코드는 다음과 같다.

const badAutoSelectAndPin = () => {
  setAttributeFilters(
    attributeFilters.map(/* 1) 자동 선택 */)
  );

  setAttributeFilters(
    attributeFilters.map(/* 2) 선택된 것 pinned 처리 */)
  );
};
두 번의 업데이트 모두 같은 attributeFilters 변수를 기준으로 계산하고 있다.
여기서 놓치기 쉬운 점이 하나 있다. attributeFilters는 이미 렌더링에 사용된, 과거의 state 스냅샷이다.

 

React는 성능을 위해 같은 이벤트 안에서 일어난 여러 setState를 batching(배칭) 한다. 즉, 두 번의 setAttributeFilters가 한 번에 모여 처리될 수 있다.

 

React 입장에서는 이렇게 보인다.

  1. attributeFilters(old)를 기준으로 첫 번째 업데이트 계산
  2. 여전히 attributeFilters(old)를 기준으로 두 번째 업데이트 계산
  3. 두 결과를 섞거나, 마지막 것을 적용

그 결과:

  • 1단계에서 Plant, Vendor를 selected=true로 바꾸는 로직은 존재하지만
  • 2단계에서 참조하는 attributeFilters는 여전히 기존 값(old) 이기 때문에
    f.selected는 여전히 false로 남아 있다.
  • 따라서 pin 로직이 기대대로 동작하지 않고
    아무 것도 pinned 되지 않거나, 엉뚱한 결과가 나올 수 있다.

이게 바로 stale state 문제다.

 

2. prev로 자동선택 + pin: batching 속에서도 항상 최신 상태

const goodAutoSelectAndPin = () => {
  setAttributeFilters((prev) =>
    prev.map(/* 1) 자동 선택 */)
  );

  setAttributeFilters((prev) =>
    prev.map(/* 2) 선택된 것 pinned 처리 */)
  );
};

 

여기서 중요한 것은 함수형 업데이트를 쓰고 있다는 점이다.

setAttributeFilters((prev) => {
  // prev는 이 시점에서 React가 가진 "가장 최신 상태"
});

 

React는 여러 업데이트를 배칭하더라도, 각 호출마다 순서대로 prev를 전달한다.

흐름을 살펴보면:

  1. 첫 번째 setAttributeFilters(prev => ...) 실행
    • prev는 기존 state
    • 결과: Plant, Vendor가 selected=true가 된 새 state A
  2. 두 번째 setAttributeFilters(prev => ...) 실행
    • 이때 prev는 이미 A 상태
    • 즉, selected=true가 반영된 상태를 기준으로 pinned 계산
  3. 최종 state는
    • Plant, Vendor: selected=true, pinned=true
    • 나머지: 그대로

prev를 사용함으로써, 업데이트가 몇 번 배칭되든 항상 그 시점 기준의 최신 state에 대해 계산할 수 있게 된다.

 

3. React의 batching은 왜 이런 문제를 만들까?

정리해 보면:

  • React의 setState는 즉시 state를 바꾸는 함수가 아니다.
  • 변경 요청을 스케줄링하고, React가 적절한 시점에 모아서(batching) 처리한다.
  • 같은 이벤트 루프 안에서 여러 번 setState를 호출하면 React는 이를 한 번의 렌더링으로 합쳐 처리하려고 한다.

이때 다음과 같은 코드가 문제를 일으킨다.

setAttributeFilters(attributeFilters.map(...));
setAttributeFilters(attributeFilters.map(...));
두 번째 줄도 업데이트 전의 attributeFilters 값을 보고 동작할 수 있다. 즉, 첫 번째 결과가 두 번째에 반영되지 않아

그래서 React는 함수형 업데이트를 제공한다.

setAttributeFilters(prev => prev.map(...));
setAttributeFilters(prev => prev.map(...));
 

여기서 prev는 바로 직전 업데이트까지 모두 반영한 최신 값이다. React가 배칭을 하더라도 연속 업데이트를 안전하게 처리할 수 있다.

 

4. 숫자/불린 같은 Primitive도 예외가 아니다.

이 문제는 객체 배열에서만 생기는 게 아니다.
number, boolean 같은 primitive state에서도 동일하게 나타난다.

// 원하는 만큼 올라가지 않을 수 있다.
setCount(count + 1);
setCount(count + 1);

// 항상 2가 증가한다.
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// 연달아 토글할 때 꼬일 수 있다.
setOpen(!open);
setOpen(!open);

// 항상 현재 값을 기준으로 토글된다.
setOpen(prev => !prev);
setOpen(prev => !prev);

 

정리: prev 업데이트는 언제 써야 할까?

  • 같은 state를 연속으로 여러 번 업데이트할 때
  • 이전 state 값을 계산에 참고할 때
  • 이벤트 핸들러 안에서 setState를 두 번 이상 호출할 때
  • 비동기, 배칭 환경에서도 항상 최신 state를 기준으로 동작해야 할 때