blog

React Query + useEffect 동기화(Syncer) 패턴의 Race Condition

Front-end / React#React#TanStack Query#useEffect#flushSync#트러블슈팅

증상: 진행 중인 배치에 시리얼 번호(S/N)를 일부만 입력하고 저장하면, 저장에 성공한 뒤 스캔 테이블의 행이 전부 사라졌다.

배경: Syncer 패턴

배치 등록 화면은 서버에서 받은 부품 목록을 편집 가능한 로컬 상태(scanRows)로 복사해 쓰고 있었다. 이 복사를 useEffect로 하는 작은 컴포넌트(BatchDetailSyncer)가 담당했다.

const BatchDetailSyncer = ({ data, selectedBatchSeq, initializedBatchSeqRef, setSelectedState }) => {
  useEffect(() => {
    setSelectedState((prev) => {
      if (!prev || prev.kind !== 'existing') return prev;

      // 이미 초기화했거나, 사용자가 스캔 중이면 덮어쓰지 않음
      if (initializedBatchSeqRef.current === selectedBatchSeq || prev.scanRows.length > 0) {
        return prev;
      }

      initializedBatchSeqRef.current = selectedBatchSeq;
      return { ...prev, scanRows: data.map(toScanRow) };
    });
  }, [data, selectedBatchSeq, initializedBatchSeqRef, setSelectedState]);

  return null;
};

저장 후에는 쿼리를 다시 불러오고, 가드를 풀어서 Syncer가 최신 서버 데이터로 행을 다시 채우게 할 생각이었다.

문제가 된 코드

onSuccess: async ({ data }) => {
  // 1. 쿼리 무효화 (리패치 완료까지 대기)
  await Promise.all([
    queryClient.invalidateQueries({ queryKey: queryKeys.batchList() }),
    queryClient.invalidateQueries({ queryKey: queryKeys.batchDetail(batchSeq) }),
  ]);

  // 2. 가드 해제
  initializedBatchSeqRef.current = null;

  // 3. 행 비우기 → Syncer가 다시 채워 주길 기대
  setSelectedState((prev) => ({ ...prev, scanRows: [] }));
},

원인 분석

invalidateQueries는 해당 쿼리를 구독 중인 컴포넌트가 있으면 리패치가 끝날 때까지 기다린 뒤 resolve된다. 즉 await 이후에는 새 데이터가 이미 캐시에 들어와 있다.

새 data가 Syncer에 전달되어 effect가 먼저 실행되면, 그 시점에는 가드(initializedBatchSeqRef)가 아직 풀리지 않았으므로 아무것도 하지 않는다. 그 뒤에 가드를 풀고 scanRows: []를 설정해도, data는 더 이상 바뀌지 않으니 effect가 다시 실행되지 않는다. 결과적으로 빈 행 상태로 고정된다.

await invalidateQueries (리패치 완료)
  → 새 data 전달 → Syncer effect 실행
    → 가드가 아직 batchSeq → 건너뜀
가드 해제, scanRows = []
  → data 변화 없음 → Syncer 재실행 없음
→ 빈 테이블로 고정

이 버그는 세 조건이 겹쳐야 재현된다.

조건내용
Syncer 패턴쿼리 data를 의존성으로 하는 useEffect로 로컬 상태를 동기화
ref 가드ref.current === id로 중복 초기화를 막음
순서리패치를 기다린 뒤에 가드 해제·상태 초기화

참고: 새 data가 언제 컴포넌트에 반영되는지는 TanStack Query의 알림 배치(notifyManager)와 React 렌더 스케줄링에 따라 달라진다. 위 타임라인은 재현 환경에서 관찰한 순서이며, 핵심은 "리패치 결과가 반영되는 시점과 가드 해제 시점의 순서를 코드가 보장하지 않는다"는 점이다.

해결: 리패치 전에 상태를 확정하기

가드 해제와 상태 초기화를 리패치보다 먼저 하고, flushSync로 즉시 커밋했다.

import { flushSync } from 'react-dom';

onSuccess: async ({ data }) => {
  flushSync(() => {
    initializedBatchSeqRef.current = null;
    setSelectedState((prev) => {
      if (!prev || prev.kind !== 'existing') return prev;
      return { ...prev, scanRows: [] };
    });
  });

  await Promise.all([
    queryClient.invalidateQueries({ queryKey: queryKeys.batchList() }),
    queryClient.invalidateQueries({ queryKey: queryKeys.batchDetail(batchSeq) }),
  ]);
},
flushSync: 가드 해제 + scanRows = [] 즉시 커밋
await invalidateQueries
  → 새 data → Syncer effect 실행
    → 가드 null, scanRows 비어 있음 → 최신 데이터로 행 재구성

flushSync는 콜백 안의 상태 업데이트를 즉시 동기적으로 렌더·커밋하게 한다. React 문서는 성능상 꼭 필요할 때만 쓰라고 권한다. 여기서는 순서를 맞추는 것이 목적이라, 가드 해제와 setState를 리패치 앞으로 옮기는 것이 핵심이고 flushSync는 그 상태가 리패치 결과보다 먼저 커밋되도록 보장하는 장치다.

대안

A. 저장 후 직접 행을 구성

Syncer에 기대지 않고, 리패치가 끝난 캐시에서 바로 읽어 상태를 만든다.

await queryClient.invalidateQueries({ queryKey: queryKeys.batchDetail(batchSeq) });

const fresh = queryClient.getQueryData<PartItem[]>(queryKeys.batchDetail(batchSeq)) ?? [];
initializedBatchSeqRef.current = batchSeq;
setSelectedState((prev) => ({ ...prev, scanRows: fresh.map(toScanRow) }));

순서가 코드에 그대로 드러나 이해하기 쉽다.

B. key로 편집 영역을 다시 마운트

서버 데이터를 로컬 상태로 복사하는 컴포넌트는, useEffect로 동기화하는 대신 초기값으로만 받고 데이터 버전이 바뀌면 key를 바꿔 다시 마운트하는 방식이 React가 권장하는 패턴이다.

<BatchScanEditor key={`${batchSeq}-${dataUpdatedAt}`} initialRows={data.map(toScanRow)} />

ref 가드와 동기화 effect가 사라지므로 이런 종류의 타이밍 버그가 구조적으로 생기지 않는다.

요약

항목내용
버그 유형비동기 리패치와 로컬 상태 동기화의 순서 문제
핵심 원인await invalidateQueries는 리패치 완료까지 기다리므로, 그 뒤의 가드 해제가 Syncer 실행보다 늦을 수 있음
해결가드 해제·상태 초기화를 리패치 전에 flushSync로 커밋
교훈서버 상태를 useEffect로 로컬 상태에 복사하는 패턴은 타이밍 버그를 만들기 쉽다. 가능하면 key 리셋이나 명시적 초기화로 바꾼다.

참고