blog

setState updater 안에서 ref를 바꾸면 생기는 버그

Front-end / React#React#useState#useRef#순수 함수#StrictMode#Concurrent

증상

목록에서 카드 1 → 카드 2 → 카드 1을 차례로 선택하면, 카드 1의 상세 영역에 카드 2의 데이터가 들어가 있었다. 원인은 setState의 updater 함수 안에서 ref.current를 바꾸는 코드였다.

규칙: updater는 순수 함수여야 한다

setState(prev => next)에 넘기는 updater는 같은 prev에 대해 항상 같은 결과를 반환하고, 바깥의 아무것도 바꾸지 않아야 한다. React가 updater를 여러 번 호출할 수 있기 때문이다.

  • StrictMode(개발 모드): 불순한 updater를 찾아내려고 updater를 일부러 두 번 호출한다.
  • Concurrent 렌더링: 진행 중인 렌더가 더 급한 업데이트에 밀려 버려지면, React는 대기 중인 업데이트들을 기준 상태부터 다시 적용한다. 이때 updater가 다시 호출된다.

state는 버려진 렌더와 함께 원래대로 돌아가지만, ref는 렌더 바깥의 mutable 값이라 롤백되지 않는다. updater 안에서 ref를 바꾸면 버려진 시도의 흔적이 ref에 남는다.

문제 코드

// 카드 선택 핸들러
setSelectedState((prev) => {
  const isSame = prev?.kind === 'existing' && prev.batchSeq === batchSeq;
  if (!isSame) initializedBatchSeqRef.current = null; // ❌ updater 안에서 ref 변경

  return { kind: 'existing', batchSeq, batchInfo: batch, scanRows: isSame ? prev.scanRows : [] };
});
// 서버 데이터를 로컬 상태로 옮기는 effect
setSelectedState((prev) => {
  if (!prev || prev.kind !== 'existing') return prev;
  if (initializedBatchSeqRef.current === selectedBatchSeq) return prev;

  initializedBatchSeqRef.current = selectedBatchSeq; // ❌ updater 안에서 ref 변경
  return { ...prev, scanRows: data.map(toScanRow) };
});

카드를 빠르게 바꾸면 "카드 2 선택 → 카드 2 데이터 채우기 → 카드 1 선택" 업데이트가 연달아 쌓인다. 이 업데이트들이 다시 적용되면서 updater가 반복 호출되면, ref는 마지막으로 호출된 updater가 남긴 값(때로는 카드 2의 번호)을 갖게 된다. 그 상태에서 가드가 판단을 잘못해 카드 2의 행이 카드 1에 들어갔다. 어떤 값이 남는지는 업데이트가 재적용되는 순서에 따라 달라지므로 재현이 불규칙하다는 점이 이 버그를 더 찾기 어렵게 만든다.

수정: ref 읽기·쓰기를 updater 밖으로

const handleSelectExisting = (batch: BatchListItem) => {
  const { batchSeq } = batch;

  // ✅ 이벤트 핸들러에서 한 번만 실행
  const isSame = selectedState?.kind === 'existing' && selectedState.batchSeq === batchSeq;
  if (!isSame) initializedBatchSeqRef.current = null;

  setSelectedState((prev) => {
    const isSamePrev = prev?.kind === 'existing' && prev.batchSeq === batchSeq;
    return {
      kind: 'existing',
      batchSeq,
      batchInfo: batch,
      scanRows: isSamePrev ? prev.scanRows : [], // prev만 참조
    };
  });
};
useEffect(() => {
  // ✅ 가드 판단은 effect 본문에서
  if (initializedBatchSeqRef.current === selectedBatchSeq) return;

  setSelectedState((prev) => {
    if (!prev || prev.kind !== 'existing') return prev;
    return { ...prev, scanRows: data.map(toScanRow) };
  });

  // ✅ ref 기록도 effect 본문에서 (커밋 이후 1회 실행)
  initializedBatchSeqRef.current = selectedBatchSeq;
}, [data, selectedBatchSeq]);

ref를 바꿔도 되는 곳

위치가능이유
useEffect / useLayoutEffect 본문✅커밋 이후 실행
이벤트 핸들러 본문✅사용자 동작 1회당 1번 실행
setState updater❌여러 번 호출될 수 있음
렌더 함수 본문❌StrictMode·Concurrent 렌더에서 여러 번 실행되고, 버려질 수 있음 (최초 1회 lazy 초기화 패턴은 예외)

더 나아가

이 버그는 "서버 데이터를 useEffect로 로컬 상태에 복사하고, 중복 복사를 ref 가드로 막는" 구조에서 나왔다. 선택한 카드가 바뀔 때 편집 영역에 key={batchSeq}를 주어 다시 마운트하고, 서버 데이터는 초기값으로만 받으면 ref 가드 자체가 필요 없어진다.

요약

  1. setState updater는 prev로 다음 상태를 계산하기만 한다. 외부 값(ref, 변수, 스토어)을 바꾸지 않는다.
  2. ref 변경은 이벤트 핸들러나 effect 본문에서 한다.
  3. StrictMode에서 updater가 두 번 호출되는 것은 이런 불순한 코드를 일찍 드러내기 위한 장치다. 개발 모드에서 이상 동작이 보이면 먼저 의심한다.

참고